mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +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.
113 lines
4.7 KiB
Python
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)
|