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
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:
parent
8f3870412d
commit
578793d097
5 changed files with 342 additions and 20 deletions
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue