From d572f7b8472a82f6978b00c5ac217199f33e90cd Mon Sep 17 00:00:00 2001 From: Roger Date: Tue, 8 Sep 2026 16:16:08 +0000 Subject: [PATCH] feat(boiler): historico de sessoes, dias da semana e desconexoes Tuya Co-authored-by: Cursor --- Boiler-Tuya-Energy/PROJECT.md | 35 ++- Boiler-Tuya-Energy/README.md | 5 +- Boiler-Tuya-Energy/tuya_boiler_server.py | 338 ++++++++++++++++++++--- 3 files changed, 345 insertions(+), 33 deletions(-) diff --git a/Boiler-Tuya-Energy/PROJECT.md b/Boiler-Tuya-Energy/PROJECT.md index 547e6d8..56a5183 100644 --- a/Boiler-Tuya-Energy/PROJECT.md +++ b/Boiler-Tuya-Energy/PROJECT.md @@ -43,4 +43,37 @@ Credenciais Tuya: `boiler.env` no CT120 (não no Git). Token DNS: `Infra-Network - Medição real de corrente/tensão neste interruptor - Controlo ON/OFF pelo dashboard - Alterar `/etc/network/interfaces` do Proxmox -- Misturar com o Tapo do R630 \ No newline at end of file +- Misturar com o Tapo do R630 +## Histórico detalhado e conectividade (2026-09-08) + +O SQLite preserva eventos Tuya além da janela de retenção da nuvem: + +- `switch_events`: transições ON/OFF normalizadas +- `device_events`: log bruto com `event_id`, origem, código e valor +- `event_id=1`: dispositivo online +- `event_id=2`: dispositivo offline (Wi-Fi/nuvem) +- `event_id=5`: comando enviado ao dispositivo +- `event_id=7`: estado reportado pelo dispositivo + +O relatório por período contém: + +1. Resumo diário: dia da semana, quantidade de ligações, minutos ON, kWh, custo, desconexões e tempo offline. +2. Cada sessão: horário ON, horário OFF, duração, consumo/custo estimado e origem. +3. Conectividade: início offline, retorno online e duração de cada queda. +4. Agregação por dia da semana. +5. CSV com linhas `dia`, `ligacao` e `desconexao`. + +### Identidade de quem ligou + +A API Tuya **não fornece nome, email ou ID do utilizador** neste log. `event_from` permite apenas classificar a origem: + +| Código | Origem | +|---|---| +| 1 | dispositivo (inclui botão físico/estado reportado) | +| 2 | app/cliente | +| 3 | plataforma de terceiros/automação | +| 4 | nuvem | + +Logo, `app/cliente` prova que veio de um cliente, mas não distingue Roger de outro membro da casa. Só é possível inferir uma pessoa se ela for a única com acesso; não é prova técnica. + +Backups de implantação: código e DB anteriores em `/opt/boiler-energy/*.bak-20260908-1712`. diff --git a/Boiler-Tuya-Energy/README.md b/Boiler-Tuya-Energy/README.md index 0e9f27e..6dfc9be 100644 --- a/Boiler-Tuya-Energy/README.md +++ b/Boiler-Tuya-Energy/README.md @@ -7,4 +7,7 @@ Projeto dedicado (não é o dashboard Tapo do R630). - Public: https://boiler.myvexx.com/ - Spec/ops: [PROJECT.md](PROJECT.md) -Código neste folder: `tuya_boiler_server.py`, `boiler-energy.service`, `boiler.env.example`. \ No newline at end of file +Código neste folder: `tuya_boiler_server.py`, `boiler-energy.service`, `boiler.env.example`. +## Histórico + +O SQLite e o relatório guardam sessões ON/OFF, horários, duração, origem, dias da semana e quedas de conectividade. A API não identifica o utilizador pelo nome/email. diff --git a/Boiler-Tuya-Energy/tuya_boiler_server.py b/Boiler-Tuya-Energy/tuya_boiler_server.py index 3e573bb..b9b0864 100644 --- a/Boiler-Tuya-Energy/tuya_boiler_server.py +++ b/Boiler-Tuya-Energy/tuya_boiler_server.py @@ -110,6 +110,20 @@ class Store: ); CREATE INDEX IF NOT EXISTS idx_switch_events_time ON switch_events(event_time_ms); + CREATE TABLE IF NOT EXISTS device_events ( + event_time_ms INTEGER NOT NULL, + event_id INTEGER NOT NULL, + event_from TEXT NOT NULL DEFAULT '', + code TEXT NOT NULL DEFAULT '', + value TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '', + received_at TEXT NOT NULL, + PRIMARY KEY(event_time_ms, event_id, event_from, code, value) + ); + CREATE INDEX IF NOT EXISTS idx_device_events_time + ON device_events(event_time_ms); + CREATE INDEX IF NOT EXISTS idx_device_events_type + ON device_events(event_id, event_time_ms); CREATE TABLE IF NOT EXISTS sync_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT NOT NULL, @@ -131,6 +145,18 @@ class Store: before = self.db.total_changes now = datetime.now(timezone.utc).isoformat() for item in logs: + self.db.execute( + "INSERT OR IGNORE INTO device_events VALUES (?,?,?,?,?,?,?)", + ( + int(item["event_time"]), + int(item.get("event_id", 0)), + str(item.get("event_from", "")), + str(item.get("code", "")), + str(item.get("value", item.get("event_value", ""))), + str(item.get("status", "")), + now, + ), + ) if item.get("code") != "switch_1": continue raw = str(item.get("value", "")).lower() @@ -168,13 +194,94 @@ class Store: result: list[dict] = [] for row in rows: - event = {"ms": row["event_time_ms"], "on": bool(row["value"])} + event = { + "ms": row["event_time_ms"], + "on": bool(row["value"]), + "source_code": str(row["event_from"]), + } # Tuya often emits the same transition twice from cloud/device. if result and event["on"] == result[-1]["on"]: continue result.append(event) return result + @staticmethod + def source_label(code: str) -> str: + return { + "1": "dispositivo", + "2": "app/cliente", + "3": "plataforma/automação", + "4": "nuvem", + "-1": "desconhecido", + }.get(str(code), "desconhecido") + + @staticmethod + def weekday_name(value: date) -> str: + return [ + "segunda-feira", + "terça-feira", + "quarta-feira", + "quinta-feira", + "sexta-feira", + "sábado", + "domingo", + ][value.weekday()] + + def connectivity(self, start_ms: int, end_ms: int) -> dict: + previous = self.db.execute( + "SELECT * FROM device_events WHERE event_id IN (1,2) AND event_time_ms < ? " + "ORDER BY event_time_ms DESC LIMIT 1", + (start_ms,), + ).fetchone() + rows = self.db.execute( + "SELECT * FROM device_events WHERE event_id IN (1,2) " + "AND event_time_ms >= ? AND event_time_ms < ? ORDER BY event_time_ms", + (start_ms, end_ms), + ).fetchall() + offline_at = start_ms if previous and previous["event_id"] == 2 else None + incidents: list[dict] = [] + disconnects = 0 + for row in rows: + event_ms = int(row["event_time_ms"]) + if row["event_id"] == 2: + disconnects += 1 + if offline_at is None: + offline_at = event_ms + elif row["event_id"] == 1 and offline_at is not None: + begin = datetime.fromtimestamp( + offline_at / 1000, timezone.utc + ).astimezone(TZ) + finish = datetime.fromtimestamp( + event_ms / 1000, timezone.utc + ).astimezone(TZ) + incidents.append( + { + "offline_at": begin.isoformat(timespec="seconds"), + "online_at": finish.isoformat(timespec="seconds"), + "weekday": self.weekday_name(begin.date()), + "duration_min": round((event_ms - offline_at) / 60000, 1), + "ongoing": False, + } + ) + offline_at = None + if offline_at is not None: + stop_ms = min(int(time.time() * 1000), end_ms) + begin = datetime.fromtimestamp(offline_at / 1000, timezone.utc).astimezone(TZ) + incidents.append( + { + "offline_at": begin.isoformat(timespec="seconds"), + "online_at": None, + "weekday": self.weekday_name(begin.date()), + "duration_min": round(max(0, stop_ms - offline_at) / 60000, 1), + "ongoing": True, + } + ) + return { + "disconnects": disconnects, + "offline_min": round(sum(x["duration_min"] for x in incidents), 1), + "incidents": incidents, + } + def report(self, start_day: date, end_day: date) -> dict: start = datetime.combine(start_day, dt_time.min, TZ) end = datetime.combine(end_day + timedelta(days=1), dt_time.min, TZ) @@ -182,38 +289,83 @@ class Store: end_ms = int(end.timestamp() * 1000) events = self.normalized_events(start_ms, end_ms) - intervals: list[tuple[datetime, datetime]] = [] + sessions: list[dict] = [] on_at: int | None = None + on_source = "" for event in events: if event["on"] and on_at is None: on_at = max(event["ms"], start_ms) + on_source = event.get("source_code", "") elif not event["on"] and on_at is not None: stop = min(event["ms"], end_ms) if stop > on_at: - intervals.append( - ( - datetime.fromtimestamp(on_at / 1000, timezone.utc).astimezone(TZ), - datetime.fromtimestamp(stop / 1000, timezone.utc).astimezone(TZ), - ) + begin = datetime.fromtimestamp( + on_at / 1000, timezone.utc + ).astimezone(TZ) + finish = datetime.fromtimestamp( + stop / 1000, timezone.utc + ).astimezone(TZ) + duration_min = (stop - on_at) / 60000 + kwh = duration_min / 60 * POWER_KW + sessions.append( + { + "on_at": begin.isoformat(timespec="seconds"), + "off_at": finish.isoformat(timespec="seconds"), + "calculation_end_at": finish.isoformat(timespec="seconds"), + "date": begin.date().isoformat(), + "weekday": self.weekday_name(begin.date()), + "duration_min": round(duration_min, 1), + "kwh": round(kwh, 3), + "cost_eur": round(kwh * UNIT_RATE, 2), + "source": self.source_label(on_source), + "source_code": on_source, + "ongoing": False, + } ) on_at = None if on_at is not None: stop_ms = min(int(time.time() * 1000), end_ms) if stop_ms > on_at: - intervals.append( - ( - datetime.fromtimestamp(on_at / 1000, timezone.utc).astimezone(TZ), - datetime.fromtimestamp(stop_ms / 1000, timezone.utc).astimezone(TZ), - ) + begin = datetime.fromtimestamp( + on_at / 1000, timezone.utc + ).astimezone(TZ) + calculation_end = datetime.fromtimestamp( + stop_ms / 1000, timezone.utc + ).astimezone(TZ) + duration_min = (stop_ms - on_at) / 60000 + kwh = duration_min / 60 * POWER_KW + sessions.append( + { + "on_at": begin.isoformat(timespec="seconds"), + "off_at": None, + "calculation_end_at": calculation_end.isoformat(timespec="seconds"), + "date": begin.date().isoformat(), + "weekday": self.weekday_name(begin.date()), + "duration_min": round(duration_min, 1), + "kwh": round(kwh, 3), + "cost_eur": round(kwh * UNIT_RATE, 2), + "source": self.source_label(on_source), + "source_code": on_source, + "ongoing": True, + } ) daily: dict[date, dict] = {} cursor = start_day while cursor <= end_day: - daily[cursor] = {"date": cursor.isoformat(), "seconds": 0.0, "starts": 0} + daily[cursor] = { + "date": cursor.isoformat(), + "weekday": self.weekday_name(cursor), + "seconds": 0.0, + "starts": 0, + "disconnects": 0, + "offline_min": 0.0, + } cursor += timedelta(days=1) - for begin, finish in intervals: + for session in sessions: + begin = datetime.fromisoformat(session["on_at"]) + finish = datetime.fromisoformat(session["calculation_end_at"]) daily[begin.date()]["starts"] += 1 point = begin while point < finish: @@ -223,6 +375,13 @@ class Store: daily[point.date()]["seconds"] += (segment_end - point).total_seconds() point = segment_end + connection = self.connectivity(start_ms, end_ms) + for incident in connection["incidents"]: + incident_day = datetime.fromisoformat(incident["offline_at"]).date() + if incident_day in daily: + daily[incident_day]["disconnects"] += 1 + daily[incident_day]["offline_min"] += incident["duration_min"] + rows = [] for item in daily.values(): hours = item.pop("seconds") / 3600 @@ -230,6 +389,7 @@ class Store: rows.append( { **item, + "offline_min": round(item["offline_min"], 1), "runtime_min": round(hours * 60, 1), "kwh": round(kwh, 3), "cost_eur": round(kwh * UNIT_RATE, 2), @@ -237,6 +397,25 @@ class Store: ) total_min = sum(row["runtime_min"] for row in rows) total_kwh = sum(row["kwh"] for row in rows) + weekday_summary: list[dict] = [] + for weekday in [ + "segunda-feira", + "terça-feira", + "quarta-feira", + "quinta-feira", + "sexta-feira", + "sábado", + "domingo", + ]: + matching = [row for row in rows if row["weekday"] == weekday] + weekday_summary.append( + { + "weekday": weekday, + "starts": sum(row["starts"] for row in matching), + "runtime_min": round(sum(row["runtime_min"] for row in matching), 1), + "disconnects": sum(row["disconnects"] for row in matching), + } + ) return { "ok": True, "from": start_day.isoformat(), @@ -245,11 +424,21 @@ class Store: "rate_eur_kwh": UNIT_RATE, "measurement": "estimated_from_on_time", "days": rows, + "sessions": sessions, + "connectivity": connection, + "weekdays": weekday_summary, + "user_identity_available": False, + "user_identity_note": ( + "A API informa apenas a origem (dispositivo, app/cliente, " + "plataforma/automação ou nuvem), não o nome/email do utilizador." + ), "totals": { "starts": sum(row["starts"] for row in rows), "runtime_min": round(total_min, 1), "kwh": round(total_kwh, 3), "cost_eur": round(total_kwh * UNIT_RATE, 2), + "disconnects": connection["disconnects"], + "offline_min": connection["offline_min"], }, } @@ -282,17 +471,32 @@ class TuyaMonitor: if not self.connected: self._connect() now_ms = int(time.time() * 1000) - params = { - "type": "1,2,7", - "start_time": now_ms - days * 86400 * 1000, - "end_time": now_ms, - "size": 100, - } - response = self.api.get(f"/v1.0/devices/{DEVICE_ID}/logs", params) - if not response.get("success"): - self.connected = False - raise RuntimeError(response.get("msg", "falha ao consultar logs")) - return response.get("result", {}).get("logs", []) + logs: list[dict] = [] + row_key = "" + seen_keys: set[str] = set() + for _page in range(20): + params = { + "type": "1,2,5,7,9,10", + "start_time": now_ms - days * 86400 * 1000, + "end_time": now_ms, + "size": 100, + } + if row_key: + params["start_row_key"] = row_key + response = self.api.get(f"/v1.0/devices/{DEVICE_ID}/logs", params) + if not response.get("success"): + self.connected = False + raise RuntimeError(response.get("msg", "falha ao consultar logs")) + result = response.get("result", {}) + logs.extend(result.get("logs", [])) + if not result.get("has_next"): + break + next_key = result.get("next_row_key", "") + if not next_key or next_key in seen_keys: + break + seen_keys.add(next_key) + row_key = next_key + return logs async def sync(self, days: int = 14) -> dict: async with self.lock: @@ -349,6 +553,7 @@ header{padding:22px 20px 12px;text-align:center;font-weight:700}.body{padding:0 .report{margin-top:12px}.dates{display:flex;gap:8px;margin:10px 0}.dates input{width:100%;padding:9px;border:1px solid #d9dfe3;border-radius:10px} button,a.btn{border:0;border-radius:12px;background:var(--tuya);color:#fff;padding:10px 14px;font-weight:650;text-decoration:none;cursor:pointer} table{width:100%;border-collapse:collapse;font-size:.76rem}th,td{padding:8px 4px;border-bottom:1px solid #edf0f2;text-align:right}th:first-child,td:first-child{text-align:left} +.scroll{overflow:auto;max-height:300px}.section-title{display:block;margin:18px 0 6px}.pill{display:inline-block;background:#f2f4f6;border-radius:8px;padding:3px 7px;margin:2px;font-size:.72rem} .warning{font-size:.75rem;background:#fff6df;color:#765c14;border-radius:12px;padding:10px;margin-top:12px}.error{color:#b22710}
Boiler Shower · Tuya
Interruptor do boiler
a iniciar…
@@ -356,17 +561,30 @@ table{width:100%;border-collapse:collapse;font-size:.76rem}th,td{padding:8px 4px
Hoje
kWh estimados
Custo hoje
36,52 c/kWh
Tempo hoje
relé ON
-
Ligações hoje
ciclos detectados
+
Ligações hoje
ciclos detectados
+
Desconexões
Wi-Fi/nuvem hoje
+
Tempo offline
detectado hoje
Relatório por período
CSV
-
DataONminkWh
+Resumo diário +
Data/diaONminkWhquedas
+Cada ligação +
DiaLigouDesligouminOrigem
+Desconexões Wi-Fi/nuvem +
+
DiaOfflineOnlinemin
+Eventos por dia da semana
+
Estimativa: este modelo Tuya não possui medidor elétrico. kWh = 2,3 kW × tempo em que o relé ficou ON. Um termóstato interno pode reduzir o consumo real.
+
Quem ligou: a API Tuya mostra apenas a origem (app/cliente, automação, dispositivo ou nuvem); não fornece nome/email do utilizador.
""" @@ -418,10 +636,68 @@ async def api_report_csv(request: web.Request) -> web.Response: report = store.report(start, end) output = io.StringIO() writer = csv.writer(output) - writer.writerow(["data", "ligacoes", "minutos_on", "kwh_estimados", "custo_eur"]) + writer.writerow( + [ + "tipo", + "data", + "dia_semana", + "ligou_offline", + "desligou_online", + "origem", + "ligacoes", + "minutos", + "kwh_estimados", + "custo_eur", + "desconexoes", + ] + ) for row in report["days"]: writer.writerow( - [row["date"], row["starts"], row["runtime_min"], row["kwh"], row["cost_eur"]] + [ + "dia", + row["date"], + row["weekday"], + "", + "", + "", + row["starts"], + row["runtime_min"], + row["kwh"], + row["cost_eur"], + row["disconnects"], + ] + ) + for session in report["sessions"]: + writer.writerow( + [ + "ligacao", + session["date"], + session["weekday"], + session["on_at"], + session["off_at"] or "", + session["source"], + 1, + session["duration_min"], + session["kwh"], + session["cost_eur"], + "", + ] + ) + for incident in report["connectivity"]["incidents"]: + writer.writerow( + [ + "desconexao", + incident["offline_at"][:10], + incident["weekday"], + incident["offline_at"], + incident["online_at"] or "", + "", + "", + incident["duration_min"], + "", + "", + 1, + ] ) return web.Response( text=output.getvalue(),