Compare commits

..

No commits in common. "master" and "v1.6.0" have entirely different histories.

13 changed files with 80 additions and 5063 deletions

1
.gitignore vendored
View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -9,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')

View file

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

View file

@ -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')

View file

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

View file

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

View file

@ -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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -12,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,164 +126,7 @@ 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 // Login com Google (Sign-In) — recebe ID token, valida no Google, cria/loga user
app.post('/api/bms/diag-log', requireAuth, (req, res) => {
const { log } = req.body || {};
if (!log || typeof log !== 'string') return res.status(400).json({ error: 'log string required' });
const dir = path.join(db.dataDir, 'diag-logs');
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const file = path.join(dir, `${req.user.id}-${ts}.txt`);
try {
fs.writeFileSync(file, log.slice(0, 50000));
db.audit(req.user.id, 'bms_diag_log', 'bluetooth', null, { bytes: log.length, file: path.basename(file) }, req.ip);
res.json({ ok: true, file: path.basename(file) });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ADMIN: lista TODOS os logs (BOAT_TOKEN apenas)
app.get('/api/bms/diag-log/_all', requireAuth, (req, res) => {
if (!req.user.viaBoatToken) return res.status(403).json({ error: 'admin only' });
const dir = path.join(db.dataDir, 'diag-logs');
try {
if (!fs.existsSync(dir)) return res.json({ files: [] });
const files = fs.readdirSync(dir).map(f => {
const stat = fs.statSync(path.join(dir, f));
return { name: f, size: stat.size, mtime: stat.mtime };
}).sort((a, b) => b.mtime - a.mtime);
res.json({ files });
} catch (e) { res.status(500).json({ error: e.message }) }
});
// Lista logs disponíveis (debug)
app.get('/api/bms/diag-log', requireAuth, (req, res) => {
const dir = path.join(db.dataDir, 'diag-logs');
try {
if (!fs.existsSync(dir)) return res.json({ files: [] });
const files = fs.readdirSync(dir)
.filter(f => f.startsWith(`${req.user.id}-`))
.map(f => {
const stat = fs.statSync(path.join(dir, f));
return { name: f, size: stat.size, mtime: stat.mtime };
})
.sort((a, b) => b.mtime - a.mtime);
res.json({ files });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Lê conteúdo de um log específico
app.get('/api/bms/diag-log/:file', requireAuth, (req, res) => {
const file = req.params.file.replace(/[^a-zA-Z0-9._-]/g, '');
// Admin (BOAT_TOKEN) lê qualquer; user normal só os próprios
if (!req.user.viaBoatToken && !file.startsWith(`${req.user.id}-`)) {
return res.status(403).json({ error: 'forbidden' });
}
const fullPath = path.join(db.dataDir, 'diag-logs', file);
try {
if (!fs.existsSync(fullPath)) return res.status(404).json({ error: 'not found' });
const content = fs.readFileSync(fullPath, 'utf8');
res.type('text/plain').send(content);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ===== IoT (Smart Life / Tuya) =====
// Proxy assinado pra Tuya Cloud API. Access Secret nunca vai pro client.
app.get('/api/iot/devices', requireAuth, async (req, res) => {
if (!tuya.isEnabled()) return tuya.disabledResponse(res);
try {
const r = await tuya.listDevices();
if (r.error) return res.status(502).json({ error: r.error, code: r.code });
// Enriquece com label humano
const devices = r.devices.map(d => ({ ...d, category_label: tuya.categoryLabel(d.category) }));
res.json({ devices });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/iot/status/:deviceId', requireAuth, async (req, res) => {
if (!tuya.isEnabled()) return tuya.disabledResponse(res);
const deviceId = (req.params.deviceId || '').replace(/[^a-zA-Z0-9]/g, '');
if (!deviceId) return res.status(400).json({ error: 'deviceId required' });
try {
const r = await tuya.getDeviceStatus(deviceId);
if (r.error) return res.status(502).json({ error: r.error, code: r.code });
res.json({ status: r.status });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.post('/api/iot/command/:deviceId', requireAuth, async (req, res) => {
if (!tuya.isEnabled()) return tuya.disabledResponse(res);
const deviceId = (req.params.deviceId || '').replace(/[^a-zA-Z0-9]/g, '');
const { commands } = req.body || {};
if (!deviceId) return res.status(400).json({ error: 'deviceId required' });
if (!Array.isArray(commands) || commands.length === 0) {
return res.status(400).json({ error: 'commands array required' });
}
// Validação básica: cada item precisa ter code:string + value
for (const c of commands) {
if (!c || typeof c.code !== 'string') {
return res.status(400).json({ error: 'each command needs {code:string, value:any}' });
}
}
try {
const r = await tuya.sendCommand(deviceId, commands);
if (!r.ok) return res.status(502).json({ error: r.error, code: r.code });
db.audit(req.user.id, 'iot_command', 'tuya', deviceId, { commands }, req.ip);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ===== Google Login via OAuth redirect (pra apps Capacitor onde GSI popup não funciona) =====
// In-memory store: session_id → { tokens, createdAt }. Auto-expira em 10min.
const pendingGoogleSessions = new Map();
setInterval(() => {
const now = Date.now();
for (const [k, v] of pendingGoogleSessions.entries()) {
if (now - v.createdAt > 10 * 60 * 1000) pendingGoogleSessions.delete(k);
}
}, 60000);
// App chama isso pra obter URL pro browser externo
app.get('/api/auth/google/start', (req, res) => {
if (!gcal.isEnabled()) return gcal.disabledResponse(res);
const sessionId = req.query.session || ('s_' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2));
// Reusa buildAuthUrl mas com flow=login e session encoded em state
// Truque: passa userId=0 (não-autenticado) + flow no returnTo
const stateRaw = Buffer.from(JSON.stringify({ uid: 0, flow: 'login', session: sessionId, n: Math.random().toString(36).slice(2) })).toString('base64url');
const u = new URL('https://accounts.google.com/o/oauth2/v2/auth');
u.searchParams.set('client_id', process.env.GOOGLE_CLIENT_ID);
u.searchParams.set('redirect_uri', process.env.GOOGLE_REDIRECT_URI);
u.searchParams.set('response_type', 'code');
u.searchParams.set('scope', 'openid email profile');
u.searchParams.set('access_type', 'online');
u.searchParams.set('prompt', 'select_account');
u.searchParams.set('state', stateRaw);
res.json({ url: u.toString(), session: sessionId });
});
// App polling: retorna 204 se ainda esperando, 200 com tokens se Google completou
app.get('/api/auth/google/poll', (req, res) => {
const session = req.query.session;
if (!session) return res.status(400).json({ error: 'session required' });
const data = pendingGoogleSessions.get(session);
if (!data) return res.status(204).send();
pendingGoogleSessions.delete(session); // one-shot
res.json(data.tokens);
});
// Login com Google (Sign-In via popup do GSI no web) — recebe ID token, valida no Google, cria/loga user
app.post('/api/auth/google', async (req, res) => { app.post('/api/auth/google', async (req, res) => {
const { credential } = req.body || {}; const { credential } = req.body || {};
if (!credential) return res.status(400).json({ error: 'credential (Google ID token) required' }); if (!credential) return res.status(400).json({ error: 'credential (Google ID token) required' });
@ -467,7 +309,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.6.0/Shivao-v1.6.0.apk';
app.get('/apk', (req, res) => res.redirect(302, LATEST_APK_URL)); app.get('/apk', (req, res) => res.redirect(302, LATEST_APK_URL));
// Página A4 imprimível com QR Code + instruções (cola no barco/marina) // Página A4 imprimível com QR Code + instruções (cola no barco/marina)
@ -786,45 +628,11 @@ app.get('/api/google/callback', async (req, res) => {
let parsed; let parsed;
try { parsed = JSON.parse(Buffer.from(state, 'base64url').toString('utf8')); } try { parsed = JSON.parse(Buffer.from(state, 'base64url').toString('utf8')); }
catch (e) { return res.status(400).send('state inválido'); } catch (e) { return res.status(400).send('state inválido'); }
const userId = parsed.uid;
if (!userId) return res.status(400).send('state sem uid');
try { try {
const tokens = await gcal.exchangeCodeForTokens(code); const tokens = await gcal.exchangeCodeForTokens(code);
const userInfo = await gcal.getUserInfo(tokens.access_token); const userInfo = await gcal.getUserInfo(tokens.access_token);
// === Flow LOGIN (do app via /api/auth/google/start) ===
if (parsed.flow === 'login' && parsed.session) {
let user = db.findUserByEmail(userInfo.email);
if (!user) {
const randomPwd = 'google-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
const hash = await hashPassword(randomPwd);
const id = db.createUser(userInfo.email, hash, userInfo.name || userInfo.email.split('@')[0]);
db.audit(id, 'user_signup_google', 'user', String(id), { email: userInfo.email }, req.ip);
user = db.findUserById(id);
}
db.updateLastLogin(user.id);
db.audit(user.id, 'user_login_google', 'user', String(user.id), {}, req.ip);
const safe = db.findUserById(user.id);
// Salva tokens em memória pra app coletar via /poll
pendingGoogleSessions.set(parsed.session, {
tokens: {
user: safe,
accessToken: signAccessToken(safe),
refreshToken: signRefreshToken(safe),
},
createdAt: Date.now(),
});
return res.send(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Login OK</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>body{font-family:system-ui;background:#0e2a3d;color:#fff;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;text-align:center;padding:20px}h1{color:#d4a04a;margin:0 0 16px}p{margin:8px 0;line-height:1.5}.big{font-size:64px;margin-bottom:16px}</style></head>
<body><div><div class="big"></div><h1>Logado com sucesso</h1>
<p>Você está conectado como <strong>${userInfo.email}</strong></p>
<p style="opacity:.7">Volte pro app ele vai detectar o login automaticamente em alguns segundos.</p>
<script>setTimeout(()=>{try{window.close()}catch(e){}},3000)</script>
</div></body></html>`);
}
// === Flow CALENDAR (conectar Google Calendar pra user já logado) ===
const userId = parsed.uid;
if (!userId) return res.status(400).send('state sem uid');
db.saveGoogleConnection(userId, { db.saveGoogleConnection(userId, {
access_token: tokens.access_token, access_token: tokens.access_token,
refresh_token: tokens.refresh_token, refresh_token: tokens.refresh_token,
@ -832,6 +640,7 @@ app.get('/api/google/callback', async (req, res) => {
email: userInfo.email, email: userInfo.email,
}); });
db.audit(userId, 'google_connected', 'google_calendar', null, { email: userInfo.email }, req.ip); db.audit(userId, 'google_connected', 'google_calendar', null, { email: userInfo.email }, req.ip);
// Redireciona pra app com flag de sucesso
const returnTo = parsed.rt || '/'; const returnTo = parsed.rt || '/';
res.send(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Google conectado</title> res.send(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Google conectado</title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
@ -843,7 +652,7 @@ app.get('/api/google/callback', async (req, res) => {
</div></body></html>`); </div></body></html>`);
} catch (e) { } catch (e) {
console.warn('[google] callback failed', e.message); console.warn('[google] callback failed', e.message);
res.status(500).send('Erro: ' + e.message); res.status(500).send('Erro ao conectar Google: ' + e.message);
} }
}); });

View file

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