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
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:
parent
5dd3362469
commit
8f3870412d
5 changed files with 352 additions and 28 deletions
|
|
@ -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('');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue