Compare commits

...

28 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
PontualTech / Karlão
cd4aa9c753 feat(ble): botão Copiar log + Limpar + painel sempre aberto v1.10.6
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Karlão mandou log antigo achando que era novo (não dava pra distinguir
log v1.10.4 de v1.10.5 sem ler timestamps). Adicionado botão 📋 Copiar
log que copia texto puro pro clipboard com header 'Shivao vX.Y.Z · log
diagnóstico' — fica óbvio qual versão está rodando.

Mudanças:
- <details> agora abre por padrão (open attribute)
- Botão 📋 Copiar log (navigator.clipboard + fallback textarea)
- Botão 🗑 Limpar pra zerar histórico antes de novo teste
- Painel max-height 200→300px + font-family mono
- Toast confirma cópia OK

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 07:30:56 -03:00
PontualTech / Karlão
2fca191676 fix(ble): bmsManualRead reconecta GATT antes do probe v1.10.5
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Bug v1.10.4: clicar 🔄 Re-ler gerava 'getServices erro: Bluetooth LE
not initialized' porque Android desconecta GATT em background pra
economizar bateria, mas state.btDevices ainda mostra 'conectado'.

Fix: bmsManualRead agora faz 3 passos sequenciais com diagnóstico:
1. ensureBleNativeReady() — garante plugin inicializado
2. ble.connect({deviceId, timeout:15000}) — reconecta GATT (silent
   se 'already connected')
3. bmsProbeAndAttach() — probe completo

Cada passo emite log próprio: "Plugin init OK", "GATT reconectado"
ou "GATT já conectado", "🔍 Enumerando characteristics..."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 07:18:44 -03:00
PontualTech / Karlão
840f0b0dc5 fix(ble): remove requestMtu/requestConnectionPriority — crash em plugin v6.x v1.10.4
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Bug crítico v1.10.3: app crashava ao parear ou clicar Re-ler.
Causa: chamadas a ble.requestConnectionPriority() e ble.requestMtu()
não existem no @capacitor-community/bluetooth-le v6.1.0 (foram
adicionadas em v7+). Sem o método, o plugin lança exception nativa
não-tratada que escapa do try/catch JS e derruba o WebView Capacitor.

Fix:
- Remove requestMtu + requestConnectionPriority
- getServices() chamado UMA vez (não no loop por vendor)
- Filtra services por prefixo vendor (ff00, fff0, ffe0, 0203)
- Lista todos chars descobertos com properties no diagnóstico
- Loga "getServices retornou N services" pra confirmar que enumeração rodou

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 06:59:35 -03:00
PontualTech / Karlão
bba53e4548 feat(bms): versão visível no diagnóstico + MTU bump + connection HIGH v1.10.3
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Karlão reportou log v1.10.1 quando deveria ser v1.10.2 — usuário não
sabia se atualização chegou. Adicionado:

- "Shivao v1.10.X" mostrado no card BMS (status line)
- Primeira linha do log: "📦 Shivao v1.10.3 · Probe iniciado"
- requestConnectionPriority=HIGH antes do probe (alguns BMS exigem)
- requestMtu(247) — Xiaoxiang BMS oficiais às vezes ignoram comandos
  enviados em MTU baixo (23 default), exigem 247 pra responder

Próximo passo: usuário atualiza pra v1.10.3, primeira linha do log
confirma versão. Se ainda zero RX, partiremos pra CCCD descriptor manual.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:04:59 -03:00
PontualTech / Karlão
ca3dd4d7b2 feat(bms): probe automático de protocolo BMS (JBD/JK/Daly) v1.10.2
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Bug v1.10.1: BMS bat2 do Karlão expõe service ff00 mas zero RX no log
após enviar comando JBD 0x03. Significa BMS usa firmware proprietário
não-JBD.

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:24:00 -03:00
PontualTech / Karlão
578793d097 feat(bms): dashboard visual + RX log bytes + writeWithoutResponse fallback v1.10.1
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
3 problemas atacados após teste de Karlão:

1) BMS conectou mas não respondeu o comando 0x03:
   - Log mostra "← RX X bytes: hex" pra cada notification recebida
   - Listener registrado ANTES de startNotifications (fix de race condition)
   - Wait 500ms entre subscribe e primeiro write (alguns BMS precisam wake)
   - Após 5s sem resposta, tenta writeWithoutResponse automaticamente
   - Botão 🔄 Re-ler manual no card pra forçar query

2) Karlão pediu "monitor visual humano":
   - Modal full-screen " Monitor da Bateria" com:
     * Círculo SoC grande SVG (ring chart 160x160) com cor por nível
     * Status flow grande:  CARREGANDO / ↓ DESCARGA / — REPOUSO
     * Tempo restante calculado (descarga = remainCap/current)
     * Tempo até cheia (carga = (totalCap-remainCap)/current)
     * 4 cards: Tensão · Corrente · Potência · Capacidade
     * Linha info: ciclos · temperaturas · firmware version
     * Grid de células coloridas por health (vermelho <3.0V, verde >3.6V)
     * Auto-refresh 10s enquanto modal aberto
   - Botão 📊 Monitor no card BMS abre dashboard

3) Estado de erro mais claro:
   - Dashboard mostra "Aguardando dados..." se b.voltage ainda não chegou
   - Diagnóstico log destaca chunks RX em azul

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:06:44 -03:00
PontualTech / Karlão
8f3870412d feat(bms): parser JBD/LLT Power BMS — voltagem, corrente, SOC, células, temps v1.10.0
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Identificado pelo diagnóstico: BMS do Karlão (bat2) usa protocolo JBD
(Jiabaida) — service ff00, notify ff01, write ff02. Padrão de mercado
para BMS chineses (Overkill Solar, Hankzor, JBD oficial, LLT Power,
Xiaoxiang) — cobre ~80% dos BMS BLE de lítio.

Implementação:
- Auto-detect: ao parear, se device tem service ff00 → ativa parser JBD
- bmsAttachJBD() subscribe na char ff01 (notify) + envia comando 0x03
- Comando: DD A5 03 00 FF FD 77 (Read Basic Info)
- Reassembly de chunks BLE (max 20 bytes/chunk) até receber 0x77 (end)
- Parser decodifica: voltage (uint16/100), current (int16/100, signed),
  remaining/total capacity (Ah), cycle count, protection bitfield,
  SoC (%), FET status, cell count, temperatures (kelvin*10 → °C)
- Re-poll a cada 30s pra atualizar dados em tempo real
- Auto-sync lastBattery com BMS soc pra card resumido

UI expandida:
- Card BMS com 3 stats grandes: TENSÃO (V) · CORRENTE (A) · POTÊNCIA (W)
- Cor dinâmica: verde se carregando (current>0), amarelo se descarregando
- Linha extra: status flow + capacidade (remain/total Ah) + ciclos + temps
- Block de células individuais (4S/8S/16S detectado automaticamente)
- Border-left do card colorido conforme estado de fluxo

Protocolo de referência: gitlab.com/bms-tools/bms-tools

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:56:48 -03:00
PontualTech / Karlão
5dd3362469 feat(ble): diagnóstico verboso pra debugar pareamento BLE v1.9.2
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Karlão reportou: "localiza mas não pareia" (picker abre, seleciona
device, mas conexão falha silenciosa). Sem ver onde trava, impossível fix.

Adicionado:
- setBleDiag() exibe cada step com timestamp + cor (info/ok/warn/err)
- Painel <details> expansível "📋 Diagnóstico" no card BLE
- Logs em cada operação: backend, init, picker, connect, getServices,
  battery read, notifications, device info
- Timeout do connect aumentado: 15s → 30s (BMS podem demorar)
- getServices() lista UUIDs descobertos no device — descobre se BMS
  expõe Battery Service padrão ou só protocolo proprietário
- Mensagens explícitas de erro em cada catch (e.message ou errorMessage)

Próximo passo: Karlão testa, abre painel diagnóstico, me passa screenshot
ou copia o log. Daí descubro exatamente onde trava (timeout, sem service,
permissão negada, etc).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:39:41 -03:00
PontualTech / Karlão
52ee668879 fix(ble): plugin nativo @capacitor-community/bluetooth-le pra APK Android v1.9.1
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run
Bug v1.9.0: APK Android mostrava "Web Bluetooth não suportado" porque
Android System WebView desabilita Web Bluetooth API por padrão (segurança).

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:24:50 -03:00
13 changed files with 3133 additions and 239 deletions

1
.gitignore vendored
View file

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

File diff suppressed because it is too large Load diff

View file

@ -7,8 +7,8 @@ android {
applicationId "br.com.pontualtech.shivao"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 13
versionName "1.9.0"
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

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

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

View file

@ -1,6 +1,6 @@
{
"name": "shivao-mobile",
"version": "1.9.0",
"version": "1.12.0",
"description": "Shivao app nativo (Capacitor wrapper Android/iOS)",
"main": "index.js",
"type": "module",
@ -12,6 +12,7 @@
"ios:open": "npx cap open ios"
},
"dependencies": {
"@capacitor-community/bluetooth-le": "^6.1.0",
"@capacitor/android": "^6.1.2",
"@capacitor/app": "^6.0.1",
"@capacitor/core": "^6.1.2",

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

File diff suppressed because it is too large Load diff

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.9.0/Shivao-v1.9.0.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';
}