mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
A Shelly reports one energy figure — aenergy.total, a lifetime counter in Wh that never resets. Bambuddy had a single REST energy field and filed whatever it found under "today", so the value never reset at midnight, and Yesterday and Total stayed at zero: get_energy() simply never set those keys. With `total` unpopulated, the hourly snapshot recorder skipped the plug, so the Statistics page's energy figure was zero as well, not just the Settings card. Split the REST energy config in two: rest_energy_path still means "used today", rest_energy_total_path means "lifetime counter". A Shelly has only the latter; a Tasmota behind a REST bridge has both; sharing a URL costs one fetch, not two. Then derive Today and Yesterday from that counter using the snapshots we were already taking: today = counter now - counter at the last local midnight; yesterday = the gap between the two previous midnights. Local midnight, not UTC — a UTC boundary rolls Today over at 02:00 in Berlin. The snapshot loop now ticks on the local hour so a reading lands on the boundary instead of up to an hour early. A counter that goes backwards (factory reset) reports nothing rather than a negative. Collateral, found while verifying on both engines: the smart-plug DateTime columns are naive UTC but the code wrote aware datetimes into them. SQLite drops the offset; asyncpg raises DataError. So on Postgres every snapshot capture raised inside the loop's except, and every status poll raised on last_checked — the whole subsystem was dead on the database we recommend for multi-printer installs. All plug timestamps are naive UTC now. Existing REST users with a cumulative path in the today field must move it to the new lifetime field; the form and wiki now name which counter each wants.
120 lines
4.5 KiB
Python
120 lines
4.5 KiB
Python
"""Derive Today / Yesterday from a smart plug's lifetime energy counter (#2539).
|
|
|
|
Most plugs report exactly one energy number, and it is a lifetime counter: a
|
|
Shelly's ``aenergy.total`` only ever climbs. Only Tasmota reports Today and
|
|
Yesterday itself. So for everything else, those two numbers have to be computed
|
|
from the difference between the counter now and the counter at a day boundary —
|
|
which is what the hourly ``smart_plug_energy_snapshots`` rows (#941) already
|
|
record.
|
|
|
|
today = live_total - counter at the most recent local midnight
|
|
yesterday = that midnight's counter - the previous midnight's counter
|
|
|
|
Two things this is careful about:
|
|
|
|
* **Local midnight, not UTC midnight.** With ``TZ=Europe/Berlin`` a UTC day
|
|
boundary rolls "Today" over at 01:00 or 02:00 wall-clock, which matches
|
|
neither what the user sees nor what the plug's own daily counter would do.
|
|
|
|
* **Counters reset.** A factory reset or some firmware updates zero a Shelly's
|
|
``aenergy.total``. The delta then goes negative, and a negative kWh reading is
|
|
worse than an absent one — so we return None and let the UI show a blank
|
|
rather than a number that is definitely wrong.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
|
|
from backend.app.utils.local_time import local_day_start, to_naive_utc
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def _counter_at(db: AsyncSession, plug_id: int, boundary: datetime) -> float | None:
|
|
"""The plug's lifetime counter as of ``boundary`` — i.e. the last snapshot
|
|
taken at or before it. None when the plug has no snapshot that far back,
|
|
which is the normal state of a fresh install or a fresh upgrade.
|
|
"""
|
|
result = await db.execute(
|
|
select(SmartPlugEnergySnapshot.lifetime_kwh)
|
|
.where(
|
|
SmartPlugEnergySnapshot.plug_id == plug_id,
|
|
SmartPlugEnergySnapshot.recorded_at <= to_naive_utc(boundary),
|
|
)
|
|
.order_by(SmartPlugEnergySnapshot.recorded_at.desc())
|
|
.limit(1)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def derive_today_yesterday(
|
|
db: AsyncSession,
|
|
plug_id: int,
|
|
live_total_kwh: float,
|
|
*,
|
|
now_utc: datetime | None = None,
|
|
) -> tuple[float | None, float | None]:
|
|
"""Return ``(today_kwh, yesterday_kwh)`` derived from the lifetime counter.
|
|
|
|
Either or both may be None while the snapshot history is still filling up:
|
|
Today needs one snapshot from before this local midnight (so it is available
|
|
within an hour of the first boundary the install lives through), Yesterday
|
|
needs one from before the midnight before that.
|
|
"""
|
|
now = now_utc or datetime.now(timezone.utc)
|
|
midnight_today = local_day_start(now)
|
|
midnight_yesterday = local_day_start(now, days_ago=1)
|
|
|
|
base_today = await _counter_at(db, plug_id, midnight_today)
|
|
if base_today is None:
|
|
# No snapshot from before today began — nothing can be derived yet.
|
|
return None, None
|
|
|
|
today: float | None = live_total_kwh - base_today
|
|
if today < 0:
|
|
logger.info(
|
|
"Plug %s: lifetime counter went backwards (%.3f < %.3f) — "
|
|
"device counter was probably reset; reporting no value for today",
|
|
plug_id,
|
|
live_total_kwh,
|
|
base_today,
|
|
)
|
|
today = None
|
|
|
|
base_yesterday = await _counter_at(db, plug_id, midnight_yesterday)
|
|
if base_yesterday is None:
|
|
return today, None
|
|
|
|
yesterday: float | None = base_today - base_yesterday
|
|
if yesterday < 0:
|
|
yesterday = None
|
|
|
|
return today, yesterday
|
|
|
|
|
|
async def fill_derived_energy(db: AsyncSession, plug_id: int, energy: dict) -> dict:
|
|
"""Fill in Today / Yesterday on an energy dict that only has a lifetime total.
|
|
|
|
A no-op for Tasmota, which reports both itself — a device that knows its own
|
|
daily usage is more accurate than our hourly-snapshot arithmetic, so a value
|
|
already present is never overwritten.
|
|
"""
|
|
total = energy.get("total")
|
|
if total is None:
|
|
return energy
|
|
if energy.get("today") is not None and energy.get("yesterday") is not None:
|
|
return energy
|
|
|
|
today, yesterday = await derive_today_yesterday(db, plug_id, float(total))
|
|
if energy.get("today") is None and today is not None:
|
|
energy["today"] = round(today, 3)
|
|
if energy.get("yesterday") is None and yesterday is not None:
|
|
energy["yesterday"] = round(yesterday, 3)
|
|
return energy
|