Compare commits
No commits in common. "master" and "v1.10.3" have entirely different histories.
11 changed files with 175 additions and 1950 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
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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 19
|
||||||
versionName "1.12.0"
|
versionName "1.10.3"
|
||||||
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.3",
|
||||||
"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
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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.3/Shivao-v1.10.3.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