- ${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
+
+
+
+ ${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;i
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 `
-
${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
+
+
+
+ ${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)