feat(bms): dashboard visual + RX log bytes + writeWithoutResponse fallback v1.10.1
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run

3 problemas atacados após teste de Karlão:

1) BMS conectou mas não respondeu o comando 0x03:
   - Log mostra "← RX X bytes: hex" pra cada notification recebida
   - Listener registrado ANTES de startNotifications (fix de race condition)
   - Wait 500ms entre subscribe e primeiro write (alguns BMS precisam wake)
   - Após 5s sem resposta, tenta writeWithoutResponse automaticamente
   - Botão 🔄 Re-ler manual no card pra forçar query

2) Karlão pediu "monitor visual humano":
   - Modal full-screen " Monitor da Bateria" com:
     * Círculo SoC grande SVG (ring chart 160x160) com cor por nível
     * Status flow grande:  CARREGANDO / ↓ DESCARGA / — REPOUSO
     * Tempo restante calculado (descarga = remainCap/current)
     * Tempo até cheia (carga = (totalCap-remainCap)/current)
     * 4 cards: Tensão · Corrente · Potência · Capacidade
     * Linha info: ciclos · temperaturas · firmware version
     * Grid de células coloridas por health (vermelho <3.0V, verde >3.6V)
     * Auto-refresh 10s enquanto modal aberto
   - Botão 📊 Monitor no card BMS abre dashboard

3) Estado de erro mais claro:
   - Dashboard mostra "Aguardando dados..." se b.voltage ainda não chegou
   - Diagnóstico log destaca chunks RX em azul

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PontualTech / Karlão 2026-04-28 17:06:44 -03:00
parent 8f3870412d
commit 578793d097
5 changed files with 342 additions and 20 deletions

View file

@ -2542,6 +2542,21 @@ Hora: {HORA}</textarea>
</div> </div>
</div> </div>
<!-- BMS Dashboard Modal (monitor de bordo) -->
<div class="modal-backdrop" id="bms-dashboard-modal">
<div class="modal" style="max-width:560px;background:#0a1f30;color:#e8f1f8">
<div class="modal-head" style="border-bottom:1px solid rgba(255,255,255,.08);background:#0d2538">
<h3 id="bms-dash-title" style="color:#06b6d4;font-weight:700;letter-spacing:.04em">⚡ Monitor da Bateria</h3>
<button class="icon-btn" onclick="closeModal('bms-dashboard-modal')" style="color:#e8f1f8"></button>
</div>
<div class="modal-body" id="bms-dash-body" style="padding:20px;background:#0a1f30"></div>
<div class="modal-foot" style="background:#0d2538;border-top:1px solid rgba(255,255,255,.08)">
<button class="btn" id="bms-dash-refresh" onclick="bmsManualRead(window._currentDashDeviceId);setTimeout(renderBmsDashboard,2500)">🔄 Re-ler</button>
<button class="btn" onclick="closeModal('bms-dashboard-modal')">Fechar</button>
</div>
</div>
</div>
<!-- Welcome / Login Screen --> <!-- Welcome / Login Screen -->
<div class="welcome-screen" id="welcome-screen" style="display:none"> <div class="welcome-screen" id="welcome-screen" style="display:none">
<div class="welcome-card"> <div class="welcome-card">
@ -5983,21 +5998,32 @@ async function bmsAttachJBD(deviceId,deviceName){
const ble=window.Capacitor.Plugins.BluetoothLe; const ble=window.Capacitor.Plugins.BluetoothLe;
try{ try{
setBleDiag('Detectando JBD BMS protocol...','info'); setBleDiag('Detectando JBD BMS protocol...','info');
// Subscribe nas notificações // Subscribe nas notificações com listener registrado ANTES de start
await ble.startNotifications({deviceId,service:BMS_JBD_SERVICE,characteristic:BMS_JBD_NOTIFY}); const listenerKey='notification|'+deviceId+'|'+BMS_JBD_SERVICE+'|'+BMS_JBD_NOTIFY;
ble.addListener('notification|'+deviceId+'|'+BMS_JBD_SERVICE+'|'+BMS_JBD_NOTIFY,(ev)=>{ ble.addListener(listenerKey,(ev)=>{
const dv=parseDataView(ev.value); const dv=parseDataView(ev.value);
const hex=Array.from(new Uint8Array(dv.buffer)).map(b=>b.toString(16).padStart(2,'0')).join(' ');
setBleDiag('← RX '+dv.byteLength+' bytes: '+hex.slice(0,80)+(hex.length>80?'...':''),'info');
bmsHandleChunk(deviceId,dv,deviceName); bmsHandleChunk(deviceId,dv,deviceName);
}); });
setBleDiag('Notify ff01 OK · enviando query...','ok'); await ble.startNotifications({deviceId,service:BMS_JBD_SERVICE,characteristic:BMS_JBD_NOTIFY});
// Solicita dados básicos setBleDiag('Notify ff01 ativo · aguardando 500ms...','ok');
await new Promise(r=>setTimeout(r,500)); // alguns BMS precisam wake-up
setBleDiag('→ TX comando 0x03 (basic info)','info');
await bmsQueryBasic(deviceId); await bmsQueryBasic(deviceId);
// Re-poll a cada 30s
const dev=state.btDevices?.find(d=>d.id===deviceId); const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev){ if(dev){
dev.isJBD=true; dev.isJBD=true;
if(dev._pollInterval)clearInterval(dev._pollInterval); if(dev._pollInterval)clearInterval(dev._pollInterval);
} }
// Se em 5s não chegou resposta, tenta com writeWithoutResponse
setTimeout(async()=>{
const dev2=state.btDevices?.find(d=>d.id===deviceId);
if(dev2&&!dev2.bms?.voltage){
setBleDiag('Sem resposta · tentando writeWithoutResponse...','warn');
await bmsQueryBasic(deviceId,true).catch(e=>setBleDiag('writeWoR falhou: '+e.message,'err'));
}
},5000);
setInterval(()=>bmsQueryBasic(deviceId).catch(()=>{}),30000); setInterval(()=>bmsQueryBasic(deviceId).catch(()=>{}),30000);
return true; return true;
}catch(e){ }catch(e){
@ -6006,10 +6032,26 @@ async function bmsAttachJBD(deviceId,deviceName){
} }
} }
async function bmsQueryBasic(deviceId){ async function bmsQueryBasic(deviceId,withoutResponse){
const ble=window.Capacitor?.Plugins?.BluetoothLe; const ble=window.Capacitor?.Plugins?.BluetoothLe;
if(!ble)return; if(!ble)return;
await ble.write({deviceId,service:BMS_JBD_SERVICE,characteristic:BMS_JBD_WRITE,value:bytesToBase64(BMS_CMD_BASIC)}); const fn=withoutResponse?'writeWithoutResponse':'write';
await ble[fn]({deviceId,service:BMS_JBD_SERVICE,characteristic:BMS_JBD_WRITE,value:bytesToBase64(BMS_CMD_BASIC)});
}
// Re-leitura manual a partir do botão UI
async function bmsManualRead(deviceId){
setBleDiag('🔄 Re-leitura manual...','info');
try{
await bmsQueryBasic(deviceId);
setTimeout(async()=>{
const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev&&!dev.bms?.voltage){
setBleDiag('Tentando writeWithoutResponse...','warn');
await bmsQueryBasic(deviceId,true);
}
},3000);
}catch(e){setBleDiag('Manual read falhou: '+(e.message||e.errorMessage),'err')}
} }
function bmsHandleChunk(deviceId,dv,deviceName){ function bmsHandleChunk(deviceId,dv,deviceName){
@ -6185,7 +6227,9 @@ function renderBluetoothCard(){
${bat!=null?`<span style="color:${batColor};font-weight:700">${bat}%</span> · `:''}${isConnected?'conectado':'offline'}${d.manufacturer?` · ${escapeHtml(d.manufacturer)}`:''}${d.model?` ${escapeHtml(d.model)}`:''} ${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>
</div> </div>
${d.isJBD?`<button class="btn btn-sm btn-primary" onclick="openBmsDashboard('${d.id}')" title="Abrir monitor">📊</button>`:''}
${!isConnected?`<button class="btn btn-sm" onclick="reconnectBluetoothDevice('${d.id}')" title="Reconectar"></button>`:''} ${!isConnected?`<button class="btn btn-sm" onclick="reconnectBluetoothDevice('${d.id}')" title="Reconectar"></button>`:''}
${d.isJBD?`<button class="btn btn-sm" onclick="bmsManualRead('${d.id}')" title="Re-ler">🔄</button>`:''}
<button class="btn btn-sm btn-danger" onclick="removeBluetoothDevice('${d.id}')" title="Remover"></button> <button class="btn btn-sm btn-danger" onclick="removeBluetoothDevice('${d.id}')" title="Remover"></button>
</div> </div>
${bmsBlock} ${bmsBlock}
@ -6196,6 +6240,123 @@ function renderBluetoothCard(){
// Re-render quando entra na aba Mais // Re-render quando entra na aba Mais
function refreshBluetoothCard(){renderBluetoothCard()} function refreshBluetoothCard(){renderBluetoothCard()}
// ============ BMS DASHBOARD (monitor de bordo full-screen) ============
let _dashRefreshTimer=null;
function openBmsDashboard(deviceId){
window._currentDashDeviceId=deviceId;
const dev=state.btDevices?.find(d=>d.id===deviceId);
if(!dev)return;
document.getElementById('bms-dash-title').textContent='⚡ '+(dev.name||'Bateria');
renderBmsDashboard();
openModal('bms-dashboard-modal');
// Auto-refresh a cada 5s enquanto modal aberto
if(_dashRefreshTimer)clearInterval(_dashRefreshTimer);
_dashRefreshTimer=setInterval(()=>{
if(document.getElementById('bms-dashboard-modal')?.classList.contains('show')){
bmsManualRead(deviceId);
setTimeout(renderBmsDashboard,2000);
}else{
clearInterval(_dashRefreshTimer);_dashRefreshTimer=null;
}
},10000);
}
function renderBmsDashboard(){
const id=window._currentDashDeviceId;
const dev=state.btDevices?.find(d=>d.id===id);
const body=document.getElementById('bms-dash-body');
if(!dev||!body)return;
const b=dev.bms||{};
if(!b.voltage){
body.innerHTML=`
<div style="text-align:center;padding:40px 20px;color:#7d97ad">
<div style="font-size:48px;margin-bottom:12px"></div>
<div style="font-size:16px;font-weight:600;color:#e8f1f8;margin-bottom:6px">Aguardando dados da bateria...</div>
<div style="font-size:13px">O BMS deve responder em alguns segundos. Se demorar, toque <strong>🔄 Re-ler</strong> abaixo.</div>
</div>`;
return;
}
const isCharging=b.current>0;
const isDischarging=b.current<0;
const power=Math.abs(b.voltage*b.current).toFixed(0);
const flowText=isCharging?'⚡ CARREGANDO':isDischarging?'↓ DESCARGA':'— REPOUSO';
const flowColor=isCharging?'#10b981':isDischarging?'#f59e0b':'#7d97ad';
const socColor=b.soc<20?'#ef4444':b.soc<50?'#f59e0b':'#10b981';
// Tempo restante (descarga) ou tempo até cheia (carga)
let timeRemaining='';
if(isDischarging&&b.remainCap){
const hours=b.remainCap/Math.abs(b.current);
timeRemaining=hours>1?`~${hours.toFixed(1)}h restantes`:`~${Math.round(hours*60)}min restantes`;
}else if(isCharging&&b.totalCap&&b.remainCap){
const hoursToFull=(b.totalCap-b.remainCap)/b.current;
timeRemaining=hoursToFull>0?`~${hoursToFull.toFixed(1)}h até cheia`:'Quase cheia';
}
const cellsBlock=b.cells&&b.cells.length>0?`
<div style="margin-top:20px">
<div style="font-size:11px;color:#7d97ad;letter-spacing:.14em;margin-bottom:8px;text-transform:uppercase">Células (${b.cells.length}S)</div>
<div style="display:grid;grid-template-columns:repeat(${Math.min(b.cells.length,4)},1fr);gap:6px">
${b.cells.map((v,i)=>{
const c=v<3.0?'#ef4444':v<3.3?'#f59e0b':v>3.6?'#10b981':'#06b6d4';
return `<div style="background:#0d2538;border-radius:6px;padding:8px;text-align:center;border:1px solid rgba(255,255,255,.06)">
<div style="font-size:9px;color:#7d97ad">C${i+1}</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:13px;font-weight:700;color:${c}">${v.toFixed(3)}V</div>
</div>`;
}).join('')}
</div>
</div>`:'';
body.innerHTML=`
<!-- SoC big circle -->
<div style="text-align:center;margin-bottom:24px">
<div style="position:relative;display:inline-block;width:160px;height:160px">
<svg viewBox="0 0 160 160" style="transform:rotate(-90deg)">
<circle cx="80" cy="80" r="72" fill="none" stroke="rgba(255,255,255,.08)" stroke-width="14"/>
<circle cx="80" cy="80" r="72" fill="none" stroke="${socColor}" stroke-width="14" stroke-linecap="round"
stroke-dasharray="${(2*Math.PI*72).toFixed(0)}"
stroke-dashoffset="${((2*Math.PI*72)*(1-b.soc/100)).toFixed(0)}"
style="transition:stroke-dashoffset 1s ease,stroke 0.5s"/>
</svg>
<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center">
<div style="font-family:'JetBrains Mono',monospace;font-size:42px;font-weight:700;color:${socColor};line-height:1">${b.soc||'?'}<span style="font-size:18px">%</span></div>
<div style="font-size:10px;color:#7d97ad;letter-spacing:.18em;text-transform:uppercase;margin-top:4px">Estado de Carga</div>
</div>
</div>
</div>
<!-- Status flow -->
<div style="text-align:center;margin-bottom:20px;font-family:'JetBrains Mono',monospace;font-size:14px;color:${flowColor};letter-spacing:.14em;font-weight:700">${flowText}${timeRemaining?' · '+timeRemaining:''}</div>
<!-- Stats grid -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:14px">
<div style="background:#0d2538;border-radius:10px;padding:14px;border-left:3px solid #06b6d4">
<div style="font-size:10px;color:#7d97ad;letter-spacing:.14em;text-transform:uppercase">Tensão Total</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:24px;font-weight:700;color:#e8f1f8;margin-top:4px">${b.voltage.toFixed(2)}<span style="font-size:14px;color:#7d97ad"> V</span></div>
</div>
<div style="background:#0d2538;border-radius:10px;padding:14px;border-left:3px solid ${flowColor}">
<div style="font-size:10px;color:#7d97ad;letter-spacing:.14em;text-transform:uppercase">Corrente</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:24px;font-weight:700;color:${flowColor};margin-top:4px">${b.current.toFixed(2)}<span style="font-size:14px;color:#7d97ad"> A</span></div>
</div>
<div style="background:#0d2538;border-radius:10px;padding:14px;border-left:3px solid #06b6d4">
<div style="font-size:10px;color:#7d97ad;letter-spacing:.14em;text-transform:uppercase">Potência</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:24px;font-weight:700;color:#e8f1f8;margin-top:4px">${power}<span style="font-size:14px;color:#7d97ad"> W</span></div>
</div>
<div style="background:#0d2538;border-radius:10px;padding:14px;border-left:3px solid #06b6d4">
<div style="font-size:10px;color:#7d97ad;letter-spacing:.14em;text-transform:uppercase">Capacidade</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:24px;font-weight:700;color:#e8f1f8;margin-top:4px">${b.remainCap?b.remainCap.toFixed(1):'?'}<span style="font-size:14px;color:#7d97ad"> /${b.totalCap?b.totalCap.toFixed(0):'?'}Ah</span></div>
</div>
</div>
<!-- Bottom info row -->
<div style="display:flex;justify-content:space-between;font-family:'JetBrains Mono',monospace;font-size:11px;color:#b3c5d6;background:#0d2538;padding:10px 14px;border-radius:8px">
<span>♻ ${b.cycles||0} ciclos</span>
<span>${b.temps&&b.temps.length>0?'🌡 '+b.temps.map(t=>t+'°C').join(', '):''}</span>
<span>v${b.swVersion||'?'}</span>
</div>
${cellsBlock}
<div style="text-align:center;margin-top:14px;font-size:10px;color:#7d97ad">Última leitura: ${new Date(b.lastRead).toLocaleTimeString('pt-BR')} · auto-refresh 10s</div>
`;
}
// ============ RAYMARINE / NMEA 2000 GATEWAY ============ // ============ RAYMARINE / NMEA 2000 GATEWAY ============
// Slot pra quando Karlão tiver gateway NMEA 2000 → WiFi (Yacht Devices YDWG-02, etc) // Slot pra quando Karlão tiver gateway NMEA 2000 → WiFi (Yacht Devices YDWG-02, etc)

View file

@ -7,8 +7,8 @@ android {
applicationId "br.com.pontualtech.shivao" applicationId "br.com.pontualtech.shivao"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 16 versionCode 17
versionName "1.10.0" versionName "1.10.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // 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", "name": "shivao-mobile",
"version": "1.10.0", "version": "1.10.1",
"description": "Shivao app nativo (Capacitor wrapper Android/iOS)", "description": "Shivao app nativo (Capacitor wrapper Android/iOS)",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",

View file

@ -2542,6 +2542,21 @@ Hora: {HORA}</textarea>
</div> </div>
</div> </div>
<!-- BMS Dashboard Modal (monitor de bordo) -->
<div class="modal-backdrop" id="bms-dashboard-modal">
<div class="modal" style="max-width:560px;background:#0a1f30;color:#e8f1f8">
<div class="modal-head" style="border-bottom:1px solid rgba(255,255,255,.08);background:#0d2538">
<h3 id="bms-dash-title" style="color:#06b6d4;font-weight:700;letter-spacing:.04em">⚡ Monitor da Bateria</h3>
<button class="icon-btn" onclick="closeModal('bms-dashboard-modal')" style="color:#e8f1f8"></button>
</div>
<div class="modal-body" id="bms-dash-body" style="padding:20px;background:#0a1f30"></div>
<div class="modal-foot" style="background:#0d2538;border-top:1px solid rgba(255,255,255,.08)">
<button class="btn" id="bms-dash-refresh" onclick="bmsManualRead(window._currentDashDeviceId);setTimeout(renderBmsDashboard,2500)">🔄 Re-ler</button>
<button class="btn" onclick="closeModal('bms-dashboard-modal')">Fechar</button>
</div>
</div>
</div>
<!-- Welcome / Login Screen --> <!-- Welcome / Login Screen -->
<div class="welcome-screen" id="welcome-screen" style="display:none"> <div class="welcome-screen" id="welcome-screen" style="display:none">
<div class="welcome-card"> <div class="welcome-card">
@ -5983,21 +5998,32 @@ async function bmsAttachJBD(deviceId,deviceName){
const ble=window.Capacitor.Plugins.BluetoothLe; const ble=window.Capacitor.Plugins.BluetoothLe;
try{ try{
setBleDiag('Detectando JBD BMS protocol...','info'); setBleDiag('Detectando JBD BMS protocol...','info');
// Subscribe nas notificações // Subscribe nas notificações com listener registrado ANTES de start
await ble.startNotifications({deviceId,service:BMS_JBD_SERVICE,characteristic:BMS_JBD_NOTIFY}); const listenerKey='notification|'+deviceId+'|'+BMS_JBD_SERVICE+'|'+BMS_JBD_NOTIFY;
ble.addListener('notification|'+deviceId+'|'+BMS_JBD_SERVICE+'|'+BMS_JBD_NOTIFY,(ev)=>{ ble.addListener(listenerKey,(ev)=>{
const dv=parseDataView(ev.value); const dv=parseDataView(ev.value);
const hex=Array.from(new Uint8Array(dv.buffer)).map(b=>b.toString(16).padStart(2,'0')).join(' ');
setBleDiag('← RX '+dv.byteLength+' bytes: '+hex.slice(0,80)+(hex.length>80?'...':''),'info');
bmsHandleChunk(deviceId,dv,deviceName); bmsHandleChunk(deviceId,dv,deviceName);
}); });
setBleDiag('Notify ff01 OK · enviando query...','ok'); await ble.startNotifications({deviceId,service:BMS_JBD_SERVICE,characteristic:BMS_JBD_NOTIFY});
// Solicita dados básicos setBleDiag('Notify ff01 ativo · aguardando 500ms...','ok');
await new Promise(r=>setTimeout(r,500)); // alguns BMS precisam wake-up
setBleDiag('→ TX comando 0x03 (basic info)','info');
await bmsQueryBasic(deviceId); await bmsQueryBasic(deviceId);
// Re-poll a cada 30s
const dev=state.btDevices?.find(d=>d.id===deviceId); const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev){ if(dev){
dev.isJBD=true; dev.isJBD=true;
if(dev._pollInterval)clearInterval(dev._pollInterval); if(dev._pollInterval)clearInterval(dev._pollInterval);
} }
// Se em 5s não chegou resposta, tenta com writeWithoutResponse
setTimeout(async()=>{
const dev2=state.btDevices?.find(d=>d.id===deviceId);
if(dev2&&!dev2.bms?.voltage){
setBleDiag('Sem resposta · tentando writeWithoutResponse...','warn');
await bmsQueryBasic(deviceId,true).catch(e=>setBleDiag('writeWoR falhou: '+e.message,'err'));
}
},5000);
setInterval(()=>bmsQueryBasic(deviceId).catch(()=>{}),30000); setInterval(()=>bmsQueryBasic(deviceId).catch(()=>{}),30000);
return true; return true;
}catch(e){ }catch(e){
@ -6006,10 +6032,26 @@ async function bmsAttachJBD(deviceId,deviceName){
} }
} }
async function bmsQueryBasic(deviceId){ async function bmsQueryBasic(deviceId,withoutResponse){
const ble=window.Capacitor?.Plugins?.BluetoothLe; const ble=window.Capacitor?.Plugins?.BluetoothLe;
if(!ble)return; if(!ble)return;
await ble.write({deviceId,service:BMS_JBD_SERVICE,characteristic:BMS_JBD_WRITE,value:bytesToBase64(BMS_CMD_BASIC)}); const fn=withoutResponse?'writeWithoutResponse':'write';
await ble[fn]({deviceId,service:BMS_JBD_SERVICE,characteristic:BMS_JBD_WRITE,value:bytesToBase64(BMS_CMD_BASIC)});
}
// Re-leitura manual a partir do botão UI
async function bmsManualRead(deviceId){
setBleDiag('🔄 Re-leitura manual...','info');
try{
await bmsQueryBasic(deviceId);
setTimeout(async()=>{
const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev&&!dev.bms?.voltage){
setBleDiag('Tentando writeWithoutResponse...','warn');
await bmsQueryBasic(deviceId,true);
}
},3000);
}catch(e){setBleDiag('Manual read falhou: '+(e.message||e.errorMessage),'err')}
} }
function bmsHandleChunk(deviceId,dv,deviceName){ function bmsHandleChunk(deviceId,dv,deviceName){
@ -6185,7 +6227,9 @@ function renderBluetoothCard(){
${bat!=null?`<span style="color:${batColor};font-weight:700">${bat}%</span> · `:''}${isConnected?'conectado':'offline'}${d.manufacturer?` · ${escapeHtml(d.manufacturer)}`:''}${d.model?` ${escapeHtml(d.model)}`:''} ${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>
</div> </div>
${d.isJBD?`<button class="btn btn-sm btn-primary" onclick="openBmsDashboard('${d.id}')" title="Abrir monitor">📊</button>`:''}
${!isConnected?`<button class="btn btn-sm" onclick="reconnectBluetoothDevice('${d.id}')" title="Reconectar"></button>`:''} ${!isConnected?`<button class="btn btn-sm" onclick="reconnectBluetoothDevice('${d.id}')" title="Reconectar"></button>`:''}
${d.isJBD?`<button class="btn btn-sm" onclick="bmsManualRead('${d.id}')" title="Re-ler">🔄</button>`:''}
<button class="btn btn-sm btn-danger" onclick="removeBluetoothDevice('${d.id}')" title="Remover"></button> <button class="btn btn-sm btn-danger" onclick="removeBluetoothDevice('${d.id}')" title="Remover"></button>
</div> </div>
${bmsBlock} ${bmsBlock}
@ -6196,6 +6240,123 @@ function renderBluetoothCard(){
// Re-render quando entra na aba Mais // Re-render quando entra na aba Mais
function refreshBluetoothCard(){renderBluetoothCard()} function refreshBluetoothCard(){renderBluetoothCard()}
// ============ BMS DASHBOARD (monitor de bordo full-screen) ============
let _dashRefreshTimer=null;
function openBmsDashboard(deviceId){
window._currentDashDeviceId=deviceId;
const dev=state.btDevices?.find(d=>d.id===deviceId);
if(!dev)return;
document.getElementById('bms-dash-title').textContent='⚡ '+(dev.name||'Bateria');
renderBmsDashboard();
openModal('bms-dashboard-modal');
// Auto-refresh a cada 5s enquanto modal aberto
if(_dashRefreshTimer)clearInterval(_dashRefreshTimer);
_dashRefreshTimer=setInterval(()=>{
if(document.getElementById('bms-dashboard-modal')?.classList.contains('show')){
bmsManualRead(deviceId);
setTimeout(renderBmsDashboard,2000);
}else{
clearInterval(_dashRefreshTimer);_dashRefreshTimer=null;
}
},10000);
}
function renderBmsDashboard(){
const id=window._currentDashDeviceId;
const dev=state.btDevices?.find(d=>d.id===id);
const body=document.getElementById('bms-dash-body');
if(!dev||!body)return;
const b=dev.bms||{};
if(!b.voltage){
body.innerHTML=`
<div style="text-align:center;padding:40px 20px;color:#7d97ad">
<div style="font-size:48px;margin-bottom:12px"></div>
<div style="font-size:16px;font-weight:600;color:#e8f1f8;margin-bottom:6px">Aguardando dados da bateria...</div>
<div style="font-size:13px">O BMS deve responder em alguns segundos. Se demorar, toque <strong>🔄 Re-ler</strong> abaixo.</div>
</div>`;
return;
}
const isCharging=b.current>0;
const isDischarging=b.current<0;
const power=Math.abs(b.voltage*b.current).toFixed(0);
const flowText=isCharging?'⚡ CARREGANDO':isDischarging?'↓ DESCARGA':'— REPOUSO';
const flowColor=isCharging?'#10b981':isDischarging?'#f59e0b':'#7d97ad';
const socColor=b.soc<20?'#ef4444':b.soc<50?'#f59e0b':'#10b981';
// Tempo restante (descarga) ou tempo até cheia (carga)
let timeRemaining='';
if(isDischarging&&b.remainCap){
const hours=b.remainCap/Math.abs(b.current);
timeRemaining=hours>1?`~${hours.toFixed(1)}h restantes`:`~${Math.round(hours*60)}min restantes`;
}else if(isCharging&&b.totalCap&&b.remainCap){
const hoursToFull=(b.totalCap-b.remainCap)/b.current;
timeRemaining=hoursToFull>0?`~${hoursToFull.toFixed(1)}h até cheia`:'Quase cheia';
}
const cellsBlock=b.cells&&b.cells.length>0?`
<div style="margin-top:20px">
<div style="font-size:11px;color:#7d97ad;letter-spacing:.14em;margin-bottom:8px;text-transform:uppercase">Células (${b.cells.length}S)</div>
<div style="display:grid;grid-template-columns:repeat(${Math.min(b.cells.length,4)},1fr);gap:6px">
${b.cells.map((v,i)=>{
const c=v<3.0?'#ef4444':v<3.3?'#f59e0b':v>3.6?'#10b981':'#06b6d4';
return `<div style="background:#0d2538;border-radius:6px;padding:8px;text-align:center;border:1px solid rgba(255,255,255,.06)">
<div style="font-size:9px;color:#7d97ad">C${i+1}</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:13px;font-weight:700;color:${c}">${v.toFixed(3)}V</div>
</div>`;
}).join('')}
</div>
</div>`:'';
body.innerHTML=`
<!-- SoC big circle -->
<div style="text-align:center;margin-bottom:24px">
<div style="position:relative;display:inline-block;width:160px;height:160px">
<svg viewBox="0 0 160 160" style="transform:rotate(-90deg)">
<circle cx="80" cy="80" r="72" fill="none" stroke="rgba(255,255,255,.08)" stroke-width="14"/>
<circle cx="80" cy="80" r="72" fill="none" stroke="${socColor}" stroke-width="14" stroke-linecap="round"
stroke-dasharray="${(2*Math.PI*72).toFixed(0)}"
stroke-dashoffset="${((2*Math.PI*72)*(1-b.soc/100)).toFixed(0)}"
style="transition:stroke-dashoffset 1s ease,stroke 0.5s"/>
</svg>
<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center">
<div style="font-family:'JetBrains Mono',monospace;font-size:42px;font-weight:700;color:${socColor};line-height:1">${b.soc||'?'}<span style="font-size:18px">%</span></div>
<div style="font-size:10px;color:#7d97ad;letter-spacing:.18em;text-transform:uppercase;margin-top:4px">Estado de Carga</div>
</div>
</div>
</div>
<!-- Status flow -->
<div style="text-align:center;margin-bottom:20px;font-family:'JetBrains Mono',monospace;font-size:14px;color:${flowColor};letter-spacing:.14em;font-weight:700">${flowText}${timeRemaining?' · '+timeRemaining:''}</div>
<!-- Stats grid -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:14px">
<div style="background:#0d2538;border-radius:10px;padding:14px;border-left:3px solid #06b6d4">
<div style="font-size:10px;color:#7d97ad;letter-spacing:.14em;text-transform:uppercase">Tensão Total</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:24px;font-weight:700;color:#e8f1f8;margin-top:4px">${b.voltage.toFixed(2)}<span style="font-size:14px;color:#7d97ad"> V</span></div>
</div>
<div style="background:#0d2538;border-radius:10px;padding:14px;border-left:3px solid ${flowColor}">
<div style="font-size:10px;color:#7d97ad;letter-spacing:.14em;text-transform:uppercase">Corrente</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:24px;font-weight:700;color:${flowColor};margin-top:4px">${b.current.toFixed(2)}<span style="font-size:14px;color:#7d97ad"> A</span></div>
</div>
<div style="background:#0d2538;border-radius:10px;padding:14px;border-left:3px solid #06b6d4">
<div style="font-size:10px;color:#7d97ad;letter-spacing:.14em;text-transform:uppercase">Potência</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:24px;font-weight:700;color:#e8f1f8;margin-top:4px">${power}<span style="font-size:14px;color:#7d97ad"> W</span></div>
</div>
<div style="background:#0d2538;border-radius:10px;padding:14px;border-left:3px solid #06b6d4">
<div style="font-size:10px;color:#7d97ad;letter-spacing:.14em;text-transform:uppercase">Capacidade</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:24px;font-weight:700;color:#e8f1f8;margin-top:4px">${b.remainCap?b.remainCap.toFixed(1):'?'}<span style="font-size:14px;color:#7d97ad"> /${b.totalCap?b.totalCap.toFixed(0):'?'}Ah</span></div>
</div>
</div>
<!-- Bottom info row -->
<div style="display:flex;justify-content:space-between;font-family:'JetBrains Mono',monospace;font-size:11px;color:#b3c5d6;background:#0d2538;padding:10px 14px;border-radius:8px">
<span>♻ ${b.cycles||0} ciclos</span>
<span>${b.temps&&b.temps.length>0?'🌡 '+b.temps.map(t=>t+'°C').join(', '):''}</span>
<span>v${b.swVersion||'?'}</span>
</div>
${cellsBlock}
<div style="text-align:center;margin-top:14px;font-size:10px;color:#7d97ad">Última leitura: ${new Date(b.lastRead).toLocaleTimeString('pt-BR')} · auto-refresh 10s</div>
`;
}
// ============ RAYMARINE / NMEA 2000 GATEWAY ============ // ============ RAYMARINE / NMEA 2000 GATEWAY ============
// Slot pra quando Karlão tiver gateway NMEA 2000 → WiFi (Yacht Devices YDWG-02, etc) // Slot pra quando Karlão tiver gateway NMEA 2000 → WiFi (Yacht Devices YDWG-02, etc)

View file

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