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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:34:02 -03:00

196 lines
6.7 KiB
JavaScript

// Shivao Service Worker — offline real
// Estratégia: shell precachado, tiles cache-first, windy network-first, /api passa direto.
// Versão usada nos cache names — bumpa essa string pra invalidar caches antigos em deploys.
const VERSION = 'shivao-v1.12.0';
const SHELL_CACHE = `shivao-shell-${VERSION}`;
const TILES_CACHE = 'shivao-tiles-v1'; // separado pra não invalidar tiles em update do shell
const WINDY_CACHE = `shivao-windy-${VERSION}`;
// Recursos do "shell" (UI essencial). Pré-cacheados no install.
const SHELL_URLS = [
'/',
'/manifest.json',
'/icon.svg',
// Leaflet (cdnjs)
'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css',
'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(SHELL_CACHE).then(async (cache) => {
// Tenta cachear cada um individualmente — falha de 1 não derruba install
await Promise.all(SHELL_URLS.map(url =>
cache.add(url).catch(e => console.warn('[SW] precache miss:', url, e.message))
));
}).then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys => Promise.all(
// Remove caches antigos do shell e windy (não tiles — que tem própria versão)
keys.filter(k => k.startsWith('shivao-shell-') && k !== SHELL_CACHE)
.concat(keys.filter(k => k.startsWith('shivao-windy-') && k !== WINDY_CACHE))
.map(k => caches.delete(k))
)).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (event.request.method !== 'GET') return; // POST/PUT/DELETE passam direto
// /api do Shivão: passa direto, não interferir em sync/heartbeat/auth
if (url.pathname.startsWith('/api/')) return;
// Map tiles (OSM): cache-first, com network fallback
if (url.host === 'tile.openstreetmap.org' || url.pathname.match(/\.(png|jpg|jpeg)$/) && url.pathname.match(/\/\d+\/\d+\/\d+\./)) {
event.respondWith(cacheFirst(event.request, TILES_CACHE));
return;
}
// Windy API: network-first, fallback cache (mostra última previsão quando offline)
if (url.host === 'api.windy.com') {
event.respondWith(networkFirst(event.request, WINDY_CACHE, 3600 * 1000)); // TTL 1h
return;
}
// Open-Meteo (fallback meteo): mesmo padrão
if (url.host === 'api.open-meteo.com' || url.host === 'marine-api.open-meteo.com') {
event.respondWith(networkFirst(event.request, WINDY_CACHE, 3600 * 1000));
return;
}
// Fonts Google (fontes carregam no startup): cache-first
if (url.host === 'fonts.googleapis.com' || url.host === 'fonts.gstatic.com') {
event.respondWith(cacheFirst(event.request, SHELL_CACHE));
return;
}
// CDN Leaflet e similares
if (url.host === 'cdnjs.cloudflare.com') {
event.respondWith(cacheFirst(event.request, SHELL_CACHE));
return;
}
// Mesma origem (HTML/JS/CSS do app): stale-while-revalidate
if (url.origin === self.location.origin) {
event.respondWith(staleWhileRevalidate(event.request, SHELL_CACHE));
return;
}
// Resto: deixa rede tratar
});
async function cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) return cached;
try {
const fresh = await fetch(request);
if (fresh.ok) cache.put(request, fresh.clone()).catch(() => {});
return fresh;
} catch (e) {
return new Response('offline-no-cache', { status: 503, statusText: 'Offline' });
}
}
async function networkFirst(request, cacheName, ttlMs) {
const cache = await caches.open(cacheName);
try {
const fresh = await fetch(request);
if (fresh.ok) cache.put(request, fresh.clone()).catch(() => {});
return fresh;
} catch (e) {
const cached = await cache.match(request);
if (cached) return cached;
throw e;
}
}
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then(res => {
if (res.ok) cache.put(request, res.clone()).catch(() => {});
return res;
}).catch(() => cached || new Response('offline', { status: 503 }));
return cached || fetchPromise;
}
// Mensagens da página: pré-cachear tiles de uma área
self.addEventListener('message', (event) => {
if (!event.data || !event.data.type) return;
if (event.data.type === 'PRECACHE_TILES') {
const { bounds, minZoom, maxZoom } = event.data;
precacheTiles(bounds, minZoom, maxZoom).then(stats => {
event.source && event.source.postMessage({ type: 'PRECACHE_DONE', ...stats });
}).catch(err => {
event.source && event.source.postMessage({ type: 'PRECACHE_ERROR', error: err.message });
});
}
if (event.data.type === 'CACHE_SIZE') {
cacheSizes().then(sizes => {
event.source && event.source.postMessage({ type: 'CACHE_SIZE_REPORT', sizes });
});
}
if (event.data.type === 'CLEAR_TILES') {
caches.delete(TILES_CACHE).then(ok => {
event.source && event.source.postMessage({ type: 'TILES_CLEARED', ok });
});
}
});
// Calcula tiles necessários e baixa em paralelo (max 6 simultâneos)
async function precacheTiles(bounds, minZoom, maxZoom) {
const cache = await caches.open(TILES_CACHE);
const tiles = [];
for (let z = minZoom; z <= maxZoom; z++) {
const min = lngLatToTile(bounds.west, bounds.north, z);
const max = lngLatToTile(bounds.east, bounds.south, z);
for (let x = min.x; x <= max.x; x++) {
for (let y = min.y; y <= max.y; y++) {
tiles.push({ z, x, y });
}
}
}
let done = 0, failed = 0;
const total = tiles.length;
// Limita a 6 conexões paralelas pra respeitar OSM tile policy
const queue = tiles.slice();
await Promise.all([...Array(6)].map(async () => {
while (queue.length) {
const t = queue.shift();
const url = `https://tile.openstreetmap.org/${t.z}/${t.x}/${t.y}.png`;
try {
const res = await fetch(url, { headers: { 'User-Agent': 'Shivao-PWA' } });
if (res.ok) await cache.put(url, res);
else failed++;
} catch { failed++; }
done++;
}
}));
return { total, done, failed };
}
function lngLatToTile(lng, lat, z) {
const x = Math.floor((lng + 180) / 360 * Math.pow(2, z));
const y = Math.floor((1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * Math.pow(2, z));
return { x, y };
}
async function cacheSizes() {
const result = {};
for (const name of [SHELL_CACHE, TILES_CACHE, WINDY_CACHE]) {
const cache = await caches.open(name);
const keys = await cache.keys();
result[name] = keys.length;
}
return result;
}