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}
Interruptor do boilera iniciar…
@@ -356,17 +561,30 @@ table{width:100%;border-collapse:collapse;font-size:.76rem}th,td{padding:8px 4px
-
Ligações hoje
—
ciclos detectados
+Ligações hoje
—
ciclos detectados
+Desconexões
—
Wi-Fi/nuvem hoje
+Tempo offline
—
detectado hoje
+Resumo diário
+
+Cada ligação
+
+Desconexões Wi-Fi/nuvem
+
+
+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.