feat(bms): parser JBD/LLT Power BMS — voltagem, corrente, SOC, células, temps v1.10.0
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run

Identificado pelo diagnóstico: BMS do Karlão (bat2) usa protocolo JBD
(Jiabaida) — service ff00, notify ff01, write ff02. Padrão de mercado
para BMS chineses (Overkill Solar, Hankzor, JBD oficial, LLT Power,
Xiaoxiang) — cobre ~80% dos BMS BLE de lítio.

Implementação:
- Auto-detect: ao parear, se device tem service ff00 → ativa parser JBD
- bmsAttachJBD() subscribe na char ff01 (notify) + envia comando 0x03
- Comando: DD A5 03 00 FF FD 77 (Read Basic Info)
- Reassembly de chunks BLE (max 20 bytes/chunk) até receber 0x77 (end)
- Parser decodifica: voltage (uint16/100), current (int16/100, signed),
  remaining/total capacity (Ah), cycle count, protection bitfield,
  SoC (%), FET status, cell count, temperatures (kelvin*10 → °C)
- Re-poll a cada 30s pra atualizar dados em tempo real
- Auto-sync lastBattery com BMS soc pra card resumido

UI expandida:
- Card BMS com 3 stats grandes: TENSÃO (V) · CORRENTE (A) · POTÊNCIA (W)
- Cor dinâmica: verde se carregando (current>0), amarelo se descarregando
- Linha extra: status flow + capacidade (remain/total Ah) + ciclos + temps
- Block de células individuais (4S/8S/16S detectado automaticamente)
- Border-left do card colorido conforme estado de fluxo

Protocolo de referência: gitlab.com/bms-tools/bms-tools

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PontualTech / Karlão 2026-04-28 16:56:48 -03:00
parent 5dd3362469
commit 8f3870412d
5 changed files with 352 additions and 28 deletions

View file

@ -5843,7 +5843,14 @@ async function pairBluetoothDevice(){
}
saveState();
renderBluetoothCard();
// Se detectou JBD BMS, ativa parser proprietário
if(info.isJBD){
const ok=await bmsAttachJBD(deviceId,deviceName);
if(ok)toast('✓ '+deviceName+' · JBD BMS ativo');
else toast('✓ '+deviceName+' (BMS detectado mas falha no parser)');
}else{
toast('✓ '+deviceName+(info.battery!=null?' · '+info.battery+'%':' (sem leitura de bateria)'));
}
}catch(e){
if(e.name==='NotFoundError'||/cancel/i.test(e.message||'')){setBleDiag('Cancelado','warn');return}
const msg=e.message||e.errorMessage||JSON.stringify(e).slice(0,100)||'erro desconhecido';
@ -5869,12 +5876,18 @@ async function connectAndRead(deviceId,deviceName){
const conn=_bleConnections.get(deviceId)||{};
conn.backend='capacitor';conn.deviceId=deviceId;conn.connected=true;
_bleConnections.set(deviceId,conn);
// Discover all services pra diagnóstico
// Discover all services pra diagnóstico + auto-detect protocols
try{
const r=await ble.getServices({deviceId});
const svcs=(r.services||r||[]).map(s=>s.uuid||s).slice(0,8);
setBleDiag('Serviços encontrados: '+svcs.length+' ('+svcs.map(u=>u.slice(4,8)).join(', ')+')','info');
info.services=svcs;
// Auto-detect: service ff00 = JBD/LLT Power BMS
const hasJbd=svcs.some(u=>u.toLowerCase().startsWith('0000ff00'));
if(hasJbd){
setBleDiag('🔋 JBD BMS protocol detectado!','ok');
info.isJBD=true;
}
}catch(e){setBleDiag('getServices falhou: '+e.message,'warn')}
// Battery
try{
@ -5947,6 +5960,122 @@ function parseDataView(v){
return new DataView(new ArrayBuffer(0));
}
// ============ JBD / LLT Power / Xiaoxiang BMS PARSER ============
// Protocolo público: https://gitlab.com/bms-tools/bms-tools/-/blob/master/JBD_REGISTER_MAP.md
// Service: ff00 (vendor) · Notify char: ff01 · Write char: ff02
const BMS_JBD_SERVICE='0000ff00-0000-1000-8000-00805f9b34fb';
const BMS_JBD_NOTIFY ='0000ff01-0000-1000-8000-00805f9b34fb';
const BMS_JBD_WRITE ='0000ff02-0000-1000-8000-00805f9b34fb';
// Comandos (DD A5 [cmd] 00 FF [checksum] 77)
const BMS_CMD_BASIC =[0xDD,0xA5,0x03,0x00,0xFF,0xFD,0x77];
const BMS_CMD_CELLS =[0xDD,0xA5,0x04,0x00,0xFF,0xFC,0x77];
const _bmsBuffers=new Map(); // deviceId → Uint8Array (acumula chunks BLE de 20 bytes)
function bytesToBase64(arr){
let bin='';for(const b of arr)bin+=String.fromCharCode(b);
return btoa(bin);
}
async function bmsAttachJBD(deviceId,deviceName){
const backend=bleBackend();
if(backend!=='capacitor'){setBleDiag('JBD parser requer Capacitor (APK)','warn');return false}
const ble=window.Capacitor.Plugins.BluetoothLe;
try{
setBleDiag('Detectando JBD BMS protocol...','info');
// Subscribe nas notificações
await ble.startNotifications({deviceId,service:BMS_JBD_SERVICE,characteristic:BMS_JBD_NOTIFY});
ble.addListener('notification|'+deviceId+'|'+BMS_JBD_SERVICE+'|'+BMS_JBD_NOTIFY,(ev)=>{
const dv=parseDataView(ev.value);
bmsHandleChunk(deviceId,dv,deviceName);
});
setBleDiag('Notify ff01 OK · enviando query...','ok');
// Solicita dados básicos
await bmsQueryBasic(deviceId);
// Re-poll a cada 30s
const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev){
dev.isJBD=true;
if(dev._pollInterval)clearInterval(dev._pollInterval);
}
setInterval(()=>bmsQueryBasic(deviceId).catch(()=>{}),30000);
return true;
}catch(e){
setBleDiag('JBD attach falhou: '+(e.message||e.errorMessage),'err');
return false;
}
}
async function bmsQueryBasic(deviceId){
const ble=window.Capacitor?.Plugins?.BluetoothLe;
if(!ble)return;
await ble.write({deviceId,service:BMS_JBD_SERVICE,characteristic:BMS_JBD_WRITE,value:bytesToBase64(BMS_CMD_BASIC)});
}
function bmsHandleChunk(deviceId,dv,deviceName){
// BLE max 20 bytes por chunk. Acumula até receber pacote completo (termina em 0x77)
const buf=_bmsBuffers.get(deviceId)||new Uint8Array(0);
const chunk=new Uint8Array(dv.buffer,dv.byteOffset,dv.byteLength);
const merged=new Uint8Array(buf.length+chunk.length);
merged.set(buf);merged.set(chunk,buf.length);
// Verifica se terminou
if(merged[merged.length-1]===0x77 && merged[0]===0xDD){
_bmsBuffers.delete(deviceId);
bmsParse(deviceId,merged,deviceName);
}else{
_bmsBuffers.set(deviceId,merged);
}
}
function bmsParse(deviceId,bytes,deviceName){
if(bytes.length<7||bytes[0]!==0xDD)return;
const cmd=bytes[1];
const status=bytes[2];
if(status!==0x00){setBleDiag('BMS retornou erro 0x'+status.toString(16),'warn');return}
const dataLen=bytes[3];
const data=bytes.slice(4,4+dataLen);
const dev=state.btDevices?.find(d=>d.id===deviceId);
if(!dev)return;
if(!dev.bms)dev.bms={};
if(cmd===0x03){
// Basic info
const dv=new DataView(data.buffer,data.byteOffset,data.byteLength);
dev.bms.voltage=dv.getUint16(0,false)/100; // V
dev.bms.current=dv.getInt16(2,false)/100; // A (positivo=carga, negativo=descarga)
dev.bms.remainCap=dv.getUint16(4,false)/100; // Ah
dev.bms.totalCap=dv.getUint16(6,false)/100; // Ah
dev.bms.cycles=dv.getUint16(8,false);
dev.bms.protectionStatus=dv.getUint16(16,false);
dev.bms.swVersion=data[18];
dev.bms.soc=data[19]; // % estado de carga
dev.bms.fetStatus=data[20]; // bit0=charge MOS, bit1=discharge MOS
dev.bms.cellCount=data[21];
dev.bms.ntcCount=data[22];
// Temperaturas (uint16 cada, kelvin*10)
dev.bms.temps=[];
for(let i=0;i<dev.bms.ntcCount;i++){
const k=dv.getUint16(23+i*2,false)/10;
dev.bms.temps.push(+(k-273.15).toFixed(1));
}
dev.bms.lastRead=Date.now();
// Sincroniza nivel de bateria padrão pro card resumido
dev.lastBattery=dev.bms.soc;
dev.lastSeen=Date.now();
saveState();
renderBluetoothCard();
setBleDiag(`BMS lido · ${dev.bms.voltage}V · ${dev.bms.current}A · ${dev.bms.soc}% · ${dev.bms.cellCount}S · ${dev.bms.temps[0]||'?'}°C`,'ok');
}else if(cmd===0x04){
// Cell voltages (uint16 mV each)
const dv=new DataView(data.buffer,data.byteOffset,data.byteLength);
dev.bms.cells=[];
for(let i=0;i<data.length;i+=2){
dev.bms.cells.push(dv.getUint16(i,false)/1000);
}
saveState();
renderBluetoothCard();
}
}
async function reconnectBluetoothDevice(id){
const dev=state.btDevices?.find(d=>d.id===id);
if(!dev){toast('Dispositivo não encontrado');return}
@ -6013,20 +6142,53 @@ function renderBluetoothCard(){
}
el.innerHTML=devices.map(d=>{
const conn=_bleConnections.get(d.id);
const isConnected=conn?.device?.gatt?.connected;
const isConnected=conn?.connected||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">
const batIcon=bat==null?'❓':bat<20?'🪫':'🔋';
// BMS extra info se disponível
let bmsBlock='';
if(d.bms&&d.bms.voltage!=null){
const b=d.bms;
const isCharging=b.current>0;
const isDischarging=b.current<0;
const power=Math.abs(b.voltage*b.current).toFixed(0);
const flow=isCharging?'⚡ Carregando':isDischarging?'↓ Descarga':'— Idle';
const flowColor=isCharging?'var(--m-ok,#10b981)':isDischarging?'var(--m-warn,#f59e0b)':'var(--m-text-soft,#7d97ad)';
const cellsHtml=b.cells&&b.cells.length>0
? `<div style="margin-top:8px;font-family:var(--f-mono);font-size:10px;color:var(--m-text-soft,#7d97ad);line-height:1.6">Células: ${b.cells.map(v=>v.toFixed(3)+'V').join(' · ')}</div>`
: '';
const tempsHtml=b.temps&&b.temps.length>0
? ` · 🌡 ${b.temps.map(t=>t+'°C').join(', ')}`
: '';
bmsBlock=`
<div style="margin-top:10px;padding:10px;background:var(--m-bg,#0d2538);border-radius:6px;border-left:3px solid ${flowColor}">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;font-family:var(--f-mono);font-size:13px">
<div><div style="font-size:9px;color:var(--m-text-soft);letter-spacing:.1em">TENSÃO</div><div style="color:var(--m-text);font-weight:700;font-size:16px">${b.voltage.toFixed(2)}V</div></div>
<div><div style="font-size:9px;color:var(--m-text-soft);letter-spacing:.1em">CORRENTE</div><div style="color:${flowColor};font-weight:700;font-size:16px">${b.current.toFixed(2)}A</div></div>
<div><div style="font-size:9px;color:var(--m-text-soft);letter-spacing:.1em">POTÊNCIA</div><div style="color:var(--m-accent,#06b6d4);font-weight:700;font-size:16px">${power}W</div></div>
</div>
<div style="margin-top:8px;display:flex;justify-content:space-between;font-family:var(--f-mono);font-size:11px;color:var(--m-text-mid,#b3c5d6)">
<span>${flow}</span>
<span>${b.remainCap?b.remainCap.toFixed(1)+'/'+b.totalCap.toFixed(0)+'Ah':''}</span>
<span>♻ ${b.cycles||0} ciclos${tempsHtml}</span>
</div>
${cellsHtml}
</div>`;
}
return `<div class="bt-device" style="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="display:flex;align-items:center;gap:12px">
<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-weight:600;color:var(--m-text,#e8f1f8);font-size:14px">${escapeHtml(d.name)}${d.isJBD?' · 🔋 JBD BMS':''}</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>
${bmsBlock}
</div>`;
}).join('');
}

View file

@ -7,8 +7,8 @@ android {
applicationId "br.com.pontualtech.shivao"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 15
versionName "1.9.2"
versionCode 16
versionName "1.10.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.9.2",
"version": "1.10.0",
"description": "Shivao app nativo (Capacitor wrapper Android/iOS)",
"main": "index.js",
"type": "module",

View file

@ -5843,7 +5843,14 @@ async function pairBluetoothDevice(){
}
saveState();
renderBluetoothCard();
// Se detectou JBD BMS, ativa parser proprietário
if(info.isJBD){
const ok=await bmsAttachJBD(deviceId,deviceName);
if(ok)toast('✓ '+deviceName+' · JBD BMS ativo');
else toast('✓ '+deviceName+' (BMS detectado mas falha no parser)');
}else{
toast('✓ '+deviceName+(info.battery!=null?' · '+info.battery+'%':' (sem leitura de bateria)'));
}
}catch(e){
if(e.name==='NotFoundError'||/cancel/i.test(e.message||'')){setBleDiag('Cancelado','warn');return}
const msg=e.message||e.errorMessage||JSON.stringify(e).slice(0,100)||'erro desconhecido';
@ -5869,12 +5876,18 @@ async function connectAndRead(deviceId,deviceName){
const conn=_bleConnections.get(deviceId)||{};
conn.backend='capacitor';conn.deviceId=deviceId;conn.connected=true;
_bleConnections.set(deviceId,conn);
// Discover all services pra diagnóstico
// Discover all services pra diagnóstico + auto-detect protocols
try{
const r=await ble.getServices({deviceId});
const svcs=(r.services||r||[]).map(s=>s.uuid||s).slice(0,8);
setBleDiag('Serviços encontrados: '+svcs.length+' ('+svcs.map(u=>u.slice(4,8)).join(', ')+')','info');
info.services=svcs;
// Auto-detect: service ff00 = JBD/LLT Power BMS
const hasJbd=svcs.some(u=>u.toLowerCase().startsWith('0000ff00'));
if(hasJbd){
setBleDiag('🔋 JBD BMS protocol detectado!','ok');
info.isJBD=true;
}
}catch(e){setBleDiag('getServices falhou: '+e.message,'warn')}
// Battery
try{
@ -5947,6 +5960,122 @@ function parseDataView(v){
return new DataView(new ArrayBuffer(0));
}
// ============ JBD / LLT Power / Xiaoxiang BMS PARSER ============
// Protocolo público: https://gitlab.com/bms-tools/bms-tools/-/blob/master/JBD_REGISTER_MAP.md
// Service: ff00 (vendor) · Notify char: ff01 · Write char: ff02
const BMS_JBD_SERVICE='0000ff00-0000-1000-8000-00805f9b34fb';
const BMS_JBD_NOTIFY ='0000ff01-0000-1000-8000-00805f9b34fb';
const BMS_JBD_WRITE ='0000ff02-0000-1000-8000-00805f9b34fb';
// Comandos (DD A5 [cmd] 00 FF [checksum] 77)
const BMS_CMD_BASIC =[0xDD,0xA5,0x03,0x00,0xFF,0xFD,0x77];
const BMS_CMD_CELLS =[0xDD,0xA5,0x04,0x00,0xFF,0xFC,0x77];
const _bmsBuffers=new Map(); // deviceId → Uint8Array (acumula chunks BLE de 20 bytes)
function bytesToBase64(arr){
let bin='';for(const b of arr)bin+=String.fromCharCode(b);
return btoa(bin);
}
async function bmsAttachJBD(deviceId,deviceName){
const backend=bleBackend();
if(backend!=='capacitor'){setBleDiag('JBD parser requer Capacitor (APK)','warn');return false}
const ble=window.Capacitor.Plugins.BluetoothLe;
try{
setBleDiag('Detectando JBD BMS protocol...','info');
// Subscribe nas notificações
await ble.startNotifications({deviceId,service:BMS_JBD_SERVICE,characteristic:BMS_JBD_NOTIFY});
ble.addListener('notification|'+deviceId+'|'+BMS_JBD_SERVICE+'|'+BMS_JBD_NOTIFY,(ev)=>{
const dv=parseDataView(ev.value);
bmsHandleChunk(deviceId,dv,deviceName);
});
setBleDiag('Notify ff01 OK · enviando query...','ok');
// Solicita dados básicos
await bmsQueryBasic(deviceId);
// Re-poll a cada 30s
const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev){
dev.isJBD=true;
if(dev._pollInterval)clearInterval(dev._pollInterval);
}
setInterval(()=>bmsQueryBasic(deviceId).catch(()=>{}),30000);
return true;
}catch(e){
setBleDiag('JBD attach falhou: '+(e.message||e.errorMessage),'err');
return false;
}
}
async function bmsQueryBasic(deviceId){
const ble=window.Capacitor?.Plugins?.BluetoothLe;
if(!ble)return;
await ble.write({deviceId,service:BMS_JBD_SERVICE,characteristic:BMS_JBD_WRITE,value:bytesToBase64(BMS_CMD_BASIC)});
}
function bmsHandleChunk(deviceId,dv,deviceName){
// BLE max 20 bytes por chunk. Acumula até receber pacote completo (termina em 0x77)
const buf=_bmsBuffers.get(deviceId)||new Uint8Array(0);
const chunk=new Uint8Array(dv.buffer,dv.byteOffset,dv.byteLength);
const merged=new Uint8Array(buf.length+chunk.length);
merged.set(buf);merged.set(chunk,buf.length);
// Verifica se terminou
if(merged[merged.length-1]===0x77 && merged[0]===0xDD){
_bmsBuffers.delete(deviceId);
bmsParse(deviceId,merged,deviceName);
}else{
_bmsBuffers.set(deviceId,merged);
}
}
function bmsParse(deviceId,bytes,deviceName){
if(bytes.length<7||bytes[0]!==0xDD)return;
const cmd=bytes[1];
const status=bytes[2];
if(status!==0x00){setBleDiag('BMS retornou erro 0x'+status.toString(16),'warn');return}
const dataLen=bytes[3];
const data=bytes.slice(4,4+dataLen);
const dev=state.btDevices?.find(d=>d.id===deviceId);
if(!dev)return;
if(!dev.bms)dev.bms={};
if(cmd===0x03){
// Basic info
const dv=new DataView(data.buffer,data.byteOffset,data.byteLength);
dev.bms.voltage=dv.getUint16(0,false)/100; // V
dev.bms.current=dv.getInt16(2,false)/100; // A (positivo=carga, negativo=descarga)
dev.bms.remainCap=dv.getUint16(4,false)/100; // Ah
dev.bms.totalCap=dv.getUint16(6,false)/100; // Ah
dev.bms.cycles=dv.getUint16(8,false);
dev.bms.protectionStatus=dv.getUint16(16,false);
dev.bms.swVersion=data[18];
dev.bms.soc=data[19]; // % estado de carga
dev.bms.fetStatus=data[20]; // bit0=charge MOS, bit1=discharge MOS
dev.bms.cellCount=data[21];
dev.bms.ntcCount=data[22];
// Temperaturas (uint16 cada, kelvin*10)
dev.bms.temps=[];
for(let i=0;i<dev.bms.ntcCount;i++){
const k=dv.getUint16(23+i*2,false)/10;
dev.bms.temps.push(+(k-273.15).toFixed(1));
}
dev.bms.lastRead=Date.now();
// Sincroniza nivel de bateria padrão pro card resumido
dev.lastBattery=dev.bms.soc;
dev.lastSeen=Date.now();
saveState();
renderBluetoothCard();
setBleDiag(`BMS lido · ${dev.bms.voltage}V · ${dev.bms.current}A · ${dev.bms.soc}% · ${dev.bms.cellCount}S · ${dev.bms.temps[0]||'?'}°C`,'ok');
}else if(cmd===0x04){
// Cell voltages (uint16 mV each)
const dv=new DataView(data.buffer,data.byteOffset,data.byteLength);
dev.bms.cells=[];
for(let i=0;i<data.length;i+=2){
dev.bms.cells.push(dv.getUint16(i,false)/1000);
}
saveState();
renderBluetoothCard();
}
}
async function reconnectBluetoothDevice(id){
const dev=state.btDevices?.find(d=>d.id===id);
if(!dev){toast('Dispositivo não encontrado');return}
@ -6013,20 +6142,53 @@ function renderBluetoothCard(){
}
el.innerHTML=devices.map(d=>{
const conn=_bleConnections.get(d.id);
const isConnected=conn?.device?.gatt?.connected;
const isConnected=conn?.connected||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">
const batIcon=bat==null?'❓':bat<20?'🪫':'🔋';
// BMS extra info se disponível
let bmsBlock='';
if(d.bms&&d.bms.voltage!=null){
const b=d.bms;
const isCharging=b.current>0;
const isDischarging=b.current<0;
const power=Math.abs(b.voltage*b.current).toFixed(0);
const flow=isCharging?'⚡ Carregando':isDischarging?'↓ Descarga':'— Idle';
const flowColor=isCharging?'var(--m-ok,#10b981)':isDischarging?'var(--m-warn,#f59e0b)':'var(--m-text-soft,#7d97ad)';
const cellsHtml=b.cells&&b.cells.length>0
? `<div style="margin-top:8px;font-family:var(--f-mono);font-size:10px;color:var(--m-text-soft,#7d97ad);line-height:1.6">Células: ${b.cells.map(v=>v.toFixed(3)+'V').join(' · ')}</div>`
: '';
const tempsHtml=b.temps&&b.temps.length>0
? ` · 🌡 ${b.temps.map(t=>t+'°C').join(', ')}`
: '';
bmsBlock=`
<div style="margin-top:10px;padding:10px;background:var(--m-bg,#0d2538);border-radius:6px;border-left:3px solid ${flowColor}">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;font-family:var(--f-mono);font-size:13px">
<div><div style="font-size:9px;color:var(--m-text-soft);letter-spacing:.1em">TENSÃO</div><div style="color:var(--m-text);font-weight:700;font-size:16px">${b.voltage.toFixed(2)}V</div></div>
<div><div style="font-size:9px;color:var(--m-text-soft);letter-spacing:.1em">CORRENTE</div><div style="color:${flowColor};font-weight:700;font-size:16px">${b.current.toFixed(2)}A</div></div>
<div><div style="font-size:9px;color:var(--m-text-soft);letter-spacing:.1em">POTÊNCIA</div><div style="color:var(--m-accent,#06b6d4);font-weight:700;font-size:16px">${power}W</div></div>
</div>
<div style="margin-top:8px;display:flex;justify-content:space-between;font-family:var(--f-mono);font-size:11px;color:var(--m-text-mid,#b3c5d6)">
<span>${flow}</span>
<span>${b.remainCap?b.remainCap.toFixed(1)+'/'+b.totalCap.toFixed(0)+'Ah':''}</span>
<span>♻ ${b.cycles||0} ciclos${tempsHtml}</span>
</div>
${cellsHtml}
</div>`;
}
return `<div class="bt-device" style="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="display:flex;align-items:center;gap:12px">
<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-weight:600;color:var(--m-text,#e8f1f8);font-size:14px">${escapeHtml(d.name)}${d.isJBD?' · 🔋 JBD BMS':''}</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>
${bmsBlock}
</div>`;
}).join('');
}

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.9.2/Shivao-v1.9.2.apk';
const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.10.0/Shivao-v1.10.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)