feat(boiler): historico de sessoes, dias da semana e desconexoes Tuya

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Roger 2026-09-08 16:16:08 +00:00
parent 38fd9fe63d
commit d572f7b847
3 changed files with 345 additions and 33 deletions

View file

@ -44,3 +44,36 @@ Credenciais Tuya: `boiler.env` no CT120 (não no Git). Token DNS: `Infra-Network
- Controlo ON/OFF pelo dashboard
- Alterar `/etc/network/interfaces` do Proxmox
- 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`.

View file

@ -8,3 +8,6 @@ Projeto dedicado (não é o dashboard Tapo do R630).
- Spec/ops: [PROJECT.md](PROJECT.md)
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.

View file

@ -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}
</style></head><body><main class="phone"><header>Boiler Shower · Tuya</header><div class="body">
<section class="hero"><div class="top"><div><b>Interruptor do boiler</b><div class="sub" id="sync">a iniciar</div></div><span class="dot" id="dot"></span></div>
@ -356,17 +561,30 @@ table{width:100%;border-collapse:collapse;font-size:.76rem}th,td{padding:8px 4px
<div class="grid"><div class="card"><div class="label">Hoje</div><div class="value" id="todayKwh"></div><small>kWh estimados</small></div>
<div class="card"><div class="label">Custo hoje</div><div class="value" id="todayCost"></div><small>36,52 c/kWh</small></div>
<div class="card"><div class="label">Tempo hoje</div><div class="value" id="todayTime"></div><small>relé ON</small></div>
<div class="card"><div class="label">Ligações hoje</div><div class="value" id="todayStarts"></div><small>ciclos detectados</small></div></div>
<div class="card"><div class="label">Ligações hoje</div><div class="value" id="todayStarts"></div><small>ciclos detectados</small></div>
<div class="card"><div class="label">Desconexões</div><div class="value" id="todayDrops"></div><small>Wi-Fi/nuvem hoje</small></div>
<div class="card"><div class="label">Tempo offline</div><div class="value" id="todayOffline"></div><small>detectado hoje</small></div></div>
<section class="card report"><b>Relatório por período</b><div class="dates"><input type="date" id="from"><input type="date" id="to"></div>
<button id="run">Gerar</button> <a class="btn" id="csv">CSV</a>
<div class="value" id="total" style="margin-top:14px"></div><div class="sub" id="summary"></div>
<div style="overflow:auto;max-height:300px"><table><thead><tr><th>Data</th><th>ON</th><th>min</th><th>kWh</th><th></th></tr></thead><tbody id="rows"></tbody></table></div></section>
<span class="section-title"><b>Resumo diário</b></span>
<div class="scroll"><table><thead><tr><th>Data/dia</th><th>ON</th><th>min</th><th>kWh</th><th></th><th>quedas</th></tr></thead><tbody id="rows"></tbody></table></div>
<span class="section-title"><b>Cada ligação</b></span>
<div class="scroll"><table><thead><tr><th>Dia</th><th>Ligou</th><th>Desligou</th><th>min</th><th>Origem</th></tr></thead><tbody id="sessions"></tbody></table></div>
<span class="section-title"><b>Desconexões Wi-Fi/nuvem</b></span>
<div id="connectionSummary" class="sub"></div>
<div class="scroll"><table><thead><tr><th>Dia</th><th>Offline</th><th>Online</th><th>min</th></tr></thead><tbody id="connections"></tbody></table></div>
<span class="section-title"><b>Eventos por dia da semana</b></span><div id="weekdays"></div>
</section>
<div class="warning"><b>Estimativa:</b> 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.</div>
<div class="warning"><b>Quem ligou:</b> a API Tuya mostra apenas a origem (app/cliente, automação, dispositivo ou nuvem); não fornece nome/email do utilizador.</div>
</div></main><script>
const $=id=>document.getElementById(id), eur=n=>new Intl.NumberFormat('pt-PT',{style:'currency',currency:'EUR'}).format(n);
const hm=s=>{if(!s)return '';const d=new Date(s);return d.toLocaleTimeString('pt-PT',{hour:'2-digit',minute:'2-digit',second:'2-digit'})};
const wd=s=>s.replace('-feira','').slice(0,3);
const today=new Date(), ago=new Date(Date.now()-29*864e5); $('to').value=today.toISOString().slice(0,10); $('from').value=ago.toISOString().slice(0,10);
async function status(){try{const s=await (await fetch('api/status')).json(); if(!s.ok)throw Error(s.error); $('state').textContent=s.is_on?'LIGADO':'DESLIGADO'; $('dot').className='dot '+(s.is_on?'on':''); $('sync').textContent='Sincronizado '+new Date(s.ts).toLocaleString('pt-PT'); $('todayKwh').textContent=s.today.kwh.toFixed(3); $('todayCost').textContent=eur(s.today.cost_eur); $('todayTime').textContent=s.today.runtime_min.toFixed(0)+' min'; $('todayStarts').textContent=s.today.starts}catch(e){$('sync').innerHTML='<span class=error>'+e.message+'</span>'}}
async function report(){const f=$('from').value,t=$('to').value,r=await (await fetch(`api/report?from=${f}&to=${t}`)).json(); if(!r.ok){$('total').textContent=r.error;return}$('total').textContent=`${r.totals.kwh.toFixed(3)} kWh · ${eur(r.totals.cost_eur)}`;$('summary').textContent=`${r.totals.runtime_min.toFixed(0)} min ligados · ${r.totals.starts} ligações`;$('rows').innerHTML=r.days.map(d=>`<tr><td>${d.date}</td><td>${d.starts}</td><td>${d.runtime_min.toFixed(0)}</td><td>${d.kwh.toFixed(3)}</td><td>${eur(d.cost_eur)}</td></tr>`).join('');$('csv').href=`api/report.csv?from=${f}&to=${t}`}
async function status(){try{const s=await (await fetch('api/status')).json(); if(!s.ok)throw Error(s.error); $('state').textContent=s.is_on?'LIGADO':'DESLIGADO'; $('dot').className='dot '+(s.is_on?'on':''); $('sync').textContent='Sincronizado '+new Date(s.ts).toLocaleString('pt-PT'); $('todayKwh').textContent=s.today.kwh.toFixed(3); $('todayCost').textContent=eur(s.today.cost_eur); $('todayTime').textContent=s.today.runtime_min.toFixed(0)+' min'; $('todayStarts').textContent=s.today.starts;$('todayDrops').textContent=s.today.disconnects||0;$('todayOffline').textContent=(s.today.offline_min||0).toFixed(0)+' min'}catch(e){$('sync').innerHTML='<span class=error>'+e.message+'</span>'}}
async function report(){const f=$('from').value,t=$('to').value,r=await (await fetch(`api/report?from=${f}&to=${t}`)).json(); if(!r.ok){$('total').textContent=r.error;return}$('total').textContent=`${r.totals.kwh.toFixed(3)} kWh · ${eur(r.totals.cost_eur)}`;$('summary').textContent=`${r.totals.runtime_min.toFixed(0)} min ligados · ${r.totals.starts} ligações · ${r.connectivity.disconnects} desconexões`;$('rows').innerHTML=r.days.map(d=>`<tr><td>${d.date}<br><small>${wd(d.weekday)}</small></td><td>${d.starts}</td><td>${d.runtime_min.toFixed(0)}</td><td>${d.kwh.toFixed(3)}</td><td>${eur(d.cost_eur)}</td><td>${d.disconnects}</td></tr>`).join('');$('sessions').innerHTML=r.sessions.map(s=>`<tr><td>${s.date}<br><small>${wd(s.weekday)}</small></td><td>${hm(s.on_at)}</td><td>${hm(s.off_at)}</td><td>${s.duration_min.toFixed(1)}</td><td>${s.source}</td></tr>`).join('')||'<tr><td colspan=5>Sem ligações no período</td></tr>';$('connectionSummary').textContent=`${r.connectivity.disconnects} quedas · ${r.connectivity.offline_min.toFixed(1)} min offline`;$('connections').innerHTML=r.connectivity.incidents.map(c=>`<tr><td>${c.weekday}</td><td>${hm(c.offline_at)}</td><td>${hm(c.online_at)}</td><td>${c.duration_min.toFixed(1)}</td></tr>`).join('')||'<tr><td colspan=4>Sem desconexões no período</td></tr>';$('weekdays').innerHTML=r.weekdays.map(w=>`<span class=pill>${w.weekday}: ${w.starts} ON / ${w.runtime_min.toFixed(0)} min</span>`).join('');$('csv').href=`api/report.csv?from=${f}&to=${t}`}
$('run').onclick=report; status();report();setInterval(status,60000);
</script></body></html>"""
@ -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(),