plug_energy_history.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. """Derive Today / Yesterday from a smart plug's lifetime energy counter (#2539).
  2. Most plugs report exactly one energy number, and it is a lifetime counter: a
  3. Shelly's ``aenergy.total`` only ever climbs. Only Tasmota reports Today and
  4. Yesterday itself. So for everything else, those two numbers have to be computed
  5. from the difference between the counter now and the counter at a day boundary —
  6. which is what the hourly ``smart_plug_energy_snapshots`` rows (#941) already
  7. record.
  8. today = live_total - counter at the most recent local midnight
  9. yesterday = that midnight's counter - the previous midnight's counter
  10. Two things this is careful about:
  11. * **Local midnight, not UTC midnight.** With ``TZ=Europe/Berlin`` a UTC day
  12. boundary rolls "Today" over at 01:00 or 02:00 wall-clock, which matches
  13. neither what the user sees nor what the plug's own daily counter would do.
  14. * **Counters reset.** A factory reset or some firmware updates zero a Shelly's
  15. ``aenergy.total``. The delta then goes negative, and a negative kWh reading is
  16. worse than an absent one — so we return None and let the UI show a blank
  17. rather than a number that is definitely wrong.
  18. """
  19. from __future__ import annotations
  20. import logging
  21. from datetime import datetime, timezone
  22. from sqlalchemy import select
  23. from sqlalchemy.ext.asyncio import AsyncSession
  24. from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
  25. from backend.app.utils.local_time import local_day_start, to_naive_utc
  26. logger = logging.getLogger(__name__)
  27. async def _counter_at(db: AsyncSession, plug_id: int, boundary: datetime) -> float | None:
  28. """The plug's lifetime counter as of ``boundary`` — i.e. the last snapshot
  29. taken at or before it. None when the plug has no snapshot that far back,
  30. which is the normal state of a fresh install or a fresh upgrade.
  31. """
  32. result = await db.execute(
  33. select(SmartPlugEnergySnapshot.lifetime_kwh)
  34. .where(
  35. SmartPlugEnergySnapshot.plug_id == plug_id,
  36. SmartPlugEnergySnapshot.recorded_at <= to_naive_utc(boundary),
  37. )
  38. .order_by(SmartPlugEnergySnapshot.recorded_at.desc())
  39. .limit(1)
  40. )
  41. return result.scalar_one_or_none()
  42. async def derive_today_yesterday(
  43. db: AsyncSession,
  44. plug_id: int,
  45. live_total_kwh: float,
  46. *,
  47. now_utc: datetime | None = None,
  48. ) -> tuple[float | None, float | None]:
  49. """Return ``(today_kwh, yesterday_kwh)`` derived from the lifetime counter.
  50. Either or both may be None while the snapshot history is still filling up:
  51. Today needs one snapshot from before this local midnight (so it is available
  52. within an hour of the first boundary the install lives through), Yesterday
  53. needs one from before the midnight before that.
  54. """
  55. now = now_utc or datetime.now(timezone.utc)
  56. midnight_today = local_day_start(now)
  57. midnight_yesterday = local_day_start(now, days_ago=1)
  58. base_today = await _counter_at(db, plug_id, midnight_today)
  59. if base_today is None:
  60. # No snapshot from before today began — nothing can be derived yet.
  61. return None, None
  62. today: float | None = live_total_kwh - base_today
  63. if today < 0:
  64. logger.info(
  65. "Plug %s: lifetime counter went backwards (%.3f < %.3f) — "
  66. "device counter was probably reset; reporting no value for today",
  67. plug_id,
  68. live_total_kwh,
  69. base_today,
  70. )
  71. today = None
  72. base_yesterday = await _counter_at(db, plug_id, midnight_yesterday)
  73. if base_yesterday is None:
  74. return today, None
  75. yesterday: float | None = base_today - base_yesterday
  76. if yesterday < 0:
  77. yesterday = None
  78. return today, yesterday
  79. async def fill_derived_energy(db: AsyncSession, plug_id: int, energy: dict) -> dict:
  80. """Fill in Today / Yesterday on an energy dict that only has a lifetime total.
  81. A no-op for Tasmota, which reports both itself — a device that knows its own
  82. daily usage is more accurate than our hourly-snapshot arithmetic, so a value
  83. already present is never overwritten.
  84. """
  85. total = energy.get("total")
  86. if total is None:
  87. return energy
  88. if energy.get("today") is not None and energy.get("yesterday") is not None:
  89. return energy
  90. today, yesterday = await derive_today_yesterday(db, plug_id, float(total))
  91. if energy.get("today") is None and today is not None:
  92. energy["today"] = round(today, 3)
  93. if energy.get("yesterday") is None and yesterday is not None:
  94. energy["yesterday"] = round(yesterday, 3)
  95. return energy