Files
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

113 lines
4.7 KiB
Python

"""Local-timezone helpers.
Bambuddy has no timezone *setting* — it takes the container's ``TZ`` env var,
the same value the support package reports. Anything that has to reason about a
calendar day ("today", "yesterday", "run the backup at 03:00") needs this,
because a day boundary computed in UTC rolls over at 01:00 or 02:00 wall-clock
for most of Europe, which is neither what the user sees nor what their smart
plug's own daily counter does.
Lived in ``services/local_backup`` until #2539, when the smart-plug energy
history needed the same day boundary and reaching into another service's private
helper stopped being defensible.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime, timedelta, timezone, tzinfo
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
logger = logging.getLogger(__name__)
def local_zone() -> tzinfo:
"""Resolve the local timezone from the ``TZ`` env var.
Falls back to UTC when ``TZ`` is unset or unrecognised, so a missing value
degrades to the legacy behaviour rather than crashing.
On Windows the embedded Python in our installer doesn't carry an IANA tz
database, so ``ZoneInfo(...)`` — including ``ZoneInfo("UTC")`` — raises
``ZoneInfoNotFoundError`` unless the ``tzdata`` PyPI package is installed.
requirements.txt pins ``tzdata`` on win32, but to stay resilient on installs
that haven't refreshed deps we fall through to the stdlib
``datetime.timezone.utc`` as a last resort; it satisfies every
``astimezone`` / ``str()`` call site without needing the IANA DB.
"""
tz_name = os.environ.get("TZ", "").strip()
if tz_name:
try:
return ZoneInfo(tz_name)
except ZoneInfoNotFoundError:
logger.warning("Unrecognised TZ env value %r, falling back to UTC", tz_name)
try:
return ZoneInfo("UTC")
except ZoneInfoNotFoundError:
return timezone.utc
def utcnow_naive() -> datetime:
"""Current UTC time, tzinfo stripped.
Bambuddy's ``DateTime`` columns are naive and hold UTC; only the few that
genuinely need an offset are declared ``DateTime(timezone=True)``. SQLite
silently tolerates an aware value written to a naive column (its bind
processor reads the fields and drops the offset), which is why aware writes
survived here for so long — but **asyncpg rejects them outright** with
``DataError: invalid input for query argument``, so on Postgres the write
raises. Use this for anything destined for a naive column.
"""
return datetime.now(timezone.utc).replace(tzinfo=None)
def to_naive_utc(dt: datetime | None) -> datetime | None:
"""Normalise a datetime to naive UTC for binding against a naive column.
Accepts naive (assumed already UTC) or aware; returns None unchanged.
"""
if dt is None:
return None
if dt.tzinfo is None:
return dt
return dt.astimezone(timezone.utc).replace(tzinfo=None)
def local_day_start(now_utc: datetime, *, days_ago: int = 0) -> datetime:
"""Return midnight local time, ``days_ago`` days back, as a UTC instant.
``days_ago=0`` is the midnight that began the current local day; ``1`` is the
one before it. Subtracting whole days from the *local* wall clock rather than
from the UTC instant is what keeps this correct across a DST transition, where
a calendar day is 23 or 25 hours long, not 24.
``fold=0`` resolves the ambiguous wall-clock hour at DST fall-back to the
earlier instance. The spring-forward gap cannot bite here: the synthesized
time is always midnight, and no timezone in the IANA database skips it.
"""
tz = local_zone()
local_now = now_utc.astimezone(tz)
local_midnight = local_now.replace(hour=0, minute=0, second=0, microsecond=0, fold=0)
if days_ago:
# Step back in local days, then re-pin to midnight: (midnight - 24h) can
# land at 23:00 or 01:00 of the previous day across a DST change.
local_midnight = (local_midnight - timedelta(days=days_ago)).replace(
hour=0, minute=0, second=0, microsecond=0, fold=0
)
return local_midnight.astimezone(timezone.utc)
def next_local_hour(now_utc: datetime) -> datetime:
"""Return the next top-of-the-hour *local* time, as a UTC instant.
Aligning to the local hour rather than the UTC hour is deliberate: it
guarantees a tick lands exactly on local midnight in every timezone,
including the half- and quarter-hour offsets (India, Nepal, Chatham) where
local midnight is not on a UTC hour boundary at all.
"""
tz = local_zone()
local_now = now_utc.astimezone(tz)
local_next = (local_now + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0, fold=0)
return local_next.astimezone(timezone.utc)