feat(ble): Web Bluetooth (Battery Service genérico) + slot Raymarine NMEA gateway v1.9.0
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run

Bluetooth & Acessórios (aba Mais):
- Pareamento Web Bluetooth API (acceptAllDevices)
- Lê Battery Service padrão (UUID 0x180F) + characteristic 0x2A19
- Subscribe pra notificações em tempo real (battery_level changes)
- Lê Device Info Service (manufacturer + model)
- Lista persistente de devices pareados (state.btDevices)
- Reconexão via navigator.bluetooth.getDevices() (Chrome ≥85)
- Status visual: 🪫/🔋 + cor por nível (verde >50, amarelo 20-50, vermelho <20)
- Cleanup ao remover device (disconnect GATT + remove do state)

Raymarine Gateway (slot, parser em v1.10):
- Card config com IP + porta TCP/UDP do gateway NMEA 2000→WiFi
- Sugere Yacht Devices YDWG-02 / Actisense W2K-1
- Salva em state.nmeaGateway pra parser futuro
- Sem gateway físico ainda, só persiste config

Limitações documentadas no UI:
- iOS Safari não suporta Web Bluetooth (precisa @capacitor/community/bluetooth-le em v1.10)
- Reconexão automática varia por device (Web Bluetooth não persiste connections)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PontualTech / Karlão 2026-04-28 16:07:37 -03:00
parent 0921d98ef3
commit a6a35c6d6f
5 changed files with 422 additions and 6 deletions

View file

@ -1970,6 +1970,31 @@ Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção
<div id="chart-status" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:8px;letter-spacing:.04em;line-height:1.55"></div>
</div>
<!-- Bluetooth & Instrumentos -->
<div class="export-card" id="bluetooth-card">
<div class="export-card-title">📡 Bluetooth · Baterias & Acessórios</div>
<div class="export-card-text" style="margin-bottom:8px">Pareie BMS de bateria de lítio (com BLE), fones, smartwatch, smart shunts. Mostra nível de carga em tempo real no app.</div>
<div id="bt-support" style="font-family:var(--f-mono);font-size:10.5px;letter-spacing:.04em;margin-bottom:10px">Verificando suporte...</div>
<button class="btn btn-block btn-primary" onclick="pairBluetoothDevice()">+ Parear novo dispositivo Bluetooth</button>
<div id="bt-list" style="margin-top:14px"></div>
<div class="field-hint" style="margin-top:8px">Limitações: <strong>iOS Safari não suporta Web Bluetooth</strong> (use no Chrome PC ou Android). Reconexão automática varia por device.</div>
</div>
<!-- Raymarine Gateway -->
<div class="export-card" id="nmea-gateway-card">
<div class="export-card-title">⚓ Instrumentos Raymarine (gateway NMEA 2000)</div>
<div class="export-card-text" style="margin-bottom:10px">Pra ler dados de Raymarine (profundidade, vento, GPS, piloto automático), instale um gateway NMEA 2000→WiFi no barco e conecte ao bus SeaTalkNG. Recomendados: <strong>Yacht Devices YDWG-02</strong>, <strong>Actisense W2K-1</strong>.</div>
<div class="field"><label class="field-label">IP do Gateway</label>
<input type="text" id="nmea-gateway-ip" placeholder="ex: 192.168.4.1" style="font-family:var(--f-mono);font-size:13px">
</div>
<div class="field"><label class="field-label">Porta TCP/UDP</label>
<input type="number" id="nmea-gateway-port" placeholder="ex: 1457 (YDWG default)" style="font-family:var(--f-mono);font-size:13px">
</div>
<button class="btn btn-block" onclick="saveNmeaGatewayCfg()">Salvar configuração</button>
<div id="nmea-gateway-status" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:8px;letter-spacing:.04em;line-height:1.55"></div>
<div class="field-hint" style="margin-top:8px"><strong>Status:</strong> apenas slot de configuração. Parser NMEA 2000 PGNs (depth, wind, AIS, autopilot) será ativado em v1.10 quando você tiver o gateway físico instalado.</div>
</div>
<!-- Export OpenCPN -->
<div class="export-card" id="opencpn-card">
<div class="export-card-title">🗺️ Exportar para OpenCPN</div>
@ -3262,7 +3287,7 @@ function switchPanel(name){
if(p)p.classList.add('active');
// FAB visível em panels que têm "criar item"
document.getElementById('fab').style.display=['trips','maintenance','pending','zones','overview'].includes(name)?'flex':'none';
if(name==='export'){updateStorageInfo();bindCloudInputs();renderCloudStatus();renderShareList();bindWeatherInputs();renderAuthBox();refreshGoogleStatus()}
if(name==='export'){updateStorageInfo();bindCloudInputs();renderCloudStatus();renderShareList();bindWeatherInputs();renderAuthBox();refreshGoogleStatus();renderBluetoothCard();renderNmeaGatewayCard()}
if(name==='pending'&&_gcalConnected&&Date.now()-_gcalLastPullAt>GCAL_PULL_INTERVAL_MS)googlePullNow();
if(name==='zones')renderZones();
window.scrollTo(0,0);
@ -5717,6 +5742,189 @@ function testChartCfg(){
toast('Cartas salvas — abra um mapa pra ver');
}
// ============ BLUETOOTH (Web Bluetooth API — Battery Service genérico) ============
// Funciona em Chrome PC + Chrome Android. NÃO funciona em Safari (iOS).
// Pra suporte iOS: precisa plugin nativo @capacitor/community/bluetooth-le (próxima versão).
const BLE_BATTERY_SERVICE='battery_service'; // 0x180F
const BLE_BATTERY_CHAR='battery_level'; // 0x2A19
const BLE_DEVICE_INFO='device_information'; // 0x180A
const BLE_OPTIONAL_SERVICES=['device_information'];
const _bleConnections=new Map(); // id → {device, server, batteryChar}
function bleSupported(){return !!navigator.bluetooth}
async function pairBluetoothDevice(){
if(!bleSupported()){
toast('Web Bluetooth não suportado neste navegador. Use Chrome no PC ou Android.');
return;
}
try{
const device=await navigator.bluetooth.requestDevice({
// Filtro permissivo: aceita qualquer device com Battery Service OU qualquer device que o usuário escolher
acceptAllDevices:true,
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO],
});
if(!device){return}
// Conecta + lê info inicial
const info=await connectAndRead(device);
// Salva no state
if(!state.btDevices)state.btDevices=[];
const existing=state.btDevices.find(d=>d.id===device.id);
if(existing){
Object.assign(existing,{name:device.name||existing.name,lastBattery:info.battery,lastSeen:Date.now(),manufacturer:info.manufacturer,model:info.model});
}else{
state.btDevices.push({
id:device.id,
name:device.name||'Dispositivo BLE',
lastBattery:info.battery,
lastSeen:Date.now(),
manufacturer:info.manufacturer,
model:info.model,
addedAt:Date.now(),
});
}
saveState();
renderBluetoothCard();
toast('✓ '+(device.name||'Dispositivo')+' pareado'+(info.battery!=null?' · '+info.battery+'%':''));
// Listener pra desconexão
device.addEventListener('gattserverdisconnected',()=>{
_bleConnections.delete(device.id);
renderBluetoothCard();
});
}catch(e){
if(e.name==='NotFoundError')return; // user cancelou
console.warn('[ble] pair failed',e);
toast('Erro: '+e.message);
}
}
async function connectAndRead(device){
const info={battery:null,manufacturer:null,model:null};
try{
const server=await device.gatt.connect();
_bleConnections.set(device.id,{device,server});
// Battery Service (opcional)
try{
const svc=await server.getPrimaryService(BLE_BATTERY_SERVICE);
const batChar=await svc.getCharacteristic(BLE_BATTERY_CHAR);
const val=await batChar.readValue();
info.battery=val.getUint8(0);
// Subscribe pra notificações em tempo real
try{
await batChar.startNotifications();
batChar.addEventListener('characteristicvaluechanged',(ev)=>{
const newVal=ev.target.value.getUint8(0);
const dev=state.btDevices?.find(d=>d.id===device.id);
if(dev){dev.lastBattery=newVal;dev.lastSeen=Date.now();saveState();renderBluetoothCard()}
});
}catch(e){/* notification not supported */}
_bleConnections.get(device.id).batteryChar=batChar;
}catch(e){/* sem battery service */}
// Device Info (opcional)
try{
const svc=await server.getPrimaryService(BLE_DEVICE_INFO);
try{const c=await svc.getCharacteristic('manufacturer_name_string');info.manufacturer=new TextDecoder().decode(await c.readValue())}catch{}
try{const c=await svc.getCharacteristic('model_number_string');info.model=new TextDecoder().decode(await c.readValue())}catch{}
}catch(e){/* sem device info */}
}catch(e){console.warn('[ble] connect failed',device.name,e.message)}
return info;
}
async function reconnectBluetoothDevice(id){
// Web Bluetooth não persiste. Tenta getDevices() (suportado em alguns Chrome ≥85)
if(!bleSupported())return;
if(!navigator.bluetooth.getDevices){
toast('Reconexão automática não suportada — toque "Parear" pra escolher de novo');
return;
}
try{
const devices=await navigator.bluetooth.getDevices();
const dev=devices.find(d=>d.id===id);
if(!dev){toast('Device não encontrado nas permissões — repare manualmente');return}
toast('Reconectando '+(dev.name||'dispositivo')+'...');
await connectAndRead(dev);
renderBluetoothCard();
}catch(e){
console.warn('[ble] reconnect failed',e.message);
toast('Falha: '+e.message);
}
}
function removeBluetoothDevice(id){
if(!confirm('Remover dispositivo da lista?'))return;
const conn=_bleConnections.get(id);
if(conn?.device?.gatt?.connected)try{conn.device.gatt.disconnect()}catch{}
_bleConnections.delete(id);
state.btDevices=(state.btDevices||[]).filter(d=>d.id!==id);
saveState();
renderBluetoothCard();
}
function renderBluetoothCard(){
const el=document.getElementById('bt-list');
const supportEl=document.getElementById('bt-support');
if(!el)return;
if(supportEl){
supportEl.textContent=bleSupported()
? 'Bluetooth disponível neste navegador.'
: 'Web Bluetooth indisponível (use Chrome no PC ou Android — iOS Safari não suporta).';
supportEl.style.color=bleSupported()?'var(--m-ok,#10b981)':'var(--m-warn,#f59e0b)';
}
const devices=state.btDevices||[];
if(devices.length===0){
el.innerHTML='<div style="color:var(--m-text-soft);font-size:13px;padding:12px 0">Nenhum dispositivo pareado ainda.</div>';
return;
}
el.innerHTML=devices.map(d=>{
const conn=_bleConnections.get(d.id);
const isConnected=conn?.device?.gatt?.connected;
const bat=d.lastBattery;
const batColor=bat==null?'var(--m-text-soft)':bat<20?'var(--m-danger,#ef4444)':bat<50?'var(--m-warn,#f59e0b)':'var(--m-ok,#10b981)';
const batIcon=bat==null?'❓':bat<20?'🪫':bat<50?'🔋':'🔋';
return `<div class="bt-device" style="display:flex;align-items:center;gap:12px;padding:12px;background:var(--m-bg-2,#0f2a40);border:1px solid var(--m-border,rgba(255,255,255,.08));border-radius:8px;margin-bottom:8px">
<div style="font-size:24px">${batIcon}</div>
<div style="flex:1;min-width:0">
<div style="font-weight:600;color:var(--m-text,#e8f1f8);font-size:14px">${escapeHtml(d.name)}</div>
<div style="font-family:var(--f-mono);font-size:11px;color:var(--m-text-soft,#7d97ad);margin-top:2px">
${bat!=null?`<span style="color:${batColor};font-weight:700">${bat}%</span> · `:''}${isConnected?'conectado':'offline'}${d.manufacturer?` · ${escapeHtml(d.manufacturer)}`:''}${d.model?` ${escapeHtml(d.model)}`:''}
</div>
</div>
${!isConnected?`<button class="btn btn-sm" onclick="reconnectBluetoothDevice('${d.id}')" title="Reconectar"></button>`:''}
<button class="btn btn-sm btn-danger" onclick="removeBluetoothDevice('${d.id}')" title="Remover"></button>
</div>`;
}).join('');
}
// Re-render quando entra na aba Mais
function refreshBluetoothCard(){renderBluetoothCard()}
// ============ RAYMARINE / NMEA 2000 GATEWAY ============
// Slot pra quando Karlão tiver gateway NMEA 2000 → WiFi (Yacht Devices YDWG-02, etc)
function saveNmeaGatewayCfg(){
const ip=document.getElementById('nmea-gateway-ip')?.value.trim()||'';
const port=document.getElementById('nmea-gateway-port')?.value.trim()||'';
state.nmeaGateway={ip,port:port?parseInt(port):0,enabled:!!ip};
saveState();
toast(ip?'Gateway salvo · será usado em viagens futuras':'Gateway desativado');
renderNmeaGatewayCard();
}
function renderNmeaGatewayCard(){
const ipEl=document.getElementById('nmea-gateway-ip');
const portEl=document.getElementById('nmea-gateway-port');
if(!ipEl||!portEl)return;
ipEl.value=state.nmeaGateway?.ip||'';
portEl.value=state.nmeaGateway?.port||'';
const st=document.getElementById('nmea-gateway-status');
if(st){
if(state.nmeaGateway?.ip)st.innerHTML='<span style="color:var(--m-ok,#10b981)">Configurado · '+escapeHtml(state.nmeaGateway.ip)+':'+(state.nmeaGateway.port||'?')+'</span>';
else st.innerHTML='<span style="color:var(--m-text-soft,#7d97ad)">Não configurado · sem leitura de instrumentos Raymarine</span>';
}
}
// ============ EXPORT GPX para OpenCPN ============
function gpxEscape(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[c]))}

View file

@ -7,8 +7,8 @@ android {
applicationId "br.com.pontualtech.shivao"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 12
versionName "1.8.0"
versionCode 13
versionName "1.9.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -1,6 +1,6 @@
{
"name": "shivao-mobile",
"version": "1.8.0",
"version": "1.9.0",
"description": "Shivao app nativo (Capacitor wrapper Android/iOS)",
"main": "index.js",
"type": "module",

View file

@ -1970,6 +1970,31 @@ Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção
<div id="chart-status" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:8px;letter-spacing:.04em;line-height:1.55"></div>
</div>
<!-- Bluetooth & Instrumentos -->
<div class="export-card" id="bluetooth-card">
<div class="export-card-title">📡 Bluetooth · Baterias & Acessórios</div>
<div class="export-card-text" style="margin-bottom:8px">Pareie BMS de bateria de lítio (com BLE), fones, smartwatch, smart shunts. Mostra nível de carga em tempo real no app.</div>
<div id="bt-support" style="font-family:var(--f-mono);font-size:10.5px;letter-spacing:.04em;margin-bottom:10px">Verificando suporte...</div>
<button class="btn btn-block btn-primary" onclick="pairBluetoothDevice()">+ Parear novo dispositivo Bluetooth</button>
<div id="bt-list" style="margin-top:14px"></div>
<div class="field-hint" style="margin-top:8px">Limitações: <strong>iOS Safari não suporta Web Bluetooth</strong> (use no Chrome PC ou Android). Reconexão automática varia por device.</div>
</div>
<!-- Raymarine Gateway -->
<div class="export-card" id="nmea-gateway-card">
<div class="export-card-title">⚓ Instrumentos Raymarine (gateway NMEA 2000)</div>
<div class="export-card-text" style="margin-bottom:10px">Pra ler dados de Raymarine (profundidade, vento, GPS, piloto automático), instale um gateway NMEA 2000→WiFi no barco e conecte ao bus SeaTalkNG. Recomendados: <strong>Yacht Devices YDWG-02</strong>, <strong>Actisense W2K-1</strong>.</div>
<div class="field"><label class="field-label">IP do Gateway</label>
<input type="text" id="nmea-gateway-ip" placeholder="ex: 192.168.4.1" style="font-family:var(--f-mono);font-size:13px">
</div>
<div class="field"><label class="field-label">Porta TCP/UDP</label>
<input type="number" id="nmea-gateway-port" placeholder="ex: 1457 (YDWG default)" style="font-family:var(--f-mono);font-size:13px">
</div>
<button class="btn btn-block" onclick="saveNmeaGatewayCfg()">Salvar configuração</button>
<div id="nmea-gateway-status" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-top:8px;letter-spacing:.04em;line-height:1.55"></div>
<div class="field-hint" style="margin-top:8px"><strong>Status:</strong> apenas slot de configuração. Parser NMEA 2000 PGNs (depth, wind, AIS, autopilot) será ativado em v1.10 quando você tiver o gateway físico instalado.</div>
</div>
<!-- Export OpenCPN -->
<div class="export-card" id="opencpn-card">
<div class="export-card-title">🗺️ Exportar para OpenCPN</div>
@ -3262,7 +3287,7 @@ function switchPanel(name){
if(p)p.classList.add('active');
// FAB visível em panels que têm "criar item"
document.getElementById('fab').style.display=['trips','maintenance','pending','zones','overview'].includes(name)?'flex':'none';
if(name==='export'){updateStorageInfo();bindCloudInputs();renderCloudStatus();renderShareList();bindWeatherInputs();renderAuthBox();refreshGoogleStatus()}
if(name==='export'){updateStorageInfo();bindCloudInputs();renderCloudStatus();renderShareList();bindWeatherInputs();renderAuthBox();refreshGoogleStatus();renderBluetoothCard();renderNmeaGatewayCard()}
if(name==='pending'&&_gcalConnected&&Date.now()-_gcalLastPullAt>GCAL_PULL_INTERVAL_MS)googlePullNow();
if(name==='zones')renderZones();
window.scrollTo(0,0);
@ -5717,6 +5742,189 @@ function testChartCfg(){
toast('Cartas salvas — abra um mapa pra ver');
}
// ============ BLUETOOTH (Web Bluetooth API — Battery Service genérico) ============
// Funciona em Chrome PC + Chrome Android. NÃO funciona em Safari (iOS).
// Pra suporte iOS: precisa plugin nativo @capacitor/community/bluetooth-le (próxima versão).
const BLE_BATTERY_SERVICE='battery_service'; // 0x180F
const BLE_BATTERY_CHAR='battery_level'; // 0x2A19
const BLE_DEVICE_INFO='device_information'; // 0x180A
const BLE_OPTIONAL_SERVICES=['device_information'];
const _bleConnections=new Map(); // id → {device, server, batteryChar}
function bleSupported(){return !!navigator.bluetooth}
async function pairBluetoothDevice(){
if(!bleSupported()){
toast('Web Bluetooth não suportado neste navegador. Use Chrome no PC ou Android.');
return;
}
try{
const device=await navigator.bluetooth.requestDevice({
// Filtro permissivo: aceita qualquer device com Battery Service OU qualquer device que o usuário escolher
acceptAllDevices:true,
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO],
});
if(!device){return}
// Conecta + lê info inicial
const info=await connectAndRead(device);
// Salva no state
if(!state.btDevices)state.btDevices=[];
const existing=state.btDevices.find(d=>d.id===device.id);
if(existing){
Object.assign(existing,{name:device.name||existing.name,lastBattery:info.battery,lastSeen:Date.now(),manufacturer:info.manufacturer,model:info.model});
}else{
state.btDevices.push({
id:device.id,
name:device.name||'Dispositivo BLE',
lastBattery:info.battery,
lastSeen:Date.now(),
manufacturer:info.manufacturer,
model:info.model,
addedAt:Date.now(),
});
}
saveState();
renderBluetoothCard();
toast('✓ '+(device.name||'Dispositivo')+' pareado'+(info.battery!=null?' · '+info.battery+'%':''));
// Listener pra desconexão
device.addEventListener('gattserverdisconnected',()=>{
_bleConnections.delete(device.id);
renderBluetoothCard();
});
}catch(e){
if(e.name==='NotFoundError')return; // user cancelou
console.warn('[ble] pair failed',e);
toast('Erro: '+e.message);
}
}
async function connectAndRead(device){
const info={battery:null,manufacturer:null,model:null};
try{
const server=await device.gatt.connect();
_bleConnections.set(device.id,{device,server});
// Battery Service (opcional)
try{
const svc=await server.getPrimaryService(BLE_BATTERY_SERVICE);
const batChar=await svc.getCharacteristic(BLE_BATTERY_CHAR);
const val=await batChar.readValue();
info.battery=val.getUint8(0);
// Subscribe pra notificações em tempo real
try{
await batChar.startNotifications();
batChar.addEventListener('characteristicvaluechanged',(ev)=>{
const newVal=ev.target.value.getUint8(0);
const dev=state.btDevices?.find(d=>d.id===device.id);
if(dev){dev.lastBattery=newVal;dev.lastSeen=Date.now();saveState();renderBluetoothCard()}
});
}catch(e){/* notification not supported */}
_bleConnections.get(device.id).batteryChar=batChar;
}catch(e){/* sem battery service */}
// Device Info (opcional)
try{
const svc=await server.getPrimaryService(BLE_DEVICE_INFO);
try{const c=await svc.getCharacteristic('manufacturer_name_string');info.manufacturer=new TextDecoder().decode(await c.readValue())}catch{}
try{const c=await svc.getCharacteristic('model_number_string');info.model=new TextDecoder().decode(await c.readValue())}catch{}
}catch(e){/* sem device info */}
}catch(e){console.warn('[ble] connect failed',device.name,e.message)}
return info;
}
async function reconnectBluetoothDevice(id){
// Web Bluetooth não persiste. Tenta getDevices() (suportado em alguns Chrome ≥85)
if(!bleSupported())return;
if(!navigator.bluetooth.getDevices){
toast('Reconexão automática não suportada — toque "Parear" pra escolher de novo');
return;
}
try{
const devices=await navigator.bluetooth.getDevices();
const dev=devices.find(d=>d.id===id);
if(!dev){toast('Device não encontrado nas permissões — repare manualmente');return}
toast('Reconectando '+(dev.name||'dispositivo')+'...');
await connectAndRead(dev);
renderBluetoothCard();
}catch(e){
console.warn('[ble] reconnect failed',e.message);
toast('Falha: '+e.message);
}
}
function removeBluetoothDevice(id){
if(!confirm('Remover dispositivo da lista?'))return;
const conn=_bleConnections.get(id);
if(conn?.device?.gatt?.connected)try{conn.device.gatt.disconnect()}catch{}
_bleConnections.delete(id);
state.btDevices=(state.btDevices||[]).filter(d=>d.id!==id);
saveState();
renderBluetoothCard();
}
function renderBluetoothCard(){
const el=document.getElementById('bt-list');
const supportEl=document.getElementById('bt-support');
if(!el)return;
if(supportEl){
supportEl.textContent=bleSupported()
? 'Bluetooth disponível neste navegador.'
: 'Web Bluetooth indisponível (use Chrome no PC ou Android — iOS Safari não suporta).';
supportEl.style.color=bleSupported()?'var(--m-ok,#10b981)':'var(--m-warn,#f59e0b)';
}
const devices=state.btDevices||[];
if(devices.length===0){
el.innerHTML='<div style="color:var(--m-text-soft);font-size:13px;padding:12px 0">Nenhum dispositivo pareado ainda.</div>';
return;
}
el.innerHTML=devices.map(d=>{
const conn=_bleConnections.get(d.id);
const isConnected=conn?.device?.gatt?.connected;
const bat=d.lastBattery;
const batColor=bat==null?'var(--m-text-soft)':bat<20?'var(--m-danger,#ef4444)':bat<50?'var(--m-warn,#f59e0b)':'var(--m-ok,#10b981)';
const batIcon=bat==null?'❓':bat<20?'🪫':bat<50?'🔋':'🔋';
return `<div class="bt-device" style="display:flex;align-items:center;gap:12px;padding:12px;background:var(--m-bg-2,#0f2a40);border:1px solid var(--m-border,rgba(255,255,255,.08));border-radius:8px;margin-bottom:8px">
<div style="font-size:24px">${batIcon}</div>
<div style="flex:1;min-width:0">
<div style="font-weight:600;color:var(--m-text,#e8f1f8);font-size:14px">${escapeHtml(d.name)}</div>
<div style="font-family:var(--f-mono);font-size:11px;color:var(--m-text-soft,#7d97ad);margin-top:2px">
${bat!=null?`<span style="color:${batColor};font-weight:700">${bat}%</span> · `:''}${isConnected?'conectado':'offline'}${d.manufacturer?` · ${escapeHtml(d.manufacturer)}`:''}${d.model?` ${escapeHtml(d.model)}`:''}
</div>
</div>
${!isConnected?`<button class="btn btn-sm" onclick="reconnectBluetoothDevice('${d.id}')" title="Reconectar"></button>`:''}
<button class="btn btn-sm btn-danger" onclick="removeBluetoothDevice('${d.id}')" title="Remover"></button>
</div>`;
}).join('');
}
// Re-render quando entra na aba Mais
function refreshBluetoothCard(){renderBluetoothCard()}
// ============ RAYMARINE / NMEA 2000 GATEWAY ============
// Slot pra quando Karlão tiver gateway NMEA 2000 → WiFi (Yacht Devices YDWG-02, etc)
function saveNmeaGatewayCfg(){
const ip=document.getElementById('nmea-gateway-ip')?.value.trim()||'';
const port=document.getElementById('nmea-gateway-port')?.value.trim()||'';
state.nmeaGateway={ip,port:port?parseInt(port):0,enabled:!!ip};
saveState();
toast(ip?'Gateway salvo · será usado em viagens futuras':'Gateway desativado');
renderNmeaGatewayCard();
}
function renderNmeaGatewayCard(){
const ipEl=document.getElementById('nmea-gateway-ip');
const portEl=document.getElementById('nmea-gateway-port');
if(!ipEl||!portEl)return;
ipEl.value=state.nmeaGateway?.ip||'';
portEl.value=state.nmeaGateway?.port||'';
const st=document.getElementById('nmea-gateway-status');
if(st){
if(state.nmeaGateway?.ip)st.innerHTML='<span style="color:var(--m-ok,#10b981)">Configurado · '+escapeHtml(state.nmeaGateway.ip)+':'+(state.nmeaGateway.port||'?')+'</span>';
else st.innerHTML='<span style="color:var(--m-text-soft,#7d97ad)">Não configurado · sem leitura de instrumentos Raymarine</span>';
}
}
// ============ EXPORT GPX para OpenCPN ============
function gpxEscape(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[c]))}

View file

@ -347,7 +347,7 @@ app.get('/.well-known/assetlinks.json', (req, res) => {
});
// Atalho: /apk redireciona pra última APK release no Forgejo
const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.8.0/Shivao-v1.8.0.apk';
const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.9.0/Shivao-v1.9.0.apk';
app.get('/apk', (req, res) => res.redirect(302, LATEST_APK_URL));
// Página A4 imprimível com QR Code + instruções (cola no barco/marina)