Compare commits
No commits in common. "master" and "v1.10.4" have entirely different histories.
11 changed files with 105 additions and 1894 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -15,7 +15,6 @@ server/data/
|
||||||
!.env.example
|
!.env.example
|
||||||
**/.env
|
**/.env
|
||||||
**/.env.*
|
**/.env.*
|
||||||
!**/.env.example
|
|
||||||
|
|
||||||
# OS / IDE
|
# OS / IDE
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
||||||
|
|
@ -1977,41 +1977,13 @@ Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção
|
||||||
<div id="bt-support" style="font-family:var(--f-mono);font-size:10.5px;letter-spacing:.04em;margin-bottom:10px">Verificando suporte...</div>
|
<div id="bt-support" style="font-family:var(--f-mono);font-size:10.5px;letter-spacing:.04em;margin-bottom:10px">Verificando suporte...</div>
|
||||||
<button class="btn btn-block btn-primary" onclick="pairBluetoothDevice()">+ Parear novo dispositivo Bluetooth</button>
|
<button class="btn btn-block btn-primary" onclick="pairBluetoothDevice()">+ Parear novo dispositivo Bluetooth</button>
|
||||||
<div id="bt-list" style="margin-top:14px"></div>
|
<div id="bt-list" style="margin-top:14px"></div>
|
||||||
<details style="margin-top:10px" open>
|
<details style="margin-top:10px">
|
||||||
<summary style="cursor:pointer;font-family:var(--f-mono);font-size:11px;color:var(--m-text-soft,#7d97ad);letter-spacing:.06em">📋 Diagnóstico (logs do pareamento)</summary>
|
<summary style="cursor:pointer;font-family:var(--f-mono);font-size:11px;color:var(--m-text-soft,#7d97ad);letter-spacing:.06em">📋 Diagnóstico (logs do pareamento)</summary>
|
||||||
<div style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap">
|
<div id="bt-diag" style="background:var(--m-bg-2,#0f2a40);border:1px solid var(--m-border,rgba(255,255,255,.08));border-radius:6px;padding:10px;margin-top:6px;max-height:200px;overflow-y:auto"></div>
|
||||||
<button class="btn btn-sm btn-primary" onclick="sendDiagLogToServer()" style="flex:1;min-width:140px">📤 Enviar pro servidor</button>
|
|
||||||
<button class="btn btn-sm" onclick="document.getElementById('bt-diag').innerHTML='';setBleDiag('Log limpo','info')" style="flex:1;min-width:80px">🗑 Limpar</button>
|
|
||||||
</div>
|
|
||||||
<div id="bt-diag" style="background:var(--m-bg-2,#0f2a40);border:1px solid var(--m-border,rgba(255,255,255,.08));border-radius:6px;padding:10px;margin-top:6px;max-height:300px;overflow-y:auto;font-family:var(--f-mono);font-size:11px"></div>
|
|
||||||
</details>
|
</details>
|
||||||
<div class="field-hint" style="margin-top:8px">Limitações: <strong>iOS Safari não suporta Web Bluetooth</strong>. APK Android usa plugin nativo. BMS proprietários (Victron, JBD) podem aparecer mas não expor Battery Service padrão.</div>
|
<div 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>
|
||||||
|
|
@ -2638,7 +2610,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'},smartDevices:[]};
|
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'}};
|
||||||
|
|
||||||
// ============ 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.
|
||||||
|
|
@ -3770,284 +3742,23 @@ 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)
|
await openDB();loadState();bindHeader();await renderAll();
|
||||||
try{
|
document.getElementById('fab').style.display='none';
|
||||||
// Detecta crash BLE da sessão anterior via breadcrumb
|
loadTrackingState();
|
||||||
try{
|
loadAnchorState();
|
||||||
const lastStep=localStorage.getItem('shivao_ble_last_step');
|
initBattery();
|
||||||
if(lastStep){
|
initServiceWorker();
|
||||||
setTimeout(()=>{
|
initSensorWidget();
|
||||||
try{alert('⚠ Crash detectado na sessão anterior!\nÚltima ação BLE: '+lastStep+'\n\nMe envie esta mensagem.')}catch{}
|
// Realtime sync: conecta WebSocket se cloud configurada
|
||||||
},2000);
|
setSyncStatus(cloudConfigured()?'syncing':'disabled');
|
||||||
localStorage.removeItem('shivao_ble_last_step');
|
if(cloudConfigured()){rtConnect();refreshGoogleStatus()}
|
||||||
}
|
// tenta auto-fetch do tempo após pequeno delay
|
||||||
}catch{}
|
setTimeout(maybeAutoFetchWeather,3000);
|
||||||
await openDB();
|
// Welcome screen — só pra usuários sem login
|
||||||
try{loadState()}catch(e){console.error('[boot] loadState',e);try{localStorage.removeItem(STORAGE_KEY)}catch{}/* corrupt state — reset */}
|
setTimeout(maybeShowWelcome,300);
|
||||||
// Migration defensiva: limpa entries inválidas em state.btDevices
|
// Retoma polling do OAuth se app foi morto durante login Google
|
||||||
if(state.btDevices&&Array.isArray(state.btDevices)){
|
setTimeout(resumePollingIfPending,500);
|
||||||
state.btDevices=state.btDevices.filter(d=>d&&typeof d==='object'&&d.id);
|
|
||||||
}else{
|
|
||||||
state.btDevices=[];
|
|
||||||
}
|
|
||||||
bindHeader();
|
|
||||||
await renderAll();
|
|
||||||
try{document.getElementById('fab').style.display='none'}catch(e){}
|
|
||||||
try{loadTrackingState()}catch(e){console.error('[boot] tracking',e)}
|
|
||||||
try{loadAnchorState()}catch(e){console.error('[boot] anchor',e)}
|
|
||||||
try{initBattery()}catch(e){console.error('[boot] battery',e)}
|
|
||||||
try{initServiceWorker()}catch(e){console.error('[boot] sw',e)}
|
|
||||||
try{initSensorWidget()}catch(e){console.error('[boot] sensors',e)}
|
|
||||||
try{initSmartHome()}catch(e){console.error('[boot] smarthome',e)}
|
|
||||||
try{setSyncStatus(cloudConfigured()?'syncing':'disabled')}catch(e){}
|
|
||||||
if(cloudConfigured()){
|
|
||||||
try{rtConnect()}catch(e){console.error('[boot] rt',e)}
|
|
||||||
try{refreshGoogleStatus()}catch(e){console.error('[boot] gcal',e)}
|
|
||||||
}
|
|
||||||
setTimeout(()=>{try{maybeAutoFetchWeather()}catch(e){}},3000);
|
|
||||||
// Pede permissão de notificações (alertas tempestade)
|
|
||||||
setTimeout(async()=>{
|
|
||||||
try{
|
|
||||||
const ln=window.Capacitor?.Plugins?.LocalNotifications;
|
|
||||||
if(ln){await ln.requestPermissions().catch(()=>{})}
|
|
||||||
else if('Notification' in window&&Notification.permission==='default'){
|
|
||||||
await Notification.requestPermission().catch(()=>{});
|
|
||||||
}
|
|
||||||
}catch{}
|
|
||||||
},5000);
|
|
||||||
setTimeout(()=>{try{maybeShowWelcome()}catch(e){}},300);
|
|
||||||
setTimeout(()=>{try{resumePollingIfPending()}catch(e){}},500);
|
|
||||||
}catch(e){
|
|
||||||
// Crash no boot — mostra alert nativo (sobrevive Capacitor crash) + tenta auto-recovery
|
|
||||||
const msg='Boot error: '+(e.message||e)+'\n'+(e.stack||'').slice(0,300);
|
|
||||||
console.error('[BOOT CRASH]',e);
|
|
||||||
try{alert(msg)}catch{}
|
|
||||||
try{localStorage.clear()}catch{}
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
// Re-tenta init Google Sign-In quando o script async carrega
|
// Re-tenta init Google Sign-In quando o script async carrega
|
||||||
window.addEventListener('load',()=>setTimeout(()=>{if(document.getElementById('welcome-screen').style.display==='flex')initGoogleSignIn()},500));
|
window.addEventListener('load',()=>setTimeout(()=>{if(document.getElementById('welcome-screen').style.display==='flex')initGoogleSignIn()},500));
|
||||||
|
|
@ -5844,172 +5555,19 @@ async function fetchWeatherOpenMeteo(lat,lng){
|
||||||
if(weather.fetching)return;
|
if(weather.fetching)return;
|
||||||
weather.fetching=true;
|
weather.fetching=true;
|
||||||
try{
|
try{
|
||||||
// Forecast: vento, temp, pressão, lightning (CAPE), gust
|
const fUrl=`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=temperature_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m,weather_code&hourly=wind_speed_10m,wind_direction_10m,weather_code&forecast_hours=24&wind_speed_unit=kn&timezone=auto`;
|
||||||
const fUrl=`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=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`;
|
const mUrl=`https://marine-api.open-meteo.com/v1/marine?latitude=${lat}&longitude=${lng}¤t=wave_height,wave_direction,wave_period&hourly=wave_height&forecast_hours=24&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}¤t=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; // só 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).
|
||||||
|
|
@ -6035,47 +5593,8 @@ 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')renderWindyWeather(el,d);
|
if(d.provider==='windy')return renderWindyWeather(el,d);
|
||||||
else renderOpenMeteoWeather(el,d);
|
return 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){
|
||||||
|
|
@ -6271,66 +5790,6 @@ async function ensureBleNativeReady(){
|
||||||
_bleNativeInitialized=true;
|
_bleNativeInitialized=true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Envia log diagnóstico direto pro servidor (evita crash de clipboard/share em WebView)
|
|
||||||
async function sendDiagLogToServer(){
|
|
||||||
const el=document.getElementById('bt-diag');
|
|
||||||
if(!el){toast('Log vazio');return}
|
|
||||||
const txt=`Shivao v${APP_VERSION} · log diagnóstico\n\n`+(el.innerText||el.textContent||'').trim();
|
|
||||||
if(!cloudConfigured()){toast('Faça login primeiro pra enviar log');return}
|
|
||||||
toast('📤 Enviando log pro servidor...');
|
|
||||||
try{
|
|
||||||
const r=await cloudFetch('/api/bms/diag-log',{method:'POST',body:JSON.stringify({log:txt})});
|
|
||||||
const j=await r.json();
|
|
||||||
if(j.ok)toast('✓ Log enviado · '+(j.file||'OK'));
|
|
||||||
else throw new Error(j.error||'falhou');
|
|
||||||
}catch(e){toast('Erro: '+e.message)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mostra log num modal pro usuário copiar/compartilhar manualmente
|
|
||||||
function copyDiagLog(){
|
|
||||||
const el=document.getElementById('bt-diag');
|
|
||||||
if(!el){toast('Log vazio');return}
|
|
||||||
const txt=`Shivao v${APP_VERSION} · log diagnóstico\n\n`+(el.innerText||el.textContent||'').trim();
|
|
||||||
let modal=document.getElementById('bt-log-modal');
|
|
||||||
if(modal)modal.remove();
|
|
||||||
modal=document.createElement('div');
|
|
||||||
modal.id='bt-log-modal';
|
|
||||||
modal.style.cssText='position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.85);display:flex;flex-direction:column;padding:16px;backdrop-filter:blur(8px)';
|
|
||||||
// safe escape
|
|
||||||
const esc=txt.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
||||||
modal.innerHTML=`
|
|
||||||
<div style="background:#0d2538;border:1px solid #06b6d4;border-radius:12px;padding:16px;display:flex;flex-direction:column;height:100%;max-height:95vh">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
|
||||||
<h3 style="margin:0;color:#06b6d4;font-size:15px">📋 Log diagnóstico</h3>
|
|
||||||
<button onclick="document.getElementById('bt-log-modal').remove()" style="background:transparent;border:none;color:#fff;font-size:24px;cursor:pointer;padding:4px 12px">✕</button>
|
|
||||||
</div>
|
|
||||||
<p style="color:#b3c5d6;font-size:12px;margin:0 0 8px;line-height:1.4">Toque dentro da caixa e segure pra selecionar tudo. Ou toque ↗ Compartilhar pra enviar via WhatsApp.</p>
|
|
||||||
<textarea id="bt-log-textarea" readonly style="flex:1;width:100%;min-height:280px;background:#0a1f30;color:#e8f1f8;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:10px;font-family:'JetBrains Mono',monospace;font-size:11px;resize:none;-webkit-user-select:text;user-select:text;-webkit-touch-callout:default;outline:none">${esc}</textarea>
|
|
||||||
<div style="display:flex;gap:8px;margin-top:10px">
|
|
||||||
<button onclick="bmsShareLog()" style="flex:1;background:#10b981;color:#001a25;border:none;padding:12px;border-radius:8px;font-weight:600;cursor:pointer;font-size:14px">↗ Compartilhar</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
// Auto-seleciona o texto
|
|
||||||
setTimeout(()=>{
|
|
||||||
const ta=document.getElementById('bt-log-textarea');
|
|
||||||
if(ta){ta.focus();try{ta.select()}catch{}}
|
|
||||||
},200);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bmsShareLog(){
|
|
||||||
const ta=document.getElementById('bt-log-textarea');
|
|
||||||
if(!ta)return;
|
|
||||||
if(navigator.share){
|
|
||||||
try{
|
|
||||||
await navigator.share({title:'Shivao log diagnóstico',text:ta.value});
|
|
||||||
}catch(e){/* user cancelou */}
|
|
||||||
}else{
|
|
||||||
toast('Compartilhar não disponível · use seleção manual');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Diagnóstico visível: mostra cada passo no card BLE
|
// Diagnóstico visível: mostra cada passo no card BLE
|
||||||
function setBleDiag(msg,type){
|
function setBleDiag(msg,type){
|
||||||
const el=document.getElementById('bt-diag');
|
const el=document.getElementById('bt-diag');
|
||||||
|
|
@ -6345,12 +5804,6 @@ function setBleDiag(msg,type){
|
||||||
console.log('[ble]',msg);
|
console.log('[ble]',msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Breadcrumb persistente: grava ANTES de cada chamada nativa pra sobreviver crash do WebView
|
|
||||||
function bleCrumb(step){
|
|
||||||
try{localStorage.setItem('shivao_ble_last_step',step+' @ '+new Date().toISOString())}catch{}
|
|
||||||
}
|
|
||||||
function bleCrumbClear(){try{localStorage.removeItem('shivao_ble_last_step')}catch{}}
|
|
||||||
|
|
||||||
async function pairBluetoothDevice(){
|
async function pairBluetoothDevice(){
|
||||||
const backend=bleBackend();
|
const backend=bleBackend();
|
||||||
setBleDiag('Backend: '+(backend||'NENHUM'),backend?'info':'err');
|
setBleDiag('Backend: '+(backend||'NENHUM'),backend?'info':'err');
|
||||||
|
|
@ -6358,10 +5811,8 @@ async function pairBluetoothDevice(){
|
||||||
try{
|
try{
|
||||||
let deviceId,deviceName;
|
let deviceId,deviceName;
|
||||||
if(backend==='capacitor'){
|
if(backend==='capacitor'){
|
||||||
bleCrumb('ensureBleNativeReady');
|
|
||||||
setBleDiag('Inicializando plugin nativo...');
|
setBleDiag('Inicializando plugin nativo...');
|
||||||
await ensureBleNativeReady();
|
await ensureBleNativeReady();
|
||||||
bleCrumb('requestDevice');
|
|
||||||
setBleDiag('Plugin OK · abrindo picker...');
|
setBleDiag('Plugin OK · abrindo picker...');
|
||||||
const ble=window.Capacitor.Plugins.BluetoothLe;
|
const ble=window.Capacitor.Plugins.BluetoothLe;
|
||||||
const result=await ble.requestDevice({
|
const result=await ble.requestDevice({
|
||||||
|
|
@ -6369,16 +5820,15 @@ async function pairBluetoothDevice(){
|
||||||
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO],
|
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO],
|
||||||
allowDuplicates:false,
|
allowDuplicates:false,
|
||||||
});
|
});
|
||||||
if(!result?.deviceId){bleCrumbClear();setBleDiag('Picker cancelado','warn');return}
|
if(!result?.deviceId){setBleDiag('Picker cancelado','warn');return}
|
||||||
deviceId=result.deviceId;
|
deviceId=result.deviceId;
|
||||||
deviceName=result.name||'Dispositivo BLE';
|
deviceName=result.name||'Dispositivo BLE';
|
||||||
bleCrumb('selected:'+deviceName);
|
|
||||||
setBleDiag('Selecionado: '+deviceName+' ('+deviceId+')','ok');
|
setBleDiag('Selecionado: '+deviceName+' ('+deviceId+')','ok');
|
||||||
}else{
|
}else{
|
||||||
setBleDiag('Abrindo picker do navegador...');
|
setBleDiag('Abrindo picker do navegador...');
|
||||||
const device=await navigator.bluetooth.requestDevice({
|
const device=await navigator.bluetooth.requestDevice({
|
||||||
acceptAllDevices:true,
|
acceptAllDevices:true,
|
||||||
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO,'0000ff00-0000-1000-8000-00805f9b34fb','0000fff0-0000-1000-8000-00805f9b34fb','0000ffe0-0000-1000-8000-00805f9b34fb'],
|
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO],
|
||||||
});
|
});
|
||||||
if(!device){return}
|
if(!device){return}
|
||||||
deviceId=device.id;
|
deviceId=device.id;
|
||||||
|
|
@ -6408,11 +5858,11 @@ async function pairBluetoothDevice(){
|
||||||
}
|
}
|
||||||
saveState();
|
saveState();
|
||||||
renderBluetoothCard();
|
renderBluetoothCard();
|
||||||
// Se detectou JBD BMS OU backend é web (sempre tenta probe — descobre serviço dinâmico), ativa probe
|
// Se detectou JBD BMS, ativa parser proprietário
|
||||||
if(info.isJBD||backend==='web'){
|
if(info.isJBD){
|
||||||
const ok=await bmsAttachJBD(deviceId,deviceName);
|
const ok=await bmsAttachJBD(deviceId,deviceName);
|
||||||
if(ok)toast('✓ '+deviceName+' · BMS ativo');
|
if(ok)toast('✓ '+deviceName+' · JBD BMS ativo');
|
||||||
else toast('✓ '+deviceName+' (sem BMS detectável)');
|
else toast('✓ '+deviceName+' (BMS detectado mas falha no parser)');
|
||||||
}else{
|
}else{
|
||||||
toast('✓ '+deviceName+(info.battery!=null?' · '+info.battery+'%':' (sem leitura de bateria)'));
|
toast('✓ '+deviceName+(info.battery!=null?' · '+info.battery+'%':' (sem leitura de bateria)'));
|
||||||
}
|
}
|
||||||
|
|
@ -6432,7 +5882,6 @@ async function connectAndRead(deviceId,deviceName){
|
||||||
if(backend==='capacitor'){
|
if(backend==='capacitor'){
|
||||||
const ble=window.Capacitor.Plugins.BluetoothLe;
|
const ble=window.Capacitor.Plugins.BluetoothLe;
|
||||||
try{
|
try{
|
||||||
bleCrumb('ble.connect');
|
|
||||||
await ble.connect({deviceId,timeout:30000});
|
await ble.connect({deviceId,timeout:30000});
|
||||||
setBleDiag('GATT conectado','ok');
|
setBleDiag('GATT conectado','ok');
|
||||||
}catch(e){
|
}catch(e){
|
||||||
|
|
@ -6442,12 +5891,13 @@ async function connectAndRead(deviceId,deviceName){
|
||||||
const conn=_bleConnections.get(deviceId)||{};
|
const conn=_bleConnections.get(deviceId)||{};
|
||||||
conn.backend='capacitor';conn.deviceId=deviceId;conn.connected=true;
|
conn.backend='capacitor';conn.deviceId=deviceId;conn.connected=true;
|
||||||
_bleConnections.set(deviceId,conn);
|
_bleConnections.set(deviceId,conn);
|
||||||
|
// Discover all services pra diagnóstico + auto-detect protocols
|
||||||
try{
|
try{
|
||||||
bleCrumb('ble.getServices');
|
|
||||||
const r=await ble.getServices({deviceId});
|
const r=await ble.getServices({deviceId});
|
||||||
const svcs=(r.services||r||[]).map(s=>s.uuid||s).slice(0,8);
|
const svcs=(r.services||r||[]).map(s=>s.uuid||s).slice(0,8);
|
||||||
setBleDiag('Serviços encontrados: '+svcs.length+' ('+svcs.map(u=>u.slice(4,8)).join(', ')+')','info');
|
setBleDiag('Serviços encontrados: '+svcs.length+' ('+svcs.map(u=>u.slice(4,8)).join(', ')+')','info');
|
||||||
info.services=svcs;
|
info.services=svcs;
|
||||||
|
// Auto-detect: service ff00 = JBD/LLT Power BMS
|
||||||
const hasJbd=svcs.some(u=>u.toLowerCase().startsWith('0000ff00'));
|
const hasJbd=svcs.some(u=>u.toLowerCase().startsWith('0000ff00'));
|
||||||
if(hasJbd){
|
if(hasJbd){
|
||||||
setBleDiag('🔋 JBD BMS protocol detectado!','ok');
|
setBleDiag('🔋 JBD BMS protocol detectado!','ok');
|
||||||
|
|
@ -6542,107 +5992,10 @@ function bytesToBase64(arr){
|
||||||
return btoa(bin);
|
return btoa(bin);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Probe via Web Bluetooth API (Chrome PC)
|
// Probe: lista characteristics + identifica notify/write chars + tenta protocolos
|
||||||
async function bmsProbeWebBluetooth(deviceId,deviceName){
|
|
||||||
const conn=_bleConnections.get(deviceId);
|
|
||||||
const device=conn?.device;
|
|
||||||
if(!device){setBleDiag('Device sem referência (re-pareie)','err');return false}
|
|
||||||
try{
|
|
||||||
setBleDiag(`📦 Shivao v${APP_VERSION} · Probe WEB iniciado`,'info');
|
|
||||||
const server=device.gatt.connected?device.gatt:await device.gatt.connect();
|
|
||||||
setBleDiag('GATT web conectado','ok');
|
|
||||||
let svc=null;
|
|
||||||
try{svc=await server.getPrimaryService('0000ff00-0000-1000-8000-00805f9b34fb');setBleDiag('Service ff00 OK','info')}
|
|
||||||
catch(e){setBleDiag('Service ff00 falhou: '+e.message,'err');return false}
|
|
||||||
const chars=await svc.getCharacteristics();
|
|
||||||
setBleDiag(`Svc ff00 · ${chars.length} chars`,'info');
|
|
||||||
let notifyChar=null,writeChar=null;
|
|
||||||
for(const c of chars){
|
|
||||||
const p=c.properties;
|
|
||||||
const propsStr=[p.notify&&'notify',p.indicate&&'indicate',p.write&&'write',p.writeWithoutResponse&&'wnr',p.read&&'read'].filter(Boolean).join(',');
|
|
||||||
const cu=c.uuid.toLowerCase();
|
|
||||||
setBleDiag(` ${cu.slice(4,8)} [${propsStr}]`,'info');
|
|
||||||
if(!notifyChar&&(p.notify||p.indicate))notifyChar=c;
|
|
||||||
if(!writeChar&&(p.write||p.writeWithoutResponse))writeChar=c;
|
|
||||||
}
|
|
||||||
if(!notifyChar){setBleDiag('Sem char notify','err');return false}
|
|
||||||
if(!writeChar){
|
|
||||||
// Workaround: BMS chinês declara ff02 só [read] mas aceita writes (firmware permissivo).
|
|
||||||
// Força usar primeira char não-notify e tenta writeValue mesmo assim.
|
|
||||||
writeChar=chars.find(c=>c.uuid.toLowerCase()!==notifyChar.uuid.toLowerCase())||chars[0];
|
|
||||||
setBleDiag(`⚠ Sem property write · forçando ${writeChar.uuid.slice(4,8)}`,'warn');
|
|
||||||
}
|
|
||||||
setBleDiag(`Notify=${notifyChar.uuid.slice(4,8)} Write=${writeChar.uuid.slice(4,8)}`,'ok');
|
|
||||||
notifyChar.addEventListener('characteristicvaluechanged',(ev)=>{
|
|
||||||
const dv=ev.target.value;
|
|
||||||
const hex=Array.from(new Uint8Array(dv.buffer)).map(b=>b.toString(16).padStart(2,'0')).join(' ');
|
|
||||||
setBleDiag(`← RX ${dv.byteLength}b: ${hex.slice(0,100)}${hex.length>100?'...':''}`,'ok');
|
|
||||||
// Se há buffer pendente JBD, é chunk de continuação — roteia sempre
|
|
||||||
if(_bmsBuffers.has(deviceId)){
|
|
||||||
bmsHandleChunk(deviceId,dv,deviceName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Buffer vazio: primeiro byte determina protocolo (início de novo pacote)
|
|
||||||
const first=new Uint8Array(dv.buffer)[0];
|
|
||||||
if(first===0xDD)bmsHandleChunk(deviceId,dv,deviceName);
|
|
||||||
else if(first===0xAA)bmsHandleJK(deviceId,dv,deviceName);
|
|
||||||
else if(first===0xA5)bmsHandleDaly(deviceId,dv,deviceName);
|
|
||||||
});
|
|
||||||
await notifyChar.startNotifications();
|
|
||||||
setBleDiag('Notify ativo · iniciando wake...','ok');
|
|
||||||
await new Promise(r=>setTimeout(r,500));
|
|
||||||
try{
|
|
||||||
const fn=writeChar.properties.writeWithoutResponse?'writeValueWithoutResponse':'writeValue';
|
|
||||||
await writeChar[fn](new Uint8Array([0x5A,0x5A,0x5A,0x5A]));
|
|
||||||
setBleDiag('Wake 5A x4 enviado','info');
|
|
||||||
}catch(e){setBleDiag('Wake skip: '+e.message,'info')}
|
|
||||||
await new Promise(r=>setTimeout(r,1500));
|
|
||||||
const PROTOCOLS=[
|
|
||||||
{name:'JBD-0x03',bytes:[0xDD,0xA5,0x03,0x00,0xFF,0xFD,0x77]},
|
|
||||||
{name:'JK-getInfo',bytes:[0xAA,0x55,0x90,0xEB,0x96,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x10]},
|
|
||||||
{name:'Daly-getInfo',bytes:[0xA5,0x80,0x90,0x08,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xBD]},
|
|
||||||
];
|
|
||||||
for(const p of PROTOCOLS){
|
|
||||||
try{
|
|
||||||
setBleDiag(`→ TX ${p.name}`,'info');
|
|
||||||
const fn=writeChar.properties.writeWithoutResponse?'writeValueWithoutResponse':'writeValue';
|
|
||||||
await writeChar[fn](new Uint8Array(p.bytes));
|
|
||||||
setBleDiag(`✔ write ${p.name} OK`,'info');
|
|
||||||
await new Promise(r=>setTimeout(r,2500));
|
|
||||||
const dev=state.btDevices?.find(d=>d.id===deviceId);
|
|
||||||
if(dev?.bms?.voltage||dev?._lastRxAt){
|
|
||||||
setBleDiag(`✓ ${p.name} respondeu! · polling 5s ativado`,'ok');
|
|
||||||
if(dev){dev.bmsProtocol=p.name;dev.isJBD=true;saveState()}
|
|
||||||
conn.notifyChar=notifyChar;conn.writeChar=writeChar;
|
|
||||||
// Polling a cada 5s pra atualização contínua
|
|
||||||
if(conn._pollInterval)clearInterval(conn._pollInterval);
|
|
||||||
conn._pollInterval=setInterval(async()=>{
|
|
||||||
try{
|
|
||||||
const fn2=writeChar.properties.writeWithoutResponse?'writeValueWithoutResponse':'writeValue';
|
|
||||||
await writeChar[fn2](new Uint8Array(p.bytes));
|
|
||||||
}catch(e){
|
|
||||||
// Conexão caiu: para o polling
|
|
||||||
if(/disconnected|connection|gatt/i.test(e.message||'')){
|
|
||||||
setBleDiag('Polling interrompido: '+e.message,'warn');
|
|
||||||
clearInterval(conn._pollInterval);conn._pollInterval=null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},5000);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
setBleDiag(`✗ ${p.name} sem RX`,'info');
|
|
||||||
}catch(e){setBleDiag(`${p.name} erro: ${e.message}`,'warn')}
|
|
||||||
}
|
|
||||||
setBleDiag('⚠ Nenhum protocolo respondeu','err');
|
|
||||||
return false;
|
|
||||||
}catch(e){setBleDiag('Probe web falhou: '+e.message,'err');return false}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Probe Capacitor: lista characteristics + identifica notify/write chars + tenta protocolos
|
|
||||||
async function bmsProbeAndAttach(deviceId,deviceName){
|
async function bmsProbeAndAttach(deviceId,deviceName){
|
||||||
const backend=bleBackend();
|
const backend=bleBackend();
|
||||||
if(backend==='web')return bmsProbeWebBluetooth(deviceId,deviceName);
|
if(backend!=='capacitor'){setBleDiag('Probe requer Capacitor (APK)','warn');return false}
|
||||||
if(backend!=='capacitor'){setBleDiag('Backend desconhecido','warn');return false}
|
|
||||||
const ble=window.Capacitor.Plugins.BluetoothLe;
|
const ble=window.Capacitor.Plugins.BluetoothLe;
|
||||||
try{
|
try{
|
||||||
setBleDiag(`📦 Shivao v${APP_VERSION} · Probe iniciado`,'info');
|
setBleDiag(`📦 Shivao v${APP_VERSION} · Probe iniciado`,'info');
|
||||||
|
|
@ -6680,49 +6033,28 @@ async function bmsProbeAndAttach(deviceId,deviceName){
|
||||||
setBleDiag('Não achei chars notify+write em services vendor','err');
|
setBleDiag('Não achei chars notify+write em services vendor','err');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Detecta se write char só aceita writeWithoutResponse
|
setBleDiag(`Notify=${notifyChar.slice(4,8)} Write=${writeChar.slice(4,8)} Svc=${foundService.slice(4,8)}`,'ok');
|
||||||
let writeOnlyWnr=false;
|
|
||||||
for(const svc of allSvcs){
|
|
||||||
if((svc.uuid||'').toLowerCase()!==foundService)continue;
|
|
||||||
for(const c of (svc.characteristics||[])){
|
|
||||||
if((c.uuid||'').toLowerCase()===writeChar){
|
|
||||||
const p=c.properties||{};
|
|
||||||
writeOnlyWnr=(!p.write&&p.writeWithoutResponse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setBleDiag(`Notify=${notifyChar.slice(4,8)} Write=${writeChar.slice(4,8)}${writeOnlyWnr?' (force-wnr)':''} Svc=${foundService.slice(4,8)}`,'ok');
|
|
||||||
// Subscribe + handler
|
// Subscribe + handler
|
||||||
const listenerKey='notification|'+deviceId+'|'+foundService+'|'+notifyChar;
|
const listenerKey='notification|'+deviceId+'|'+foundService+'|'+notifyChar;
|
||||||
ble.addListener(listenerKey,(ev)=>{
|
ble.addListener(listenerKey,(ev)=>{
|
||||||
const dv=parseDataView(ev.value);
|
const dv=parseDataView(ev.value);
|
||||||
const hex=Array.from(new Uint8Array(dv.buffer)).map(b=>b.toString(16).padStart(2,'0')).join(' ');
|
const hex=Array.from(new Uint8Array(dv.buffer)).map(b=>b.toString(16).padStart(2,'0')).join(' ');
|
||||||
setBleDiag('← RX '+dv.byteLength+'b: '+hex.slice(0,100)+(hex.length>100?'...':''),'ok');
|
setBleDiag('← RX '+dv.byteLength+'b: '+hex.slice(0,100)+(hex.length>100?'...':''),'ok');
|
||||||
// Se buffer JBD pendente, é continuação — roteia sempre
|
// Detecta protocolo por byte de início
|
||||||
if(_bmsBuffers.has(deviceId)){
|
|
||||||
bmsHandleChunk(deviceId,dv,deviceName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Detecta protocolo por byte de início (novo pacote)
|
|
||||||
const first=new Uint8Array(dv.buffer)[0];
|
const first=new Uint8Array(dv.buffer)[0];
|
||||||
if(first===0xDD)bmsHandleChunk(deviceId,dv,deviceName); // JBD
|
if(first===0xDD)bmsHandleChunk(deviceId,dv,deviceName); // JBD
|
||||||
else if(first===0xAA)bmsHandleJK(deviceId,dv,deviceName); // JK BMS
|
else if(first===0xAA)bmsHandleJK(deviceId,dv,deviceName); // JK BMS
|
||||||
else if(first===0xA5)bmsHandleDaly(deviceId,dv,deviceName); // Daly
|
else if(first===0xA5)bmsHandleDaly(deviceId,dv,deviceName); // Daly
|
||||||
});
|
});
|
||||||
try{
|
await ble.startNotifications({deviceId,service:foundService,characteristic:notifyChar});
|
||||||
await ble.startNotifications({deviceId,service:foundService,characteristic:notifyChar});
|
setBleDiag('Notify ativo · aguardando 800ms...','ok');
|
||||||
setBleDiag('Notify ativo · aguardando 800ms...','ok');
|
|
||||||
}catch(e){setBleDiag('startNotifications erro: '+(e.message||e.errorMessage||'?'),'err');return false}
|
|
||||||
await new Promise(r=>setTimeout(r,800));
|
await new Promise(r=>setTimeout(r,800));
|
||||||
// Wake-up REMOVIDO no path Capacitor — chamadas extra causavam crash nativo
|
|
||||||
// Provamos no PC (Web Bluetooth) que JBD-0x03 direto funciona
|
|
||||||
// Salva config no device pra reuso
|
// Salva config no device pra reuso
|
||||||
const dev=state.btDevices?.find(d=>d.id===deviceId);
|
const dev=state.btDevices?.find(d=>d.id===deviceId);
|
||||||
if(dev){
|
if(dev){
|
||||||
dev.bmsService=foundService;
|
dev.bmsService=foundService;
|
||||||
dev.bmsNotifyChar=notifyChar;
|
dev.bmsNotifyChar=notifyChar;
|
||||||
dev.bmsWriteChar=writeChar;
|
dev.bmsWriteChar=writeChar;
|
||||||
dev.bmsForceWnr=writeOnlyWnr;
|
|
||||||
dev.isJBD=true;
|
dev.isJBD=true;
|
||||||
saveState();
|
saveState();
|
||||||
}
|
}
|
||||||
|
|
@ -6738,12 +6070,8 @@ async function bmsWriteCmd(deviceId,bytes,withoutResponse){
|
||||||
const ble=window.Capacitor?.Plugins?.BluetoothLe;
|
const ble=window.Capacitor?.Plugins?.BluetoothLe;
|
||||||
const dev=state.btDevices?.find(d=>d.id===deviceId);
|
const dev=state.btDevices?.find(d=>d.id===deviceId);
|
||||||
if(!ble||!dev?.bmsService||!dev?.bmsWriteChar)throw new Error('config BMS ausente');
|
if(!ble||!dev?.bmsService||!dev?.bmsWriteChar)throw new Error('config BMS ausente');
|
||||||
const useWnr=withoutResponse||dev.bmsForceWnr;
|
const fn=withoutResponse?'writeWithoutResponse':'write';
|
||||||
const fn=useWnr?'writeWithoutResponse':'write';
|
await ble[fn]({deviceId,service:dev.bmsService,characteristic:dev.bmsWriteChar,value:bytesToBase64(bytes)});
|
||||||
// Timeout 3s: plugins/firmware podem travar mesmo com WNR
|
|
||||||
const writePromise=ble[fn]({deviceId,service:dev.bmsService,characteristic:dev.bmsWriteChar,value:bytesToBase64(bytes)});
|
|
||||||
const timeoutPromise=new Promise((_,rej)=>setTimeout(()=>rej(new Error('write timeout 3s')),3000));
|
|
||||||
await Promise.race([writePromise,timeoutPromise]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bmsTryProtocols(deviceId){
|
async function bmsTryProtocols(deviceId){
|
||||||
|
|
@ -6756,37 +6084,18 @@ async function bmsTryProtocols(deviceId){
|
||||||
for(const p of PROTOCOLS){
|
for(const p of PROTOCOLS){
|
||||||
try{
|
try{
|
||||||
setBleDiag(`→ TX ${p.name}: ${p.bytes.map(b=>b.toString(16).padStart(2,'0')).join(' ').slice(0,40)}`,'info');
|
setBleDiag(`→ TX ${p.name}: ${p.bytes.map(b=>b.toString(16).padStart(2,'0')).join(' ').slice(0,40)}`,'info');
|
||||||
try{
|
await bmsWriteCmd(deviceId,p.bytes,p.wnr);
|
||||||
await bmsWriteCmd(deviceId,p.bytes,p.wnr);
|
// Espera 2s pra ver se gerou RX
|
||||||
setBleDiag(`✔ write ${p.name} retornou`,'info');
|
|
||||||
}catch(we){
|
|
||||||
setBleDiag(`✗ write ${p.name} erro: ${we.message||we.errorMessage}`,'warn');
|
|
||||||
// Continue pro próximo protocolo mesmo se write falhar
|
|
||||||
}
|
|
||||||
// Aguarda RX por 2.5s
|
|
||||||
await new Promise(r=>setTimeout(r,2500));
|
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! · polling 5s ativado`,'ok');
|
setBleDiag(`✓ ${p.name} respondeu!`,'ok');
|
||||||
|
// Configura poll periódico com este protocolo
|
||||||
if(dev)dev.bmsProtocol=p.name;
|
if(dev)dev.bmsProtocol=p.name;
|
||||||
// Polling 5s pra atualização contínua (era 30s — usuário queria constante)
|
setInterval(async()=>{try{await bmsWriteCmd(deviceId,p.bytes,p.wnr)}catch{}},30000);
|
||||||
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{
|
|
||||||
setBleDiag(`✗ ${p.name} sem RX em 2.5s`,'info');
|
|
||||||
}
|
}
|
||||||
}catch(e){setBleDiag(`${p.name} loop erro: ${e.message||e.errorMessage}`,'warn')}
|
}catch(e){setBleDiag(`${p.name} falhou: ${e.message||e.errorMessage}`,'warn')}
|
||||||
}
|
}
|
||||||
setBleDiag('⚠ Nenhum protocolo funcionou. BMS pode usar firmware proprietário não documentado.','err');
|
setBleDiag('⚠ Nenhum protocolo funcionou. BMS pode usar firmware proprietário não documentado.','err');
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -6816,30 +6125,12 @@ async function bmsQueryBasic(deviceId,withoutResponse){
|
||||||
await bmsWriteCmd(deviceId,BMS_CMD_BASIC,withoutResponse);
|
await bmsWriteCmd(deviceId,BMS_CMD_BASIC,withoutResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-leitura manual: garante init + reconecta GATT + roda probe
|
// Re-leitura manual a partir do botão UI
|
||||||
async function bmsManualRead(deviceId){
|
async function bmsManualRead(deviceId){
|
||||||
setBleDiag('🔄 Re-leitura manual','info');
|
setBleDiag('🔄 Re-leitura manual · re-rodando probe completo...','info');
|
||||||
const dev=state.btDevices?.find(d=>d.id===deviceId);
|
|
||||||
const deviceName=dev?.name||'BMS';
|
|
||||||
try{
|
try{
|
||||||
// 1. Garante plugin inicializado
|
// Re-roda probe completo (lista chars de novo + tenta protocolos)
|
||||||
await ensureBleNativeReady();
|
await bmsProbeAndAttach(deviceId,state.btDevices?.find(d=>d.id===deviceId)?.name||'BMS');
|
||||||
setBleDiag('Plugin init OK','info');
|
|
||||||
// 2. Reconecta GATT (Android pode ter desconectado em background)
|
|
||||||
const ble=window.Capacitor?.Plugins?.BluetoothLe;
|
|
||||||
if(ble){
|
|
||||||
try{
|
|
||||||
await ble.connect({deviceId,timeout:15000});
|
|
||||||
setBleDiag('GATT reconectado','ok');
|
|
||||||
await new Promise(r=>setTimeout(r,500));
|
|
||||||
}catch(e){
|
|
||||||
const msg=e.message||e.errorMessage||'?';
|
|
||||||
if(/already/i.test(msg))setBleDiag('GATT já conectado','info');
|
|
||||||
else setBleDiag('Reconnect erro: '+msg,'warn');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 3. Roda probe completo
|
|
||||||
await bmsProbeAndAttach(deviceId,deviceName);
|
|
||||||
}catch(e){setBleDiag('Manual read falhou: '+(e.message||e.errorMessage),'err')}
|
}catch(e){setBleDiag('Manual read falhou: '+(e.message||e.errorMessage),'err')}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6956,7 +6247,7 @@ async function removeBluetoothDevice(id){
|
||||||
renderBluetoothCard();
|
renderBluetoothCard();
|
||||||
}
|
}
|
||||||
|
|
||||||
const APP_VERSION='1.12.0';
|
const APP_VERSION='1.10.4';
|
||||||
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');
|
||||||
|
|
|
||||||
|
|
@ -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 33
|
versionCode 20
|
||||||
versionName "1.12.0"
|
versionName "1.10.4"
|
||||||
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.
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ 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')
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,6 @@
|
||||||
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')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "shivao-mobile",
|
"name": "shivao-mobile",
|
||||||
"version": "1.12.0",
|
"version": "1.10.4",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
# ======================================================
|
|
||||||
# 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
|
|
||||||
|
|
@ -1977,41 +1977,13 @@ Marque zonas de proibição (alarme alto) ou atenção (aviso suave). Detecção
|
||||||
<div id="bt-support" style="font-family:var(--f-mono);font-size:10.5px;letter-spacing:.04em;margin-bottom:10px">Verificando suporte...</div>
|
<div id="bt-support" style="font-family:var(--f-mono);font-size:10.5px;letter-spacing:.04em;margin-bottom:10px">Verificando suporte...</div>
|
||||||
<button class="btn btn-block btn-primary" onclick="pairBluetoothDevice()">+ Parear novo dispositivo Bluetooth</button>
|
<button class="btn btn-block btn-primary" onclick="pairBluetoothDevice()">+ Parear novo dispositivo Bluetooth</button>
|
||||||
<div id="bt-list" style="margin-top:14px"></div>
|
<div id="bt-list" style="margin-top:14px"></div>
|
||||||
<details style="margin-top:10px" open>
|
<details style="margin-top:10px">
|
||||||
<summary style="cursor:pointer;font-family:var(--f-mono);font-size:11px;color:var(--m-text-soft,#7d97ad);letter-spacing:.06em">📋 Diagnóstico (logs do pareamento)</summary>
|
<summary style="cursor:pointer;font-family:var(--f-mono);font-size:11px;color:var(--m-text-soft,#7d97ad);letter-spacing:.06em">📋 Diagnóstico (logs do pareamento)</summary>
|
||||||
<div style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap">
|
<div id="bt-diag" style="background:var(--m-bg-2,#0f2a40);border:1px solid var(--m-border,rgba(255,255,255,.08));border-radius:6px;padding:10px;margin-top:6px;max-height:200px;overflow-y:auto"></div>
|
||||||
<button class="btn btn-sm btn-primary" onclick="sendDiagLogToServer()" style="flex:1;min-width:140px">📤 Enviar pro servidor</button>
|
|
||||||
<button class="btn btn-sm" onclick="document.getElementById('bt-diag').innerHTML='';setBleDiag('Log limpo','info')" style="flex:1;min-width:80px">🗑 Limpar</button>
|
|
||||||
</div>
|
|
||||||
<div id="bt-diag" style="background:var(--m-bg-2,#0f2a40);border:1px solid var(--m-border,rgba(255,255,255,.08));border-radius:6px;padding:10px;margin-top:6px;max-height:300px;overflow-y:auto;font-family:var(--f-mono);font-size:11px"></div>
|
|
||||||
</details>
|
</details>
|
||||||
<div class="field-hint" style="margin-top:8px">Limitações: <strong>iOS Safari não suporta Web Bluetooth</strong>. APK Android usa plugin nativo. BMS proprietários (Victron, JBD) podem aparecer mas não expor Battery Service padrão.</div>
|
<div 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>
|
||||||
|
|
@ -2638,7 +2610,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'},smartDevices:[]};
|
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'}};
|
||||||
|
|
||||||
// ============ 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.
|
||||||
|
|
@ -3770,284 +3742,23 @@ 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)
|
await openDB();loadState();bindHeader();await renderAll();
|
||||||
try{
|
document.getElementById('fab').style.display='none';
|
||||||
// Detecta crash BLE da sessão anterior via breadcrumb
|
loadTrackingState();
|
||||||
try{
|
loadAnchorState();
|
||||||
const lastStep=localStorage.getItem('shivao_ble_last_step');
|
initBattery();
|
||||||
if(lastStep){
|
initServiceWorker();
|
||||||
setTimeout(()=>{
|
initSensorWidget();
|
||||||
try{alert('⚠ Crash detectado na sessão anterior!\nÚltima ação BLE: '+lastStep+'\n\nMe envie esta mensagem.')}catch{}
|
// Realtime sync: conecta WebSocket se cloud configurada
|
||||||
},2000);
|
setSyncStatus(cloudConfigured()?'syncing':'disabled');
|
||||||
localStorage.removeItem('shivao_ble_last_step');
|
if(cloudConfigured()){rtConnect();refreshGoogleStatus()}
|
||||||
}
|
// tenta auto-fetch do tempo após pequeno delay
|
||||||
}catch{}
|
setTimeout(maybeAutoFetchWeather,3000);
|
||||||
await openDB();
|
// Welcome screen — só pra usuários sem login
|
||||||
try{loadState()}catch(e){console.error('[boot] loadState',e);try{localStorage.removeItem(STORAGE_KEY)}catch{}/* corrupt state — reset */}
|
setTimeout(maybeShowWelcome,300);
|
||||||
// Migration defensiva: limpa entries inválidas em state.btDevices
|
// Retoma polling do OAuth se app foi morto durante login Google
|
||||||
if(state.btDevices&&Array.isArray(state.btDevices)){
|
setTimeout(resumePollingIfPending,500);
|
||||||
state.btDevices=state.btDevices.filter(d=>d&&typeof d==='object'&&d.id);
|
|
||||||
}else{
|
|
||||||
state.btDevices=[];
|
|
||||||
}
|
|
||||||
bindHeader();
|
|
||||||
await renderAll();
|
|
||||||
try{document.getElementById('fab').style.display='none'}catch(e){}
|
|
||||||
try{loadTrackingState()}catch(e){console.error('[boot] tracking',e)}
|
|
||||||
try{loadAnchorState()}catch(e){console.error('[boot] anchor',e)}
|
|
||||||
try{initBattery()}catch(e){console.error('[boot] battery',e)}
|
|
||||||
try{initServiceWorker()}catch(e){console.error('[boot] sw',e)}
|
|
||||||
try{initSensorWidget()}catch(e){console.error('[boot] sensors',e)}
|
|
||||||
try{initSmartHome()}catch(e){console.error('[boot] smarthome',e)}
|
|
||||||
try{setSyncStatus(cloudConfigured()?'syncing':'disabled')}catch(e){}
|
|
||||||
if(cloudConfigured()){
|
|
||||||
try{rtConnect()}catch(e){console.error('[boot] rt',e)}
|
|
||||||
try{refreshGoogleStatus()}catch(e){console.error('[boot] gcal',e)}
|
|
||||||
}
|
|
||||||
setTimeout(()=>{try{maybeAutoFetchWeather()}catch(e){}},3000);
|
|
||||||
// Pede permissão de notificações (alertas tempestade)
|
|
||||||
setTimeout(async()=>{
|
|
||||||
try{
|
|
||||||
const ln=window.Capacitor?.Plugins?.LocalNotifications;
|
|
||||||
if(ln){await ln.requestPermissions().catch(()=>{})}
|
|
||||||
else if('Notification' in window&&Notification.permission==='default'){
|
|
||||||
await Notification.requestPermission().catch(()=>{});
|
|
||||||
}
|
|
||||||
}catch{}
|
|
||||||
},5000);
|
|
||||||
setTimeout(()=>{try{maybeShowWelcome()}catch(e){}},300);
|
|
||||||
setTimeout(()=>{try{resumePollingIfPending()}catch(e){}},500);
|
|
||||||
}catch(e){
|
|
||||||
// Crash no boot — mostra alert nativo (sobrevive Capacitor crash) + tenta auto-recovery
|
|
||||||
const msg='Boot error: '+(e.message||e)+'\n'+(e.stack||'').slice(0,300);
|
|
||||||
console.error('[BOOT CRASH]',e);
|
|
||||||
try{alert(msg)}catch{}
|
|
||||||
try{localStorage.clear()}catch{}
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
// Re-tenta init Google Sign-In quando o script async carrega
|
// Re-tenta init Google Sign-In quando o script async carrega
|
||||||
window.addEventListener('load',()=>setTimeout(()=>{if(document.getElementById('welcome-screen').style.display==='flex')initGoogleSignIn()},500));
|
window.addEventListener('load',()=>setTimeout(()=>{if(document.getElementById('welcome-screen').style.display==='flex')initGoogleSignIn()},500));
|
||||||
|
|
@ -5844,172 +5555,19 @@ async function fetchWeatherOpenMeteo(lat,lng){
|
||||||
if(weather.fetching)return;
|
if(weather.fetching)return;
|
||||||
weather.fetching=true;
|
weather.fetching=true;
|
||||||
try{
|
try{
|
||||||
// Forecast: vento, temp, pressão, lightning (CAPE), gust
|
const fUrl=`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=temperature_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m,weather_code&hourly=wind_speed_10m,wind_direction_10m,weather_code&forecast_hours=24&wind_speed_unit=kn&timezone=auto`;
|
||||||
const fUrl=`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=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`;
|
const mUrl=`https://marine-api.open-meteo.com/v1/marine?latitude=${lat}&longitude=${lng}¤t=wave_height,wave_direction,wave_period&hourly=wave_height&forecast_hours=24&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}¤t=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; // só 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).
|
||||||
|
|
@ -6035,47 +5593,8 @@ 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')renderWindyWeather(el,d);
|
if(d.provider==='windy')return renderWindyWeather(el,d);
|
||||||
else renderOpenMeteoWeather(el,d);
|
return 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){
|
||||||
|
|
@ -6271,66 +5790,6 @@ async function ensureBleNativeReady(){
|
||||||
_bleNativeInitialized=true;
|
_bleNativeInitialized=true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Envia log diagnóstico direto pro servidor (evita crash de clipboard/share em WebView)
|
|
||||||
async function sendDiagLogToServer(){
|
|
||||||
const el=document.getElementById('bt-diag');
|
|
||||||
if(!el){toast('Log vazio');return}
|
|
||||||
const txt=`Shivao v${APP_VERSION} · log diagnóstico\n\n`+(el.innerText||el.textContent||'').trim();
|
|
||||||
if(!cloudConfigured()){toast('Faça login primeiro pra enviar log');return}
|
|
||||||
toast('📤 Enviando log pro servidor...');
|
|
||||||
try{
|
|
||||||
const r=await cloudFetch('/api/bms/diag-log',{method:'POST',body:JSON.stringify({log:txt})});
|
|
||||||
const j=await r.json();
|
|
||||||
if(j.ok)toast('✓ Log enviado · '+(j.file||'OK'));
|
|
||||||
else throw new Error(j.error||'falhou');
|
|
||||||
}catch(e){toast('Erro: '+e.message)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mostra log num modal pro usuário copiar/compartilhar manualmente
|
|
||||||
function copyDiagLog(){
|
|
||||||
const el=document.getElementById('bt-diag');
|
|
||||||
if(!el){toast('Log vazio');return}
|
|
||||||
const txt=`Shivao v${APP_VERSION} · log diagnóstico\n\n`+(el.innerText||el.textContent||'').trim();
|
|
||||||
let modal=document.getElementById('bt-log-modal');
|
|
||||||
if(modal)modal.remove();
|
|
||||||
modal=document.createElement('div');
|
|
||||||
modal.id='bt-log-modal';
|
|
||||||
modal.style.cssText='position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.85);display:flex;flex-direction:column;padding:16px;backdrop-filter:blur(8px)';
|
|
||||||
// safe escape
|
|
||||||
const esc=txt.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
||||||
modal.innerHTML=`
|
|
||||||
<div style="background:#0d2538;border:1px solid #06b6d4;border-radius:12px;padding:16px;display:flex;flex-direction:column;height:100%;max-height:95vh">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
|
||||||
<h3 style="margin:0;color:#06b6d4;font-size:15px">📋 Log diagnóstico</h3>
|
|
||||||
<button onclick="document.getElementById('bt-log-modal').remove()" style="background:transparent;border:none;color:#fff;font-size:24px;cursor:pointer;padding:4px 12px">✕</button>
|
|
||||||
</div>
|
|
||||||
<p style="color:#b3c5d6;font-size:12px;margin:0 0 8px;line-height:1.4">Toque dentro da caixa e segure pra selecionar tudo. Ou toque ↗ Compartilhar pra enviar via WhatsApp.</p>
|
|
||||||
<textarea id="bt-log-textarea" readonly style="flex:1;width:100%;min-height:280px;background:#0a1f30;color:#e8f1f8;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:10px;font-family:'JetBrains Mono',monospace;font-size:11px;resize:none;-webkit-user-select:text;user-select:text;-webkit-touch-callout:default;outline:none">${esc}</textarea>
|
|
||||||
<div style="display:flex;gap:8px;margin-top:10px">
|
|
||||||
<button onclick="bmsShareLog()" style="flex:1;background:#10b981;color:#001a25;border:none;padding:12px;border-radius:8px;font-weight:600;cursor:pointer;font-size:14px">↗ Compartilhar</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
// Auto-seleciona o texto
|
|
||||||
setTimeout(()=>{
|
|
||||||
const ta=document.getElementById('bt-log-textarea');
|
|
||||||
if(ta){ta.focus();try{ta.select()}catch{}}
|
|
||||||
},200);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bmsShareLog(){
|
|
||||||
const ta=document.getElementById('bt-log-textarea');
|
|
||||||
if(!ta)return;
|
|
||||||
if(navigator.share){
|
|
||||||
try{
|
|
||||||
await navigator.share({title:'Shivao log diagnóstico',text:ta.value});
|
|
||||||
}catch(e){/* user cancelou */}
|
|
||||||
}else{
|
|
||||||
toast('Compartilhar não disponível · use seleção manual');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Diagnóstico visível: mostra cada passo no card BLE
|
// Diagnóstico visível: mostra cada passo no card BLE
|
||||||
function setBleDiag(msg,type){
|
function setBleDiag(msg,type){
|
||||||
const el=document.getElementById('bt-diag');
|
const el=document.getElementById('bt-diag');
|
||||||
|
|
@ -6345,12 +5804,6 @@ function setBleDiag(msg,type){
|
||||||
console.log('[ble]',msg);
|
console.log('[ble]',msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Breadcrumb persistente: grava ANTES de cada chamada nativa pra sobreviver crash do WebView
|
|
||||||
function bleCrumb(step){
|
|
||||||
try{localStorage.setItem('shivao_ble_last_step',step+' @ '+new Date().toISOString())}catch{}
|
|
||||||
}
|
|
||||||
function bleCrumbClear(){try{localStorage.removeItem('shivao_ble_last_step')}catch{}}
|
|
||||||
|
|
||||||
async function pairBluetoothDevice(){
|
async function pairBluetoothDevice(){
|
||||||
const backend=bleBackend();
|
const backend=bleBackend();
|
||||||
setBleDiag('Backend: '+(backend||'NENHUM'),backend?'info':'err');
|
setBleDiag('Backend: '+(backend||'NENHUM'),backend?'info':'err');
|
||||||
|
|
@ -6358,10 +5811,8 @@ async function pairBluetoothDevice(){
|
||||||
try{
|
try{
|
||||||
let deviceId,deviceName;
|
let deviceId,deviceName;
|
||||||
if(backend==='capacitor'){
|
if(backend==='capacitor'){
|
||||||
bleCrumb('ensureBleNativeReady');
|
|
||||||
setBleDiag('Inicializando plugin nativo...');
|
setBleDiag('Inicializando plugin nativo...');
|
||||||
await ensureBleNativeReady();
|
await ensureBleNativeReady();
|
||||||
bleCrumb('requestDevice');
|
|
||||||
setBleDiag('Plugin OK · abrindo picker...');
|
setBleDiag('Plugin OK · abrindo picker...');
|
||||||
const ble=window.Capacitor.Plugins.BluetoothLe;
|
const ble=window.Capacitor.Plugins.BluetoothLe;
|
||||||
const result=await ble.requestDevice({
|
const result=await ble.requestDevice({
|
||||||
|
|
@ -6369,16 +5820,15 @@ async function pairBluetoothDevice(){
|
||||||
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO],
|
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO],
|
||||||
allowDuplicates:false,
|
allowDuplicates:false,
|
||||||
});
|
});
|
||||||
if(!result?.deviceId){bleCrumbClear();setBleDiag('Picker cancelado','warn');return}
|
if(!result?.deviceId){setBleDiag('Picker cancelado','warn');return}
|
||||||
deviceId=result.deviceId;
|
deviceId=result.deviceId;
|
||||||
deviceName=result.name||'Dispositivo BLE';
|
deviceName=result.name||'Dispositivo BLE';
|
||||||
bleCrumb('selected:'+deviceName);
|
|
||||||
setBleDiag('Selecionado: '+deviceName+' ('+deviceId+')','ok');
|
setBleDiag('Selecionado: '+deviceName+' ('+deviceId+')','ok');
|
||||||
}else{
|
}else{
|
||||||
setBleDiag('Abrindo picker do navegador...');
|
setBleDiag('Abrindo picker do navegador...');
|
||||||
const device=await navigator.bluetooth.requestDevice({
|
const device=await navigator.bluetooth.requestDevice({
|
||||||
acceptAllDevices:true,
|
acceptAllDevices:true,
|
||||||
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO,'0000ff00-0000-1000-8000-00805f9b34fb','0000fff0-0000-1000-8000-00805f9b34fb','0000ffe0-0000-1000-8000-00805f9b34fb'],
|
optionalServices:[BLE_BATTERY_SERVICE,BLE_DEVICE_INFO],
|
||||||
});
|
});
|
||||||
if(!device){return}
|
if(!device){return}
|
||||||
deviceId=device.id;
|
deviceId=device.id;
|
||||||
|
|
@ -6408,11 +5858,11 @@ async function pairBluetoothDevice(){
|
||||||
}
|
}
|
||||||
saveState();
|
saveState();
|
||||||
renderBluetoothCard();
|
renderBluetoothCard();
|
||||||
// Se detectou JBD BMS OU backend é web (sempre tenta probe — descobre serviço dinâmico), ativa probe
|
// Se detectou JBD BMS, ativa parser proprietário
|
||||||
if(info.isJBD||backend==='web'){
|
if(info.isJBD){
|
||||||
const ok=await bmsAttachJBD(deviceId,deviceName);
|
const ok=await bmsAttachJBD(deviceId,deviceName);
|
||||||
if(ok)toast('✓ '+deviceName+' · BMS ativo');
|
if(ok)toast('✓ '+deviceName+' · JBD BMS ativo');
|
||||||
else toast('✓ '+deviceName+' (sem BMS detectável)');
|
else toast('✓ '+deviceName+' (BMS detectado mas falha no parser)');
|
||||||
}else{
|
}else{
|
||||||
toast('✓ '+deviceName+(info.battery!=null?' · '+info.battery+'%':' (sem leitura de bateria)'));
|
toast('✓ '+deviceName+(info.battery!=null?' · '+info.battery+'%':' (sem leitura de bateria)'));
|
||||||
}
|
}
|
||||||
|
|
@ -6432,7 +5882,6 @@ async function connectAndRead(deviceId,deviceName){
|
||||||
if(backend==='capacitor'){
|
if(backend==='capacitor'){
|
||||||
const ble=window.Capacitor.Plugins.BluetoothLe;
|
const ble=window.Capacitor.Plugins.BluetoothLe;
|
||||||
try{
|
try{
|
||||||
bleCrumb('ble.connect');
|
|
||||||
await ble.connect({deviceId,timeout:30000});
|
await ble.connect({deviceId,timeout:30000});
|
||||||
setBleDiag('GATT conectado','ok');
|
setBleDiag('GATT conectado','ok');
|
||||||
}catch(e){
|
}catch(e){
|
||||||
|
|
@ -6442,12 +5891,13 @@ async function connectAndRead(deviceId,deviceName){
|
||||||
const conn=_bleConnections.get(deviceId)||{};
|
const conn=_bleConnections.get(deviceId)||{};
|
||||||
conn.backend='capacitor';conn.deviceId=deviceId;conn.connected=true;
|
conn.backend='capacitor';conn.deviceId=deviceId;conn.connected=true;
|
||||||
_bleConnections.set(deviceId,conn);
|
_bleConnections.set(deviceId,conn);
|
||||||
|
// Discover all services pra diagnóstico + auto-detect protocols
|
||||||
try{
|
try{
|
||||||
bleCrumb('ble.getServices');
|
|
||||||
const r=await ble.getServices({deviceId});
|
const r=await ble.getServices({deviceId});
|
||||||
const svcs=(r.services||r||[]).map(s=>s.uuid||s).slice(0,8);
|
const svcs=(r.services||r||[]).map(s=>s.uuid||s).slice(0,8);
|
||||||
setBleDiag('Serviços encontrados: '+svcs.length+' ('+svcs.map(u=>u.slice(4,8)).join(', ')+')','info');
|
setBleDiag('Serviços encontrados: '+svcs.length+' ('+svcs.map(u=>u.slice(4,8)).join(', ')+')','info');
|
||||||
info.services=svcs;
|
info.services=svcs;
|
||||||
|
// Auto-detect: service ff00 = JBD/LLT Power BMS
|
||||||
const hasJbd=svcs.some(u=>u.toLowerCase().startsWith('0000ff00'));
|
const hasJbd=svcs.some(u=>u.toLowerCase().startsWith('0000ff00'));
|
||||||
if(hasJbd){
|
if(hasJbd){
|
||||||
setBleDiag('🔋 JBD BMS protocol detectado!','ok');
|
setBleDiag('🔋 JBD BMS protocol detectado!','ok');
|
||||||
|
|
@ -6542,107 +5992,10 @@ function bytesToBase64(arr){
|
||||||
return btoa(bin);
|
return btoa(bin);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Probe via Web Bluetooth API (Chrome PC)
|
// Probe: lista characteristics + identifica notify/write chars + tenta protocolos
|
||||||
async function bmsProbeWebBluetooth(deviceId,deviceName){
|
|
||||||
const conn=_bleConnections.get(deviceId);
|
|
||||||
const device=conn?.device;
|
|
||||||
if(!device){setBleDiag('Device sem referência (re-pareie)','err');return false}
|
|
||||||
try{
|
|
||||||
setBleDiag(`📦 Shivao v${APP_VERSION} · Probe WEB iniciado`,'info');
|
|
||||||
const server=device.gatt.connected?device.gatt:await device.gatt.connect();
|
|
||||||
setBleDiag('GATT web conectado','ok');
|
|
||||||
let svc=null;
|
|
||||||
try{svc=await server.getPrimaryService('0000ff00-0000-1000-8000-00805f9b34fb');setBleDiag('Service ff00 OK','info')}
|
|
||||||
catch(e){setBleDiag('Service ff00 falhou: '+e.message,'err');return false}
|
|
||||||
const chars=await svc.getCharacteristics();
|
|
||||||
setBleDiag(`Svc ff00 · ${chars.length} chars`,'info');
|
|
||||||
let notifyChar=null,writeChar=null;
|
|
||||||
for(const c of chars){
|
|
||||||
const p=c.properties;
|
|
||||||
const propsStr=[p.notify&&'notify',p.indicate&&'indicate',p.write&&'write',p.writeWithoutResponse&&'wnr',p.read&&'read'].filter(Boolean).join(',');
|
|
||||||
const cu=c.uuid.toLowerCase();
|
|
||||||
setBleDiag(` ${cu.slice(4,8)} [${propsStr}]`,'info');
|
|
||||||
if(!notifyChar&&(p.notify||p.indicate))notifyChar=c;
|
|
||||||
if(!writeChar&&(p.write||p.writeWithoutResponse))writeChar=c;
|
|
||||||
}
|
|
||||||
if(!notifyChar){setBleDiag('Sem char notify','err');return false}
|
|
||||||
if(!writeChar){
|
|
||||||
// Workaround: BMS chinês declara ff02 só [read] mas aceita writes (firmware permissivo).
|
|
||||||
// Força usar primeira char não-notify e tenta writeValue mesmo assim.
|
|
||||||
writeChar=chars.find(c=>c.uuid.toLowerCase()!==notifyChar.uuid.toLowerCase())||chars[0];
|
|
||||||
setBleDiag(`⚠ Sem property write · forçando ${writeChar.uuid.slice(4,8)}`,'warn');
|
|
||||||
}
|
|
||||||
setBleDiag(`Notify=${notifyChar.uuid.slice(4,8)} Write=${writeChar.uuid.slice(4,8)}`,'ok');
|
|
||||||
notifyChar.addEventListener('characteristicvaluechanged',(ev)=>{
|
|
||||||
const dv=ev.target.value;
|
|
||||||
const hex=Array.from(new Uint8Array(dv.buffer)).map(b=>b.toString(16).padStart(2,'0')).join(' ');
|
|
||||||
setBleDiag(`← RX ${dv.byteLength}b: ${hex.slice(0,100)}${hex.length>100?'...':''}`,'ok');
|
|
||||||
// Se há buffer pendente JBD, é chunk de continuação — roteia sempre
|
|
||||||
if(_bmsBuffers.has(deviceId)){
|
|
||||||
bmsHandleChunk(deviceId,dv,deviceName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Buffer vazio: primeiro byte determina protocolo (início de novo pacote)
|
|
||||||
const first=new Uint8Array(dv.buffer)[0];
|
|
||||||
if(first===0xDD)bmsHandleChunk(deviceId,dv,deviceName);
|
|
||||||
else if(first===0xAA)bmsHandleJK(deviceId,dv,deviceName);
|
|
||||||
else if(first===0xA5)bmsHandleDaly(deviceId,dv,deviceName);
|
|
||||||
});
|
|
||||||
await notifyChar.startNotifications();
|
|
||||||
setBleDiag('Notify ativo · iniciando wake...','ok');
|
|
||||||
await new Promise(r=>setTimeout(r,500));
|
|
||||||
try{
|
|
||||||
const fn=writeChar.properties.writeWithoutResponse?'writeValueWithoutResponse':'writeValue';
|
|
||||||
await writeChar[fn](new Uint8Array([0x5A,0x5A,0x5A,0x5A]));
|
|
||||||
setBleDiag('Wake 5A x4 enviado','info');
|
|
||||||
}catch(e){setBleDiag('Wake skip: '+e.message,'info')}
|
|
||||||
await new Promise(r=>setTimeout(r,1500));
|
|
||||||
const PROTOCOLS=[
|
|
||||||
{name:'JBD-0x03',bytes:[0xDD,0xA5,0x03,0x00,0xFF,0xFD,0x77]},
|
|
||||||
{name:'JK-getInfo',bytes:[0xAA,0x55,0x90,0xEB,0x96,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x10]},
|
|
||||||
{name:'Daly-getInfo',bytes:[0xA5,0x80,0x90,0x08,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xBD]},
|
|
||||||
];
|
|
||||||
for(const p of PROTOCOLS){
|
|
||||||
try{
|
|
||||||
setBleDiag(`→ TX ${p.name}`,'info');
|
|
||||||
const fn=writeChar.properties.writeWithoutResponse?'writeValueWithoutResponse':'writeValue';
|
|
||||||
await writeChar[fn](new Uint8Array(p.bytes));
|
|
||||||
setBleDiag(`✔ write ${p.name} OK`,'info');
|
|
||||||
await new Promise(r=>setTimeout(r,2500));
|
|
||||||
const dev=state.btDevices?.find(d=>d.id===deviceId);
|
|
||||||
if(dev?.bms?.voltage||dev?._lastRxAt){
|
|
||||||
setBleDiag(`✓ ${p.name} respondeu! · polling 5s ativado`,'ok');
|
|
||||||
if(dev){dev.bmsProtocol=p.name;dev.isJBD=true;saveState()}
|
|
||||||
conn.notifyChar=notifyChar;conn.writeChar=writeChar;
|
|
||||||
// Polling a cada 5s pra atualização contínua
|
|
||||||
if(conn._pollInterval)clearInterval(conn._pollInterval);
|
|
||||||
conn._pollInterval=setInterval(async()=>{
|
|
||||||
try{
|
|
||||||
const fn2=writeChar.properties.writeWithoutResponse?'writeValueWithoutResponse':'writeValue';
|
|
||||||
await writeChar[fn2](new Uint8Array(p.bytes));
|
|
||||||
}catch(e){
|
|
||||||
// Conexão caiu: para o polling
|
|
||||||
if(/disconnected|connection|gatt/i.test(e.message||'')){
|
|
||||||
setBleDiag('Polling interrompido: '+e.message,'warn');
|
|
||||||
clearInterval(conn._pollInterval);conn._pollInterval=null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},5000);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
setBleDiag(`✗ ${p.name} sem RX`,'info');
|
|
||||||
}catch(e){setBleDiag(`${p.name} erro: ${e.message}`,'warn')}
|
|
||||||
}
|
|
||||||
setBleDiag('⚠ Nenhum protocolo respondeu','err');
|
|
||||||
return false;
|
|
||||||
}catch(e){setBleDiag('Probe web falhou: '+e.message,'err');return false}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Probe Capacitor: lista characteristics + identifica notify/write chars + tenta protocolos
|
|
||||||
async function bmsProbeAndAttach(deviceId,deviceName){
|
async function bmsProbeAndAttach(deviceId,deviceName){
|
||||||
const backend=bleBackend();
|
const backend=bleBackend();
|
||||||
if(backend==='web')return bmsProbeWebBluetooth(deviceId,deviceName);
|
if(backend!=='capacitor'){setBleDiag('Probe requer Capacitor (APK)','warn');return false}
|
||||||
if(backend!=='capacitor'){setBleDiag('Backend desconhecido','warn');return false}
|
|
||||||
const ble=window.Capacitor.Plugins.BluetoothLe;
|
const ble=window.Capacitor.Plugins.BluetoothLe;
|
||||||
try{
|
try{
|
||||||
setBleDiag(`📦 Shivao v${APP_VERSION} · Probe iniciado`,'info');
|
setBleDiag(`📦 Shivao v${APP_VERSION} · Probe iniciado`,'info');
|
||||||
|
|
@ -6680,49 +6033,28 @@ async function bmsProbeAndAttach(deviceId,deviceName){
|
||||||
setBleDiag('Não achei chars notify+write em services vendor','err');
|
setBleDiag('Não achei chars notify+write em services vendor','err');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Detecta se write char só aceita writeWithoutResponse
|
setBleDiag(`Notify=${notifyChar.slice(4,8)} Write=${writeChar.slice(4,8)} Svc=${foundService.slice(4,8)}`,'ok');
|
||||||
let writeOnlyWnr=false;
|
|
||||||
for(const svc of allSvcs){
|
|
||||||
if((svc.uuid||'').toLowerCase()!==foundService)continue;
|
|
||||||
for(const c of (svc.characteristics||[])){
|
|
||||||
if((c.uuid||'').toLowerCase()===writeChar){
|
|
||||||
const p=c.properties||{};
|
|
||||||
writeOnlyWnr=(!p.write&&p.writeWithoutResponse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setBleDiag(`Notify=${notifyChar.slice(4,8)} Write=${writeChar.slice(4,8)}${writeOnlyWnr?' (force-wnr)':''} Svc=${foundService.slice(4,8)}`,'ok');
|
|
||||||
// Subscribe + handler
|
// Subscribe + handler
|
||||||
const listenerKey='notification|'+deviceId+'|'+foundService+'|'+notifyChar;
|
const listenerKey='notification|'+deviceId+'|'+foundService+'|'+notifyChar;
|
||||||
ble.addListener(listenerKey,(ev)=>{
|
ble.addListener(listenerKey,(ev)=>{
|
||||||
const dv=parseDataView(ev.value);
|
const dv=parseDataView(ev.value);
|
||||||
const hex=Array.from(new Uint8Array(dv.buffer)).map(b=>b.toString(16).padStart(2,'0')).join(' ');
|
const hex=Array.from(new Uint8Array(dv.buffer)).map(b=>b.toString(16).padStart(2,'0')).join(' ');
|
||||||
setBleDiag('← RX '+dv.byteLength+'b: '+hex.slice(0,100)+(hex.length>100?'...':''),'ok');
|
setBleDiag('← RX '+dv.byteLength+'b: '+hex.slice(0,100)+(hex.length>100?'...':''),'ok');
|
||||||
// Se buffer JBD pendente, é continuação — roteia sempre
|
// Detecta protocolo por byte de início
|
||||||
if(_bmsBuffers.has(deviceId)){
|
|
||||||
bmsHandleChunk(deviceId,dv,deviceName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Detecta protocolo por byte de início (novo pacote)
|
|
||||||
const first=new Uint8Array(dv.buffer)[0];
|
const first=new Uint8Array(dv.buffer)[0];
|
||||||
if(first===0xDD)bmsHandleChunk(deviceId,dv,deviceName); // JBD
|
if(first===0xDD)bmsHandleChunk(deviceId,dv,deviceName); // JBD
|
||||||
else if(first===0xAA)bmsHandleJK(deviceId,dv,deviceName); // JK BMS
|
else if(first===0xAA)bmsHandleJK(deviceId,dv,deviceName); // JK BMS
|
||||||
else if(first===0xA5)bmsHandleDaly(deviceId,dv,deviceName); // Daly
|
else if(first===0xA5)bmsHandleDaly(deviceId,dv,deviceName); // Daly
|
||||||
});
|
});
|
||||||
try{
|
await ble.startNotifications({deviceId,service:foundService,characteristic:notifyChar});
|
||||||
await ble.startNotifications({deviceId,service:foundService,characteristic:notifyChar});
|
setBleDiag('Notify ativo · aguardando 800ms...','ok');
|
||||||
setBleDiag('Notify ativo · aguardando 800ms...','ok');
|
|
||||||
}catch(e){setBleDiag('startNotifications erro: '+(e.message||e.errorMessage||'?'),'err');return false}
|
|
||||||
await new Promise(r=>setTimeout(r,800));
|
await new Promise(r=>setTimeout(r,800));
|
||||||
// Wake-up REMOVIDO no path Capacitor — chamadas extra causavam crash nativo
|
|
||||||
// Provamos no PC (Web Bluetooth) que JBD-0x03 direto funciona
|
|
||||||
// Salva config no device pra reuso
|
// Salva config no device pra reuso
|
||||||
const dev=state.btDevices?.find(d=>d.id===deviceId);
|
const dev=state.btDevices?.find(d=>d.id===deviceId);
|
||||||
if(dev){
|
if(dev){
|
||||||
dev.bmsService=foundService;
|
dev.bmsService=foundService;
|
||||||
dev.bmsNotifyChar=notifyChar;
|
dev.bmsNotifyChar=notifyChar;
|
||||||
dev.bmsWriteChar=writeChar;
|
dev.bmsWriteChar=writeChar;
|
||||||
dev.bmsForceWnr=writeOnlyWnr;
|
|
||||||
dev.isJBD=true;
|
dev.isJBD=true;
|
||||||
saveState();
|
saveState();
|
||||||
}
|
}
|
||||||
|
|
@ -6738,12 +6070,8 @@ async function bmsWriteCmd(deviceId,bytes,withoutResponse){
|
||||||
const ble=window.Capacitor?.Plugins?.BluetoothLe;
|
const ble=window.Capacitor?.Plugins?.BluetoothLe;
|
||||||
const dev=state.btDevices?.find(d=>d.id===deviceId);
|
const dev=state.btDevices?.find(d=>d.id===deviceId);
|
||||||
if(!ble||!dev?.bmsService||!dev?.bmsWriteChar)throw new Error('config BMS ausente');
|
if(!ble||!dev?.bmsService||!dev?.bmsWriteChar)throw new Error('config BMS ausente');
|
||||||
const useWnr=withoutResponse||dev.bmsForceWnr;
|
const fn=withoutResponse?'writeWithoutResponse':'write';
|
||||||
const fn=useWnr?'writeWithoutResponse':'write';
|
await ble[fn]({deviceId,service:dev.bmsService,characteristic:dev.bmsWriteChar,value:bytesToBase64(bytes)});
|
||||||
// Timeout 3s: plugins/firmware podem travar mesmo com WNR
|
|
||||||
const writePromise=ble[fn]({deviceId,service:dev.bmsService,characteristic:dev.bmsWriteChar,value:bytesToBase64(bytes)});
|
|
||||||
const timeoutPromise=new Promise((_,rej)=>setTimeout(()=>rej(new Error('write timeout 3s')),3000));
|
|
||||||
await Promise.race([writePromise,timeoutPromise]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bmsTryProtocols(deviceId){
|
async function bmsTryProtocols(deviceId){
|
||||||
|
|
@ -6756,37 +6084,18 @@ async function bmsTryProtocols(deviceId){
|
||||||
for(const p of PROTOCOLS){
|
for(const p of PROTOCOLS){
|
||||||
try{
|
try{
|
||||||
setBleDiag(`→ TX ${p.name}: ${p.bytes.map(b=>b.toString(16).padStart(2,'0')).join(' ').slice(0,40)}`,'info');
|
setBleDiag(`→ TX ${p.name}: ${p.bytes.map(b=>b.toString(16).padStart(2,'0')).join(' ').slice(0,40)}`,'info');
|
||||||
try{
|
await bmsWriteCmd(deviceId,p.bytes,p.wnr);
|
||||||
await bmsWriteCmd(deviceId,p.bytes,p.wnr);
|
// Espera 2s pra ver se gerou RX
|
||||||
setBleDiag(`✔ write ${p.name} retornou`,'info');
|
|
||||||
}catch(we){
|
|
||||||
setBleDiag(`✗ write ${p.name} erro: ${we.message||we.errorMessage}`,'warn');
|
|
||||||
// Continue pro próximo protocolo mesmo se write falhar
|
|
||||||
}
|
|
||||||
// Aguarda RX por 2.5s
|
|
||||||
await new Promise(r=>setTimeout(r,2500));
|
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! · polling 5s ativado`,'ok');
|
setBleDiag(`✓ ${p.name} respondeu!`,'ok');
|
||||||
|
// Configura poll periódico com este protocolo
|
||||||
if(dev)dev.bmsProtocol=p.name;
|
if(dev)dev.bmsProtocol=p.name;
|
||||||
// Polling 5s pra atualização contínua (era 30s — usuário queria constante)
|
setInterval(async()=>{try{await bmsWriteCmd(deviceId,p.bytes,p.wnr)}catch{}},30000);
|
||||||
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{
|
|
||||||
setBleDiag(`✗ ${p.name} sem RX em 2.5s`,'info');
|
|
||||||
}
|
}
|
||||||
}catch(e){setBleDiag(`${p.name} loop erro: ${e.message||e.errorMessage}`,'warn')}
|
}catch(e){setBleDiag(`${p.name} falhou: ${e.message||e.errorMessage}`,'warn')}
|
||||||
}
|
}
|
||||||
setBleDiag('⚠ Nenhum protocolo funcionou. BMS pode usar firmware proprietário não documentado.','err');
|
setBleDiag('⚠ Nenhum protocolo funcionou. BMS pode usar firmware proprietário não documentado.','err');
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -6816,30 +6125,12 @@ async function bmsQueryBasic(deviceId,withoutResponse){
|
||||||
await bmsWriteCmd(deviceId,BMS_CMD_BASIC,withoutResponse);
|
await bmsWriteCmd(deviceId,BMS_CMD_BASIC,withoutResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-leitura manual: garante init + reconecta GATT + roda probe
|
// Re-leitura manual a partir do botão UI
|
||||||
async function bmsManualRead(deviceId){
|
async function bmsManualRead(deviceId){
|
||||||
setBleDiag('🔄 Re-leitura manual','info');
|
setBleDiag('🔄 Re-leitura manual · re-rodando probe completo...','info');
|
||||||
const dev=state.btDevices?.find(d=>d.id===deviceId);
|
|
||||||
const deviceName=dev?.name||'BMS';
|
|
||||||
try{
|
try{
|
||||||
// 1. Garante plugin inicializado
|
// Re-roda probe completo (lista chars de novo + tenta protocolos)
|
||||||
await ensureBleNativeReady();
|
await bmsProbeAndAttach(deviceId,state.btDevices?.find(d=>d.id===deviceId)?.name||'BMS');
|
||||||
setBleDiag('Plugin init OK','info');
|
|
||||||
// 2. Reconecta GATT (Android pode ter desconectado em background)
|
|
||||||
const ble=window.Capacitor?.Plugins?.BluetoothLe;
|
|
||||||
if(ble){
|
|
||||||
try{
|
|
||||||
await ble.connect({deviceId,timeout:15000});
|
|
||||||
setBleDiag('GATT reconectado','ok');
|
|
||||||
await new Promise(r=>setTimeout(r,500));
|
|
||||||
}catch(e){
|
|
||||||
const msg=e.message||e.errorMessage||'?';
|
|
||||||
if(/already/i.test(msg))setBleDiag('GATT já conectado','info');
|
|
||||||
else setBleDiag('Reconnect erro: '+msg,'warn');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 3. Roda probe completo
|
|
||||||
await bmsProbeAndAttach(deviceId,deviceName);
|
|
||||||
}catch(e){setBleDiag('Manual read falhou: '+(e.message||e.errorMessage),'err')}
|
}catch(e){setBleDiag('Manual read falhou: '+(e.message||e.errorMessage),'err')}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6956,7 +6247,7 @@ async function removeBluetoothDevice(id){
|
||||||
renderBluetoothCard();
|
renderBluetoothCard();
|
||||||
}
|
}
|
||||||
|
|
||||||
const APP_VERSION='1.12.0';
|
const APP_VERSION='1.10.4';
|
||||||
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');
|
||||||
|
|
|
||||||
|
|
@ -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.12.0';
|
const VERSION = 'shivao-v1.7.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}`;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ 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');
|
||||||
|
|
@ -127,125 +126,6 @@ app.get('/api/auth/me', requireAuth, (req, res) => {
|
||||||
res.json(req.user);
|
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) =====
|
// ===== 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();
|
||||||
|
|
@ -467,7 +347,7 @@ app.get('/.well-known/assetlinks.json', (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Atalho: /apk redireciona pra última APK release no Forgejo
|
// Atalho: /apk redireciona pra última APK release no Forgejo
|
||||||
const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.12.0/Shivao-v1.12.0.apk';
|
const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.10.4/Shivao-v1.10.4.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)
|
||||||
|
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
// 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';
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue