diff --git a/app/diario-bordo.html b/app/diario-bordo.html index 5375287..f7ec915 100644 --- a/app/diario-bordo.html +++ b/app/diario-bordo.html @@ -5843,7 +5843,14 @@ async function pairBluetoothDevice(){ } saveState(); renderBluetoothCard(); - toast('✓ '+deviceName+(info.battery!=null?' · '+info.battery+'%':' (sem leitura de bateria)')); + // 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;id.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 `
-
${batIcon}
-
-
${escapeHtml(d.name)}
-
- ${bat!=null?`${bat}% · `:''}${isConnected?'conectado':'offline'}${d.manufacturer?` · ${escapeHtml(d.manufacturer)}`:''}${d.model?` ${escapeHtml(d.model)}`:''} + 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 + ? `
Células: ${b.cells.map(v=>v.toFixed(3)+'V').join(' · ')}
` + : ''; + const tempsHtml=b.temps&&b.temps.length>0 + ? ` · 🌡 ${b.temps.map(t=>t+'°C').join(', ')}` + : ''; + bmsBlock=` +
+
+
TENSÃO
${b.voltage.toFixed(2)}V
+
CORRENTE
${b.current.toFixed(2)}A
+
POTÊNCIA
${power}W
+
+
+ ${flow} + ${b.remainCap?b.remainCap.toFixed(1)+'/'+b.totalCap.toFixed(0)+'Ah':''} + ♻ ${b.cycles||0} ciclos${tempsHtml} +
+ ${cellsHtml} +
`; + } + return `
+
+
${batIcon}
+
+
${escapeHtml(d.name)}${d.isJBD?' · 🔋 JBD BMS':''}
+
+ ${bat!=null?`${bat}% · `:''}${isConnected?'conectado':'offline'}${d.manufacturer?` · ${escapeHtml(d.manufacturer)}`:''}${d.model?` ${escapeHtml(d.model)}`:''} +
+ ${!isConnected?``:''} +
- ${!isConnected?``:''} - + ${bmsBlock}
`; }).join(''); } diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 778d4fd..2214bc1 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -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. diff --git a/mobile/package.json b/mobile/package.json index e8685a6..6c500e2 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -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", diff --git a/server/public/index.html b/server/public/index.html index 5375287..f7ec915 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -5843,7 +5843,14 @@ async function pairBluetoothDevice(){ } saveState(); renderBluetoothCard(); - toast('✓ '+deviceName+(info.battery!=null?' · '+info.battery+'%':' (sem leitura de bateria)')); + // 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;id.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 `
-
${batIcon}
-
-
${escapeHtml(d.name)}
-
- ${bat!=null?`${bat}% · `:''}${isConnected?'conectado':'offline'}${d.manufacturer?` · ${escapeHtml(d.manufacturer)}`:''}${d.model?` ${escapeHtml(d.model)}`:''} + 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 + ? `
Células: ${b.cells.map(v=>v.toFixed(3)+'V').join(' · ')}
` + : ''; + const tempsHtml=b.temps&&b.temps.length>0 + ? ` · 🌡 ${b.temps.map(t=>t+'°C').join(', ')}` + : ''; + bmsBlock=` +
+
+
TENSÃO
${b.voltage.toFixed(2)}V
+
CORRENTE
${b.current.toFixed(2)}A
+
POTÊNCIA
${power}W
+
+
+ ${flow} + ${b.remainCap?b.remainCap.toFixed(1)+'/'+b.totalCap.toFixed(0)+'Ah':''} + ♻ ${b.cycles||0} ciclos${tempsHtml} +
+ ${cellsHtml} +
`; + } + return `
+
+
${batIcon}
+
+
${escapeHtml(d.name)}${d.isJBD?' · 🔋 JBD BMS':''}
+
+ ${bat!=null?`${bat}% · `:''}${isConnected?'conectado':'offline'}${d.manufacturer?` · ${escapeHtml(d.manufacturer)}`:''}${d.model?` ${escapeHtml(d.model)}`:''} +
+ ${!isConnected?``:''} +
- ${!isConnected?``:''} - + ${bmsBlock}
`; }).join(''); } diff --git a/server/src/index.js b/server/src/index.js index 4b28ad2..811ae72 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -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)