fix(ble): plugin nativo @capacitor-community/bluetooth-le pra APK Android v1.9.1
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run

Bug v1.9.0: APK Android mostrava "Web Bluetooth não suportado" porque
Android System WebView desabilita Web Bluetooth API por padrão (segurança).

Fix: instala plugin @capacitor-community/bluetooth-le@^6.1.0 (compatível
com Capacitor 6) que expõe API nativa Android/iOS. JS detecta backend:
- Capacitor (APK): usa window.Capacitor.Plugins.BluetoothLe
- Browser web: usa navigator.bluetooth (Chrome PC continua funcionando)

Mudanças:
- mobile/package.json: nova dep @capacitor-community/bluetooth-le ^6.1.0
- AndroidManifest.xml: BLUETOOTH_SCAN (neverForLocation), BLUETOOTH_CONNECT,
  BLUETOOTH/BLUETOOTH_ADMIN (Android ≤30), uses-feature bluetooth_le
- bleBackend() detecta runtime, ensureBleNativeReady() inicializa plugin
- pairBluetoothDevice + connectAndRead + reconnect + remove abstraem backend
- UUIDs em formato 128-bit (compatível com ambos)
- parseDataView helper: plugin envia value como base64, web envia DataView

iOS: plugin suporta nativamente — quando build iOS for feito, funciona.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PontualTech / Karlão 2026-04-28 16:24:50 -03:00
parent a6a35c6d6f
commit 52ee668879
7 changed files with 369 additions and 156 deletions

View file

@ -5742,120 +5742,213 @@ function testChartCfg(){
toast('Cartas salvas — abra um mapa pra ver'); toast('Cartas salvas — abra um mapa pra ver');
} }
// ============ BLUETOOTH (Web Bluetooth API — Battery Service genérico) ============ // ============ BLUETOOTH (Web Bluetooth + Capacitor native plugin) ============
// Funciona em Chrome PC + Chrome Android. NÃO funciona em Safari (iOS). // 2 backends: navigator.bluetooth (Chrome PC/Android web) + Capacitor BluetoothLe (APK Android/iOS)
// Pra suporte iOS: precisa plugin nativo @capacitor/community/bluetooth-le (próxima versão). // UUIDs em formato 128-bit (compatível com ambos backends)
const BLE_BATTERY_SERVICE='battery_service'; // 0x180F const BLE_BATTERY_SERVICE='0000180f-0000-1000-8000-00805f9b34fb'; // 0x180F
const BLE_BATTERY_CHAR='battery_level'; // 0x2A19 const BLE_BATTERY_CHAR='00002a19-0000-1000-8000-00805f9b34fb'; // 0x2A19
const BLE_DEVICE_INFO='device_information'; // 0x180A const BLE_DEVICE_INFO='0000180a-0000-1000-8000-00805f9b34fb'; // 0x180A
const BLE_OPTIONAL_SERVICES=['device_information']; const BLE_MANUFACTURER_CHAR='00002a29-0000-1000-8000-00805f9b34fb';
const BLE_MODEL_CHAR='00002a24-0000-1000-8000-00805f9b34fb';
const _bleConnections=new Map(); // id → {device, server, batteryChar} const _bleConnections=new Map(); // id → {device, server, batteryChar}
function bleSupported(){return !!navigator.bluetooth} // Detecta backend: Capacitor nativo se disponível, senão Web Bluetooth
function bleBackend(){
if(window.Capacitor?.Plugins?.BluetoothLe)return 'capacitor';
if(navigator.bluetooth)return 'web';
return null;
}
function bleSupported(){return bleBackend()!==null}
let _bleNativeInitialized=false;
async function ensureBleNativeReady(){
if(_bleNativeInitialized)return;
const ble=window.Capacitor?.Plugins?.BluetoothLe;
if(!ble)return;
await ble.initialize({androidNeverForLocation:true});
_bleNativeInitialized=true;
}
async function pairBluetoothDevice(){ async function pairBluetoothDevice(){
if(!bleSupported()){ const backend=bleBackend();
toast('Web Bluetooth não suportado neste navegador. Use Chrome no PC ou Android.'); if(!backend){
toast('Bluetooth indisponível neste dispositivo.');
return; return;
} }
try{ try{
let deviceId,deviceName;
if(backend==='capacitor'){
await ensureBleNativeReady();
const ble=window.Capacitor.Plugins.BluetoothLe;
const result=await ble.requestDevice({
services:[],
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO],
allowDuplicates:false,
});
if(!result?.deviceId){return}
deviceId=result.deviceId;
deviceName=result.name||'Dispositivo BLE';
}else{
const device=await navigator.bluetooth.requestDevice({ const device=await navigator.bluetooth.requestDevice({
// Filtro permissivo: aceita qualquer device com Battery Service OU qualquer device que o usuário escolher
acceptAllDevices:true, acceptAllDevices:true,
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO], optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO],
}); });
if(!device){return} if(!device){return}
deviceId=device.id;
deviceName=device.name||'Dispositivo BLE';
_bleConnections.set(deviceId,{device,backend:'web'});
device.addEventListener('gattserverdisconnected',()=>{
const c=_bleConnections.get(deviceId);if(c)c.connected=false;
renderBluetoothCard();
});
}
// Conecta + lê info inicial // Conecta + lê info inicial
const info=await connectAndRead(device); const info=await connectAndRead(deviceId,deviceName);
// Salva no state // Salva no state
if(!state.btDevices)state.btDevices=[]; if(!state.btDevices)state.btDevices=[];
const existing=state.btDevices.find(d=>d.id===device.id); const existing=state.btDevices.find(d=>d.id===deviceId);
if(existing){ if(existing){
Object.assign(existing,{name:device.name||existing.name,lastBattery:info.battery,lastSeen:Date.now(),manufacturer:info.manufacturer,model:info.model}); Object.assign(existing,{name:deviceName||existing.name,lastBattery:info.battery,lastSeen:Date.now(),manufacturer:info.manufacturer,model:info.model});
}else{ }else{
state.btDevices.push({ state.btDevices.push({
id:device.id, id:deviceId,name:deviceName,
name:device.name||'Dispositivo BLE', lastBattery:info.battery,lastSeen:Date.now(),
lastBattery:info.battery, manufacturer:info.manufacturer,model:info.model,
lastSeen:Date.now(), addedAt:Date.now(),backend,
manufacturer:info.manufacturer,
model:info.model,
addedAt:Date.now(),
}); });
} }
saveState(); saveState();
renderBluetoothCard(); renderBluetoothCard();
toast('✓ '+(device.name||'Dispositivo')+' pareado'+(info.battery!=null?' · '+info.battery+'%':'')); toast('✓ '+deviceName+' pareado'+(info.battery!=null?' · '+info.battery+'%':''));
// Listener pra desconexão
device.addEventListener('gattserverdisconnected',()=>{
_bleConnections.delete(device.id);
renderBluetoothCard();
});
}catch(e){ }catch(e){
if(e.name==='NotFoundError')return; // user cancelou if(e.name==='NotFoundError'||/cancel/i.test(e.message||''))return;
console.warn('[ble] pair failed',e); console.warn('[ble] pair failed',e);
toast('Erro: '+e.message); toast('Erro: '+(e.message||e.errorMessage||'pareamento falhou'));
} }
} }
async function connectAndRead(device){ async function connectAndRead(deviceId,deviceName){
const info={battery:null,manufacturer:null,model:null}; const info={battery:null,manufacturer:null,model:null};
const backend=bleBackend();
try{ try{
if(backend==='capacitor'){
const ble=window.Capacitor.Plugins.BluetoothLe;
await ble.connect({deviceId,timeout:15000});
const conn=_bleConnections.get(deviceId)||{};
conn.backend='capacitor';conn.deviceId=deviceId;conn.connected=true;
_bleConnections.set(deviceId,conn);
// Battery
try{
const r=await ble.read({deviceId,service:BLE_BATTERY_SERVICE,characteristic:BLE_BATTERY_CHAR});
info.battery=parseDataView(r.value).getUint8(0);
try{
await ble.startNotifications({deviceId,service:BLE_BATTERY_SERVICE,characteristic:BLE_BATTERY_CHAR});
ble.addListener('notification|'+deviceId+'|'+BLE_BATTERY_SERVICE+'|'+BLE_BATTERY_CHAR,(ev)=>{
const newVal=parseDataView(ev.value).getUint8(0);
const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev){dev.lastBattery=newVal;dev.lastSeen=Date.now();saveState();renderBluetoothCard()}
});
}catch(e){}
}catch(e){}
// Device info
try{
const r=await ble.read({deviceId,service:BLE_DEVICE_INFO,characteristic:BLE_MANUFACTURER_CHAR});
info.manufacturer=new TextDecoder().decode(parseDataView(r.value));
}catch(e){}
try{
const r=await ble.read({deviceId,service:BLE_DEVICE_INFO,characteristic:BLE_MODEL_CHAR});
info.model=new TextDecoder().decode(parseDataView(r.value));
}catch(e){}
}else{
const conn=_bleConnections.get(deviceId);
const device=conn?.device;
if(!device)return info;
const server=await device.gatt.connect(); const server=await device.gatt.connect();
_bleConnections.set(device.id,{device,server}); conn.server=server;conn.connected=true;
// Battery Service (opcional)
try{ try{
const svc=await server.getPrimaryService(BLE_BATTERY_SERVICE); const svc=await server.getPrimaryService(BLE_BATTERY_SERVICE);
const batChar=await svc.getCharacteristic(BLE_BATTERY_CHAR); const batChar=await svc.getCharacteristic(BLE_BATTERY_CHAR);
const val=await batChar.readValue(); const val=await batChar.readValue();
info.battery=val.getUint8(0); info.battery=val.getUint8(0);
// Subscribe pra notificações em tempo real
try{ try{
await batChar.startNotifications(); await batChar.startNotifications();
batChar.addEventListener('characteristicvaluechanged',(ev)=>{ batChar.addEventListener('characteristicvaluechanged',(ev)=>{
const newVal=ev.target.value.getUint8(0); const newVal=ev.target.value.getUint8(0);
const dev=state.btDevices?.find(d=>d.id===device.id); const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev){dev.lastBattery=newVal;dev.lastSeen=Date.now();saveState();renderBluetoothCard()} if(dev){dev.lastBattery=newVal;dev.lastSeen=Date.now();saveState();renderBluetoothCard()}
}); });
}catch(e){/* notification not supported */} }catch{}
_bleConnections.get(device.id).batteryChar=batChar; conn.batteryChar=batChar;
}catch(e){/* sem battery service */} }catch{}
// Device Info (opcional)
try{ try{
const svc=await server.getPrimaryService(BLE_DEVICE_INFO); const svc=await server.getPrimaryService(BLE_DEVICE_INFO);
try{const c=await svc.getCharacteristic('manufacturer_name_string');info.manufacturer=new TextDecoder().decode(await c.readValue())}catch{} try{const c=await svc.getCharacteristic(BLE_MANUFACTURER_CHAR);info.manufacturer=new TextDecoder().decode(await c.readValue())}catch{}
try{const c=await svc.getCharacteristic('model_number_string');info.model=new TextDecoder().decode(await c.readValue())}catch{} try{const c=await svc.getCharacteristic(BLE_MODEL_CHAR);info.model=new TextDecoder().decode(await c.readValue())}catch{}
}catch(e){/* sem device info */} }catch{}
}catch(e){console.warn('[ble] connect failed',device.name,e.message)} }
}catch(e){console.warn('[ble] connect failed',deviceName,e.message||e.errorMessage)}
return info; return info;
} }
async function reconnectBluetoothDevice(id){ // Helper: o plugin Capacitor envia value como string base64 ou DataView; converte pra DataView
// Web Bluetooth não persiste. Tenta getDevices() (suportado em alguns Chrome ≥85) function parseDataView(v){
if(!bleSupported())return; if(v instanceof DataView)return v;
if(!navigator.bluetooth.getDevices){ if(typeof v==='string'){
toast('Reconexão automática não suportada — toque "Parear" pra escolher de novo'); // base64 → Uint8Array → DataView
return; const bin=atob(v);
const bytes=new Uint8Array(bin.length);
for(let i=0;i<bin.length;i++)bytes[i]=bin.charCodeAt(i);
return new DataView(bytes.buffer);
} }
if(v?.buffer)return new DataView(v.buffer);
return new DataView(new ArrayBuffer(0));
}
async function reconnectBluetoothDevice(id){
const dev=state.btDevices?.find(d=>d.id===id);
if(!dev){toast('Dispositivo não encontrado');return}
const backend=bleBackend();
if(!backend){toast('Bluetooth indisponível');return}
toast('Reconectando '+dev.name+'...');
try{ try{
const devices=await navigator.bluetooth.getDevices(); if(backend==='capacitor'){
const dev=devices.find(d=>d.id===id); await ensureBleNativeReady();
if(!dev){toast('Device não encontrado nas permissões — repare manualmente');return} await connectAndRead(id,dev.name);
toast('Reconectando '+(dev.name||'dispositivo')+'...');
await connectAndRead(dev);
renderBluetoothCard(); renderBluetoothCard();
}else{
// Web Bluetooth: tenta getDevices() (Chrome ≥85)
if(!navigator.bluetooth.getDevices){
toast('Reconexão automática indisponível — pareie de novo');return;
}
const devices=await navigator.bluetooth.getDevices();
const device=devices.find(d=>d.id===id);
if(!device){toast('Sem permissão p/ esse device — pareie manualmente');return}
_bleConnections.set(id,{device,backend:'web'});
device.addEventListener('gattserverdisconnected',()=>{
const c=_bleConnections.get(id);if(c)c.connected=false;renderBluetoothCard();
});
await connectAndRead(id,device.name);
renderBluetoothCard();
}
}catch(e){ }catch(e){
console.warn('[ble] reconnect failed',e.message); console.warn('[ble] reconnect failed',e.message);
toast('Falha: '+e.message); toast('Falha: '+(e.message||e.errorMessage));
} }
} }
function removeBluetoothDevice(id){ async function removeBluetoothDevice(id){
if(!confirm('Remover dispositivo da lista?'))return; if(!confirm('Remover dispositivo da lista?'))return;
const conn=_bleConnections.get(id); const conn=_bleConnections.get(id);
if(conn?.device?.gatt?.connected)try{conn.device.gatt.disconnect()}catch{} try{
if(conn?.backend==='capacitor'){
const ble=window.Capacitor?.Plugins?.BluetoothLe;
if(ble)await ble.disconnect({deviceId:id}).catch(()=>{});
}else if(conn?.device?.gatt?.connected){
conn.device.gatt.disconnect();
}
}catch{}
_bleConnections.delete(id); _bleConnections.delete(id);
state.btDevices=(state.btDevices||[]).filter(d=>d.id!==id); state.btDevices=(state.btDevices||[]).filter(d=>d.id!==id);
saveState(); saveState();

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 13 versionCode 14
versionName "1.9.0" versionName "1.9.1"
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

@ -51,4 +51,11 @@
<uses-feature android:name="android.hardware.location.gps" android:required="false" /> <uses-feature android:name="android.hardware.location.gps" android:required="false" />
<uses-feature android:name="android.hardware.sensor.barometer" android:required="false" /> <uses-feature android:name="android.hardware.sensor.barometer" android:required="false" />
<uses-feature android:name="android.hardware.sensor.compass" android:required="false" /> <uses-feature android:name="android.hardware.sensor.compass" android:required="false" />
<!-- Bluetooth LE (BMS de bateria, fones, smart shunts) -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
</manifest> </manifest>

View file

@ -1,13 +1,14 @@
{ {
"name": "shivao-mobile", "name": "shivao-mobile",
"version": "1.2.0", "version": "1.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "shivao-mobile", "name": "shivao-mobile",
"version": "1.2.0", "version": "1.9.0",
"dependencies": { "dependencies": {
"@capacitor-community/bluetooth-le": "^6.1.0",
"@capacitor/android": "^6.1.2", "@capacitor/android": "^6.1.2",
"@capacitor/app": "^6.0.1", "@capacitor/app": "^6.0.1",
"@capacitor/core": "^6.1.2", "@capacitor/core": "^6.1.2",
@ -21,6 +22,18 @@
"@capacitor/cli": "^6.1.2" "@capacitor/cli": "^6.1.2"
} }
}, },
"node_modules/@capacitor-community/bluetooth-le": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@capacitor-community/bluetooth-le/-/bluetooth-le-6.1.0.tgz",
"integrity": "sha512-hnNChEwV+xNOVqDYI4bfkQtFtvEyzBMlgYs+6xsLYTJVl0v8h6Hn3nCwjW9l6LH0tMzYaRYlFLCiGHKPHt1N0Q==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.20"
},
"peerDependencies": {
"@capacitor/core": "^6.0.0"
}
},
"node_modules/@capacitor/android": { "node_modules/@capacitor/android": {
"version": "6.2.1", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-6.2.1.tgz", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-6.2.1.tgz",
@ -343,6 +356,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
"license": "MIT"
},
"node_modules/@xmldom/xmldom": { "node_modules/@xmldom/xmldom": {
"version": "0.9.10", "version": "0.9.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",

View file

@ -1,6 +1,6 @@
{ {
"name": "shivao-mobile", "name": "shivao-mobile",
"version": "1.9.0", "version": "1.9.1",
"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",
@ -12,6 +12,7 @@
"ios:open": "npx cap open ios" "ios:open": "npx cap open ios"
}, },
"dependencies": { "dependencies": {
"@capacitor-community/bluetooth-le": "^6.1.0",
"@capacitor/android": "^6.1.2", "@capacitor/android": "^6.1.2",
"@capacitor/app": "^6.0.1", "@capacitor/app": "^6.0.1",
"@capacitor/core": "^6.1.2", "@capacitor/core": "^6.1.2",

View file

@ -5742,120 +5742,213 @@ function testChartCfg(){
toast('Cartas salvas — abra um mapa pra ver'); toast('Cartas salvas — abra um mapa pra ver');
} }
// ============ BLUETOOTH (Web Bluetooth API — Battery Service genérico) ============ // ============ BLUETOOTH (Web Bluetooth + Capacitor native plugin) ============
// Funciona em Chrome PC + Chrome Android. NÃO funciona em Safari (iOS). // 2 backends: navigator.bluetooth (Chrome PC/Android web) + Capacitor BluetoothLe (APK Android/iOS)
// Pra suporte iOS: precisa plugin nativo @capacitor/community/bluetooth-le (próxima versão). // UUIDs em formato 128-bit (compatível com ambos backends)
const BLE_BATTERY_SERVICE='battery_service'; // 0x180F const BLE_BATTERY_SERVICE='0000180f-0000-1000-8000-00805f9b34fb'; // 0x180F
const BLE_BATTERY_CHAR='battery_level'; // 0x2A19 const BLE_BATTERY_CHAR='00002a19-0000-1000-8000-00805f9b34fb'; // 0x2A19
const BLE_DEVICE_INFO='device_information'; // 0x180A const BLE_DEVICE_INFO='0000180a-0000-1000-8000-00805f9b34fb'; // 0x180A
const BLE_OPTIONAL_SERVICES=['device_information']; const BLE_MANUFACTURER_CHAR='00002a29-0000-1000-8000-00805f9b34fb';
const BLE_MODEL_CHAR='00002a24-0000-1000-8000-00805f9b34fb';
const _bleConnections=new Map(); // id → {device, server, batteryChar} const _bleConnections=new Map(); // id → {device, server, batteryChar}
function bleSupported(){return !!navigator.bluetooth} // Detecta backend: Capacitor nativo se disponível, senão Web Bluetooth
function bleBackend(){
if(window.Capacitor?.Plugins?.BluetoothLe)return 'capacitor';
if(navigator.bluetooth)return 'web';
return null;
}
function bleSupported(){return bleBackend()!==null}
let _bleNativeInitialized=false;
async function ensureBleNativeReady(){
if(_bleNativeInitialized)return;
const ble=window.Capacitor?.Plugins?.BluetoothLe;
if(!ble)return;
await ble.initialize({androidNeverForLocation:true});
_bleNativeInitialized=true;
}
async function pairBluetoothDevice(){ async function pairBluetoothDevice(){
if(!bleSupported()){ const backend=bleBackend();
toast('Web Bluetooth não suportado neste navegador. Use Chrome no PC ou Android.'); if(!backend){
toast('Bluetooth indisponível neste dispositivo.');
return; return;
} }
try{ try{
let deviceId,deviceName;
if(backend==='capacitor'){
await ensureBleNativeReady();
const ble=window.Capacitor.Plugins.BluetoothLe;
const result=await ble.requestDevice({
services:[],
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO],
allowDuplicates:false,
});
if(!result?.deviceId){return}
deviceId=result.deviceId;
deviceName=result.name||'Dispositivo BLE';
}else{
const device=await navigator.bluetooth.requestDevice({ const device=await navigator.bluetooth.requestDevice({
// Filtro permissivo: aceita qualquer device com Battery Service OU qualquer device que o usuário escolher
acceptAllDevices:true, acceptAllDevices:true,
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO], optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO],
}); });
if(!device){return} if(!device){return}
deviceId=device.id;
deviceName=device.name||'Dispositivo BLE';
_bleConnections.set(deviceId,{device,backend:'web'});
device.addEventListener('gattserverdisconnected',()=>{
const c=_bleConnections.get(deviceId);if(c)c.connected=false;
renderBluetoothCard();
});
}
// Conecta + lê info inicial // Conecta + lê info inicial
const info=await connectAndRead(device); const info=await connectAndRead(deviceId,deviceName);
// Salva no state // Salva no state
if(!state.btDevices)state.btDevices=[]; if(!state.btDevices)state.btDevices=[];
const existing=state.btDevices.find(d=>d.id===device.id); const existing=state.btDevices.find(d=>d.id===deviceId);
if(existing){ if(existing){
Object.assign(existing,{name:device.name||existing.name,lastBattery:info.battery,lastSeen:Date.now(),manufacturer:info.manufacturer,model:info.model}); Object.assign(existing,{name:deviceName||existing.name,lastBattery:info.battery,lastSeen:Date.now(),manufacturer:info.manufacturer,model:info.model});
}else{ }else{
state.btDevices.push({ state.btDevices.push({
id:device.id, id:deviceId,name:deviceName,
name:device.name||'Dispositivo BLE', lastBattery:info.battery,lastSeen:Date.now(),
lastBattery:info.battery, manufacturer:info.manufacturer,model:info.model,
lastSeen:Date.now(), addedAt:Date.now(),backend,
manufacturer:info.manufacturer,
model:info.model,
addedAt:Date.now(),
}); });
} }
saveState(); saveState();
renderBluetoothCard(); renderBluetoothCard();
toast('✓ '+(device.name||'Dispositivo')+' pareado'+(info.battery!=null?' · '+info.battery+'%':'')); toast('✓ '+deviceName+' pareado'+(info.battery!=null?' · '+info.battery+'%':''));
// Listener pra desconexão
device.addEventListener('gattserverdisconnected',()=>{
_bleConnections.delete(device.id);
renderBluetoothCard();
});
}catch(e){ }catch(e){
if(e.name==='NotFoundError')return; // user cancelou if(e.name==='NotFoundError'||/cancel/i.test(e.message||''))return;
console.warn('[ble] pair failed',e); console.warn('[ble] pair failed',e);
toast('Erro: '+e.message); toast('Erro: '+(e.message||e.errorMessage||'pareamento falhou'));
} }
} }
async function connectAndRead(device){ async function connectAndRead(deviceId,deviceName){
const info={battery:null,manufacturer:null,model:null}; const info={battery:null,manufacturer:null,model:null};
const backend=bleBackend();
try{ try{
if(backend==='capacitor'){
const ble=window.Capacitor.Plugins.BluetoothLe;
await ble.connect({deviceId,timeout:15000});
const conn=_bleConnections.get(deviceId)||{};
conn.backend='capacitor';conn.deviceId=deviceId;conn.connected=true;
_bleConnections.set(deviceId,conn);
// Battery
try{
const r=await ble.read({deviceId,service:BLE_BATTERY_SERVICE,characteristic:BLE_BATTERY_CHAR});
info.battery=parseDataView(r.value).getUint8(0);
try{
await ble.startNotifications({deviceId,service:BLE_BATTERY_SERVICE,characteristic:BLE_BATTERY_CHAR});
ble.addListener('notification|'+deviceId+'|'+BLE_BATTERY_SERVICE+'|'+BLE_BATTERY_CHAR,(ev)=>{
const newVal=parseDataView(ev.value).getUint8(0);
const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev){dev.lastBattery=newVal;dev.lastSeen=Date.now();saveState();renderBluetoothCard()}
});
}catch(e){}
}catch(e){}
// Device info
try{
const r=await ble.read({deviceId,service:BLE_DEVICE_INFO,characteristic:BLE_MANUFACTURER_CHAR});
info.manufacturer=new TextDecoder().decode(parseDataView(r.value));
}catch(e){}
try{
const r=await ble.read({deviceId,service:BLE_DEVICE_INFO,characteristic:BLE_MODEL_CHAR});
info.model=new TextDecoder().decode(parseDataView(r.value));
}catch(e){}
}else{
const conn=_bleConnections.get(deviceId);
const device=conn?.device;
if(!device)return info;
const server=await device.gatt.connect(); const server=await device.gatt.connect();
_bleConnections.set(device.id,{device,server}); conn.server=server;conn.connected=true;
// Battery Service (opcional)
try{ try{
const svc=await server.getPrimaryService(BLE_BATTERY_SERVICE); const svc=await server.getPrimaryService(BLE_BATTERY_SERVICE);
const batChar=await svc.getCharacteristic(BLE_BATTERY_CHAR); const batChar=await svc.getCharacteristic(BLE_BATTERY_CHAR);
const val=await batChar.readValue(); const val=await batChar.readValue();
info.battery=val.getUint8(0); info.battery=val.getUint8(0);
// Subscribe pra notificações em tempo real
try{ try{
await batChar.startNotifications(); await batChar.startNotifications();
batChar.addEventListener('characteristicvaluechanged',(ev)=>{ batChar.addEventListener('characteristicvaluechanged',(ev)=>{
const newVal=ev.target.value.getUint8(0); const newVal=ev.target.value.getUint8(0);
const dev=state.btDevices?.find(d=>d.id===device.id); const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev){dev.lastBattery=newVal;dev.lastSeen=Date.now();saveState();renderBluetoothCard()} if(dev){dev.lastBattery=newVal;dev.lastSeen=Date.now();saveState();renderBluetoothCard()}
}); });
}catch(e){/* notification not supported */} }catch{}
_bleConnections.get(device.id).batteryChar=batChar; conn.batteryChar=batChar;
}catch(e){/* sem battery service */} }catch{}
// Device Info (opcional)
try{ try{
const svc=await server.getPrimaryService(BLE_DEVICE_INFO); const svc=await server.getPrimaryService(BLE_DEVICE_INFO);
try{const c=await svc.getCharacteristic('manufacturer_name_string');info.manufacturer=new TextDecoder().decode(await c.readValue())}catch{} try{const c=await svc.getCharacteristic(BLE_MANUFACTURER_CHAR);info.manufacturer=new TextDecoder().decode(await c.readValue())}catch{}
try{const c=await svc.getCharacteristic('model_number_string');info.model=new TextDecoder().decode(await c.readValue())}catch{} try{const c=await svc.getCharacteristic(BLE_MODEL_CHAR);info.model=new TextDecoder().decode(await c.readValue())}catch{}
}catch(e){/* sem device info */} }catch{}
}catch(e){console.warn('[ble] connect failed',device.name,e.message)} }
}catch(e){console.warn('[ble] connect failed',deviceName,e.message||e.errorMessage)}
return info; return info;
} }
async function reconnectBluetoothDevice(id){ // Helper: o plugin Capacitor envia value como string base64 ou DataView; converte pra DataView
// Web Bluetooth não persiste. Tenta getDevices() (suportado em alguns Chrome ≥85) function parseDataView(v){
if(!bleSupported())return; if(v instanceof DataView)return v;
if(!navigator.bluetooth.getDevices){ if(typeof v==='string'){
toast('Reconexão automática não suportada — toque "Parear" pra escolher de novo'); // base64 → Uint8Array → DataView
return; const bin=atob(v);
const bytes=new Uint8Array(bin.length);
for(let i=0;i<bin.length;i++)bytes[i]=bin.charCodeAt(i);
return new DataView(bytes.buffer);
} }
if(v?.buffer)return new DataView(v.buffer);
return new DataView(new ArrayBuffer(0));
}
async function reconnectBluetoothDevice(id){
const dev=state.btDevices?.find(d=>d.id===id);
if(!dev){toast('Dispositivo não encontrado');return}
const backend=bleBackend();
if(!backend){toast('Bluetooth indisponível');return}
toast('Reconectando '+dev.name+'...');
try{ try{
const devices=await navigator.bluetooth.getDevices(); if(backend==='capacitor'){
const dev=devices.find(d=>d.id===id); await ensureBleNativeReady();
if(!dev){toast('Device não encontrado nas permissões — repare manualmente');return} await connectAndRead(id,dev.name);
toast('Reconectando '+(dev.name||'dispositivo')+'...');
await connectAndRead(dev);
renderBluetoothCard(); renderBluetoothCard();
}else{
// Web Bluetooth: tenta getDevices() (Chrome ≥85)
if(!navigator.bluetooth.getDevices){
toast('Reconexão automática indisponível — pareie de novo');return;
}
const devices=await navigator.bluetooth.getDevices();
const device=devices.find(d=>d.id===id);
if(!device){toast('Sem permissão p/ esse device — pareie manualmente');return}
_bleConnections.set(id,{device,backend:'web'});
device.addEventListener('gattserverdisconnected',()=>{
const c=_bleConnections.get(id);if(c)c.connected=false;renderBluetoothCard();
});
await connectAndRead(id,device.name);
renderBluetoothCard();
}
}catch(e){ }catch(e){
console.warn('[ble] reconnect failed',e.message); console.warn('[ble] reconnect failed',e.message);
toast('Falha: '+e.message); toast('Falha: '+(e.message||e.errorMessage));
} }
} }
function removeBluetoothDevice(id){ async function removeBluetoothDevice(id){
if(!confirm('Remover dispositivo da lista?'))return; if(!confirm('Remover dispositivo da lista?'))return;
const conn=_bleConnections.get(id); const conn=_bleConnections.get(id);
if(conn?.device?.gatt?.connected)try{conn.device.gatt.disconnect()}catch{} try{
if(conn?.backend==='capacitor'){
const ble=window.Capacitor?.Plugins?.BluetoothLe;
if(ble)await ble.disconnect({deviceId:id}).catch(()=>{});
}else if(conn?.device?.gatt?.connected){
conn.device.gatt.disconnect();
}
}catch{}
_bleConnections.delete(id); _bleConnections.delete(id);
state.btDevices=(state.btDevices||[]).filter(d=>d.id!==id); state.btDevices=(state.btDevices||[]).filter(d=>d.id!==id);
saveState(); saveState();

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