feat(bms): probe automático de protocolo BMS (JBD/JK/Daly) v1.10.2
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run

Bug v1.10.1: BMS bat2 do Karlão expõe service ff00 mas zero RX no log
após enviar comando JBD 0x03. Significa BMS usa firmware proprietário
não-JBD.

Implementação probe automático:
- Enumera characteristics de cada vendor service (ff00, fff0, ffe0, 0203)
- Lista UUIDs + propriedades (notify/indicate/write/wnr/read) no diagnóstico
- Auto-detecta notify char (com property notify ou indicate)
- Auto-detecta write char (com property write ou writeWithoutResponse)
- Salva config em dev.bmsService/Notify/WriteChar pra reuso
- Subscribe na notify char + listener com hex dump dos chunks RX
- Tenta 4 protocolos sequencialmente (espera 2.5s entre cada):
  1. JBD-0x03 (DD A5 03 00 FF FD 77)
  2. JK-getInfo (AA 55 90 EB 96 00 ... — 20 bytes)
  3. Daly-getInfo (A5 80 90 08 00 00 ... — 13 bytes)
  4. JBD writeWithoutResponse fallback
- Detecta resposta por byte de início (0xDD=JBD, 0xAA=JK, 0xA5=Daly)
- Salva bmsProtocol no device pra usar no poll periódico
- Stubs JK/Daly handlers (parsers específicos virão se BMS responder)

Botão Re-ler agora re-roda probe completo (não só re-envia mesmo comando).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PontualTech / Karlão 2026-04-28 17:24:00 -03:00
parent 578793d097
commit ca3dd4d7b2
5 changed files with 230 additions and 76 deletions

View file

@ -5992,65 +5992,142 @@ function bytesToBase64(arr){
return btoa(bin); return btoa(bin);
} }
async function bmsAttachJBD(deviceId,deviceName){ // Probe: lista characteristics + identifica notify/write chars + tenta protocolos
async function bmsProbeAndAttach(deviceId,deviceName){
const backend=bleBackend(); const backend=bleBackend();
if(backend!=='capacitor'){setBleDiag('JBD parser requer Capacitor (APK)','warn');return false} if(backend!=='capacitor'){setBleDiag('Probe requer Capacitor (APK)','warn');return false}
const ble=window.Capacitor.Plugins.BluetoothLe; const ble=window.Capacitor.Plugins.BluetoothLe;
try{ try{
setBleDiag('Detectando JBD BMS protocol...','info'); setBleDiag('🔍 Enumerando characteristics...','info');
// Subscribe nas notificações com listener registrado ANTES de start // Tenta serviços vendor: ff00, fff0 (Daly), ffe0 (JK), 0203
const listenerKey='notification|'+deviceId+'|'+BMS_JBD_SERVICE+'|'+BMS_JBD_NOTIFY; const VENDOR_SVCS=[
'0000ff00-0000-1000-8000-00805f9b34fb',
'0000fff0-0000-1000-8000-00805f9b34fb',
'0000ffe0-0000-1000-8000-00805f9b34fb',
'00000203-0000-1000-8000-00805f9b34fb',
];
let notifyChar=null,writeChar=null,foundService=null;
for(const svcId of VENDOR_SVCS){
try{
const r=await ble.getServices({deviceId});
const svcs=r.services||r||[];
const svc=svcs.find(s=>(s.uuid||'').toLowerCase()===svcId);
if(!svc)continue;
const chars=svc.characteristics||[];
if(chars.length===0)continue;
setBleDiag(`Service ${svcId.slice(4,8)} · ${chars.length} chars`,'info');
for(const c of chars){
const props=c.properties||{};
const propsStr=[props.notify&&'notify',props.indicate&&'indicate',props.write&&'write',props.writeWithoutResponse&&'wnr',props.read&&'read'].filter(Boolean).join(',');
setBleDiag(` ${(c.uuid||'').slice(4,8)} [${propsStr}]`,'info');
if(!notifyChar&&(props.notify||props.indicate)){notifyChar=c.uuid;foundService=svc.uuid}
if(!writeChar&&(props.write||props.writeWithoutResponse))writeChar=c.uuid;
}
if(notifyChar&&writeChar)break;
}catch(e){}
}
if(!notifyChar||!writeChar){
setBleDiag('Não achei chars notify+write em services vendor','err');
return false;
}
setBleDiag(`Notify=${notifyChar.slice(4,8)} Write=${writeChar.slice(4,8)} Svc=${foundService.slice(4,8)}`,'ok');
// Subscribe + handler
const listenerKey='notification|'+deviceId+'|'+foundService+'|'+notifyChar;
ble.addListener(listenerKey,(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(' '); 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'); setBleDiag('← RX '+dv.byteLength+'b: '+hex.slice(0,100)+(hex.length>100?'...':''),'ok');
bmsHandleChunk(deviceId,dv,deviceName); // Detecta protocolo por byte de início
const first=new Uint8Array(dv.buffer)[0];
if(first===0xDD)bmsHandleChunk(deviceId,dv,deviceName); // JBD
else if(first===0xAA)bmsHandleJK(deviceId,dv,deviceName); // JK BMS
else if(first===0xA5)bmsHandleDaly(deviceId,dv,deviceName); // Daly
}); });
await ble.startNotifications({deviceId,service:BMS_JBD_SERVICE,characteristic:BMS_JBD_NOTIFY}); await ble.startNotifications({deviceId,service:foundService,characteristic:notifyChar});
setBleDiag('Notify ff01 ativo · aguardando 500ms...','ok'); setBleDiag('Notify ativo · aguardando 800ms...','ok');
await new Promise(r=>setTimeout(r,500)); // alguns BMS precisam wake-up await new Promise(r=>setTimeout(r,800));
setBleDiag('→ TX comando 0x03 (basic info)','info'); // Salva config no device pra reuso
await bmsQueryBasic(deviceId);
const dev=state.btDevices?.find(d=>d.id===deviceId); const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev){ if(dev){
dev.bmsService=foundService;
dev.bmsNotifyChar=notifyChar;
dev.bmsWriteChar=writeChar;
dev.isJBD=true; dev.isJBD=true;
if(dev._pollInterval)clearInterval(dev._pollInterval); saveState();
} }
// Se em 5s não chegou resposta, tenta com writeWithoutResponse // Tenta cada protocolo até alguém responder
setTimeout(async()=>{ return await bmsTryProtocols(deviceId);
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);
return true;
}catch(e){ }catch(e){
setBleDiag('JBD attach falhou: '+(e.message||e.errorMessage),'err'); setBleDiag('Probe falhou: '+(e.message||e.errorMessage),'err');
return false; return false;
} }
} }
async function bmsQueryBasic(deviceId,withoutResponse){ async function bmsWriteCmd(deviceId,bytes,withoutResponse){
const ble=window.Capacitor?.Plugins?.BluetoothLe; const ble=window.Capacitor?.Plugins?.BluetoothLe;
if(!ble)return; const dev=state.btDevices?.find(d=>d.id===deviceId);
if(!ble||!dev?.bmsService||!dev?.bmsWriteChar)throw new Error('config BMS ausente');
const fn=withoutResponse?'writeWithoutResponse':'write'; const fn=withoutResponse?'writeWithoutResponse':'write';
await ble[fn]({deviceId,service:BMS_JBD_SERVICE,characteristic:BMS_JBD_WRITE,value:bytesToBase64(BMS_CMD_BASIC)}); await ble[fn]({deviceId,service:dev.bmsService,characteristic:dev.bmsWriteChar,value:bytesToBase64(bytes)});
}
async function bmsTryProtocols(deviceId){
const PROTOCOLS=[
{name:'JBD-0x03',bytes:[0xDD,0xA5,0x03,0x00,0xFF,0xFD,0x77]},
{name:'JK-getInfo',bytes:[0xAA,0x55,0x90,0xEB,0x96,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x10]},
{name:'Daly-getInfo',bytes:[0xA5,0x80,0x90,0x08,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xBD]},
{name:'JBD-write-no-response',bytes:[0xDD,0xA5,0x03,0x00,0xFF,0xFD,0x77],wnr:true},
];
for(const p of PROTOCOLS){
try{
setBleDiag(`→ TX ${p.name}: ${p.bytes.map(b=>b.toString(16).padStart(2,'0')).join(' ').slice(0,40)}`,'info');
await bmsWriteCmd(deviceId,p.bytes,p.wnr);
// Espera 2s pra ver se gerou RX
await new Promise(r=>setTimeout(r,2500));
const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev?.bms?.voltage||dev?._lastRxAt){
setBleDiag(`✓ ${p.name} respondeu!`,'ok');
// Configura poll periódico com este protocolo
if(dev)dev.bmsProtocol=p.name;
setInterval(async()=>{try{await bmsWriteCmd(deviceId,p.bytes,p.wnr)}catch{}},30000);
return true;
}
}catch(e){setBleDiag(`${p.name} falhou: ${e.message||e.errorMessage}`,'warn')}
}
setBleDiag('⚠ Nenhum protocolo funcionou. BMS pode usar firmware proprietário não documentado.','err');
return false;
}
// Stubs pra protocolos JK e Daly (parsers básicos)
function bmsHandleJK(deviceId,dv,deviceName){
const dev=state.btDevices?.find(d=>d.id===deviceId);if(!dev)return;
dev._lastRxAt=Date.now();
// JK protocol: header AA 55 90 EB
// Frame info varia muito por modelo — por hora só confirma RX
setBleDiag('JK BMS frame recebido (parser específico em desenvolvimento)','info');
}
function bmsHandleDaly(deviceId,dv,deviceName){
const dev=state.btDevices?.find(d=>d.id===deviceId);if(!dev)return;
dev._lastRxAt=Date.now();
setBleDiag('Daly BMS frame recebido (parser específico em desenvolvimento)','info');
}
// Compat alias - chamadas antigas viram probe
async function bmsAttachJBD(deviceId,deviceName){
return bmsProbeAndAttach(deviceId,deviceName);
}
async function bmsQueryBasic(deviceId,withoutResponse){
// Usa config descoberta no probe
await bmsWriteCmd(deviceId,BMS_CMD_BASIC,withoutResponse);
} }
// Re-leitura manual a partir do botão UI // Re-leitura manual a partir do botão UI
async function bmsManualRead(deviceId){ async function bmsManualRead(deviceId){
setBleDiag('🔄 Re-leitura manual...','info'); setBleDiag('🔄 Re-leitura manual · re-rodando probe completo...','info');
try{ try{
await bmsQueryBasic(deviceId); // Re-roda probe completo (lista chars de novo + tenta protocolos)
setTimeout(async()=>{ await bmsProbeAndAttach(deviceId,state.btDevices?.find(d=>d.id===deviceId)?.name||'BMS');
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')} }catch(e){setBleDiag('Manual read falhou: '+(e.message||e.errorMessage),'err')}
} }

View file

@ -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 17 versionCode 18
versionName "1.10.1" versionName "1.10.2"
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.

View file

@ -1,6 +1,6 @@
{ {
"name": "shivao-mobile", "name": "shivao-mobile",
"version": "1.10.1", "version": "1.10.2",
"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",

View file

@ -5992,65 +5992,142 @@ function bytesToBase64(arr){
return btoa(bin); return btoa(bin);
} }
async function bmsAttachJBD(deviceId,deviceName){ // Probe: lista characteristics + identifica notify/write chars + tenta protocolos
async function bmsProbeAndAttach(deviceId,deviceName){
const backend=bleBackend(); const backend=bleBackend();
if(backend!=='capacitor'){setBleDiag('JBD parser requer Capacitor (APK)','warn');return false} if(backend!=='capacitor'){setBleDiag('Probe requer Capacitor (APK)','warn');return false}
const ble=window.Capacitor.Plugins.BluetoothLe; const ble=window.Capacitor.Plugins.BluetoothLe;
try{ try{
setBleDiag('Detectando JBD BMS protocol...','info'); setBleDiag('🔍 Enumerando characteristics...','info');
// Subscribe nas notificações com listener registrado ANTES de start // Tenta serviços vendor: ff00, fff0 (Daly), ffe0 (JK), 0203
const listenerKey='notification|'+deviceId+'|'+BMS_JBD_SERVICE+'|'+BMS_JBD_NOTIFY; const VENDOR_SVCS=[
'0000ff00-0000-1000-8000-00805f9b34fb',
'0000fff0-0000-1000-8000-00805f9b34fb',
'0000ffe0-0000-1000-8000-00805f9b34fb',
'00000203-0000-1000-8000-00805f9b34fb',
];
let notifyChar=null,writeChar=null,foundService=null;
for(const svcId of VENDOR_SVCS){
try{
const r=await ble.getServices({deviceId});
const svcs=r.services||r||[];
const svc=svcs.find(s=>(s.uuid||'').toLowerCase()===svcId);
if(!svc)continue;
const chars=svc.characteristics||[];
if(chars.length===0)continue;
setBleDiag(`Service ${svcId.slice(4,8)} · ${chars.length} chars`,'info');
for(const c of chars){
const props=c.properties||{};
const propsStr=[props.notify&&'notify',props.indicate&&'indicate',props.write&&'write',props.writeWithoutResponse&&'wnr',props.read&&'read'].filter(Boolean).join(',');
setBleDiag(` ${(c.uuid||'').slice(4,8)} [${propsStr}]`,'info');
if(!notifyChar&&(props.notify||props.indicate)){notifyChar=c.uuid;foundService=svc.uuid}
if(!writeChar&&(props.write||props.writeWithoutResponse))writeChar=c.uuid;
}
if(notifyChar&&writeChar)break;
}catch(e){}
}
if(!notifyChar||!writeChar){
setBleDiag('Não achei chars notify+write em services vendor','err');
return false;
}
setBleDiag(`Notify=${notifyChar.slice(4,8)} Write=${writeChar.slice(4,8)} Svc=${foundService.slice(4,8)}`,'ok');
// Subscribe + handler
const listenerKey='notification|'+deviceId+'|'+foundService+'|'+notifyChar;
ble.addListener(listenerKey,(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(' '); 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'); setBleDiag('← RX '+dv.byteLength+'b: '+hex.slice(0,100)+(hex.length>100?'...':''),'ok');
bmsHandleChunk(deviceId,dv,deviceName); // Detecta protocolo por byte de início
const first=new Uint8Array(dv.buffer)[0];
if(first===0xDD)bmsHandleChunk(deviceId,dv,deviceName); // JBD
else if(first===0xAA)bmsHandleJK(deviceId,dv,deviceName); // JK BMS
else if(first===0xA5)bmsHandleDaly(deviceId,dv,deviceName); // Daly
}); });
await ble.startNotifications({deviceId,service:BMS_JBD_SERVICE,characteristic:BMS_JBD_NOTIFY}); await ble.startNotifications({deviceId,service:foundService,characteristic:notifyChar});
setBleDiag('Notify ff01 ativo · aguardando 500ms...','ok'); setBleDiag('Notify ativo · aguardando 800ms...','ok');
await new Promise(r=>setTimeout(r,500)); // alguns BMS precisam wake-up await new Promise(r=>setTimeout(r,800));
setBleDiag('→ TX comando 0x03 (basic info)','info'); // Salva config no device pra reuso
await bmsQueryBasic(deviceId);
const dev=state.btDevices?.find(d=>d.id===deviceId); const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev){ if(dev){
dev.bmsService=foundService;
dev.bmsNotifyChar=notifyChar;
dev.bmsWriteChar=writeChar;
dev.isJBD=true; dev.isJBD=true;
if(dev._pollInterval)clearInterval(dev._pollInterval); saveState();
} }
// Se em 5s não chegou resposta, tenta com writeWithoutResponse // Tenta cada protocolo até alguém responder
setTimeout(async()=>{ return await bmsTryProtocols(deviceId);
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);
return true;
}catch(e){ }catch(e){
setBleDiag('JBD attach falhou: '+(e.message||e.errorMessage),'err'); setBleDiag('Probe falhou: '+(e.message||e.errorMessage),'err');
return false; return false;
} }
} }
async function bmsQueryBasic(deviceId,withoutResponse){ async function bmsWriteCmd(deviceId,bytes,withoutResponse){
const ble=window.Capacitor?.Plugins?.BluetoothLe; const ble=window.Capacitor?.Plugins?.BluetoothLe;
if(!ble)return; const dev=state.btDevices?.find(d=>d.id===deviceId);
if(!ble||!dev?.bmsService||!dev?.bmsWriteChar)throw new Error('config BMS ausente');
const fn=withoutResponse?'writeWithoutResponse':'write'; const fn=withoutResponse?'writeWithoutResponse':'write';
await ble[fn]({deviceId,service:BMS_JBD_SERVICE,characteristic:BMS_JBD_WRITE,value:bytesToBase64(BMS_CMD_BASIC)}); await ble[fn]({deviceId,service:dev.bmsService,characteristic:dev.bmsWriteChar,value:bytesToBase64(bytes)});
}
async function bmsTryProtocols(deviceId){
const PROTOCOLS=[
{name:'JBD-0x03',bytes:[0xDD,0xA5,0x03,0x00,0xFF,0xFD,0x77]},
{name:'JK-getInfo',bytes:[0xAA,0x55,0x90,0xEB,0x96,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x10]},
{name:'Daly-getInfo',bytes:[0xA5,0x80,0x90,0x08,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xBD]},
{name:'JBD-write-no-response',bytes:[0xDD,0xA5,0x03,0x00,0xFF,0xFD,0x77],wnr:true},
];
for(const p of PROTOCOLS){
try{
setBleDiag(`→ TX ${p.name}: ${p.bytes.map(b=>b.toString(16).padStart(2,'0')).join(' ').slice(0,40)}`,'info');
await bmsWriteCmd(deviceId,p.bytes,p.wnr);
// Espera 2s pra ver se gerou RX
await new Promise(r=>setTimeout(r,2500));
const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev?.bms?.voltage||dev?._lastRxAt){
setBleDiag(`✓ ${p.name} respondeu!`,'ok');
// Configura poll periódico com este protocolo
if(dev)dev.bmsProtocol=p.name;
setInterval(async()=>{try{await bmsWriteCmd(deviceId,p.bytes,p.wnr)}catch{}},30000);
return true;
}
}catch(e){setBleDiag(`${p.name} falhou: ${e.message||e.errorMessage}`,'warn')}
}
setBleDiag('⚠ Nenhum protocolo funcionou. BMS pode usar firmware proprietário não documentado.','err');
return false;
}
// Stubs pra protocolos JK e Daly (parsers básicos)
function bmsHandleJK(deviceId,dv,deviceName){
const dev=state.btDevices?.find(d=>d.id===deviceId);if(!dev)return;
dev._lastRxAt=Date.now();
// JK protocol: header AA 55 90 EB
// Frame info varia muito por modelo — por hora só confirma RX
setBleDiag('JK BMS frame recebido (parser específico em desenvolvimento)','info');
}
function bmsHandleDaly(deviceId,dv,deviceName){
const dev=state.btDevices?.find(d=>d.id===deviceId);if(!dev)return;
dev._lastRxAt=Date.now();
setBleDiag('Daly BMS frame recebido (parser específico em desenvolvimento)','info');
}
// Compat alias - chamadas antigas viram probe
async function bmsAttachJBD(deviceId,deviceName){
return bmsProbeAndAttach(deviceId,deviceName);
}
async function bmsQueryBasic(deviceId,withoutResponse){
// Usa config descoberta no probe
await bmsWriteCmd(deviceId,BMS_CMD_BASIC,withoutResponse);
} }
// Re-leitura manual a partir do botão UI // Re-leitura manual a partir do botão UI
async function bmsManualRead(deviceId){ async function bmsManualRead(deviceId){
setBleDiag('🔄 Re-leitura manual...','info'); setBleDiag('🔄 Re-leitura manual · re-rodando probe completo...','info');
try{ try{
await bmsQueryBasic(deviceId); // Re-roda probe completo (lista chars de novo + tenta protocolos)
setTimeout(async()=>{ await bmsProbeAndAttach(deviceId,state.btDevices?.find(d=>d.id===deviceId)?.name||'BMS');
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')} }catch(e){setBleDiag('Manual read falhou: '+(e.message||e.errorMessage),'err')}
} }

View file

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