| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119 |
- """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
|