Compare commits

...

19 commits

Author SHA1 Message Date
PontualTech / Karlão
5a64e0897f feat(iot): controle Smart Life/Tuya — Casa do Barco v1.12.0
Some checks failed
Build Android (APK + AAB) / build-android (push) Has been cancelled
Servidor (proxy assinado):
- server/src/tuya.js: cliente Tuya OpenAPI com HMAC-SHA256 + token cache
  (2h TTL, retry 1x em token expirado), helpers categoryLabel
- 3 endpoints novos em server/src/index.js (todos requireAuth):
  * GET  /api/iot/devices              → lista devices da conta Smart Life
  * GET  /api/iot/status/:deviceId     → DPs (data points) atuais
  * POST /api/iot/command/:deviceId    → envia comandos {code,value}
- Audit log via db.audit('iot_command', ...) pra histórico de toggles
- 503 graceful quando TUYA_ACCESS_ID/SECRET ausentes

Client (UI):
- Card 🏠 Casa do Barco em Arquivo (após Bluetooth, antes Raymarine)
- Modal "Adicionar dispositivo" lista devices da conta Smart Life,
  permite escolher quais aparecem no Shivão (multi-select via toque)
- Cards por device com ícone por categoria (cz=tomada, dj=lâmpada,
  fs=ventilador, kt=ar-cond, sd=robô, etc.) + toggle ON/OFF + status
  online/offline + tempo desde último ping
- Toggle optimistic UI: marca novo estado imediato, reverte se falhar
- Polling 10s pra sync de status, pausa em background (economiza
  Starlink + bateria)
- Backoff: 3 falhas consec → marca offline, retry 30s
- state.smartDevices[] persistido no localStorage (mesmo padrão btDevices)

Setup (admin, 1x):
- Karlão precisa criar projeto em iot.tuya.com (5 min, gratuito) e
  adicionar TUYA_ACCESS_ID + TUYA_ACCESS_SECRET no env Coolify
- Documentação completa no .env.example com passo a passo
- Sem credenciais: card mostra "⚙ Tuya não configurado"

Bumps:
- APP_VERSION 1.11.0 → 1.12.0
- sw.js VERSION shivao-v1.11.0 → shivao-v1.12.0
- mobile/package.json + build.gradle (versionCode 32→33)
- LATEST_APK_URL atualizado pro release v1.12.0

Fix gitignore:
- .env.example em pastas nested (server/.env.example) estava bloqueado
  por **/.env.* — adicionado !**/.env.example pra liberar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:34:02 -03:00
PontualTech / Karlão
f95f3a145f feat(weather): marés via GPS + alertas tempestade v1.11.0
Some checks failed
Build Android (APK + AAB) / build-android (push) Has been cancelled
Marés (Open-Meteo Marine API, gratuito ilimitado):
- URL marine expandida pra incluir sea_level_height_msl em hourly 48h
- extractTides() faz peak detection (máximo/mínimo local 3 pontos)
- Card ' Marés' anexado ao weather widget mostrando próxima
  preamar e baixamar com hora + delta tempo + altura

Alertas de tempestade:
- checkStormConditions() avalia 7 indicadores:
  * vento sustentado (warn 25kn / danger 35kn)
  * rajadas (warn 30kn / danger 45kn)
  * ondas (warn 2m / danger 3.5m)
  * pressão caindo 3hPa em 3h (tempestade aproximando)
  * CAPE 1500/3000 J/kg (instabilidade atmosférica → trovoada)
- Banner sticky no topo (vermelho danger, amarelo warn) com toque
  pra expandir detalhes
- Push notification via Capacitor LocalNotifications (APK) ou
  Notification API (web)
- Deduplica notif: só dispara se assinatura de alertas mudou
- Permissão pedida no boot (5s delay)

Disparado automaticamente após cada fetchWeather (forecast atualiza
a cada N minutos via maybeAutoFetchWeather).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:21:03 -03:00
PontualTech / Karlão
654e597bf5 feat(bms): polling 5s constante no Web + Capacitor v1.10.19
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Karlão: 'no PC monitoramento deveria ser constante mas conecta, mostra
informação e não atualiza'.

Bug encontrado: bmsProbeWebBluetooth não tinha setInterval — apenas
fazia uma leitura inicial. Apenas o path Capacitor tinha polling de
30s (que mesmo assim era lento).

Fix:
- Web: novo setInterval(5000) chamando writeValue/writeValueWithoutResponse
- Capacitor: 30s → 5s
- Ambos param polling automaticamente se conexão GATT cair (regex
  detecta 'disconnected/connection/not connected' no error)
- Salva referência em conn._pollInterval pra clearInterval limpo

Resultado: card BMS atualiza V/A/SoC/células a cada 5s sem usuário
fazer nada. Dashboard fica 'live'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:17:16 -03:00
PontualTech / Karlão
0c0b2d2825 feat(ble): breadcrumb persistente sobrevive crash WebView v1.10.18
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Karlão reportou: APK fecha 'em seguida' ao mandar parear. v1.10.17
removeu wake-up do path Capacitor mas crash ainda persiste — agora
é em ble.requestDevice ou ble.connect/getServices.

Sem alert popup = crash nativo lado Java do plugin BLE. Try/catch JS
não captura. Solução: breadcrumb em localStorage ANTES de cada
chamada nativa.

bleCrumb(step) grava 'shivao_ble_last_step' no disco antes de:
- ensureBleNativeReady
- requestDevice
- selected:<name>
- ble.connect
- ble.getServices

Se app crashar, próxima abertura lê o breadcrumb e mostra alert
'⚠ Crash detectado · Última ação: ble.connect @ 2026...' — daí
descobrimos exatamente onde o plugin Java explode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:01:23 -03:00
PontualTech / Karlão
70b123735e fix(ble): remove wake-up do path Capacitor (crashava plugin) v1.10.17
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Karlão reportou que toda vez que tenta parear no APK, o app fecha
sem alert popup = crash nativo do plugin BLE Java.

Hipótese: as chamadas extras que adicionei (ble.read no notify char +
ble.write/writeWithoutResponse com 0x5A x4 wake bytes) crashavam o
plugin v6 em algum estado inválido.

Fix: remover wake-up sequence do path Capacitor. Mínimo viável:
1. ble.connect
2. ble.getServices
3. ble.startNotifications
4. delay 800ms
5. write JBD-0x03 com wnr forçado

A descoberta no PC (Web Bluetooth) confirmou que JBD-0x03 direto
(sem wake) já é suficiente — BMS responde com 41 bytes em 3 chunks.

startNotifications agora também envolto em try/catch que retorna
false se falhar (em vez de propagar exception nativa).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:44:16 -03:00
PontualTech / Karlão
ca66a6995f fix(boot): try/catch defensivo + migration btDevices + alert crash v1.10.16
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Karlão reportou: APK v1.10.15 crasha no boot mesmo após desinstalar
e reinstalar. Significa bug no código, não state corrompido.

Fix preventivo:
- Boot IIFE wrapped em try/catch master
- Cada init (loadState, tracking, anchor, battery, sw, sensors, rt,
  gcal) agora em try individual — falha de um não derruba o resto
- Migration defensiva de state.btDevices: filtra entries inválidas
  (null, sem id, etc)
- Se loadState crashar (state corrupto), reseta localStorage
- Crash master no try-catch chama alert() nativo + localStorage.clear()
  pra recovery automático na próxima abertura

Quando Karlão atualizar pra v1.10.16, vai aparecer mensagem específica
do erro (se ainda houver) — daí descubro causa exata em vez de
adivinhar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:39:02 -03:00
PontualTech / Karlão
18bade8768 build: bump APK pra v1.10.15 + LATEST_APK_URL
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
2026-04-29 09:03:05 -03:00
PontualTech / Karlão
56ddca53a4 fix(ble): dispatcher rotea chunks JBD de continuação v1.10.15
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
🎉 BMS RESPONDEU! Log v1.10.14 mostrou 3 chunks chegando do BMS
do Karlão:
- Chunk 1 (20b): dd 03 00 22 05 2b 02 8a... (header JBD + dados)
- Chunk 2 (20b): 00 00 d0 15 03 04 01 0b b7... (continuação)
- Chunk 3 (1b): 77 (end-of-frame)

Decoded: 13.23V (4S LiFePO4), +6.50A carregando, 21% SoC, 175 ciclos,
4 células, 27°C. PROTOCOLO JBD CORRETO.

Bug do parser: dispatcher só chamava bmsHandleChunk se primeiro byte
do chunk atual era 0xDD. Chunks 2 e 3 começam com 0x00 e 0x77 — não
roteados → reassembly nunca completou → dev.bms.voltage ficou null →
bmsTryProtocols viu '✗ JBD-0x03 sem RX'.

Fix em ambos os paths (Web Bluetooth + Capacitor):
- Se _bmsBuffers.has(deviceId), é continuação → roteia direto pra
  bmsHandleChunk
- Buffer vazio: primeiro byte determina protocolo

Próxima rodada deve mostrar 'BMS lido · 13.23V · 6.50A · 21%...' +
dashboard cheio.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:02:10 -03:00
PontualTech / Karlão
4cf670ae76 fix(ble): força write em ff02 mesmo sem property declarada v1.10.14
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
BMS chinês declara ff02 com property [read] apenas, MAS aceita writes
em background. Plugin Capacitor força através (Android stack permite).
Web Bluetooth do Chrome respeita spec — recusa writeValue se char
não tem write/wnr declarado.

Workaround: se nenhuma char tem write declarada, força usar primeira
não-notify (geralmente ff02). Tenta writeValue mesmo assim.
Browser pode lançar SecurityError, mas se BMS aceitar = funciona.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:56:52 -03:00
PontualTech / Karlão
0999da3b51 fix(ble): typo writeWithoutResponses→writeWithoutResponse v1.10.13
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Web Bluetooth log mostrou ff02 com property [read] sozinha, mas no
APK v1.10.7 o plugin reportava [wnr,read]. Discrepância revelou typo:
eu usei p.writeWithoutResponses (com S no final) mas o nome correto
da property em BluetoothCharacteristicProperties é singular —
p.writeWithoutResponse. Sempre undefined → ff02 não detectado como
writeChar → probe abortava com 'Sem chars notify+write'.

Fix: 4 ocorrências em bmsProbeWebBluetooth corrigidas. Plugin
Capacitor por sorte usa o nome diferente (writeWithoutResponse no
nested object) então não foi afetado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:42:19 -03:00
PontualTech / Karlão
a5bb6f5528 fix(sw): bump cache version pra forçar atualização v1.10.12
Karlão estava com v1.10.7 cacheado mesmo com servidor em v1.10.12.
Service Worker estava em v1.7.0 (não bumpado desde então).
Bump força activate event a deletar caches antigos.
2026-04-29 08:39:41 -03:00
PontualTech / Karlão
638ed5e37b feat(ble): probe via Web Bluetooth pro Chrome PC v1.10.12
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Karlão sugeriu testar no notebook (Chrome) onde Web Bluetooth API
é mais madura que plugin Capacitor v6. Implementado bmsProbeWebBluetooth
que usa navigator.bluetooth direto:
- getPrimaryService(ff00)
- getCharacteristics() lista chars
- writeValueWithoutResponse / writeValue conforme properties
- characteristicvaluechanged event listener
- Wake sequence + 3 protocolos JBD/JK/Daly

Quando Web descobrir o protocolo certo, copio a lógica pro path
Capacitor (APK Android também vai funcionar).

requestDevice pra browser web agora inclui ff00/fff0/ffe0 nos
optionalServices pra Web Bluetooth permitir acesso.

Sem APK rebuild (web only) — só deploy backend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:34:24 -03:00
PontualTech / Karlão
330d5aaa62 feat(ble): wake-up Xiaoxiang BMS — read inicial + 5A x4 v1.10.11
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Confirmado: BMS responde ao app oficial Xiaoxiang. Problema é técnica
de inicialização não implementada na nossa abordagem.

Adicionado wake-up sequence ANTES do probe de protocolos:
1. ble.read na notify char (acorda stack BLE)
2. delay 300ms
3. write 0x5A x4 (handshake hello observado em alguns Xiaoxiang)
4. delay 1500ms (BMS processa wake)
5. probe normal de protocolos

Cada step protegido por try/catch + timeout 2s — não trava loop.
Logs detalhados pra ver onde falha se acontecer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:21:41 -03:00
PontualTech / Karlão
24d0162397 fix(ble): write timeout 3s + log entry/exit + loop resiliente v1.10.10
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Log v1.10.9 mostrou que probe parou em '→ TX JBD-0x03' sem testar
próximos 3 protocolos (JK, Daly, JBD-wnr). Causa provável: write
trava mesmo com WNR no plugin v6.

Fix:
- bmsWriteCmd com Promise.race + timeout 3s — write nunca trava
  loop indefinidamente
- bmsTryProtocols agora loga '✔ write retornou' e '✗ sem RX em 2.5s'
  pra distinguir write OK + BMS mudo de write travado
- Try/catch interno no write — se falha, continua pro próximo
  protocolo em vez de abortar loop

Próximo log vai mostrar TODOS os 4 protocolos testados — daí saberemos
se é hardware mudo OU plugin travando.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:11:47 -03:00
PontualTech / Karlão
e5c62e913f feat(api): admin endpoint /_all lista todos diag logs (BOAT_TOKEN only) 2026-04-29 08:07:59 -03:00
PontualTech / Karlão
b81521043e feat(api): GET /api/bms/diag-log e /:file pra ler logs do servidor
Endpoints novos só backend (não muda APK):
- GET /api/bms/diag-log: lista files do user (name, size, mtime, sorted desc)
- GET /api/bms/diag-log/:file: retorna conteúdo plain text
- Path traversal protegido (regex sanitiza nome do arquivo)
- Filtra por user_id (não vê logs de outros users)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:06:11 -03:00
PontualTech / Karlão
9f32428980 feat(ble): envia diag log direto pro servidor (evita crash WebView) v1.10.9
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Karlão reportou que app reinicia ao tentar copiar log (crash em
v1.10.8 também — modal grande + textarea com Unicode pode crashar
WebView Android skinned).

Solução: novo endpoint POST /api/bms/diag-log que aceita texto
do log + auth user, salva em /data/diag-logs/{userId}-{ts}.txt
no servidor. Frontend tem botão '📤 Enviar pro servidor' (verde)
que faz fetch POST. Resposta confirma recebimento.

Eu (Claude) leio o arquivo direto do servidor — sem dependência
de clipboard, share, modal HTML, ou copy manual.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:01:30 -03:00
PontualTech / Karlão
f3183b33d1 fix(ble): copyDiagLog usa modal + share em vez de clipboard API v1.10.8
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Bug v1.10.6/7: clicar 'Copiar log' fechava o app (crash WebView).
Causa: navigator.clipboard.writeText em alguns Android skinned
(Samsung One UI, Xiaomi MIUI) requer permissão extra que sem ela
gera SecurityException nativa não capturada.

Fix: substitui por modal full-screen com textarea readonly +
selectable + botão Compartilhar (navigator.share, mais robusto).
Removido document.execCommand (deprecado, também crashava).

Karlão pode agora: (1) selecionar texto manualmente segurando na
textarea, (2) tocar Compartilhar pra mandar via WhatsApp/email
escolhendo o app no share sheet do Android.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 07:50:45 -03:00
PontualTech / Karlão
6680f8b09b fix(ble): writeWithoutResponse forçado quando ff02 só tem wnr v1.10.7
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
DESCOBERTA crítica do log do Karlão: ff02 (write char) tem properties
[wnr,read] — SÓ writeWithoutResponse, sem write. Meu probe primeiro
tentava ble.write() (com response) que trava silenciosamente esperando
ACK que o BMS nunca envia. Por isso log para em '→ TX JBD-0x03' sem
testar próximos protocolos.

Fix:
- Probe detecta properties da writeChar e seta dev.bmsForceWnr=true
  quando char tem wnr mas não write
- Log mostra '(force-wnr)' ao lado do Write= no diagnóstico
- bmsWriteCmd respeita bmsForceWnr e usa writeWithoutResponse mesmo
  quando o protocol não pediu

Esperado v1.10.7: TX JBD-0x03 com writeWithoutResponse → BMS responde
com pacote 0xDD ... 0x77 → dashboard mostra V/A/SoC/células reais.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 07:45:28 -03:00
11 changed files with 1832 additions and 127 deletions

1
.gitignore vendored
View file

@ -15,6 +15,7 @@ server/data/
!.env.example
**/.env
**/.env.*
!**/.env.example
# OS / IDE
.DS_Store

View file

@ -1979,15 +1979,39 @@ Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção
<div id="bt-list" style="margin-top:14px"></div>
<details style="margin-top:10px" open>
<summary style="cursor:pointer;font-family:var(--f-mono);font-size:11px;color:var(--m-text-soft,#7d97ad);letter-spacing:.06em">📋 Diagnóstico (logs do pareamento)</summary>
<div style="display:flex;gap:6px;margin-top:8px">
<button class="btn btn-sm" onclick="copyDiagLog()" style="flex:1">📋 Copiar log</button>
<button class="btn btn-sm" onclick="document.getElementById('bt-diag').innerHTML='';setBleDiag('Log limpo','info')" style="flex:1">🗑 Limpar</button>
<div style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap">
<button class="btn btn-sm btn-primary" onclick="sendDiagLogToServer()" style="flex:1;min-width:140px">📤 Enviar pro servidor</button>
<button class="btn btn-sm" onclick="document.getElementById('bt-diag').innerHTML='';setBleDiag('Log limpo','info')" style="flex:1;min-width:80px">🗑 Limpar</button>
</div>
<div id="bt-diag" style="background:var(--m-bg-2,#0f2a40);border:1px solid var(--m-border,rgba(255,255,255,.08));border-radius:6px;padding:10px;margin-top:6px;max-height:300px;overflow-y:auto;font-family:var(--f-mono);font-size:11px"></div>
</details>
<div class="field-hint" style="margin-top:8px">Limitações: <strong>iOS Safari não suporta Web Bluetooth</strong>. APK Android usa plugin nativo. BMS proprietários (Victron, JBD) podem aparecer mas não expor Battery Service padrão.</div>
</div>
<!-- Smart Home / Casa do Barco -->
<div class="export-card" id="smart-home-card">
<div class="export-card-title">🏠 Casa do Barco · Dispositivos Smart Life</div>
<div class="export-card-text" style="margin-bottom:10px">Controle lâmpadas, tomadas, ventiladores e qualquer dispositivo do app <strong>Smart Life</strong> (Tuya / Alexa via WiFi 2.4 GHz). Funciona com Starlink ou qualquer internet do barco.</div>
<div id="smart-status" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-bottom:10px;letter-spacing:.04em;line-height:1.55">Verificando configuração...</div>
<button class="btn btn-block btn-primary" onclick="openSmartDeviceModal()">+ Adicionar dispositivo</button>
<div id="smart-devices-list" style="margin-top:14px"></div>
<div class="field-hint" style="margin-top:8px"><strong>Setup</strong> (1x): admin precisa configurar credenciais Tuya no servidor (ver <code>.env.example</code>). Depois disso, qualquer dispositivo no Smart Life aparece aqui automático.</div>
</div>
<!-- Modal: lista dispositivos da conta Tuya pra o usuário escolher -->
<div class="modal-backdrop" id="smart-device-modal" onclick="if(event.target===this)closeModal('smart-device-modal')">
<div class="modal" style="max-width:520px">
<div class="modal-head">
<h3>🏠 Adicionar dispositivo</h3>
<button class="icon-btn" onclick="closeModal('smart-device-modal')"></button>
</div>
<div class="modal-body">
<div id="smart-modal-status" style="font-family:var(--f-mono);font-size:11px;color:var(--sepia);margin-bottom:10px;letter-spacing:.04em">Buscando dispositivos da sua conta Smart Life...</div>
<div id="smart-modal-list" class="fleet-list"></div>
</div>
</div>
</div>
<!-- Raymarine Gateway -->
<div class="export-card" id="nmea-gateway-card">
<div class="export-card-title">⚓ Instrumentos Raymarine (gateway NMEA 2000)</div>
@ -2614,7 +2638,7 @@ Hora: {HORA}</textarea>
<div class="zone-toast" id="zone-toast"></div>
<script>
const state={boat:{name:'Shivao',model:''},boats:[],activeBoatId:null,units:'metric',trips:[],maint:[],pending:[],passengers:[],contacts:[],alarmTemplate:'🚨 ALERTA: Shivao saiu da posição de fundeio.\nEstou em {LAT}, {LNG}\nMapa: https://maps.google.com/?q={LAT},{LNG}\nHora: {HORA}',cloud:{url:'',token:'',lastSync:0},auth:null,license:null,webhooks:{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}},anchorHistory:[],zones:[],checklists:[],weatherCfg:{windyKey:'',model:'gfs'}};
const state={boat:{name:'Shivao',model:''},boats:[],activeBoatId:null,units:'metric',trips:[],maint:[],pending:[],passengers:[],contacts:[],alarmTemplate:'🚨 ALERTA: Shivao saiu da posição de fundeio.\nEstou em {LAT}, {LNG}\nMapa: https://maps.google.com/?q={LAT},{LNG}\nHora: {HORA}',cloud:{url:'',token:'',lastSync:0},auth:null,license:null,webhooks:{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}},anchorHistory:[],zones:[],checklists:[],weatherCfg:{windyKey:'',model:'gfs'},smartDevices:[]};
// ============ NAUTICAL HELPERS (multi-boat + fleet + calculations) ============
// Storage interno SEMPRE em metros (ISO). Conversão só pra display.
@ -3746,23 +3770,284 @@ async function updateStorageInfo(){
}catch(e){document.getElementById('storage-info').textContent='—'}
}
// ============ SMART HOME (Tuya / Smart Life) ============
// State.smartDevices = [{id, name, category, category_label, online, lastState, lastSeen}]
// Polling de status só roda quando aba "Arquivo" está visível (economiza dados Starlink).
let _smartPollTimer=null;
let _smartConsecFails={};
function setSmartStatus(msg,kind){
const el=document.getElementById('smart-status');if(!el)return;
const colors={ok:'#22c55e',warn:'#f59e0b',err:'#ef4444',info:'var(--sepia)'};
el.style.color=colors[kind]||'var(--sepia)';
el.textContent=msg;
}
async function refreshSmartStatus(){
if(!cloudConfigured()){setSmartStatus('☁ Nuvem não configurada','warn');return}
try{
const r=await cloudFetch('/api/iot/devices');
const j=await r.json();
const n=(j.devices||[]).length;
setSmartStatus(`✓ Conectado · ${n} dispositivo${n!==1?'s':''} disponível${n!==1?'is':''} na conta Smart Life`,'ok');
}catch(e){
if((e.message||'').includes('503')||(e.message||'').includes('tuya_not_configured')){
setSmartStatus('⚙ Tuya não configurado no servidor (admin: configure TUYA_ACCESS_ID em .env)','warn');
}else{
setSmartStatus('✗ '+(e.message||'erro'),'err');
}
}
}
async function openSmartDeviceModal(){
if(!cloudConfigured()){toast('Configure a nuvem primeiro');return}
openModal('smart-device-modal');
const list=document.getElementById('smart-modal-list');
const status=document.getElementById('smart-modal-status');
list.innerHTML='';
status.textContent='Buscando dispositivos da sua conta Smart Life...';
status.style.color='var(--sepia)';
try{
const r=await cloudFetch('/api/iot/devices');
const j=await r.json();
const devices=j.devices||[];
if(devices.length===0){
status.textContent='Nenhum dispositivo encontrado. Adicione no app Smart Life primeiro.';
return;
}
status.textContent=`${devices.length} dispositivo${devices.length!==1?'s':''} encontrado${devices.length!==1?'s':''}. Toque pra adicionar:`;
list.innerHTML='';
for(const d of devices){
const already=state.smartDevices.find(s=>s.id===d.id);
const dot=d.online?'🟢':'⚪';
const item=document.createElement('div');
item.className='fleet-item';
item.style.cssText='display:flex;justify-content:space-between;align-items:center;padding:10px;border:1px solid var(--m-border,rgba(255,255,255,.08));border-radius:6px;margin-bottom:6px;'+(already?'opacity:.5':'cursor:pointer');
item.innerHTML=`<div><div style="font-weight:600">${dot} ${escapeHtml(d.name)}</div><div style="font-size:11px;color:var(--sepia);font-family:var(--f-mono)">${escapeHtml(d.category_label||d.category||'')}</div></div>${already?'<span style="font-size:11px;color:var(--sepia)">já adicionado</span>':'<button class="btn btn-sm btn-primary">+ Adicionar</button>'}`;
if(!already){
item.onclick=()=>{addSmartDevice(d);closeModal('smart-device-modal')};
}
list.appendChild(item);
}
}catch(e){
if((e.message||'').includes('503')||(e.message||'').includes('tuya_not_configured')){
status.style.color='#f59e0b';
status.innerHTML='⚙ Tuya não configurado.<br>Admin precisa adicionar <code>TUYA_ACCESS_ID</code> + <code>TUYA_ACCESS_SECRET</code> no env do servidor.<br>Veja <code>server/.env.example</code>.';
}else{
status.style.color='#ef4444';
status.textContent='Erro: '+(e.message||'falha');
}
}
}
function addSmartDevice(d){
if(!state.smartDevices)state.smartDevices=[];
if(state.smartDevices.find(s=>s.id===d.id)){toast('Já adicionado');return}
state.smartDevices.push({
id:d.id,
name:d.name,
category:d.category,
category_label:d.category_label||d.category,
online:d.online,
lastState:null,
lastSeen:Date.now(),
addedAt:Date.now(),
});
saveState();
renderSmartDevices();
toast(`+ ${d.name}`);
// Busca estado inicial
refreshSmartDeviceState(d.id).catch(()=>{});
}
function removeSmartDevice(id){
if(!confirm('Remover este dispositivo do Shivão? (não apaga do Smart Life)'))return;
state.smartDevices=(state.smartDevices||[]).filter(d=>d.id!==id);
saveState();
renderSmartDevices();
toast('Removido');
}
function smartDeviceIcon(category){
const icons={cz:'🔌',dj:'💡',kg:'🎚️',fs:'🌀',dd:'💡',xdd:'🔆',dc:'💡',tdq:'⚡',kt:'❄️',wsdcg:'🌡️',mcs:'🚪',sd:'🤖',cl:'🪟',clkg:'🪟',wnykq:'🌡️'};
return icons[category]||'🔧';
}
// Encontra DP de "switch principal" pra renderizar toggle.
// Tuya pode usar switch_1, switch_led, switch — depende do produto.
function findMainSwitch(status){
if(!Array.isArray(status))return null;
// Prioridade: switch > switch_1 > switch_led > primeiro boolean
const candidates=['switch','switch_1','switch_led'];
for(const c of candidates){
const dp=status.find(s=>s.code===c&&typeof s.value==='boolean');
if(dp)return dp;
}
return status.find(s=>typeof s.value==='boolean')||null;
}
async function refreshSmartDeviceState(id){
try{
const r=await cloudFetch('/api/iot/status/'+encodeURIComponent(id));
const j=await r.json();
const dev=state.smartDevices.find(d=>d.id===id);
if(!dev)return;
dev.lastState=j.status;
dev.online=true;
dev.lastSeen=Date.now();
_smartConsecFails[id]=0;
saveState();
renderSmartDevices();
}catch(e){
const dev=state.smartDevices.find(d=>d.id===id);
if(dev){
_smartConsecFails[id]=(_smartConsecFails[id]||0)+1;
// Backoff exponencial: marca offline após 3 falhas consecutivas
if(_smartConsecFails[id]>=3){
dev.online=false;
saveState();
renderSmartDevices();
}
}
}
}
async function toggleSmartDevice(id){
const dev=state.smartDevices.find(d=>d.id===id);
if(!dev)return;
const sw=findMainSwitch(dev.lastState);
const newVal=sw?!sw.value:true;
// OPTIMISTIC UI: atualiza imediato, reverte em caso de erro
if(sw){sw.value=newVal}else if(dev.lastState){dev.lastState.push({code:'switch',value:newVal})}else{dev.lastState=[{code:'switch',value:newVal}]}
renderSmartDevices();
try{
const code=sw?sw.code:'switch';
const r=await cloudFetch('/api/iot/command/'+encodeURIComponent(id),{
method:'POST',
body:JSON.stringify({commands:[{code,value:newVal}]}),
});
await r.json();
// Confirma re-buscando status real após 800ms (Tuya leva ~500ms pra refletir)
setTimeout(()=>{refreshSmartDeviceState(id).catch(()=>{})},800);
}catch(e){
// Reverte UI
if(sw)sw.value=!newVal;
renderSmartDevices();
toast('Falhou: '+(e.message||'erro'));
}
}
function renderSmartDevices(){
const wrap=document.getElementById('smart-devices-list');
if(!wrap)return;
const devs=state.smartDevices||[];
if(devs.length===0){wrap.innerHTML='<div class="field-hint" style="text-align:center;padding:20px 10px">Nenhum dispositivo adicionado.<br>Toque "+ Adicionar" pra ver os dispositivos da sua conta Smart Life.</div>';return}
wrap.innerHTML='';
for(const d of devs){
const sw=findMainSwitch(d.lastState);
const isOn=sw?sw.value:false;
const dot=d.online===false?'⚪':(d.online?'🟢':'🟡');
const lastSeen=d.lastSeen?Math.round((Date.now()-d.lastSeen)/1000):null;
const lastSeenStr=lastSeen==null?'':(lastSeen<60?`${lastSeen}s atrás`:lastSeen<3600?`${Math.round(lastSeen/60)}min atrás`:`${Math.round(lastSeen/3600)}h atrás`);
const card=document.createElement('div');
card.style.cssText='display:flex;align-items:center;gap:12px;padding:12px;border:1px solid var(--m-border,rgba(255,255,255,.08));border-radius:8px;margin-bottom:8px;background:var(--m-bg-2,#0f2a40)';
card.innerHTML=`
<div style="font-size:28px;line-height:1">${smartDeviceIcon(d.category)}</div>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(d.name)}</div>
<div style="font-size:10.5px;color:var(--sepia);font-family:var(--f-mono);letter-spacing:.04em">${dot} ${escapeHtml(d.category_label||'')}${lastSeenStr?' · '+lastSeenStr:''}</div>
</div>
${sw?`<button class="btn btn-sm" style="background:${isOn?'#22c55e':'#475569'};color:white;border:none;min-width:64px" onclick="toggleSmartDevice('${d.id}')">${isOn?'ON':'OFF'}</button>`:`<button class="btn btn-sm btn-primary" onclick="refreshSmartDeviceState('${d.id}')"></button>`}
<button class="icon-btn" title="Remover" onclick="removeSmartDevice('${d.id}')" style="font-size:14px"></button>
`;
wrap.appendChild(card);
}
}
function startSmartPolling(){
if(_smartPollTimer)return;
_smartPollTimer=setInterval(()=>{
if(document.hidden)return; // Pausa em background
const devs=state.smartDevices||[];
for(const d of devs){
// Backoff: não tenta tão frequente em devices offline
const fails=_smartConsecFails[d.id]||0;
const skip=fails>=3&&((Date.now()/1000|0)%30!==0);
if(skip)continue;
refreshSmartDeviceState(d.id).catch(()=>{});
}
},10000);
}
function stopSmartPolling(){
if(_smartPollTimer){clearInterval(_smartPollTimer);_smartPollTimer=null}
}
function initSmartHome(){
if(!state.smartDevices)state.smartDevices=[];
// Limpa entries inválidas (migration defensiva)
state.smartDevices=state.smartDevices.filter(d=>d&&typeof d==='object'&&d.id);
refreshSmartStatus();
renderSmartDevices();
startSmartPolling();
}
(async()=>{
await openDB();loadState();bindHeader();await renderAll();
document.getElementById('fab').style.display='none';
loadTrackingState();
loadAnchorState();
initBattery();
initServiceWorker();
initSensorWidget();
// Realtime sync: conecta WebSocket se cloud configurada
setSyncStatus(cloudConfigured()?'syncing':'disabled');
if(cloudConfigured()){rtConnect();refreshGoogleStatus()}
// tenta auto-fetch do tempo após pequeno delay
setTimeout(maybeAutoFetchWeather,3000);
// Welcome screen — só pra usuários sem login
setTimeout(maybeShowWelcome,300);
// Retoma polling do OAuth se app foi morto durante login Google
setTimeout(resumePollingIfPending,500);
// Wrapper try/catch pra capturar crash no boot (Capacitor WebView pode fechar silenciosamente)
try{
// Detecta crash BLE da sessão anterior via breadcrumb
try{
const lastStep=localStorage.getItem('shivao_ble_last_step');
if(lastStep){
setTimeout(()=>{
try{alert('⚠ Crash detectado na sessão anterior!\nÚltima ação BLE: '+lastStep+'\n\nMe envie esta mensagem.')}catch{}
},2000);
localStorage.removeItem('shivao_ble_last_step');
}
}catch{}
await openDB();
try{loadState()}catch(e){console.error('[boot] loadState',e);try{localStorage.removeItem(STORAGE_KEY)}catch{}/* corrupt state — reset */}
// Migration defensiva: limpa entries inválidas em state.btDevices
if(state.btDevices&&Array.isArray(state.btDevices)){
state.btDevices=state.btDevices.filter(d=>d&&typeof d==='object'&&d.id);
}else{
state.btDevices=[];
}
bindHeader();
await renderAll();
try{document.getElementById('fab').style.display='none'}catch(e){}
try{loadTrackingState()}catch(e){console.error('[boot] tracking',e)}
try{loadAnchorState()}catch(e){console.error('[boot] anchor',e)}
try{initBattery()}catch(e){console.error('[boot] battery',e)}
try{initServiceWorker()}catch(e){console.error('[boot] sw',e)}
try{initSensorWidget()}catch(e){console.error('[boot] sensors',e)}
try{initSmartHome()}catch(e){console.error('[boot] smarthome',e)}
try{setSyncStatus(cloudConfigured()?'syncing':'disabled')}catch(e){}
if(cloudConfigured()){
try{rtConnect()}catch(e){console.error('[boot] rt',e)}
try{refreshGoogleStatus()}catch(e){console.error('[boot] gcal',e)}
}
setTimeout(()=>{try{maybeAutoFetchWeather()}catch(e){}},3000);
// Pede permissão de notificações (alertas tempestade)
setTimeout(async()=>{
try{
const ln=window.Capacitor?.Plugins?.LocalNotifications;
if(ln){await ln.requestPermissions().catch(()=>{})}
else if('Notification' in window&&Notification.permission==='default'){
await Notification.requestPermission().catch(()=>{});
}
}catch{}
},5000);
setTimeout(()=>{try{maybeShowWelcome()}catch(e){}},300);
setTimeout(()=>{try{resumePollingIfPending()}catch(e){}},500);
}catch(e){
// Crash no boot — mostra alert nativo (sobrevive Capacitor crash) + tenta auto-recovery
const msg='Boot error: '+(e.message||e)+'\n'+(e.stack||'').slice(0,300);
console.error('[BOOT CRASH]',e);
try{alert(msg)}catch{}
try{localStorage.clear()}catch{}
}
})();
// Re-tenta init Google Sign-In quando o script async carrega
window.addEventListener('load',()=>setTimeout(()=>{if(document.getElementById('welcome-screen').style.display==='flex')initGoogleSignIn()},500));
@ -5559,19 +5844,172 @@ async function fetchWeatherOpenMeteo(lat,lng){
if(weather.fetching)return;
weather.fetching=true;
try{
const fUrl=`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=temperature_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m,weather_code&hourly=wind_speed_10m,wind_direction_10m,weather_code&forecast_hours=24&wind_speed_unit=kn&timezone=auto`;
const mUrl=`https://marine-api.open-meteo.com/v1/marine?latitude=${lat}&longitude=${lng}&current=wave_height,wave_direction,wave_period&hourly=wave_height&forecast_hours=24&timezone=auto`;
// Forecast: vento, temp, pressão, lightning (CAPE), gust
const fUrl=`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=temperature_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m,weather_code,pressure_msl&hourly=wind_speed_10m,wind_direction_10m,wind_gusts_10m,weather_code,pressure_msl,cape&forecast_hours=24&wind_speed_unit=kn&timezone=auto`;
// Marine: ondas + nível do mar (sea_level_height_msl = maré)
const mUrl=`https://marine-api.open-meteo.com/v1/marine?latitude=${lat}&longitude=${lng}&current=wave_height,wave_direction,wave_period&hourly=wave_height,sea_level_height_msl&forecast_hours=48&timezone=auto`;
const[fR,mR]=await Promise.all([fetch(fUrl),fetch(mUrl).catch(()=>null)]);
const f=await fR.json();
const m=mR&&mR.ok?await mR.json():null;
weather.data={provider:'openmeteo',forecast:f,marine:m,fetchedAt:Date.now(),lat,lng};
weather.lastFetch=Date.now();
weather.lastPos={lat,lng};
weather.tides=extractTides(m);
renderWeather();
checkStormConditions(weather.data,{lat,lng});
}catch(e){console.warn('weather',e.message)}
weather.fetching=false;
}
// Peak detection no array hourly de sea_level_height_msl
// Retorna { nextHigh: {time, height_m}, nextLow: {time, height_m} }
function extractTides(marineData){
if(!marineData?.hourly?.sea_level_height_msl||!marineData?.hourly?.time)return null;
const sl=marineData.hourly.sea_level_height_msl;
const ts=marineData.hourly.time;
const now=Date.now();
const peaks=[]; // {idx, type:'high'|'low', height, time}
for(let i=1;i<sl.length-1;i++){
if(sl[i]==null||sl[i-1]==null||sl[i+1]==null)continue;
const t=new Date(ts[i]).getTime();
if(t<now)continue; // futuros
if(sl[i]>sl[i-1]&&sl[i]>sl[i+1])peaks.push({type:'high',height:sl[i],time:t});
else if(sl[i]<sl[i-1]&&sl[i]<sl[i+1])peaks.push({type:'low',height:sl[i],time:t});
}
return{
nextHigh:peaks.find(p=>p.type==='high')||null,
nextLow:peaks.find(p=>p.type==='low')||null,
all:peaks.slice(0,8), // próximas 8 transições (~ 4 ciclos completos)
};
}
// ============ STORM ALERTS ============
// Limiares — futuros: configuráveis via Settings
const STORM_THRESHOLDS={
windWarn:25, // nós · vento sustentado
windDanger:35,
gustWarn:30,
gustDanger:45,
waveWarn:2.0, // metros
waveDanger:3.5,
pressureDropWarn:3, // hPa em 3h
capeWarn:1500, // J/kg · CAPE > 1500 = atmosfera instável (trovoada)
capeDanger:3000,
};
function checkStormConditions(weatherData,pos){
if(!weatherData)return;
const alerts=[];
const f=weatherData.forecast;
const m=weatherData.marine;
// Vento atual
let windKn=null,gustKn=null,pressureNow=null,cape=null;
if(weatherData.provider==='openmeteo'){
windKn=f?.current?.wind_speed_10m;
gustKn=f?.current?.wind_gusts_10m;
pressureNow=f?.current?.pressure_msl;
// CAPE atual = primeiro valor hourly
cape=f?.hourly?.cape?.[0];
}else if(weatherData.provider==='windy'){
const ms=weatherData.main;
const u=ms?.['wind_u-surface']?.[0]??0;
const v=ms?.['wind_v-surface']?.[0]??0;
windKn=Math.sqrt(u*u+v*v)*1.94384;
const gu=ms?.['wind_u-gust-surface']?.[0]??ms?.['gust-surface']?.[0]??0;
gustKn=gu*1.94384;
pressureNow=ms?.['pressure-surface']?.[0]?ms['pressure-surface'][0]/100:null;
}
// Wave atual
const waveM=m?.current?.wave_height;
// Pressão caindo: compara atual com 3h atrás (dado anterior em hourly)
let pressureDrop3h=0;
if(f?.hourly?.pressure_msl?.length>3){
const p0=f.hourly.pressure_msl[0];
const p3=f.hourly.pressure_msl[3];
if(p0!=null&&p3!=null)pressureDrop3h=p0-p3;
}
// Avalia condições
if(windKn!=null){
if(windKn>=STORM_THRESHOLDS.windDanger)alerts.push({level:'danger',title:'Vento crítico',msg:`${windKn.toFixed(0)}kn · evite navegar`});
else if(windKn>=STORM_THRESHOLDS.windWarn)alerts.push({level:'warn',title:'Vento forte',msg:`${windKn.toFixed(0)}kn · cuidado`});
}
if(gustKn!=null&&gustKn>=STORM_THRESHOLDS.gustDanger)alerts.push({level:'danger',title:'Rajadas perigosas',msg:`pico ${gustKn.toFixed(0)}kn`});
else if(gustKn!=null&&gustKn>=STORM_THRESHOLDS.gustWarn)alerts.push({level:'warn',title:'Rajadas fortes',msg:`pico ${gustKn.toFixed(0)}kn`});
if(waveM!=null){
if(waveM>=STORM_THRESHOLDS.waveDanger)alerts.push({level:'danger',title:'Mar perigoso',msg:`ondas ${waveM.toFixed(1)}m`});
else if(waveM>=STORM_THRESHOLDS.waveWarn)alerts.push({level:'warn',title:'Mar agitado',msg:`ondas ${waveM.toFixed(1)}m`});
}
if(pressureDrop3h>=STORM_THRESHOLDS.pressureDropWarn)alerts.push({level:'warn',title:'Pressão caindo',msg:`-${pressureDrop3h.toFixed(1)}hPa em 3h · tempestade aproximando`});
if(cape!=null){
if(cape>=STORM_THRESHOLDS.capeDanger)alerts.push({level:'danger',title:'Trovoada provável',msg:`CAPE ${Math.round(cape)} J/kg · risco de raios`});
else if(cape>=STORM_THRESHOLDS.capeWarn)alerts.push({level:'warn',title:'Atmosfera instável',msg:`CAPE ${Math.round(cape)} · trovoadas isoladas`});
}
// Salva no state pra UI ler
weather.alerts=alerts;
// Renderiza banner se alguma
renderStormBanner();
// Notificação push em alertas novos (compara com último set)
const sig=alerts.map(a=>a.level+':'+a.title).join('|');
if(alerts.length>0&&sig!==weather._lastAlertSig){
weather._lastAlertSig=sig;
sendStormNotification(alerts);
}else if(alerts.length===0){
weather._lastAlertSig='';
}
}
function renderStormBanner(){
let banner=document.getElementById('storm-banner');
const alerts=weather.alerts||[];
if(alerts.length===0){
if(banner)banner.remove();
return;
}
if(!banner){
banner=document.createElement('div');
banner.id='storm-banner';
banner.style.cssText='position:fixed;top:0;left:0;right:0;z-index:9000;padding:10px 14px;font-family:var(--f-body);font-size:13px;font-weight:600;text-align:center;cursor:pointer;backdrop-filter:blur(8px)';
banner.onclick=()=>{
const det=document.getElementById('storm-banner-details');
if(det)det.style.display=det.style.display==='block'?'none':'block';
};
document.body.appendChild(banner);
}
const worst=alerts.find(a=>a.level==='danger')||alerts[0];
const bg=worst.level==='danger'?'rgba(239,68,68,.94)':'rgba(245,158,11,.94)';
banner.style.background=bg;
banner.style.color='#fff';
banner.innerHTML=`⚠ ${worst.title} · ${worst.msg}${alerts.length>1?` (+${alerts.length-1})`:''} · toque pra detalhes
<div id="storm-banner-details" style="display:none;margin-top:8px;font-size:11.5px;font-weight:400;text-align:left;line-height:1.6">
${alerts.map(a=>`<div>${a.level==='danger'?'🔴':'🟡'} <strong>${a.title}</strong>: ${a.msg}</div>`).join('')}
</div>`;
}
async function sendStormNotification(alerts){
const worst=alerts.find(a=>a.level==='danger')||alerts[0];
const title=worst.level==='danger'?'⚠ ALERTA CRÍTICO':'🟡 Aviso meteorológico';
const body=alerts.map(a=>`${a.title}: ${a.msg}`).join(' · ');
// Capacitor LocalNotifications
try{
const ln=window.Capacitor?.Plugins?.LocalNotifications;
if(ln){
await ln.schedule({notifications:[{
id:Math.floor(Math.random()*1e9),
title,body,smallIcon:'ic_stat_anchor',
}]});
return;
}
}catch(e){console.warn('storm notif',e.message)}
// Web Notifications API
if('Notification' in window){
if(Notification.permission==='granted'){
try{new Notification(title,{body,icon:'/icon.svg',tag:'storm-alert'})}catch{}
}else if(Notification.permission!=='denied'){
try{await Notification.requestPermission()}catch{}
}
}
}
// Helpers de conversão de unidades
function uvToSpeedDir(u,v){
// u: leste-oeste (positivo = leste). v: norte-sul (positivo = norte).
@ -5597,8 +6035,47 @@ function renderWeather(){
if(!el)return;
const d=weather.data;
if(!d){el.innerHTML='';return}
if(d.provider==='windy')return renderWindyWeather(el,d);
return renderOpenMeteoWeather(el,d);
if(d.provider==='windy')renderWindyWeather(el,d);
else renderOpenMeteoWeather(el,d);
// Anexa card de marés se tiver dados
appendTidesCard(el);
}
function appendTidesCard(weatherEl){
if(!weather.tides||!weather.tides.nextHigh)return;
const t=weather.tides;
const fmtTide=(p)=>{
if(!p)return '—';
const d=new Date(p.time);
const hh=d.getHours().toString().padStart(2,'0');
const mm=d.getMinutes().toString().padStart(2,'0');
const diffMin=Math.round((d.getTime()-Date.now())/60000);
const inH=diffMin>=60?`em ${Math.floor(diffMin/60)}h${(diffMin%60).toString().padStart(2,'0')}`:`em ${diffMin}min`;
return `${hh}:${mm} (${inH}) · ${p.height>=0?'+':''}${p.height.toFixed(2)}m`;
};
const tidesHtml=`
<div style="margin-top:10px;padding:10px;background:var(--m-bg-2,rgba(0,0,0,.15));border-radius:8px;border-left:3px solid var(--m-accent,#06b6d4)">
<div style="font-family:var(--f-mono);font-size:10px;letter-spacing:.14em;color:var(--m-text-soft,#7d97ad);text-transform:uppercase;margin-bottom:6px">⚓ Marés (próx. 48h)</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:13px">
<div>
<div style="font-size:9px;color:var(--m-text-soft);letter-spacing:.1em">↑ PREAMAR</div>
<div style="font-family:var(--f-mono);font-weight:600">${fmtTide(t.nextHigh)}</div>
</div>
<div>
<div style="font-size:9px;color:var(--m-text-soft);letter-spacing:.1em">↓ BAIXAMAR</div>
<div style="font-family:var(--f-mono);font-weight:600">${fmtTide(t.nextLow)}</div>
</div>
</div>
</div>`;
// Append apenas se ainda não existe
if(!weatherEl.querySelector('.tides-block')){
const div=document.createElement('div');
div.className='tides-block';
div.innerHTML=tidesHtml;
weatherEl.appendChild(div);
}else{
weatherEl.querySelector('.tides-block').innerHTML=tidesHtml;
}
}
function renderWindyWeather(el,d){
@ -5794,24 +6271,64 @@ async function ensureBleNativeReady(){
_bleNativeInitialized=true;
}
// Copia log diagnóstico pro clipboard
async function copyDiagLog(){
// Envia log diagnóstico direto pro servidor (evita crash de clipboard/share em WebView)
async function sendDiagLogToServer(){
const el=document.getElementById('bt-diag');
if(!el){toast('Log vazio');return}
// Extrai texto puro (innerText preserva quebras de linha)
const txt=`Shivao v${APP_VERSION} · log diagnóstico\n\n`+(el.innerText||el.textContent||'').trim();
if(!cloudConfigured()){toast('Faça login primeiro pra enviar log');return}
toast('📤 Enviando log pro servidor...');
try{
if(navigator.clipboard?.writeText){
await navigator.clipboard.writeText(txt);
toast('✓ Log copiado · cole no chat');
}else{
// Fallback: textarea hack
const ta=document.createElement('textarea');
ta.value=txt;document.body.appendChild(ta);ta.select();
document.execCommand('copy');ta.remove();
toast('✓ Log copiado');
}
}catch(e){toast('Erro ao copiar: '+e.message)}
const r=await cloudFetch('/api/bms/diag-log',{method:'POST',body:JSON.stringify({log:txt})});
const j=await r.json();
if(j.ok)toast('✓ Log enviado · '+(j.file||'OK'));
else throw new Error(j.error||'falhou');
}catch(e){toast('Erro: '+e.message)}
}
// Mostra log num modal pro usuário copiar/compartilhar manualmente
function copyDiagLog(){
const el=document.getElementById('bt-diag');
if(!el){toast('Log vazio');return}
const txt=`Shivao v${APP_VERSION} · log diagnóstico\n\n`+(el.innerText||el.textContent||'').trim();
let modal=document.getElementById('bt-log-modal');
if(modal)modal.remove();
modal=document.createElement('div');
modal.id='bt-log-modal';
modal.style.cssText='position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.85);display:flex;flex-direction:column;padding:16px;backdrop-filter:blur(8px)';
// safe escape
const esc=txt.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
modal.innerHTML=`
<div style="background:#0d2538;border:1px solid #06b6d4;border-radius:12px;padding:16px;display:flex;flex-direction:column;height:100%;max-height:95vh">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<h3 style="margin:0;color:#06b6d4;font-size:15px">📋 Log diagnóstico</h3>
<button onclick="document.getElementById('bt-log-modal').remove()" style="background:transparent;border:none;color:#fff;font-size:24px;cursor:pointer;padding:4px 12px"></button>
</div>
<p style="color:#b3c5d6;font-size:12px;margin:0 0 8px;line-height:1.4">Toque dentro da caixa e segure pra selecionar tudo. Ou toque ↗ Compartilhar pra enviar via WhatsApp.</p>
<textarea id="bt-log-textarea" readonly style="flex:1;width:100%;min-height:280px;background:#0a1f30;color:#e8f1f8;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:10px;font-family:'JetBrains Mono',monospace;font-size:11px;resize:none;-webkit-user-select:text;user-select:text;-webkit-touch-callout:default;outline:none">${esc}</textarea>
<div style="display:flex;gap:8px;margin-top:10px">
<button onclick="bmsShareLog()" style="flex:1;background:#10b981;color:#001a25;border:none;padding:12px;border-radius:8px;font-weight:600;cursor:pointer;font-size:14px">↗ Compartilhar</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Auto-seleciona o texto
setTimeout(()=>{
const ta=document.getElementById('bt-log-textarea');
if(ta){ta.focus();try{ta.select()}catch{}}
},200);
}
async function bmsShareLog(){
const ta=document.getElementById('bt-log-textarea');
if(!ta)return;
if(navigator.share){
try{
await navigator.share({title:'Shivao log diagnóstico',text:ta.value});
}catch(e){/* user cancelou */}
}else{
toast('Compartilhar não disponível · use seleção manual');
}
}
// Diagnóstico visível: mostra cada passo no card BLE
@ -5828,6 +6345,12 @@ function setBleDiag(msg,type){
console.log('[ble]',msg);
}
// Breadcrumb persistente: grava ANTES de cada chamada nativa pra sobreviver crash do WebView
function bleCrumb(step){
try{localStorage.setItem('shivao_ble_last_step',step+' @ '+new Date().toISOString())}catch{}
}
function bleCrumbClear(){try{localStorage.removeItem('shivao_ble_last_step')}catch{}}
async function pairBluetoothDevice(){
const backend=bleBackend();
setBleDiag('Backend: '+(backend||'NENHUM'),backend?'info':'err');
@ -5835,8 +6358,10 @@ async function pairBluetoothDevice(){
try{
let deviceId,deviceName;
if(backend==='capacitor'){
bleCrumb('ensureBleNativeReady');
setBleDiag('Inicializando plugin nativo...');
await ensureBleNativeReady();
bleCrumb('requestDevice');
setBleDiag('Plugin OK · abrindo picker...');
const ble=window.Capacitor.Plugins.BluetoothLe;
const result=await ble.requestDevice({
@ -5844,15 +6369,16 @@ async function pairBluetoothDevice(){
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO],
allowDuplicates:false,
});
if(!result?.deviceId){setBleDiag('Picker cancelado','warn');return}
if(!result?.deviceId){bleCrumbClear();setBleDiag('Picker cancelado','warn');return}
deviceId=result.deviceId;
deviceName=result.name||'Dispositivo BLE';
bleCrumb('selected:'+deviceName);
setBleDiag('Selecionado: '+deviceName+' ('+deviceId+')','ok');
}else{
setBleDiag('Abrindo picker do navegador...');
const device=await navigator.bluetooth.requestDevice({
acceptAllDevices:true,
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO],
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO,'0000ff00-0000-1000-8000-00805f9b34fb','0000fff0-0000-1000-8000-00805f9b34fb','0000ffe0-0000-1000-8000-00805f9b34fb'],
});
if(!device){return}
deviceId=device.id;
@ -5882,11 +6408,11 @@ async function pairBluetoothDevice(){
}
saveState();
renderBluetoothCard();
// Se detectou JBD BMS, ativa parser proprietário
if(info.isJBD){
// Se detectou JBD BMS OU backend é web (sempre tenta probe — descobre serviço dinâmico), ativa probe
if(info.isJBD||backend==='web'){
const ok=await bmsAttachJBD(deviceId,deviceName);
if(ok)toast('✓ '+deviceName+' · JBD BMS ativo');
else toast('✓ '+deviceName+' (BMS detectado mas falha no parser)');
if(ok)toast('✓ '+deviceName+' · BMS ativo');
else toast('✓ '+deviceName+' (sem BMS detectável)');
}else{
toast('✓ '+deviceName+(info.battery!=null?' · '+info.battery+'%':' (sem leitura de bateria)'));
}
@ -5906,6 +6432,7 @@ async function connectAndRead(deviceId,deviceName){
if(backend==='capacitor'){
const ble=window.Capacitor.Plugins.BluetoothLe;
try{
bleCrumb('ble.connect');
await ble.connect({deviceId,timeout:30000});
setBleDiag('GATT conectado','ok');
}catch(e){
@ -5915,13 +6442,12 @@ 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 + auto-detect protocols
try{
bleCrumb('ble.getServices');
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');
@ -6016,10 +6542,107 @@ function bytesToBase64(arr){
return btoa(bin);
}
// Probe: lista characteristics + identifica notify/write chars + tenta protocolos
// Probe via Web Bluetooth API (Chrome PC)
async function bmsProbeWebBluetooth(deviceId,deviceName){
const conn=_bleConnections.get(deviceId);
const device=conn?.device;
if(!device){setBleDiag('Device sem referência (re-pareie)','err');return false}
try{
setBleDiag(`📦 Shivao v${APP_VERSION} · Probe WEB iniciado`,'info');
const server=device.gatt.connected?device.gatt:await device.gatt.connect();
setBleDiag('GATT web conectado','ok');
let svc=null;
try{svc=await server.getPrimaryService('0000ff00-0000-1000-8000-00805f9b34fb');setBleDiag('Service ff00 OK','info')}
catch(e){setBleDiag('Service ff00 falhou: '+e.message,'err');return false}
const chars=await svc.getCharacteristics();
setBleDiag(`Svc ff00 · ${chars.length} chars`,'info');
let notifyChar=null,writeChar=null;
for(const c of chars){
const p=c.properties;
const propsStr=[p.notify&&'notify',p.indicate&&'indicate',p.write&&'write',p.writeWithoutResponse&&'wnr',p.read&&'read'].filter(Boolean).join(',');
const cu=c.uuid.toLowerCase();
setBleDiag(` ${cu.slice(4,8)} [${propsStr}]`,'info');
if(!notifyChar&&(p.notify||p.indicate))notifyChar=c;
if(!writeChar&&(p.write||p.writeWithoutResponse))writeChar=c;
}
if(!notifyChar){setBleDiag('Sem char notify','err');return false}
if(!writeChar){
// Workaround: BMS chinês declara ff02 só [read] mas aceita writes (firmware permissivo).
// Força usar primeira char não-notify e tenta writeValue mesmo assim.
writeChar=chars.find(c=>c.uuid.toLowerCase()!==notifyChar.uuid.toLowerCase())||chars[0];
setBleDiag(`⚠ Sem property write · forçando ${writeChar.uuid.slice(4,8)}`,'warn');
}
setBleDiag(`Notify=${notifyChar.uuid.slice(4,8)} Write=${writeChar.uuid.slice(4,8)}`,'ok');
notifyChar.addEventListener('characteristicvaluechanged',(ev)=>{
const dv=ev.target.value;
const hex=Array.from(new Uint8Array(dv.buffer)).map(b=>b.toString(16).padStart(2,'0')).join(' ');
setBleDiag(`← RX ${dv.byteLength}b: ${hex.slice(0,100)}${hex.length>100?'...':''}`,'ok');
// Se há buffer pendente JBD, é chunk de continuação — roteia sempre
if(_bmsBuffers.has(deviceId)){
bmsHandleChunk(deviceId,dv,deviceName);
return;
}
// Buffer vazio: primeiro byte determina protocolo (início de novo pacote)
const first=new Uint8Array(dv.buffer)[0];
if(first===0xDD)bmsHandleChunk(deviceId,dv,deviceName);
else if(first===0xAA)bmsHandleJK(deviceId,dv,deviceName);
else if(first===0xA5)bmsHandleDaly(deviceId,dv,deviceName);
});
await notifyChar.startNotifications();
setBleDiag('Notify ativo · iniciando wake...','ok');
await new Promise(r=>setTimeout(r,500));
try{
const fn=writeChar.properties.writeWithoutResponse?'writeValueWithoutResponse':'writeValue';
await writeChar[fn](new Uint8Array([0x5A,0x5A,0x5A,0x5A]));
setBleDiag('Wake 5A x4 enviado','info');
}catch(e){setBleDiag('Wake skip: '+e.message,'info')}
await new Promise(r=>setTimeout(r,1500));
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]},
];
for(const p of PROTOCOLS){
try{
setBleDiag(`→ TX ${p.name}`,'info');
const fn=writeChar.properties.writeWithoutResponse?'writeValueWithoutResponse':'writeValue';
await writeChar[fn](new Uint8Array(p.bytes));
setBleDiag(`✔ write ${p.name} OK`,'info');
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! · polling 5s ativado`,'ok');
if(dev){dev.bmsProtocol=p.name;dev.isJBD=true;saveState()}
conn.notifyChar=notifyChar;conn.writeChar=writeChar;
// Polling a cada 5s pra atualização contínua
if(conn._pollInterval)clearInterval(conn._pollInterval);
conn._pollInterval=setInterval(async()=>{
try{
const fn2=writeChar.properties.writeWithoutResponse?'writeValueWithoutResponse':'writeValue';
await writeChar[fn2](new Uint8Array(p.bytes));
}catch(e){
// Conexão caiu: para o polling
if(/disconnected|connection|gatt/i.test(e.message||'')){
setBleDiag('Polling interrompido: '+e.message,'warn');
clearInterval(conn._pollInterval);conn._pollInterval=null;
}
}
},5000);
return true;
}
setBleDiag(`✗ ${p.name} sem RX`,'info');
}catch(e){setBleDiag(`${p.name} erro: ${e.message}`,'warn')}
}
setBleDiag('⚠ Nenhum protocolo respondeu','err');
return false;
}catch(e){setBleDiag('Probe web falhou: '+e.message,'err');return false}
}
// Probe Capacitor: lista characteristics + identifica notify/write chars + tenta protocolos
async function bmsProbeAndAttach(deviceId,deviceName){
const backend=bleBackend();
if(backend!=='capacitor'){setBleDiag('Probe requer Capacitor (APK)','warn');return false}
if(backend==='web')return bmsProbeWebBluetooth(deviceId,deviceName);
if(backend!=='capacitor'){setBleDiag('Backend desconhecido','warn');return false}
const ble=window.Capacitor.Plugins.BluetoothLe;
try{
setBleDiag(`📦 Shivao v${APP_VERSION} · Probe iniciado`,'info');
@ -6057,28 +6680,49 @@ async function bmsProbeAndAttach(deviceId,deviceName){
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');
// Detecta se write char só aceita writeWithoutResponse
let writeOnlyWnr=false;
for(const svc of allSvcs){
if((svc.uuid||'').toLowerCase()!==foundService)continue;
for(const c of (svc.characteristics||[])){
if((c.uuid||'').toLowerCase()===writeChar){
const p=c.properties||{};
writeOnlyWnr=(!p.write&&p.writeWithoutResponse);
}
}
}
setBleDiag(`Notify=${notifyChar.slice(4,8)} Write=${writeChar.slice(4,8)}${writeOnlyWnr?' (force-wnr)':''} Svc=${foundService.slice(4,8)}`,'ok');
// Subscribe + handler
const listenerKey='notification|'+deviceId+'|'+foundService+'|'+notifyChar;
ble.addListener(listenerKey,(ev)=>{
const dv=parseDataView(ev.value);
const hex=Array.from(new Uint8Array(dv.buffer)).map(b=>b.toString(16).padStart(2,'0')).join(' ');
setBleDiag('← RX '+dv.byteLength+'b: '+hex.slice(0,100)+(hex.length>100?'...':''),'ok');
// Detecta protocolo por byte de início
// Se buffer JBD pendente, é continuação — roteia sempre
if(_bmsBuffers.has(deviceId)){
bmsHandleChunk(deviceId,dv,deviceName);
return;
}
// Detecta protocolo por byte de início (novo pacote)
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:foundService,characteristic:notifyChar});
setBleDiag('Notify ativo · aguardando 800ms...','ok');
try{
await ble.startNotifications({deviceId,service:foundService,characteristic:notifyChar});
setBleDiag('Notify ativo · aguardando 800ms...','ok');
}catch(e){setBleDiag('startNotifications erro: '+(e.message||e.errorMessage||'?'),'err');return false}
await new Promise(r=>setTimeout(r,800));
// Wake-up REMOVIDO no path Capacitor — chamadas extra causavam crash nativo
// Provamos no PC (Web Bluetooth) que JBD-0x03 direto funciona
// Salva config no device pra reuso
const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev){
dev.bmsService=foundService;
dev.bmsNotifyChar=notifyChar;
dev.bmsWriteChar=writeChar;
dev.bmsForceWnr=writeOnlyWnr;
dev.isJBD=true;
saveState();
}
@ -6094,8 +6738,12 @@ async function bmsWriteCmd(deviceId,bytes,withoutResponse){
const ble=window.Capacitor?.Plugins?.BluetoothLe;
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';
await ble[fn]({deviceId,service:dev.bmsService,characteristic:dev.bmsWriteChar,value:bytesToBase64(bytes)});
const useWnr=withoutResponse||dev.bmsForceWnr;
const fn=useWnr?'writeWithoutResponse':'write';
// Timeout 3s: plugins/firmware podem travar mesmo com WNR
const writePromise=ble[fn]({deviceId,service:dev.bmsService,characteristic:dev.bmsWriteChar,value:bytesToBase64(bytes)});
const timeoutPromise=new Promise((_,rej)=>setTimeout(()=>rej(new Error('write timeout 3s')),3000));
await Promise.race([writePromise,timeoutPromise]);
}
async function bmsTryProtocols(deviceId){
@ -6108,18 +6756,37 @@ async function bmsTryProtocols(deviceId){
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
try{
await bmsWriteCmd(deviceId,p.bytes,p.wnr);
setBleDiag(`✔ write ${p.name} retornou`,'info');
}catch(we){
setBleDiag(`✗ write ${p.name} erro: ${we.message||we.errorMessage}`,'warn');
// Continue pro próximo protocolo mesmo se write falhar
}
// Aguarda RX por 2.5s
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
setBleDiag(`✓ ${p.name} respondeu! · polling 5s ativado`,'ok');
if(dev)dev.bmsProtocol=p.name;
setInterval(async()=>{try{await bmsWriteCmd(deviceId,p.bytes,p.wnr)}catch{}},30000);
// Polling 5s pra atualização contínua (era 30s — usuário queria constante)
const conn=_bleConnections.get(deviceId)||{};
if(conn._pollInterval)clearInterval(conn._pollInterval);
conn._pollInterval=setInterval(async()=>{
try{await bmsWriteCmd(deviceId,p.bytes,p.wnr)}
catch(e){
if(/disconnected|connection|not connected/i.test(e.message||e.errorMessage||'')){
clearInterval(conn._pollInterval);conn._pollInterval=null;
setBleDiag('Polling parou: GATT desconectou','warn');
}
}
},5000);
_bleConnections.set(deviceId,conn);
return true;
}else{
setBleDiag(`✗ ${p.name} sem RX em 2.5s`,'info');
}
}catch(e){setBleDiag(`${p.name} falhou: ${e.message||e.errorMessage}`,'warn')}
}catch(e){setBleDiag(`${p.name} loop erro: ${e.message||e.errorMessage}`,'warn')}
}
setBleDiag('⚠ Nenhum protocolo funcionou. BMS pode usar firmware proprietário não documentado.','err');
return false;
@ -6289,7 +6956,7 @@ async function removeBluetoothDevice(id){
renderBluetoothCard();
}
const APP_VERSION='1.10.6';
const APP_VERSION='1.12.0';
function renderBluetoothCard(){
const el=document.getElementById('bt-list');
const supportEl=document.getElementById('bt-support');

View file

@ -7,8 +7,8 @@ android {
applicationId "br.com.pontualtech.shivao"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 22
versionName "1.10.6"
versionCode 33
versionName "1.12.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -9,6 +9,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-community-bluetooth-le')
implementation project(':capacitor-app')
implementation project(':capacitor-geolocation')
implementation project(':capacitor-local-notifications')

View file

@ -2,6 +2,9 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-community-bluetooth-le'
project(':capacitor-community-bluetooth-le').projectDir = new File('../node_modules/@capacitor-community/bluetooth-le/android')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')

View file

@ -1,6 +1,6 @@
{
"name": "shivao-mobile",
"version": "1.10.6",
"version": "1.12.0",
"description": "Shivao app nativo (Capacitor wrapper Android/iOS)",
"main": "index.js",
"type": "module",

81
server/.env.example Normal file
View file

@ -0,0 +1,81 @@
# ======================================================
# SHIVAO CLOUD - Configuração
# Copie este arquivo para .env e preencha os valores
# ======================================================
# --- Autenticação ---
# Token único do barco. GERE UMA STRING ALEATÓRIA LONGA!
# Sugestão: openssl rand -hex 32
BOAT_TOKEN=troque-este-valor-por-uma-string-aleatoria-longa-e-secreta
# --- Dead-man switch ---
# Se o app não enviar heartbeat por X segundos enquanto fundeado,
# o servidor dispara o alarme automaticamente. Padrão: 300 (5 min)
HEARTBEAT_TIMEOUT_SEC=300
# ======================================================
# CANAIS DE NOTIFICAÇÃO (configure os que quiser usar)
# ======================================================
# --- Telegram (RECOMENDADO - grátis, instantâneo) ---
# 1. No Telegram, fale com @BotFather → /newbot → anote o token
# 2. Inicie conversa com seu novo bot
# 3. Acesse https://api.telegram.org/bot<TOKEN>/getUpdates → anote o chat.id
# Você pode enviar para múltiplos chats separando por vírgula
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_IDS=
# --- ntfy.sh (push notifications grátis sem cadastro) ---
# Instale o app ntfy no celular, escolha um tópico secreto único
# Ex: shivao-alertas-x7k9p2 — qualquer pessoa com o nome ouve, então use algo aleatório
NTFY_TOPIC=
NTFY_SERVER=https://ntfy.sh
# --- E-mail (SMTP) ---
# Para Gmail: ative 2FA, crie "App password" em
# https://myaccount.google.com/apppasswords
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM=Shivao Alertas <alerts@example.com>
# Múltiplos destinatários separados por vírgula
SMTP_TO=
# --- Twilio SMS / WhatsApp (PAGO) ---
# Crie conta em twilio.com
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_FROM_NUMBER=
TWILIO_WHATSAPP_FROM=
# Múltiplos números (com DDI, ex: +5521999998888) separados por vírgula
TWILIO_SMS_TO=
TWILIO_WHATSAPP_TO=
# --- Webhook genérico ---
# Para Discord, Slack, n8n, ou seu próprio endpoint
# Recebe POST com JSON {boat, message, lat, lng, distance, ...}
WEBHOOK_URL=
# ======================================================
# IOT (Smart Life / Tuya) — controlar dispositivos do barco
# ======================================================
# Tuya é o fabricante por trás do app Smart Life. Lâmpadas/tomadas
# brand X (Positivo, Multilaser, Intelbras, RWS) são todas Tuya.
#
# Setup (5 min, gratuito):
# 1. Crie conta em https://iot.tuya.com (use mesmo email do Smart Life)
# 2. Cloud → Development → Create Cloud Project
# - Industry: Smart Home
# - Method: Custom Development
# - Data Center: escolha o mesmo da app Smart Life
# (Eu → Account & Security → Region)
# 3. Aba Service API → autorize: IoT Core, Authorization, Smart Home Basic
# 4. Aba Devices → Link Tuya App Account → escaneia QR Code com Smart Life
# 5. Copie da aba Overview: Access ID + Access Secret
TUYA_ACCESS_ID=
TUYA_ACCESS_SECRET=
# Data center: tuyaus (US, default Brasil), tuyaeu (Europa), tuyacn (China),
# tuyain (Índia). Mude se sua conta estiver em outra região.
TUYA_BASE_URL=https://openapi.tuyaus.com

View file

@ -1979,15 +1979,39 @@ Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção
<div id="bt-list" style="margin-top:14px"></div>
<details style="margin-top:10px" open>
<summary style="cursor:pointer;font-family:var(--f-mono);font-size:11px;color:var(--m-text-soft,#7d97ad);letter-spacing:.06em">📋 Diagnóstico (logs do pareamento)</summary>
<div style="display:flex;gap:6px;margin-top:8px">
<button class="btn btn-sm" onclick="copyDiagLog()" style="flex:1">📋 Copiar log</button>
<button class="btn btn-sm" onclick="document.getElementById('bt-diag').innerHTML='';setBleDiag('Log limpo','info')" style="flex:1">🗑 Limpar</button>
<div style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap">
<button class="btn btn-sm btn-primary" onclick="sendDiagLogToServer()" style="flex:1;min-width:140px">📤 Enviar pro servidor</button>
<button class="btn btn-sm" onclick="document.getElementById('bt-diag').innerHTML='';setBleDiag('Log limpo','info')" style="flex:1;min-width:80px">🗑 Limpar</button>
</div>
<div id="bt-diag" style="background:var(--m-bg-2,#0f2a40);border:1px solid var(--m-border,rgba(255,255,255,.08));border-radius:6px;padding:10px;margin-top:6px;max-height:300px;overflow-y:auto;font-family:var(--f-mono);font-size:11px"></div>
</details>
<div class="field-hint" style="margin-top:8px">Limitações: <strong>iOS Safari não suporta Web Bluetooth</strong>. APK Android usa plugin nativo. BMS proprietários (Victron, JBD) podem aparecer mas não expor Battery Service padrão.</div>
</div>
<!-- Smart Home / Casa do Barco -->
<div class="export-card" id="smart-home-card">
<div class="export-card-title">🏠 Casa do Barco · Dispositivos Smart Life</div>
<div class="export-card-text" style="margin-bottom:10px">Controle lâmpadas, tomadas, ventiladores e qualquer dispositivo do app <strong>Smart Life</strong> (Tuya / Alexa via WiFi 2.4 GHz). Funciona com Starlink ou qualquer internet do barco.</div>
<div id="smart-status" style="font-family:var(--f-mono);font-size:10.5px;color:var(--sepia);margin-bottom:10px;letter-spacing:.04em;line-height:1.55">Verificando configuração...</div>
<button class="btn btn-block btn-primary" onclick="openSmartDeviceModal()">+ Adicionar dispositivo</button>
<div id="smart-devices-list" style="margin-top:14px"></div>
<div class="field-hint" style="margin-top:8px"><strong>Setup</strong> (1x): admin precisa configurar credenciais Tuya no servidor (ver <code>.env.example</code>). Depois disso, qualquer dispositivo no Smart Life aparece aqui automático.</div>
</div>
<!-- Modal: lista dispositivos da conta Tuya pra o usuário escolher -->
<div class="modal-backdrop" id="smart-device-modal" onclick="if(event.target===this)closeModal('smart-device-modal')">
<div class="modal" style="max-width:520px">
<div class="modal-head">
<h3>🏠 Adicionar dispositivo</h3>
<button class="icon-btn" onclick="closeModal('smart-device-modal')"></button>
</div>
<div class="modal-body">
<div id="smart-modal-status" style="font-family:var(--f-mono);font-size:11px;color:var(--sepia);margin-bottom:10px;letter-spacing:.04em">Buscando dispositivos da sua conta Smart Life...</div>
<div id="smart-modal-list" class="fleet-list"></div>
</div>
</div>
</div>
<!-- Raymarine Gateway -->
<div class="export-card" id="nmea-gateway-card">
<div class="export-card-title">⚓ Instrumentos Raymarine (gateway NMEA 2000)</div>
@ -2614,7 +2638,7 @@ Hora: {HORA}</textarea>
<div class="zone-toast" id="zone-toast"></div>
<script>
const state={boat:{name:'Shivao',model:''},boats:[],activeBoatId:null,units:'metric',trips:[],maint:[],pending:[],passengers:[],contacts:[],alarmTemplate:'🚨 ALERTA: Shivao saiu da posição de fundeio.\nEstou em {LAT}, {LNG}\nMapa: https://maps.google.com/?q={LAT},{LNG}\nHora: {HORA}',cloud:{url:'',token:'',lastSync:0},auth:null,license:null,webhooks:{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}},anchorHistory:[],zones:[],checklists:[],weatherCfg:{windyKey:'',model:'gfs'}};
const state={boat:{name:'Shivao',model:''},boats:[],activeBoatId:null,units:'metric',trips:[],maint:[],pending:[],passengers:[],contacts:[],alarmTemplate:'🚨 ALERTA: Shivao saiu da posição de fundeio.\nEstou em {LAT}, {LNG}\nMapa: https://maps.google.com/?q={LAT},{LNG}\nHora: {HORA}',cloud:{url:'',token:'',lastSync:0},auth:null,license:null,webhooks:{telegram:{token:'',chatIds:''},discord:{url:''},generic:{url:''}},anchorHistory:[],zones:[],checklists:[],weatherCfg:{windyKey:'',model:'gfs'},smartDevices:[]};
// ============ NAUTICAL HELPERS (multi-boat + fleet + calculations) ============
// Storage interno SEMPRE em metros (ISO). Conversão só pra display.
@ -3746,23 +3770,284 @@ async function updateStorageInfo(){
}catch(e){document.getElementById('storage-info').textContent='—'}
}
// ============ SMART HOME (Tuya / Smart Life) ============
// State.smartDevices = [{id, name, category, category_label, online, lastState, lastSeen}]
// Polling de status só roda quando aba "Arquivo" está visível (economiza dados Starlink).
let _smartPollTimer=null;
let _smartConsecFails={};
function setSmartStatus(msg,kind){
const el=document.getElementById('smart-status');if(!el)return;
const colors={ok:'#22c55e',warn:'#f59e0b',err:'#ef4444',info:'var(--sepia)'};
el.style.color=colors[kind]||'var(--sepia)';
el.textContent=msg;
}
async function refreshSmartStatus(){
if(!cloudConfigured()){setSmartStatus('☁ Nuvem não configurada','warn');return}
try{
const r=await cloudFetch('/api/iot/devices');
const j=await r.json();
const n=(j.devices||[]).length;
setSmartStatus(`✓ Conectado · ${n} dispositivo${n!==1?'s':''} disponível${n!==1?'is':''} na conta Smart Life`,'ok');
}catch(e){
if((e.message||'').includes('503')||(e.message||'').includes('tuya_not_configured')){
setSmartStatus('⚙ Tuya não configurado no servidor (admin: configure TUYA_ACCESS_ID em .env)','warn');
}else{
setSmartStatus('✗ '+(e.message||'erro'),'err');
}
}
}
async function openSmartDeviceModal(){
if(!cloudConfigured()){toast('Configure a nuvem primeiro');return}
openModal('smart-device-modal');
const list=document.getElementById('smart-modal-list');
const status=document.getElementById('smart-modal-status');
list.innerHTML='';
status.textContent='Buscando dispositivos da sua conta Smart Life...';
status.style.color='var(--sepia)';
try{
const r=await cloudFetch('/api/iot/devices');
const j=await r.json();
const devices=j.devices||[];
if(devices.length===0){
status.textContent='Nenhum dispositivo encontrado. Adicione no app Smart Life primeiro.';
return;
}
status.textContent=`${devices.length} dispositivo${devices.length!==1?'s':''} encontrado${devices.length!==1?'s':''}. Toque pra adicionar:`;
list.innerHTML='';
for(const d of devices){
const already=state.smartDevices.find(s=>s.id===d.id);
const dot=d.online?'🟢':'⚪';
const item=document.createElement('div');
item.className='fleet-item';
item.style.cssText='display:flex;justify-content:space-between;align-items:center;padding:10px;border:1px solid var(--m-border,rgba(255,255,255,.08));border-radius:6px;margin-bottom:6px;'+(already?'opacity:.5':'cursor:pointer');
item.innerHTML=`<div><div style="font-weight:600">${dot} ${escapeHtml(d.name)}</div><div style="font-size:11px;color:var(--sepia);font-family:var(--f-mono)">${escapeHtml(d.category_label||d.category||'')}</div></div>${already?'<span style="font-size:11px;color:var(--sepia)">já adicionado</span>':'<button class="btn btn-sm btn-primary">+ Adicionar</button>'}`;
if(!already){
item.onclick=()=>{addSmartDevice(d);closeModal('smart-device-modal')};
}
list.appendChild(item);
}
}catch(e){
if((e.message||'').includes('503')||(e.message||'').includes('tuya_not_configured')){
status.style.color='#f59e0b';
status.innerHTML='⚙ Tuya não configurado.<br>Admin precisa adicionar <code>TUYA_ACCESS_ID</code> + <code>TUYA_ACCESS_SECRET</code> no env do servidor.<br>Veja <code>server/.env.example</code>.';
}else{
status.style.color='#ef4444';
status.textContent='Erro: '+(e.message||'falha');
}
}
}
function addSmartDevice(d){
if(!state.smartDevices)state.smartDevices=[];
if(state.smartDevices.find(s=>s.id===d.id)){toast('Já adicionado');return}
state.smartDevices.push({
id:d.id,
name:d.name,
category:d.category,
category_label:d.category_label||d.category,
online:d.online,
lastState:null,
lastSeen:Date.now(),
addedAt:Date.now(),
});
saveState();
renderSmartDevices();
toast(`+ ${d.name}`);
// Busca estado inicial
refreshSmartDeviceState(d.id).catch(()=>{});
}
function removeSmartDevice(id){
if(!confirm('Remover este dispositivo do Shivão? (não apaga do Smart Life)'))return;
state.smartDevices=(state.smartDevices||[]).filter(d=>d.id!==id);
saveState();
renderSmartDevices();
toast('Removido');
}
function smartDeviceIcon(category){
const icons={cz:'🔌',dj:'💡',kg:'🎚️',fs:'🌀',dd:'💡',xdd:'🔆',dc:'💡',tdq:'⚡',kt:'❄️',wsdcg:'🌡️',mcs:'🚪',sd:'🤖',cl:'🪟',clkg:'🪟',wnykq:'🌡️'};
return icons[category]||'🔧';
}
// Encontra DP de "switch principal" pra renderizar toggle.
// Tuya pode usar switch_1, switch_led, switch — depende do produto.
function findMainSwitch(status){
if(!Array.isArray(status))return null;
// Prioridade: switch > switch_1 > switch_led > primeiro boolean
const candidates=['switch','switch_1','switch_led'];
for(const c of candidates){
const dp=status.find(s=>s.code===c&&typeof s.value==='boolean');
if(dp)return dp;
}
return status.find(s=>typeof s.value==='boolean')||null;
}
async function refreshSmartDeviceState(id){
try{
const r=await cloudFetch('/api/iot/status/'+encodeURIComponent(id));
const j=await r.json();
const dev=state.smartDevices.find(d=>d.id===id);
if(!dev)return;
dev.lastState=j.status;
dev.online=true;
dev.lastSeen=Date.now();
_smartConsecFails[id]=0;
saveState();
renderSmartDevices();
}catch(e){
const dev=state.smartDevices.find(d=>d.id===id);
if(dev){
_smartConsecFails[id]=(_smartConsecFails[id]||0)+1;
// Backoff exponencial: marca offline após 3 falhas consecutivas
if(_smartConsecFails[id]>=3){
dev.online=false;
saveState();
renderSmartDevices();
}
}
}
}
async function toggleSmartDevice(id){
const dev=state.smartDevices.find(d=>d.id===id);
if(!dev)return;
const sw=findMainSwitch(dev.lastState);
const newVal=sw?!sw.value:true;
// OPTIMISTIC UI: atualiza imediato, reverte em caso de erro
if(sw){sw.value=newVal}else if(dev.lastState){dev.lastState.push({code:'switch',value:newVal})}else{dev.lastState=[{code:'switch',value:newVal}]}
renderSmartDevices();
try{
const code=sw?sw.code:'switch';
const r=await cloudFetch('/api/iot/command/'+encodeURIComponent(id),{
method:'POST',
body:JSON.stringify({commands:[{code,value:newVal}]}),
});
await r.json();
// Confirma re-buscando status real após 800ms (Tuya leva ~500ms pra refletir)
setTimeout(()=>{refreshSmartDeviceState(id).catch(()=>{})},800);
}catch(e){
// Reverte UI
if(sw)sw.value=!newVal;
renderSmartDevices();
toast('Falhou: '+(e.message||'erro'));
}
}
function renderSmartDevices(){
const wrap=document.getElementById('smart-devices-list');
if(!wrap)return;
const devs=state.smartDevices||[];
if(devs.length===0){wrap.innerHTML='<div class="field-hint" style="text-align:center;padding:20px 10px">Nenhum dispositivo adicionado.<br>Toque "+ Adicionar" pra ver os dispositivos da sua conta Smart Life.</div>';return}
wrap.innerHTML='';
for(const d of devs){
const sw=findMainSwitch(d.lastState);
const isOn=sw?sw.value:false;
const dot=d.online===false?'⚪':(d.online?'🟢':'🟡');
const lastSeen=d.lastSeen?Math.round((Date.now()-d.lastSeen)/1000):null;
const lastSeenStr=lastSeen==null?'':(lastSeen<60?`${lastSeen}s atrás`:lastSeen<3600?`${Math.round(lastSeen/60)}min atrás`:`${Math.round(lastSeen/3600)}h atrás`);
const card=document.createElement('div');
card.style.cssText='display:flex;align-items:center;gap:12px;padding:12px;border:1px solid var(--m-border,rgba(255,255,255,.08));border-radius:8px;margin-bottom:8px;background:var(--m-bg-2,#0f2a40)';
card.innerHTML=`
<div style="font-size:28px;line-height:1">${smartDeviceIcon(d.category)}</div>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(d.name)}</div>
<div style="font-size:10.5px;color:var(--sepia);font-family:var(--f-mono);letter-spacing:.04em">${dot} ${escapeHtml(d.category_label||'')}${lastSeenStr?' · '+lastSeenStr:''}</div>
</div>
${sw?`<button class="btn btn-sm" style="background:${isOn?'#22c55e':'#475569'};color:white;border:none;min-width:64px" onclick="toggleSmartDevice('${d.id}')">${isOn?'ON':'OFF'}</button>`:`<button class="btn btn-sm btn-primary" onclick="refreshSmartDeviceState('${d.id}')"></button>`}
<button class="icon-btn" title="Remover" onclick="removeSmartDevice('${d.id}')" style="font-size:14px"></button>
`;
wrap.appendChild(card);
}
}
function startSmartPolling(){
if(_smartPollTimer)return;
_smartPollTimer=setInterval(()=>{
if(document.hidden)return; // Pausa em background
const devs=state.smartDevices||[];
for(const d of devs){
// Backoff: não tenta tão frequente em devices offline
const fails=_smartConsecFails[d.id]||0;
const skip=fails>=3&&((Date.now()/1000|0)%30!==0);
if(skip)continue;
refreshSmartDeviceState(d.id).catch(()=>{});
}
},10000);
}
function stopSmartPolling(){
if(_smartPollTimer){clearInterval(_smartPollTimer);_smartPollTimer=null}
}
function initSmartHome(){
if(!state.smartDevices)state.smartDevices=[];
// Limpa entries inválidas (migration defensiva)
state.smartDevices=state.smartDevices.filter(d=>d&&typeof d==='object'&&d.id);
refreshSmartStatus();
renderSmartDevices();
startSmartPolling();
}
(async()=>{
await openDB();loadState();bindHeader();await renderAll();
document.getElementById('fab').style.display='none';
loadTrackingState();
loadAnchorState();
initBattery();
initServiceWorker();
initSensorWidget();
// Realtime sync: conecta WebSocket se cloud configurada
setSyncStatus(cloudConfigured()?'syncing':'disabled');
if(cloudConfigured()){rtConnect();refreshGoogleStatus()}
// tenta auto-fetch do tempo após pequeno delay
setTimeout(maybeAutoFetchWeather,3000);
// Welcome screen — só pra usuários sem login
setTimeout(maybeShowWelcome,300);
// Retoma polling do OAuth se app foi morto durante login Google
setTimeout(resumePollingIfPending,500);
// Wrapper try/catch pra capturar crash no boot (Capacitor WebView pode fechar silenciosamente)
try{
// Detecta crash BLE da sessão anterior via breadcrumb
try{
const lastStep=localStorage.getItem('shivao_ble_last_step');
if(lastStep){
setTimeout(()=>{
try{alert('⚠ Crash detectado na sessão anterior!\nÚltima ação BLE: '+lastStep+'\n\nMe envie esta mensagem.')}catch{}
},2000);
localStorage.removeItem('shivao_ble_last_step');
}
}catch{}
await openDB();
try{loadState()}catch(e){console.error('[boot] loadState',e);try{localStorage.removeItem(STORAGE_KEY)}catch{}/* corrupt state — reset */}
// Migration defensiva: limpa entries inválidas em state.btDevices
if(state.btDevices&&Array.isArray(state.btDevices)){
state.btDevices=state.btDevices.filter(d=>d&&typeof d==='object'&&d.id);
}else{
state.btDevices=[];
}
bindHeader();
await renderAll();
try{document.getElementById('fab').style.display='none'}catch(e){}
try{loadTrackingState()}catch(e){console.error('[boot] tracking',e)}
try{loadAnchorState()}catch(e){console.error('[boot] anchor',e)}
try{initBattery()}catch(e){console.error('[boot] battery',e)}
try{initServiceWorker()}catch(e){console.error('[boot] sw',e)}
try{initSensorWidget()}catch(e){console.error('[boot] sensors',e)}
try{initSmartHome()}catch(e){console.error('[boot] smarthome',e)}
try{setSyncStatus(cloudConfigured()?'syncing':'disabled')}catch(e){}
if(cloudConfigured()){
try{rtConnect()}catch(e){console.error('[boot] rt',e)}
try{refreshGoogleStatus()}catch(e){console.error('[boot] gcal',e)}
}
setTimeout(()=>{try{maybeAutoFetchWeather()}catch(e){}},3000);
// Pede permissão de notificações (alertas tempestade)
setTimeout(async()=>{
try{
const ln=window.Capacitor?.Plugins?.LocalNotifications;
if(ln){await ln.requestPermissions().catch(()=>{})}
else if('Notification' in window&&Notification.permission==='default'){
await Notification.requestPermission().catch(()=>{});
}
}catch{}
},5000);
setTimeout(()=>{try{maybeShowWelcome()}catch(e){}},300);
setTimeout(()=>{try{resumePollingIfPending()}catch(e){}},500);
}catch(e){
// Crash no boot — mostra alert nativo (sobrevive Capacitor crash) + tenta auto-recovery
const msg='Boot error: '+(e.message||e)+'\n'+(e.stack||'').slice(0,300);
console.error('[BOOT CRASH]',e);
try{alert(msg)}catch{}
try{localStorage.clear()}catch{}
}
})();
// Re-tenta init Google Sign-In quando o script async carrega
window.addEventListener('load',()=>setTimeout(()=>{if(document.getElementById('welcome-screen').style.display==='flex')initGoogleSignIn()},500));
@ -5559,19 +5844,172 @@ async function fetchWeatherOpenMeteo(lat,lng){
if(weather.fetching)return;
weather.fetching=true;
try{
const fUrl=`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=temperature_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m,weather_code&hourly=wind_speed_10m,wind_direction_10m,weather_code&forecast_hours=24&wind_speed_unit=kn&timezone=auto`;
const mUrl=`https://marine-api.open-meteo.com/v1/marine?latitude=${lat}&longitude=${lng}&current=wave_height,wave_direction,wave_period&hourly=wave_height&forecast_hours=24&timezone=auto`;
// Forecast: vento, temp, pressão, lightning (CAPE), gust
const fUrl=`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=temperature_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m,weather_code,pressure_msl&hourly=wind_speed_10m,wind_direction_10m,wind_gusts_10m,weather_code,pressure_msl,cape&forecast_hours=24&wind_speed_unit=kn&timezone=auto`;
// Marine: ondas + nível do mar (sea_level_height_msl = maré)
const mUrl=`https://marine-api.open-meteo.com/v1/marine?latitude=${lat}&longitude=${lng}&current=wave_height,wave_direction,wave_period&hourly=wave_height,sea_level_height_msl&forecast_hours=48&timezone=auto`;
const[fR,mR]=await Promise.all([fetch(fUrl),fetch(mUrl).catch(()=>null)]);
const f=await fR.json();
const m=mR&&mR.ok?await mR.json():null;
weather.data={provider:'openmeteo',forecast:f,marine:m,fetchedAt:Date.now(),lat,lng};
weather.lastFetch=Date.now();
weather.lastPos={lat,lng};
weather.tides=extractTides(m);
renderWeather();
checkStormConditions(weather.data,{lat,lng});
}catch(e){console.warn('weather',e.message)}
weather.fetching=false;
}
// Peak detection no array hourly de sea_level_height_msl
// Retorna { nextHigh: {time, height_m}, nextLow: {time, height_m} }
function extractTides(marineData){
if(!marineData?.hourly?.sea_level_height_msl||!marineData?.hourly?.time)return null;
const sl=marineData.hourly.sea_level_height_msl;
const ts=marineData.hourly.time;
const now=Date.now();
const peaks=[]; // {idx, type:'high'|'low', height, time}
for(let i=1;i<sl.length-1;i++){
if(sl[i]==null||sl[i-1]==null||sl[i+1]==null)continue;
const t=new Date(ts[i]).getTime();
if(t<now)continue; // futuros
if(sl[i]>sl[i-1]&&sl[i]>sl[i+1])peaks.push({type:'high',height:sl[i],time:t});
else if(sl[i]<sl[i-1]&&sl[i]<sl[i+1])peaks.push({type:'low',height:sl[i],time:t});
}
return{
nextHigh:peaks.find(p=>p.type==='high')||null,
nextLow:peaks.find(p=>p.type==='low')||null,
all:peaks.slice(0,8), // próximas 8 transições (~ 4 ciclos completos)
};
}
// ============ STORM ALERTS ============
// Limiares — futuros: configuráveis via Settings
const STORM_THRESHOLDS={
windWarn:25, // nós · vento sustentado
windDanger:35,
gustWarn:30,
gustDanger:45,
waveWarn:2.0, // metros
waveDanger:3.5,
pressureDropWarn:3, // hPa em 3h
capeWarn:1500, // J/kg · CAPE > 1500 = atmosfera instável (trovoada)
capeDanger:3000,
};
function checkStormConditions(weatherData,pos){
if(!weatherData)return;
const alerts=[];
const f=weatherData.forecast;
const m=weatherData.marine;
// Vento atual
let windKn=null,gustKn=null,pressureNow=null,cape=null;
if(weatherData.provider==='openmeteo'){
windKn=f?.current?.wind_speed_10m;
gustKn=f?.current?.wind_gusts_10m;
pressureNow=f?.current?.pressure_msl;
// CAPE atual = primeiro valor hourly
cape=f?.hourly?.cape?.[0];
}else if(weatherData.provider==='windy'){
const ms=weatherData.main;
const u=ms?.['wind_u-surface']?.[0]??0;
const v=ms?.['wind_v-surface']?.[0]??0;
windKn=Math.sqrt(u*u+v*v)*1.94384;
const gu=ms?.['wind_u-gust-surface']?.[0]??ms?.['gust-surface']?.[0]??0;
gustKn=gu*1.94384;
pressureNow=ms?.['pressure-surface']?.[0]?ms['pressure-surface'][0]/100:null;
}
// Wave atual
const waveM=m?.current?.wave_height;
// Pressão caindo: compara atual com 3h atrás (dado anterior em hourly)
let pressureDrop3h=0;
if(f?.hourly?.pressure_msl?.length>3){
const p0=f.hourly.pressure_msl[0];
const p3=f.hourly.pressure_msl[3];
if(p0!=null&&p3!=null)pressureDrop3h=p0-p3;
}
// Avalia condições
if(windKn!=null){
if(windKn>=STORM_THRESHOLDS.windDanger)alerts.push({level:'danger',title:'Vento crítico',msg:`${windKn.toFixed(0)}kn · evite navegar`});
else if(windKn>=STORM_THRESHOLDS.windWarn)alerts.push({level:'warn',title:'Vento forte',msg:`${windKn.toFixed(0)}kn · cuidado`});
}
if(gustKn!=null&&gustKn>=STORM_THRESHOLDS.gustDanger)alerts.push({level:'danger',title:'Rajadas perigosas',msg:`pico ${gustKn.toFixed(0)}kn`});
else if(gustKn!=null&&gustKn>=STORM_THRESHOLDS.gustWarn)alerts.push({level:'warn',title:'Rajadas fortes',msg:`pico ${gustKn.toFixed(0)}kn`});
if(waveM!=null){
if(waveM>=STORM_THRESHOLDS.waveDanger)alerts.push({level:'danger',title:'Mar perigoso',msg:`ondas ${waveM.toFixed(1)}m`});
else if(waveM>=STORM_THRESHOLDS.waveWarn)alerts.push({level:'warn',title:'Mar agitado',msg:`ondas ${waveM.toFixed(1)}m`});
}
if(pressureDrop3h>=STORM_THRESHOLDS.pressureDropWarn)alerts.push({level:'warn',title:'Pressão caindo',msg:`-${pressureDrop3h.toFixed(1)}hPa em 3h · tempestade aproximando`});
if(cape!=null){
if(cape>=STORM_THRESHOLDS.capeDanger)alerts.push({level:'danger',title:'Trovoada provável',msg:`CAPE ${Math.round(cape)} J/kg · risco de raios`});
else if(cape>=STORM_THRESHOLDS.capeWarn)alerts.push({level:'warn',title:'Atmosfera instável',msg:`CAPE ${Math.round(cape)} · trovoadas isoladas`});
}
// Salva no state pra UI ler
weather.alerts=alerts;
// Renderiza banner se alguma
renderStormBanner();
// Notificação push em alertas novos (compara com último set)
const sig=alerts.map(a=>a.level+':'+a.title).join('|');
if(alerts.length>0&&sig!==weather._lastAlertSig){
weather._lastAlertSig=sig;
sendStormNotification(alerts);
}else if(alerts.length===0){
weather._lastAlertSig='';
}
}
function renderStormBanner(){
let banner=document.getElementById('storm-banner');
const alerts=weather.alerts||[];
if(alerts.length===0){
if(banner)banner.remove();
return;
}
if(!banner){
banner=document.createElement('div');
banner.id='storm-banner';
banner.style.cssText='position:fixed;top:0;left:0;right:0;z-index:9000;padding:10px 14px;font-family:var(--f-body);font-size:13px;font-weight:600;text-align:center;cursor:pointer;backdrop-filter:blur(8px)';
banner.onclick=()=>{
const det=document.getElementById('storm-banner-details');
if(det)det.style.display=det.style.display==='block'?'none':'block';
};
document.body.appendChild(banner);
}
const worst=alerts.find(a=>a.level==='danger')||alerts[0];
const bg=worst.level==='danger'?'rgba(239,68,68,.94)':'rgba(245,158,11,.94)';
banner.style.background=bg;
banner.style.color='#fff';
banner.innerHTML=`⚠ ${worst.title} · ${worst.msg}${alerts.length>1?` (+${alerts.length-1})`:''} · toque pra detalhes
<div id="storm-banner-details" style="display:none;margin-top:8px;font-size:11.5px;font-weight:400;text-align:left;line-height:1.6">
${alerts.map(a=>`<div>${a.level==='danger'?'🔴':'🟡'} <strong>${a.title}</strong>: ${a.msg}</div>`).join('')}
</div>`;
}
async function sendStormNotification(alerts){
const worst=alerts.find(a=>a.level==='danger')||alerts[0];
const title=worst.level==='danger'?'⚠ ALERTA CRÍTICO':'🟡 Aviso meteorológico';
const body=alerts.map(a=>`${a.title}: ${a.msg}`).join(' · ');
// Capacitor LocalNotifications
try{
const ln=window.Capacitor?.Plugins?.LocalNotifications;
if(ln){
await ln.schedule({notifications:[{
id:Math.floor(Math.random()*1e9),
title,body,smallIcon:'ic_stat_anchor',
}]});
return;
}
}catch(e){console.warn('storm notif',e.message)}
// Web Notifications API
if('Notification' in window){
if(Notification.permission==='granted'){
try{new Notification(title,{body,icon:'/icon.svg',tag:'storm-alert'})}catch{}
}else if(Notification.permission!=='denied'){
try{await Notification.requestPermission()}catch{}
}
}
}
// Helpers de conversão de unidades
function uvToSpeedDir(u,v){
// u: leste-oeste (positivo = leste). v: norte-sul (positivo = norte).
@ -5597,8 +6035,47 @@ function renderWeather(){
if(!el)return;
const d=weather.data;
if(!d){el.innerHTML='';return}
if(d.provider==='windy')return renderWindyWeather(el,d);
return renderOpenMeteoWeather(el,d);
if(d.provider==='windy')renderWindyWeather(el,d);
else renderOpenMeteoWeather(el,d);
// Anexa card de marés se tiver dados
appendTidesCard(el);
}
function appendTidesCard(weatherEl){
if(!weather.tides||!weather.tides.nextHigh)return;
const t=weather.tides;
const fmtTide=(p)=>{
if(!p)return '—';
const d=new Date(p.time);
const hh=d.getHours().toString().padStart(2,'0');
const mm=d.getMinutes().toString().padStart(2,'0');
const diffMin=Math.round((d.getTime()-Date.now())/60000);
const inH=diffMin>=60?`em ${Math.floor(diffMin/60)}h${(diffMin%60).toString().padStart(2,'0')}`:`em ${diffMin}min`;
return `${hh}:${mm} (${inH}) · ${p.height>=0?'+':''}${p.height.toFixed(2)}m`;
};
const tidesHtml=`
<div style="margin-top:10px;padding:10px;background:var(--m-bg-2,rgba(0,0,0,.15));border-radius:8px;border-left:3px solid var(--m-accent,#06b6d4)">
<div style="font-family:var(--f-mono);font-size:10px;letter-spacing:.14em;color:var(--m-text-soft,#7d97ad);text-transform:uppercase;margin-bottom:6px">⚓ Marés (próx. 48h)</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:13px">
<div>
<div style="font-size:9px;color:var(--m-text-soft);letter-spacing:.1em">↑ PREAMAR</div>
<div style="font-family:var(--f-mono);font-weight:600">${fmtTide(t.nextHigh)}</div>
</div>
<div>
<div style="font-size:9px;color:var(--m-text-soft);letter-spacing:.1em">↓ BAIXAMAR</div>
<div style="font-family:var(--f-mono);font-weight:600">${fmtTide(t.nextLow)}</div>
</div>
</div>
</div>`;
// Append apenas se ainda não existe
if(!weatherEl.querySelector('.tides-block')){
const div=document.createElement('div');
div.className='tides-block';
div.innerHTML=tidesHtml;
weatherEl.appendChild(div);
}else{
weatherEl.querySelector('.tides-block').innerHTML=tidesHtml;
}
}
function renderWindyWeather(el,d){
@ -5794,24 +6271,64 @@ async function ensureBleNativeReady(){
_bleNativeInitialized=true;
}
// Copia log diagnóstico pro clipboard
async function copyDiagLog(){
// Envia log diagnóstico direto pro servidor (evita crash de clipboard/share em WebView)
async function sendDiagLogToServer(){
const el=document.getElementById('bt-diag');
if(!el){toast('Log vazio');return}
// Extrai texto puro (innerText preserva quebras de linha)
const txt=`Shivao v${APP_VERSION} · log diagnóstico\n\n`+(el.innerText||el.textContent||'').trim();
if(!cloudConfigured()){toast('Faça login primeiro pra enviar log');return}
toast('📤 Enviando log pro servidor...');
try{
if(navigator.clipboard?.writeText){
await navigator.clipboard.writeText(txt);
toast('✓ Log copiado · cole no chat');
}else{
// Fallback: textarea hack
const ta=document.createElement('textarea');
ta.value=txt;document.body.appendChild(ta);ta.select();
document.execCommand('copy');ta.remove();
toast('✓ Log copiado');
}
}catch(e){toast('Erro ao copiar: '+e.message)}
const r=await cloudFetch('/api/bms/diag-log',{method:'POST',body:JSON.stringify({log:txt})});
const j=await r.json();
if(j.ok)toast('✓ Log enviado · '+(j.file||'OK'));
else throw new Error(j.error||'falhou');
}catch(e){toast('Erro: '+e.message)}
}
// Mostra log num modal pro usuário copiar/compartilhar manualmente
function copyDiagLog(){
const el=document.getElementById('bt-diag');
if(!el){toast('Log vazio');return}
const txt=`Shivao v${APP_VERSION} · log diagnóstico\n\n`+(el.innerText||el.textContent||'').trim();
let modal=document.getElementById('bt-log-modal');
if(modal)modal.remove();
modal=document.createElement('div');
modal.id='bt-log-modal';
modal.style.cssText='position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.85);display:flex;flex-direction:column;padding:16px;backdrop-filter:blur(8px)';
// safe escape
const esc=txt.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
modal.innerHTML=`
<div style="background:#0d2538;border:1px solid #06b6d4;border-radius:12px;padding:16px;display:flex;flex-direction:column;height:100%;max-height:95vh">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<h3 style="margin:0;color:#06b6d4;font-size:15px">📋 Log diagnóstico</h3>
<button onclick="document.getElementById('bt-log-modal').remove()" style="background:transparent;border:none;color:#fff;font-size:24px;cursor:pointer;padding:4px 12px"></button>
</div>
<p style="color:#b3c5d6;font-size:12px;margin:0 0 8px;line-height:1.4">Toque dentro da caixa e segure pra selecionar tudo. Ou toque ↗ Compartilhar pra enviar via WhatsApp.</p>
<textarea id="bt-log-textarea" readonly style="flex:1;width:100%;min-height:280px;background:#0a1f30;color:#e8f1f8;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:10px;font-family:'JetBrains Mono',monospace;font-size:11px;resize:none;-webkit-user-select:text;user-select:text;-webkit-touch-callout:default;outline:none">${esc}</textarea>
<div style="display:flex;gap:8px;margin-top:10px">
<button onclick="bmsShareLog()" style="flex:1;background:#10b981;color:#001a25;border:none;padding:12px;border-radius:8px;font-weight:600;cursor:pointer;font-size:14px">↗ Compartilhar</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Auto-seleciona o texto
setTimeout(()=>{
const ta=document.getElementById('bt-log-textarea');
if(ta){ta.focus();try{ta.select()}catch{}}
},200);
}
async function bmsShareLog(){
const ta=document.getElementById('bt-log-textarea');
if(!ta)return;
if(navigator.share){
try{
await navigator.share({title:'Shivao log diagnóstico',text:ta.value});
}catch(e){/* user cancelou */}
}else{
toast('Compartilhar não disponível · use seleção manual');
}
}
// Diagnóstico visível: mostra cada passo no card BLE
@ -5828,6 +6345,12 @@ function setBleDiag(msg,type){
console.log('[ble]',msg);
}
// Breadcrumb persistente: grava ANTES de cada chamada nativa pra sobreviver crash do WebView
function bleCrumb(step){
try{localStorage.setItem('shivao_ble_last_step',step+' @ '+new Date().toISOString())}catch{}
}
function bleCrumbClear(){try{localStorage.removeItem('shivao_ble_last_step')}catch{}}
async function pairBluetoothDevice(){
const backend=bleBackend();
setBleDiag('Backend: '+(backend||'NENHUM'),backend?'info':'err');
@ -5835,8 +6358,10 @@ async function pairBluetoothDevice(){
try{
let deviceId,deviceName;
if(backend==='capacitor'){
bleCrumb('ensureBleNativeReady');
setBleDiag('Inicializando plugin nativo...');
await ensureBleNativeReady();
bleCrumb('requestDevice');
setBleDiag('Plugin OK · abrindo picker...');
const ble=window.Capacitor.Plugins.BluetoothLe;
const result=await ble.requestDevice({
@ -5844,15 +6369,16 @@ async function pairBluetoothDevice(){
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO],
allowDuplicates:false,
});
if(!result?.deviceId){setBleDiag('Picker cancelado','warn');return}
if(!result?.deviceId){bleCrumbClear();setBleDiag('Picker cancelado','warn');return}
deviceId=result.deviceId;
deviceName=result.name||'Dispositivo BLE';
bleCrumb('selected:'+deviceName);
setBleDiag('Selecionado: '+deviceName+' ('+deviceId+')','ok');
}else{
setBleDiag('Abrindo picker do navegador...');
const device=await navigator.bluetooth.requestDevice({
acceptAllDevices:true,
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO],
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO,'0000ff00-0000-1000-8000-00805f9b34fb','0000fff0-0000-1000-8000-00805f9b34fb','0000ffe0-0000-1000-8000-00805f9b34fb'],
});
if(!device){return}
deviceId=device.id;
@ -5882,11 +6408,11 @@ async function pairBluetoothDevice(){
}
saveState();
renderBluetoothCard();
// Se detectou JBD BMS, ativa parser proprietário
if(info.isJBD){
// Se detectou JBD BMS OU backend é web (sempre tenta probe — descobre serviço dinâmico), ativa probe
if(info.isJBD||backend==='web'){
const ok=await bmsAttachJBD(deviceId,deviceName);
if(ok)toast('✓ '+deviceName+' · JBD BMS ativo');
else toast('✓ '+deviceName+' (BMS detectado mas falha no parser)');
if(ok)toast('✓ '+deviceName+' · BMS ativo');
else toast('✓ '+deviceName+' (sem BMS detectável)');
}else{
toast('✓ '+deviceName+(info.battery!=null?' · '+info.battery+'%':' (sem leitura de bateria)'));
}
@ -5906,6 +6432,7 @@ async function connectAndRead(deviceId,deviceName){
if(backend==='capacitor'){
const ble=window.Capacitor.Plugins.BluetoothLe;
try{
bleCrumb('ble.connect');
await ble.connect({deviceId,timeout:30000});
setBleDiag('GATT conectado','ok');
}catch(e){
@ -5915,13 +6442,12 @@ 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 + auto-detect protocols
try{
bleCrumb('ble.getServices');
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');
@ -6016,10 +6542,107 @@ function bytesToBase64(arr){
return btoa(bin);
}
// Probe: lista characteristics + identifica notify/write chars + tenta protocolos
// Probe via Web Bluetooth API (Chrome PC)
async function bmsProbeWebBluetooth(deviceId,deviceName){
const conn=_bleConnections.get(deviceId);
const device=conn?.device;
if(!device){setBleDiag('Device sem referência (re-pareie)','err');return false}
try{
setBleDiag(`📦 Shivao v${APP_VERSION} · Probe WEB iniciado`,'info');
const server=device.gatt.connected?device.gatt:await device.gatt.connect();
setBleDiag('GATT web conectado','ok');
let svc=null;
try{svc=await server.getPrimaryService('0000ff00-0000-1000-8000-00805f9b34fb');setBleDiag('Service ff00 OK','info')}
catch(e){setBleDiag('Service ff00 falhou: '+e.message,'err');return false}
const chars=await svc.getCharacteristics();
setBleDiag(`Svc ff00 · ${chars.length} chars`,'info');
let notifyChar=null,writeChar=null;
for(const c of chars){
const p=c.properties;
const propsStr=[p.notify&&'notify',p.indicate&&'indicate',p.write&&'write',p.writeWithoutResponse&&'wnr',p.read&&'read'].filter(Boolean).join(',');
const cu=c.uuid.toLowerCase();
setBleDiag(` ${cu.slice(4,8)} [${propsStr}]`,'info');
if(!notifyChar&&(p.notify||p.indicate))notifyChar=c;
if(!writeChar&&(p.write||p.writeWithoutResponse))writeChar=c;
}
if(!notifyChar){setBleDiag('Sem char notify','err');return false}
if(!writeChar){
// Workaround: BMS chinês declara ff02 só [read] mas aceita writes (firmware permissivo).
// Força usar primeira char não-notify e tenta writeValue mesmo assim.
writeChar=chars.find(c=>c.uuid.toLowerCase()!==notifyChar.uuid.toLowerCase())||chars[0];
setBleDiag(`⚠ Sem property write · forçando ${writeChar.uuid.slice(4,8)}`,'warn');
}
setBleDiag(`Notify=${notifyChar.uuid.slice(4,8)} Write=${writeChar.uuid.slice(4,8)}`,'ok');
notifyChar.addEventListener('characteristicvaluechanged',(ev)=>{
const dv=ev.target.value;
const hex=Array.from(new Uint8Array(dv.buffer)).map(b=>b.toString(16).padStart(2,'0')).join(' ');
setBleDiag(`← RX ${dv.byteLength}b: ${hex.slice(0,100)}${hex.length>100?'...':''}`,'ok');
// Se há buffer pendente JBD, é chunk de continuação — roteia sempre
if(_bmsBuffers.has(deviceId)){
bmsHandleChunk(deviceId,dv,deviceName);
return;
}
// Buffer vazio: primeiro byte determina protocolo (início de novo pacote)
const first=new Uint8Array(dv.buffer)[0];
if(first===0xDD)bmsHandleChunk(deviceId,dv,deviceName);
else if(first===0xAA)bmsHandleJK(deviceId,dv,deviceName);
else if(first===0xA5)bmsHandleDaly(deviceId,dv,deviceName);
});
await notifyChar.startNotifications();
setBleDiag('Notify ativo · iniciando wake...','ok');
await new Promise(r=>setTimeout(r,500));
try{
const fn=writeChar.properties.writeWithoutResponse?'writeValueWithoutResponse':'writeValue';
await writeChar[fn](new Uint8Array([0x5A,0x5A,0x5A,0x5A]));
setBleDiag('Wake 5A x4 enviado','info');
}catch(e){setBleDiag('Wake skip: '+e.message,'info')}
await new Promise(r=>setTimeout(r,1500));
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]},
];
for(const p of PROTOCOLS){
try{
setBleDiag(`→ TX ${p.name}`,'info');
const fn=writeChar.properties.writeWithoutResponse?'writeValueWithoutResponse':'writeValue';
await writeChar[fn](new Uint8Array(p.bytes));
setBleDiag(`✔ write ${p.name} OK`,'info');
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! · polling 5s ativado`,'ok');
if(dev){dev.bmsProtocol=p.name;dev.isJBD=true;saveState()}
conn.notifyChar=notifyChar;conn.writeChar=writeChar;
// Polling a cada 5s pra atualização contínua
if(conn._pollInterval)clearInterval(conn._pollInterval);
conn._pollInterval=setInterval(async()=>{
try{
const fn2=writeChar.properties.writeWithoutResponse?'writeValueWithoutResponse':'writeValue';
await writeChar[fn2](new Uint8Array(p.bytes));
}catch(e){
// Conexão caiu: para o polling
if(/disconnected|connection|gatt/i.test(e.message||'')){
setBleDiag('Polling interrompido: '+e.message,'warn');
clearInterval(conn._pollInterval);conn._pollInterval=null;
}
}
},5000);
return true;
}
setBleDiag(`✗ ${p.name} sem RX`,'info');
}catch(e){setBleDiag(`${p.name} erro: ${e.message}`,'warn')}
}
setBleDiag('⚠ Nenhum protocolo respondeu','err');
return false;
}catch(e){setBleDiag('Probe web falhou: '+e.message,'err');return false}
}
// Probe Capacitor: lista characteristics + identifica notify/write chars + tenta protocolos
async function bmsProbeAndAttach(deviceId,deviceName){
const backend=bleBackend();
if(backend!=='capacitor'){setBleDiag('Probe requer Capacitor (APK)','warn');return false}
if(backend==='web')return bmsProbeWebBluetooth(deviceId,deviceName);
if(backend!=='capacitor'){setBleDiag('Backend desconhecido','warn');return false}
const ble=window.Capacitor.Plugins.BluetoothLe;
try{
setBleDiag(`📦 Shivao v${APP_VERSION} · Probe iniciado`,'info');
@ -6057,28 +6680,49 @@ async function bmsProbeAndAttach(deviceId,deviceName){
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');
// Detecta se write char só aceita writeWithoutResponse
let writeOnlyWnr=false;
for(const svc of allSvcs){
if((svc.uuid||'').toLowerCase()!==foundService)continue;
for(const c of (svc.characteristics||[])){
if((c.uuid||'').toLowerCase()===writeChar){
const p=c.properties||{};
writeOnlyWnr=(!p.write&&p.writeWithoutResponse);
}
}
}
setBleDiag(`Notify=${notifyChar.slice(4,8)} Write=${writeChar.slice(4,8)}${writeOnlyWnr?' (force-wnr)':''} Svc=${foundService.slice(4,8)}`,'ok');
// Subscribe + handler
const listenerKey='notification|'+deviceId+'|'+foundService+'|'+notifyChar;
ble.addListener(listenerKey,(ev)=>{
const dv=parseDataView(ev.value);
const hex=Array.from(new Uint8Array(dv.buffer)).map(b=>b.toString(16).padStart(2,'0')).join(' ');
setBleDiag('← RX '+dv.byteLength+'b: '+hex.slice(0,100)+(hex.length>100?'...':''),'ok');
// Detecta protocolo por byte de início
// Se buffer JBD pendente, é continuação — roteia sempre
if(_bmsBuffers.has(deviceId)){
bmsHandleChunk(deviceId,dv,deviceName);
return;
}
// Detecta protocolo por byte de início (novo pacote)
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:foundService,characteristic:notifyChar});
setBleDiag('Notify ativo · aguardando 800ms...','ok');
try{
await ble.startNotifications({deviceId,service:foundService,characteristic:notifyChar});
setBleDiag('Notify ativo · aguardando 800ms...','ok');
}catch(e){setBleDiag('startNotifications erro: '+(e.message||e.errorMessage||'?'),'err');return false}
await new Promise(r=>setTimeout(r,800));
// Wake-up REMOVIDO no path Capacitor — chamadas extra causavam crash nativo
// Provamos no PC (Web Bluetooth) que JBD-0x03 direto funciona
// Salva config no device pra reuso
const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev){
dev.bmsService=foundService;
dev.bmsNotifyChar=notifyChar;
dev.bmsWriteChar=writeChar;
dev.bmsForceWnr=writeOnlyWnr;
dev.isJBD=true;
saveState();
}
@ -6094,8 +6738,12 @@ async function bmsWriteCmd(deviceId,bytes,withoutResponse){
const ble=window.Capacitor?.Plugins?.BluetoothLe;
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';
await ble[fn]({deviceId,service:dev.bmsService,characteristic:dev.bmsWriteChar,value:bytesToBase64(bytes)});
const useWnr=withoutResponse||dev.bmsForceWnr;
const fn=useWnr?'writeWithoutResponse':'write';
// Timeout 3s: plugins/firmware podem travar mesmo com WNR
const writePromise=ble[fn]({deviceId,service:dev.bmsService,characteristic:dev.bmsWriteChar,value:bytesToBase64(bytes)});
const timeoutPromise=new Promise((_,rej)=>setTimeout(()=>rej(new Error('write timeout 3s')),3000));
await Promise.race([writePromise,timeoutPromise]);
}
async function bmsTryProtocols(deviceId){
@ -6108,18 +6756,37 @@ async function bmsTryProtocols(deviceId){
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
try{
await bmsWriteCmd(deviceId,p.bytes,p.wnr);
setBleDiag(`✔ write ${p.name} retornou`,'info');
}catch(we){
setBleDiag(`✗ write ${p.name} erro: ${we.message||we.errorMessage}`,'warn');
// Continue pro próximo protocolo mesmo se write falhar
}
// Aguarda RX por 2.5s
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
setBleDiag(`✓ ${p.name} respondeu! · polling 5s ativado`,'ok');
if(dev)dev.bmsProtocol=p.name;
setInterval(async()=>{try{await bmsWriteCmd(deviceId,p.bytes,p.wnr)}catch{}},30000);
// Polling 5s pra atualização contínua (era 30s — usuário queria constante)
const conn=_bleConnections.get(deviceId)||{};
if(conn._pollInterval)clearInterval(conn._pollInterval);
conn._pollInterval=setInterval(async()=>{
try{await bmsWriteCmd(deviceId,p.bytes,p.wnr)}
catch(e){
if(/disconnected|connection|not connected/i.test(e.message||e.errorMessage||'')){
clearInterval(conn._pollInterval);conn._pollInterval=null;
setBleDiag('Polling parou: GATT desconectou','warn');
}
}
},5000);
_bleConnections.set(deviceId,conn);
return true;
}else{
setBleDiag(`✗ ${p.name} sem RX em 2.5s`,'info');
}
}catch(e){setBleDiag(`${p.name} falhou: ${e.message||e.errorMessage}`,'warn')}
}catch(e){setBleDiag(`${p.name} loop erro: ${e.message||e.errorMessage}`,'warn')}
}
setBleDiag('⚠ Nenhum protocolo funcionou. BMS pode usar firmware proprietário não documentado.','err');
return false;
@ -6289,7 +6956,7 @@ async function removeBluetoothDevice(id){
renderBluetoothCard();
}
const APP_VERSION='1.10.6';
const APP_VERSION='1.12.0';
function renderBluetoothCard(){
const el=document.getElementById('bt-list');
const supportEl=document.getElementById('bt-support');

View file

@ -1,7 +1,7 @@
// Shivao Service Worker — offline real
// Estratégia: shell precachado, tiles cache-first, windy network-first, /api passa direto.
// Versão usada nos cache names — bumpa essa string pra invalidar caches antigos em deploys.
const VERSION = 'shivao-v1.7.0';
const VERSION = 'shivao-v1.12.0';
const SHELL_CACHE = `shivao-shell-${VERSION}`;
const TILES_CACHE = 'shivao-tiles-v1'; // separado pra não invalidar tiles em update do shell
const WINDY_CACHE = `shivao-windy-${VERSION}`;

View file

@ -12,6 +12,7 @@ import { hashPassword, verifyPassword, signAccessToken, signRefreshToken, verify
import * as billing from './billing.js';
import { initRealtime, broadcastStateChange, getOnlineCount } from './realtime.js';
import * as gcal from './google-calendar.js';
import * as tuya from './tuya.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = parseInt(process.env.PORT || '3000');
@ -126,6 +127,125 @@ app.get('/api/auth/me', requireAuth, (req, res) => {
res.json(req.user);
});
// Diagnostic log endpoint — recebe log do BLE pra debugar
app.post('/api/bms/diag-log', requireAuth, (req, res) => {
const { log } = req.body || {};
if (!log || typeof log !== 'string') return res.status(400).json({ error: 'log string required' });
const dir = path.join(db.dataDir, 'diag-logs');
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const file = path.join(dir, `${req.user.id}-${ts}.txt`);
try {
fs.writeFileSync(file, log.slice(0, 50000));
db.audit(req.user.id, 'bms_diag_log', 'bluetooth', null, { bytes: log.length, file: path.basename(file) }, req.ip);
res.json({ ok: true, file: path.basename(file) });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ADMIN: lista TODOS os logs (BOAT_TOKEN apenas)
app.get('/api/bms/diag-log/_all', requireAuth, (req, res) => {
if (!req.user.viaBoatToken) return res.status(403).json({ error: 'admin only' });
const dir = path.join(db.dataDir, 'diag-logs');
try {
if (!fs.existsSync(dir)) return res.json({ files: [] });
const files = fs.readdirSync(dir).map(f => {
const stat = fs.statSync(path.join(dir, f));
return { name: f, size: stat.size, mtime: stat.mtime };
}).sort((a, b) => b.mtime - a.mtime);
res.json({ files });
} catch (e) { res.status(500).json({ error: e.message }) }
});
// Lista logs disponíveis (debug)
app.get('/api/bms/diag-log', requireAuth, (req, res) => {
const dir = path.join(db.dataDir, 'diag-logs');
try {
if (!fs.existsSync(dir)) return res.json({ files: [] });
const files = fs.readdirSync(dir)
.filter(f => f.startsWith(`${req.user.id}-`))
.map(f => {
const stat = fs.statSync(path.join(dir, f));
return { name: f, size: stat.size, mtime: stat.mtime };
})
.sort((a, b) => b.mtime - a.mtime);
res.json({ files });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Lê conteúdo de um log específico
app.get('/api/bms/diag-log/:file', requireAuth, (req, res) => {
const file = req.params.file.replace(/[^a-zA-Z0-9._-]/g, '');
// Admin (BOAT_TOKEN) lê qualquer; user normal só os próprios
if (!req.user.viaBoatToken && !file.startsWith(`${req.user.id}-`)) {
return res.status(403).json({ error: 'forbidden' });
}
const fullPath = path.join(db.dataDir, 'diag-logs', file);
try {
if (!fs.existsSync(fullPath)) return res.status(404).json({ error: 'not found' });
const content = fs.readFileSync(fullPath, 'utf8');
res.type('text/plain').send(content);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ===== IoT (Smart Life / Tuya) =====
// Proxy assinado pra Tuya Cloud API. Access Secret nunca vai pro client.
app.get('/api/iot/devices', requireAuth, async (req, res) => {
if (!tuya.isEnabled()) return tuya.disabledResponse(res);
try {
const r = await tuya.listDevices();
if (r.error) return res.status(502).json({ error: r.error, code: r.code });
// Enriquece com label humano
const devices = r.devices.map(d => ({ ...d, category_label: tuya.categoryLabel(d.category) }));
res.json({ devices });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/iot/status/:deviceId', requireAuth, async (req, res) => {
if (!tuya.isEnabled()) return tuya.disabledResponse(res);
const deviceId = (req.params.deviceId || '').replace(/[^a-zA-Z0-9]/g, '');
if (!deviceId) return res.status(400).json({ error: 'deviceId required' });
try {
const r = await tuya.getDeviceStatus(deviceId);
if (r.error) return res.status(502).json({ error: r.error, code: r.code });
res.json({ status: r.status });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.post('/api/iot/command/:deviceId', requireAuth, async (req, res) => {
if (!tuya.isEnabled()) return tuya.disabledResponse(res);
const deviceId = (req.params.deviceId || '').replace(/[^a-zA-Z0-9]/g, '');
const { commands } = req.body || {};
if (!deviceId) return res.status(400).json({ error: 'deviceId required' });
if (!Array.isArray(commands) || commands.length === 0) {
return res.status(400).json({ error: 'commands array required' });
}
// Validação básica: cada item precisa ter code:string + value
for (const c of commands) {
if (!c || typeof c.code !== 'string') {
return res.status(400).json({ error: 'each command needs {code:string, value:any}' });
}
}
try {
const r = await tuya.sendCommand(deviceId, commands);
if (!r.ok) return res.status(502).json({ error: r.error, code: r.code });
db.audit(req.user.id, 'iot_command', 'tuya', deviceId, { commands }, req.ip);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ===== Google Login via OAuth redirect (pra apps Capacitor onde GSI popup não funciona) =====
// In-memory store: session_id → { tokens, createdAt }. Auto-expira em 10min.
const pendingGoogleSessions = new Map();
@ -347,7 +467,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.10.6/Shivao-v1.10.6.apk';
const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.12.0/Shivao-v1.12.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)

165
server/src/tuya.js Normal file
View file

@ -0,0 +1,165 @@
// Tuya OpenAPI client — Smart Life devices via HMAC-SHA256 signing
// Docs: https://developer.tuya.com/en/docs/cloud/cloud-api-best-practice
//
// Why server-side: Access Secret never goes to client (PWA), pra evitar token
// leak via DevTools. Client só conhece deviceId; server assina e proxia.
import crypto from 'node:crypto';
const ACCESS_ID = process.env.TUYA_ACCESS_ID || '';
const ACCESS_SECRET = process.env.TUYA_ACCESS_SECRET || '';
// Tuya tem 5 data centers. Escolha o mesmo da conta Smart Life (Eu → Account → Region):
// us = openapi.tuyaus.com (default North America)
// eu = openapi.tuyaeu.com (Europe)
// cn = openapi.tuyacn.com (China)
// in = openapi.tuyain.com (India)
// sg = openapi-sg.iotbing.com (South Asia)
// Brasil normalmente cai no US.
const BASE_URL = process.env.TUYA_BASE_URL || 'https://openapi.tuyaus.com';
let cachedToken = null; // {access_token, expires_at_ms}
export function isEnabled() {
return !!(ACCESS_ID && ACCESS_SECRET);
}
export function disabledResponse(res) {
return res.status(503).json({
error: 'tuya_not_configured',
message: 'Configure TUYA_ACCESS_ID + TUYA_ACCESS_SECRET no env do servidor.',
setup_url: 'https://iot.tuya.com',
});
}
function sha256(str) {
return crypto.createHash('sha256').update(str, 'utf8').digest('hex');
}
function hmacSha256(key, str) {
return crypto.createHmac('sha256', key).update(str, 'utf8').digest('hex').toUpperCase();
}
// stringToSign = HTTPMethod + "\n" + Content-SHA256 + "\n" + Headers + "\n" + Url
// Headers fica vazio porque não usamos signedHeaders custom.
function buildStringToSign(method, urlPath, body) {
const contentSha = sha256(body || '');
return `${method.toUpperCase()}\n${contentSha}\n\n${urlPath}`;
}
// Para token endpoint: sign = client_id + t + nonce + stringToSign
// Para business endpoints: sign = client_id + access_token + t + nonce + stringToSign
function buildSignature(method, urlPath, body, withToken) {
const t = String(Date.now());
const nonce = crypto.randomBytes(16).toString('hex');
const stringToSign = buildStringToSign(method, urlPath, body);
const tokenPart = withToken && cachedToken ? cachedToken.access_token : '';
const str = ACCESS_ID + tokenPart + t + nonce + stringToSign;
const sign = hmacSha256(ACCESS_SECRET, str);
return { sign, t, nonce };
}
async function fetchToken() {
const urlPath = '/v1.0/token?grant_type=1';
const { sign, t, nonce } = buildSignature('GET', urlPath, '', false);
const r = await fetch(BASE_URL + urlPath, {
method: 'GET',
headers: {
'client_id': ACCESS_ID,
'sign': sign,
'sign_method': 'HMAC-SHA256',
't': t,
'nonce': nonce,
},
});
const j = await r.json();
if (!j.success) throw new Error(`tuya_token_failed: ${j.code} ${j.msg}`);
cachedToken = {
access_token: j.result.access_token,
refresh_token: j.result.refresh_token,
expires_at_ms: Date.now() + (j.result.expire_time * 1000) - 60000, // refresh 1min antes
};
return cachedToken;
}
async function ensureToken() {
if (cachedToken && Date.now() < cachedToken.expires_at_ms) return cachedToken;
return await fetchToken();
}
// Request genérico assinado a um endpoint Tuya OpenAPI
async function tuyaRequest(method, urlPath, body) {
await ensureToken();
const bodyStr = body ? JSON.stringify(body) : '';
const { sign, t, nonce } = buildSignature(method, urlPath, bodyStr, true);
const r = await fetch(BASE_URL + urlPath, {
method,
headers: {
'client_id': ACCESS_ID,
'access_token': cachedToken.access_token,
'sign': sign,
'sign_method': 'HMAC-SHA256',
't': t,
'nonce': nonce,
'Content-Type': 'application/json',
},
body: bodyStr || undefined,
});
const j = await r.json();
// Token expirado mid-flight: invalida + retry 1x
if (j.code === 1010 || j.code === 1011 || j.code === 1004) {
cachedToken = null;
return tuyaRequest(method, urlPath, body);
}
return j;
}
// ===== APIs públicas =====
// Lista todos os devices vinculados ao app Smart Life autorizado
// (vinculado em iot.tuya.com → Cloud → Devices → Link Tuya App Account)
export async function listDevices(uid) {
// uid é opcional; sem uid retorna devices da org. Pra Karlão (1 conta) ok sem.
const res = await tuyaRequest('GET', '/v1.3/iot-03/devices?source_type=tuyaUser&source_id=' + (uid || ''), null);
if (!res.success) return { error: res.msg, code: res.code, devices: [] };
return {
devices: (res.result?.list || []).map(d => ({
id: d.id,
name: d.name,
online: d.online,
product_id: d.product_id,
product_name: d.product_name,
category: d.category, // 'cz' = socket, 'dj' = light, 'kg' = switch, 'fs' = fan, etc.
icon: d.icon,
ip: d.ip,
})),
};
}
// Status atual do device (lista de DPs / data points)
export async function getDeviceStatus(deviceId) {
const res = await tuyaRequest('GET', `/v1.0/iot-03/devices/${deviceId}/status`, null);
if (!res.success) return { error: res.msg, code: res.code };
// Result é array tipo [{code:'switch_1', value:true}, {code:'bright_value', value:600}]
return { status: res.result || [] };
}
// Dispara comando: array de {code, value}
// Ex pra ligar: [{code:'switch_1', value:true}]
// Ex pra dimmer: [{code:'switch_led', value:true}, {code:'bright_value_v2', value:800}]
export async function sendCommand(deviceId, commands) {
const res = await tuyaRequest('POST', `/v1.0/iot-03/devices/${deviceId}/commands`, { commands });
if (!res.success) return { ok: false, error: res.msg, code: res.code };
return { ok: true };
}
// Categoria → função humanizada (ajuda UI a renderizar ícone certo)
export function categoryLabel(cat) {
const map = {
cz: 'Tomada', dj: 'Lâmpada', kg: 'Interruptor', fs: 'Ventilador',
dd: 'Fita LED', xdd: 'Luminária', dc: 'Cordão LED', tdq: 'Disjuntor',
cwwsq: 'Alimentador', kt: 'Ar-condicionado', wsdcg: 'Sensor temp/umid',
mcs: 'Sensor porta', co2bj: 'Sensor CO2', sd: 'Robô aspirador',
cl: 'Cortina', clkg: 'Switch cortina', wnykq: 'Termostato',
};
return map[cat] || cat || 'Dispositivo';
}