Files
bambuddy/backend/app/services/plug_energy_history.py
maziggy aba00598bb fix(smart-plugs): read a REST plug's lifetime counter, and derive Today/Yesterday from it (issue #2539)
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.
2026-07-11 14:22:31 +02:00

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