Compare commits

...

3 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
11 changed files with 1280 additions and 23 deletions

1
.gitignore vendored
View file

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

View file

@ -1988,6 +1988,30 @@ Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção
<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 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> </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 --> <!-- Raymarine Gateway -->
<div class="export-card" id="nmea-gateway-card"> <div class="export-card" id="nmea-gateway-card">
<div class="export-card-title">⚓ Instrumentos Raymarine (gateway NMEA 2000)</div> <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> <div class="zone-toast" id="zone-toast"></div>
<script> <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) ============ // ============ NAUTICAL HELPERS (multi-boat + fleet + calculations) ============
// Storage interno SEMPRE em metros (ISO). Conversão só pra display. // Storage interno SEMPRE em metros (ISO). Conversão só pra display.
@ -3746,6 +3770,229 @@ async function updateStorageInfo(){
}catch(e){document.getElementById('storage-info').textContent='—'} }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()=>{ (async()=>{
// Wrapper try/catch pra capturar crash no boot (Capacitor WebView pode fechar silenciosamente) // Wrapper try/catch pra capturar crash no boot (Capacitor WebView pode fechar silenciosamente)
try{ try{
@ -3775,12 +4022,23 @@ async function updateStorageInfo(){
try{initBattery()}catch(e){console.error('[boot] battery',e)} try{initBattery()}catch(e){console.error('[boot] battery',e)}
try{initServiceWorker()}catch(e){console.error('[boot] sw',e)} try{initServiceWorker()}catch(e){console.error('[boot] sw',e)}
try{initSensorWidget()}catch(e){console.error('[boot] sensors',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){} try{setSyncStatus(cloudConfigured()?'syncing':'disabled')}catch(e){}
if(cloudConfigured()){ if(cloudConfigured()){
try{rtConnect()}catch(e){console.error('[boot] rt',e)} try{rtConnect()}catch(e){console.error('[boot] rt',e)}
try{refreshGoogleStatus()}catch(e){console.error('[boot] gcal',e)} try{refreshGoogleStatus()}catch(e){console.error('[boot] gcal',e)}
} }
setTimeout(()=>{try{maybeAutoFetchWeather()}catch(e){}},3000); 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{maybeShowWelcome()}catch(e){}},300);
setTimeout(()=>{try{resumePollingIfPending()}catch(e){}},500); setTimeout(()=>{try{resumePollingIfPending()}catch(e){}},500);
}catch(e){ }catch(e){
@ -5586,19 +5844,172 @@ async function fetchWeatherOpenMeteo(lat,lng){
if(weather.fetching)return; if(weather.fetching)return;
weather.fetching=true; weather.fetching=true;
try{ 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`; // Forecast: vento, temp, pressão, lightning (CAPE), gust
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`; 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[fR,mR]=await Promise.all([fetch(fUrl),fetch(mUrl).catch(()=>null)]);
const f=await fR.json(); const f=await fR.json();
const m=mR&&mR.ok?await mR.json():null; const m=mR&&mR.ok?await mR.json():null;
weather.data={provider:'openmeteo',forecast:f,marine:m,fetchedAt:Date.now(),lat,lng}; weather.data={provider:'openmeteo',forecast:f,marine:m,fetchedAt:Date.now(),lat,lng};
weather.lastFetch=Date.now(); weather.lastFetch=Date.now();
weather.lastPos={lat,lng}; weather.lastPos={lat,lng};
weather.tides=extractTides(m);
renderWeather(); renderWeather();
checkStormConditions(weather.data,{lat,lng});
}catch(e){console.warn('weather',e.message)} }catch(e){console.warn('weather',e.message)}
weather.fetching=false; 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 // Helpers de conversão de unidades
function uvToSpeedDir(u,v){ function uvToSpeedDir(u,v){
// u: leste-oeste (positivo = leste). v: norte-sul (positivo = norte). // u: leste-oeste (positivo = leste). v: norte-sul (positivo = norte).
@ -5624,8 +6035,47 @@ function renderWeather(){
if(!el)return; if(!el)return;
const d=weather.data; const d=weather.data;
if(!d){el.innerHTML='';return} if(!d){el.innerHTML='';return}
if(d.provider==='windy')return renderWindyWeather(el,d); if(d.provider==='windy')renderWindyWeather(el,d);
return renderOpenMeteoWeather(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){ function renderWindyWeather(el,d){
@ -6161,9 +6611,23 @@ async function bmsProbeWebBluetooth(deviceId,deviceName){
await new Promise(r=>setTimeout(r,2500)); await new Promise(r=>setTimeout(r,2500));
const dev=state.btDevices?.find(d=>d.id===deviceId); const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev?.bms?.voltage||dev?._lastRxAt){ if(dev?.bms?.voltage||dev?._lastRxAt){
setBleDiag(`✓ ${p.name} respondeu!`,'ok'); setBleDiag(`✓ ${p.name} respondeu! · polling 5s ativado`,'ok');
if(dev){dev.bmsProtocol=p.name;dev.isJBD=true;saveState()} if(dev){dev.bmsProtocol=p.name;dev.isJBD=true;saveState()}
conn.notifyChar=notifyChar;conn.writeChar=writeChar; 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; return true;
} }
setBleDiag(`✗ ${p.name} sem RX`,'info'); setBleDiag(`✗ ${p.name} sem RX`,'info');
@ -6303,9 +6767,21 @@ async function bmsTryProtocols(deviceId){
await new Promise(r=>setTimeout(r,2500)); await new Promise(r=>setTimeout(r,2500));
const dev=state.btDevices?.find(d=>d.id===deviceId); const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev?.bms?.voltage||dev?._lastRxAt){ if(dev?.bms?.voltage||dev?._lastRxAt){
setBleDiag(`✓ ${p.name} respondeu!`,'ok'); setBleDiag(`✓ ${p.name} respondeu! · polling 5s ativado`,'ok');
if(dev)dev.bmsProtocol=p.name; 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; return true;
}else{ }else{
setBleDiag(`✗ ${p.name} sem RX em 2.5s`,'info'); setBleDiag(`✗ ${p.name} sem RX em 2.5s`,'info');
@ -6480,7 +6956,7 @@ async function removeBluetoothDevice(id){
renderBluetoothCard(); renderBluetoothCard();
} }
const APP_VERSION='1.10.18'; const APP_VERSION='1.12.0';
function renderBluetoothCard(){ function renderBluetoothCard(){
const el=document.getElementById('bt-list'); const el=document.getElementById('bt-list');
const supportEl=document.getElementById('bt-support'); const supportEl=document.getElementById('bt-support');

View file

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

View file

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

View file

@ -2,6 +2,9 @@
include ':capacitor-android' include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') 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' include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')

View file

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

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

@ -1988,6 +1988,30 @@ Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção
<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 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> </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 --> <!-- Raymarine Gateway -->
<div class="export-card" id="nmea-gateway-card"> <div class="export-card" id="nmea-gateway-card">
<div class="export-card-title">⚓ Instrumentos Raymarine (gateway NMEA 2000)</div> <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> <div class="zone-toast" id="zone-toast"></div>
<script> <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) ============ // ============ NAUTICAL HELPERS (multi-boat + fleet + calculations) ============
// Storage interno SEMPRE em metros (ISO). Conversão só pra display. // Storage interno SEMPRE em metros (ISO). Conversão só pra display.
@ -3746,6 +3770,229 @@ async function updateStorageInfo(){
}catch(e){document.getElementById('storage-info').textContent='—'} }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()=>{ (async()=>{
// Wrapper try/catch pra capturar crash no boot (Capacitor WebView pode fechar silenciosamente) // Wrapper try/catch pra capturar crash no boot (Capacitor WebView pode fechar silenciosamente)
try{ try{
@ -3775,12 +4022,23 @@ async function updateStorageInfo(){
try{initBattery()}catch(e){console.error('[boot] battery',e)} try{initBattery()}catch(e){console.error('[boot] battery',e)}
try{initServiceWorker()}catch(e){console.error('[boot] sw',e)} try{initServiceWorker()}catch(e){console.error('[boot] sw',e)}
try{initSensorWidget()}catch(e){console.error('[boot] sensors',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){} try{setSyncStatus(cloudConfigured()?'syncing':'disabled')}catch(e){}
if(cloudConfigured()){ if(cloudConfigured()){
try{rtConnect()}catch(e){console.error('[boot] rt',e)} try{rtConnect()}catch(e){console.error('[boot] rt',e)}
try{refreshGoogleStatus()}catch(e){console.error('[boot] gcal',e)} try{refreshGoogleStatus()}catch(e){console.error('[boot] gcal',e)}
} }
setTimeout(()=>{try{maybeAutoFetchWeather()}catch(e){}},3000); 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{maybeShowWelcome()}catch(e){}},300);
setTimeout(()=>{try{resumePollingIfPending()}catch(e){}},500); setTimeout(()=>{try{resumePollingIfPending()}catch(e){}},500);
}catch(e){ }catch(e){
@ -5586,19 +5844,172 @@ async function fetchWeatherOpenMeteo(lat,lng){
if(weather.fetching)return; if(weather.fetching)return;
weather.fetching=true; weather.fetching=true;
try{ 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`; // Forecast: vento, temp, pressão, lightning (CAPE), gust
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`; 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[fR,mR]=await Promise.all([fetch(fUrl),fetch(mUrl).catch(()=>null)]);
const f=await fR.json(); const f=await fR.json();
const m=mR&&mR.ok?await mR.json():null; const m=mR&&mR.ok?await mR.json():null;
weather.data={provider:'openmeteo',forecast:f,marine:m,fetchedAt:Date.now(),lat,lng}; weather.data={provider:'openmeteo',forecast:f,marine:m,fetchedAt:Date.now(),lat,lng};
weather.lastFetch=Date.now(); weather.lastFetch=Date.now();
weather.lastPos={lat,lng}; weather.lastPos={lat,lng};
weather.tides=extractTides(m);
renderWeather(); renderWeather();
checkStormConditions(weather.data,{lat,lng});
}catch(e){console.warn('weather',e.message)} }catch(e){console.warn('weather',e.message)}
weather.fetching=false; 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 // Helpers de conversão de unidades
function uvToSpeedDir(u,v){ function uvToSpeedDir(u,v){
// u: leste-oeste (positivo = leste). v: norte-sul (positivo = norte). // u: leste-oeste (positivo = leste). v: norte-sul (positivo = norte).
@ -5624,8 +6035,47 @@ function renderWeather(){
if(!el)return; if(!el)return;
const d=weather.data; const d=weather.data;
if(!d){el.innerHTML='';return} if(!d){el.innerHTML='';return}
if(d.provider==='windy')return renderWindyWeather(el,d); if(d.provider==='windy')renderWindyWeather(el,d);
return renderOpenMeteoWeather(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){ function renderWindyWeather(el,d){
@ -6161,9 +6611,23 @@ async function bmsProbeWebBluetooth(deviceId,deviceName){
await new Promise(r=>setTimeout(r,2500)); await new Promise(r=>setTimeout(r,2500));
const dev=state.btDevices?.find(d=>d.id===deviceId); const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev?.bms?.voltage||dev?._lastRxAt){ if(dev?.bms?.voltage||dev?._lastRxAt){
setBleDiag(`✓ ${p.name} respondeu!`,'ok'); setBleDiag(`✓ ${p.name} respondeu! · polling 5s ativado`,'ok');
if(dev){dev.bmsProtocol=p.name;dev.isJBD=true;saveState()} if(dev){dev.bmsProtocol=p.name;dev.isJBD=true;saveState()}
conn.notifyChar=notifyChar;conn.writeChar=writeChar; 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; return true;
} }
setBleDiag(`✗ ${p.name} sem RX`,'info'); setBleDiag(`✗ ${p.name} sem RX`,'info');
@ -6303,9 +6767,21 @@ async function bmsTryProtocols(deviceId){
await new Promise(r=>setTimeout(r,2500)); await new Promise(r=>setTimeout(r,2500));
const dev=state.btDevices?.find(d=>d.id===deviceId); const dev=state.btDevices?.find(d=>d.id===deviceId);
if(dev?.bms?.voltage||dev?._lastRxAt){ if(dev?.bms?.voltage||dev?._lastRxAt){
setBleDiag(`✓ ${p.name} respondeu!`,'ok'); setBleDiag(`✓ ${p.name} respondeu! · polling 5s ativado`,'ok');
if(dev)dev.bmsProtocol=p.name; 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; return true;
}else{ }else{
setBleDiag(`✗ ${p.name} sem RX em 2.5s`,'info'); setBleDiag(`✗ ${p.name} sem RX em 2.5s`,'info');
@ -6480,7 +6956,7 @@ async function removeBluetoothDevice(id){
renderBluetoothCard(); renderBluetoothCard();
} }
const APP_VERSION='1.10.18'; const APP_VERSION='1.12.0';
function renderBluetoothCard(){ function renderBluetoothCard(){
const el=document.getElementById('bt-list'); const el=document.getElementById('bt-list');
const supportEl=document.getElementById('bt-support'); const supportEl=document.getElementById('bt-support');

View file

@ -1,7 +1,7 @@
// Shivao Service Worker — offline real // Shivao Service Worker — offline real
// Estratégia: shell precachado, tiles cache-first, windy network-first, /api passa direto. // 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. // Versão usada nos cache names — bumpa essa string pra invalidar caches antigos em deploys.
const VERSION = 'shivao-v1.10.18'; const VERSION = 'shivao-v1.12.0';
const SHELL_CACHE = `shivao-shell-${VERSION}`; const SHELL_CACHE = `shivao-shell-${VERSION}`;
const TILES_CACHE = 'shivao-tiles-v1'; // separado pra não invalidar tiles em update do shell const TILES_CACHE = 'shivao-tiles-v1'; // separado pra não invalidar tiles em update do shell
const WINDY_CACHE = `shivao-windy-${VERSION}`; 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 * as billing from './billing.js';
import { initRealtime, broadcastStateChange, getOnlineCount } from './realtime.js'; import { initRealtime, broadcastStateChange, getOnlineCount } from './realtime.js';
import * as gcal from './google-calendar.js'; import * as gcal from './google-calendar.js';
import * as tuya from './tuya.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = parseInt(process.env.PORT || '3000'); const PORT = parseInt(process.env.PORT || '3000');
@ -192,6 +193,59 @@ app.get('/api/bms/diag-log/:file', requireAuth, (req, res) => {
} }
}); });
// ===== 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) ===== // ===== 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. // In-memory store: session_id → { tokens, createdAt }. Auto-expira em 10min.
const pendingGoogleSessions = new Map(); const pendingGoogleSessions = new Map();
@ -413,7 +467,7 @@ app.get('/.well-known/assetlinks.json', (req, res) => {
}); });
// Atalho: /apk redireciona pra última APK release no Forgejo // Atalho: /apk redireciona pra última APK release no Forgejo
const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.10.18/Shivao-v1.10.18.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)); app.get('/apk', (req, res) => res.redirect(302, LATEST_APK_URL));
// Página A4 imprimível com QR Code + instruções (cola no barco/marina) // Página A4 imprimível com QR Code + instruções (cola no barco/marina)

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';
}