Просмотр исходного кода

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.
maziggy 1 месяц назад
Родитель
Сommit
aba00598bb

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
CHANGELOG.md


+ 7 - 0
backend/app/api/routes/archives.py

@@ -1277,6 +1277,13 @@ async def _sum_snapshot_deltas(
     """
     from backend.app.models.smart_plug import SmartPlug
     from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
+    from backend.app.utils.local_time import to_naive_utc
+
+    # ``recorded_at`` is a naive column holding UTC. Binding an aware datetime
+    # against it raises DataError on asyncpg (SQLite silently drops the offset),
+    # which took the whole date-filtered energy figure down on Postgres.
+    dt_from = to_naive_utc(dt_from)
+    dt_to = to_naive_utc(dt_to)
 
     plug_ids_result = await db.execute(select(SmartPlug.id))
     plug_ids = [row[0] for row in plug_ids_result.all()]

+ 16 - 9
backend/app/api/routes/smart_plugs.py

@@ -1,7 +1,7 @@
 """API routes for smart plug management."""
 
 import logging
-from datetime import datetime, timedelta, timezone
+from datetime import timedelta
 
 from fastapi import APIRouter, Body, Depends, HTTPException
 from pydantic import BaseModel
@@ -36,9 +36,11 @@ from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt
 from backend.app.services.notification_service import notification_service
+from backend.app.services.plug_energy_history import fill_derived_energy
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.rest_smart_plug import rest_smart_plug_service
 from backend.app.services.tasmota import tasmota_service
+from backend.app.utils.local_time import to_naive_utc, utcnow_naive
 
 logger = logging.getLogger(__name__)
 
@@ -584,7 +586,7 @@ async def control_smart_plug(
         elif expected_state == "OFF" and plug.printer_id:
             # Mark printer offline immediately for faster UI update
             printer_manager.mark_printer_offline(plug.printer_id)
-    plug.last_checked = datetime.now(timezone.utc)
+    plug.last_checked = utcnow_naive()
     await db.commit()
 
     # Trigger associated scripts if this is a main (non-script) plug
@@ -671,7 +673,7 @@ async def get_plug_status(
             # Update last state in database
             if is_reachable and data.state:
                 plug.last_state = data.state
-                plug.last_checked = datetime.now(timezone.utc)
+                plug.last_checked = utcnow_naive()
                 await db.commit()
 
             energy_data = None
@@ -706,7 +708,7 @@ async def get_plug_status(
     # Update last state in database
     if status["reachable"]:
         plug.last_state = status["state"]
-        plug.last_checked = datetime.now(timezone.utc)
+        plug.last_checked = utcnow_naive()
         await db.commit()
 
     # Fetch energy data if device is reachable
@@ -714,6 +716,11 @@ async def get_plug_status(
     if status["reachable"]:
         energy = await service.get_energy(plug)
         if energy:
+            # Most plugs report only a lifetime counter — a Shelly has no notion
+            # of "today" at all, and Home Assistant never reports "yesterday".
+            # Fill those in from the hourly snapshots (#2539). Tasmota, which
+            # knows its own daily figures, is left alone.
+            energy = await fill_derived_energy(db, plug.id, energy)
             energy_data = SmartPlugEnergy(**energy)
 
             # Check power alerts
@@ -735,10 +742,10 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
     # Cooldown: don't alert more than once per 5 minutes
     cooldown_minutes = 5
     if plug.power_alert_last_triggered:
-        last_triggered = plug.power_alert_last_triggered
-        if last_triggered.tzinfo is None:
-            last_triggered = last_triggered.replace(tzinfo=timezone.utc)
-        time_since_last = datetime.now(timezone.utc) - last_triggered
+        # Naive UTC on both sides: the column is naive, so a row loaded fresh from
+        # the DB comes back without an offset and subtracting an aware now() would
+        # raise TypeError.
+        time_since_last = utcnow_naive() - to_naive_utc(plug.power_alert_last_triggered)
         if time_since_last < timedelta(minutes=cooldown_minutes):
             return
 
@@ -759,7 +766,7 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
         threshold = plug.power_alert_low
 
     if alert_triggered:
-        plug.power_alert_last_triggered = datetime.now(timezone.utc)
+        plug.power_alert_last_triggered = utcnow_naive()
         await db.commit()
 
         # Send notification

+ 8 - 0
backend/app/core/database.py

@@ -2143,6 +2143,14 @@ async def run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_url VARCHAR(500)")
     await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_multiplier REAL DEFAULT 1.0")
 
+    # Migration (#2539): a REST plug's lifetime energy counter, separate from its
+    # today counter. Devices differ in which they expose — a Shelly reports only
+    # a cumulative `aenergy.total`, a Tasmota behind a REST bridge reports both —
+    # and conflating the two made the cumulative value read as "today", so it
+    # never reset at midnight and "Total" stayed empty forever.
+    await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_total_path VARCHAR(200)")
+    await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_total_multiplier REAL DEFAULT 1.0")
+
     # Migration: Add batch_id column to print_queue for batch grouping
     try:
         async with conn.begin_nested():

+ 8 - 0
backend/app/models/smart_plug.py

@@ -67,10 +67,18 @@ class SmartPlug(Base):
     rest_power_path: Mapped[str | None] = mapped_column(String(200), nullable=True)  # JSON path for power (watts)
     rest_power_multiplier: Mapped[float] = mapped_column(Float, server_default="1.0")  # Unit conversion for power
     rest_energy_url: Mapped[str | None] = mapped_column(String(500), nullable=True)  # Separate URL for energy data
+    # Energy used *today*, resetting at midnight (kWh after the multiplier).
     rest_energy_path: Mapped[str | None] = mapped_column(String(200), nullable=True)  # JSON path for energy (kWh)
     rest_energy_multiplier: Mapped[float] = mapped_column(
         Float, server_default="1.0"
     )  # Unit conversion (e.g., 0.001 for Wh→kWh)
+    # Lifetime cumulative counter that never resets (#2539). A Shelly exposes only
+    # this one (`aenergy.total`, in Wh); a Tasmota behind a REST bridge exposes
+    # both. Kept separate from rest_energy_path because a cumulative counter read
+    # as "today" is silently wrong all day, and feeds Yesterday / Total / the
+    # hourly snapshots that the Statistics page's date filters run on.
+    rest_energy_total_path: Mapped[str | None] = mapped_column(String(200), nullable=True)
+    rest_energy_total_multiplier: Mapped[float] = mapped_column(Float, server_default="1.0")
 
     # Link to printer (multiple plugs/scripts can be linked to one printer)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)

+ 6 - 0
backend/app/schemas/smart_plug.py

@@ -58,8 +58,12 @@ class SmartPlugBase(BaseModel):
     rest_power_path: str | None = Field(default=None, max_length=200)
     rest_power_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
     rest_energy_url: str | None = Field(default=None, max_length=500)
+    # Today's usage, resetting at midnight.
     rest_energy_path: str | None = Field(default=None, max_length=200)
     rest_energy_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
+    # Lifetime counter that never resets (#2539) — a Shelly's `aenergy.total`.
+    rest_energy_total_path: str | None = Field(default=None, max_length=200)
+    rest_energy_total_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
 
     printer_id: int | None = None
     enabled: bool = True
@@ -153,6 +157,8 @@ class SmartPlugUpdate(BaseModel):
     rest_energy_url: str | None = None
     rest_energy_path: str | None = None
     rest_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
+    rest_energy_total_path: str | None = None
+    rest_energy_total_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
     printer_id: int | None = None
     enabled: bool | None = None
     auto_on: bool | None = None

+ 6 - 31
backend/app/services/local_backup.py

@@ -6,10 +6,8 @@ on a configurable schedule with retention management.
 
 import asyncio
 import logging
-import os
-from datetime import datetime, timedelta, timezone, tzinfo
+from datetime import datetime, timedelta, timezone
 from pathlib import Path
-from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
 
 from sqlalchemy import select
 
@@ -17,35 +15,12 @@ from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session
 from backend.app.models.settings import Settings
 
-logger = logging.getLogger(__name__)
-
-
-def _local_zone() -> tzinfo:
-    """Resolve the local timezone for scheduled-backup HH:MM interpretation.
-
-    Uses the container's ``TZ`` env var (the same value the support package
-    surfaces); falls back to UTC when unset or unrecognised so a missing TZ
-    keeps the legacy behaviour rather than crashing. See #1602 follow-up.
-
-    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 now pins ``tzdata`` on win32, but to keep
-    this 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, scheduling in UTC", tz_name)
-    try:
-        return ZoneInfo("UTC")
-    except ZoneInfoNotFoundError:
-        return timezone.utc
+# The TZ-env resolution used to live here. It moved to utils/local_time when the
+# smart-plug energy history (#2539) needed the same local day boundary. Re-exported
+# under the old private name so existing importers keep working.
+from backend.app.utils.local_time import local_zone as _local_zone
 
+logger = logging.getLogger(__name__)
 
 SCHEDULE_INTERVALS = {
     "hourly": 3600,

+ 119 - 0
backend/app/services/plug_energy_history.py

@@ -0,0 +1,119 @@
+"""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

+ 45 - 22
backend/app/services/rest_smart_plug.py

@@ -193,12 +193,25 @@ class RESTSmartPlugService:
     async def get_energy(self, plug: "SmartPlug") -> dict | None:
         """Get energy monitoring data.
 
-        Each value (power, energy) can come from its own URL or fall back to the shared status URL.
-        Multipliers are applied to convert units (e.g., Wh → kWh with multiplier 0.001).
+        Each value can come from its own URL or fall back to the shared status URL.
+        Multipliers convert units (e.g. Wh → kWh with multiplier 0.001).
+
+        Two distinct energy counters, because devices differ in which they have
+        (#2539):
+
+        - ``rest_energy_path`` — energy used **today**, resetting at midnight.
+        - ``rest_energy_total_path`` — a **lifetime** counter that never resets.
+          A Shelly exposes only this one (``aenergy.total``, in Wh). Reading it as
+          "today" is wrong all day long, and leaves Total and the hourly snapshots
+          — which the Statistics page's date filters run on — permanently empty.
+
+        Yesterday is not read from the device: no REST device we know of reports
+        it. It is derived from the lifetime counter's snapshots instead, in
+        ``services.plug_energy_history``.
 
         Returns dict with energy data or None if not available.
         """
-        if not plug.rest_power_path and not plug.rest_energy_path:
+        if not plug.rest_power_path and not plug.rest_energy_path and not plug.rest_energy_total_path:
             return None
 
         headers = self._parse_headers(plug.rest_headers)
@@ -206,30 +219,40 @@ class RESTSmartPlugService:
 
         power_url = plug.rest_power_url or plug.rest_status_url if plug.rest_power_path else None
         energy_url = plug.rest_energy_url or plug.rest_status_url if plug.rest_energy_path else None
+        # The lifetime counter almost always rides on the same response as the
+        # today counter (one Shelly RPC call returns both `apower` and
+        # `aenergy.total`), so it shares the energy URL and the dedupe below
+        # collapses them into a single fetch.
+        total_url = plug.rest_energy_url or plug.rest_status_url if plug.rest_energy_total_path else None
 
-        # Fetch data — deduplicate when both resolve to the same URL
+        # Fetch data — deduplicate when several resolve to the same URL
         fetched: dict[str, Any] = {}
 
-        for url in {power_url, energy_url} - {None}:
+        for url in {power_url, energy_url, total_url} - {None}:
             fetched[url] = await self._fetch_json(url, headers)
 
-        # Extract power value
-        if plug.rest_power_path and power_url and fetched.get(power_url) is not None:
-            raw = self._extract_json_path(fetched[power_url], plug.rest_power_path)
-            if raw is not None:
-                try:
-                    energy["power"] = float(raw) * (plug.rest_power_multiplier or 1.0)
-                except (ValueError, TypeError):
-                    pass
-
-        # Extract energy value
-        if plug.rest_energy_path and energy_url and fetched.get(energy_url) is not None:
-            raw = self._extract_json_path(fetched[energy_url], plug.rest_energy_path)
-            if raw is not None:
-                try:
-                    energy["today"] = float(raw) * (plug.rest_energy_multiplier or 1.0)
-                except (ValueError, TypeError):
-                    pass
+        def _read(path: str | None, url: str | None, multiplier: float | None) -> float | None:
+            if not path or not url or fetched.get(url) is None:
+                return None
+            raw = self._extract_json_path(fetched[url], path)
+            if raw is None:
+                return None
+            try:
+                return float(raw) * (multiplier or 1.0)
+            except (ValueError, TypeError):
+                return None
+
+        power = _read(plug.rest_power_path, power_url, plug.rest_power_multiplier)
+        if power is not None:
+            energy["power"] = power
+
+        today = _read(plug.rest_energy_path, energy_url, plug.rest_energy_multiplier)
+        if today is not None:
+            energy["today"] = today
+
+        total = _read(plug.rest_energy_total_path, total_url, plug.rest_energy_total_multiplier)
+        if total is not None:
+            energy["total"] = total
 
         return energy if energy else None
 

+ 33 - 22
backend/app/services/smart_plug_manager.py

@@ -13,6 +13,7 @@ from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.rest_smart_plug import rest_smart_plug_service
 from backend.app.services.tasmota import tasmota_service
+from backend.app.utils.local_time import next_local_hour, to_naive_utc, utcnow_naive
 
 if TYPE_CHECKING:
     from backend.app.models.smart_plug import SmartPlug
@@ -98,26 +99,34 @@ class SmartPlugManager:
             await asyncio.sleep(60)
 
     async def _snapshot_loop(self):
-        """Background loop that captures each plug's lifetime energy counter hourly.
-
-        Powers date-range queries in "total consumption" energy mode (#941). Takes
-        a snapshot shortly after startup so the first bucket isn't empty, then
-        every hour.
+        """Background loop that captures each plug's lifetime energy counter.
+
+        Powers date-range queries in "total consumption" energy mode (#941) and,
+        since #2539, the derived Today / Yesterday figures for every plug that
+        reports only a cumulative counter.
+
+        Ticks on the local hour rather than every 3600s from boot. That is what
+        makes the derivation exact: a drifting timer leaves the last snapshot
+        before midnight up to an hour early, and an hour of a printer's draw is
+        a real number of watt-hours to lose off the day boundary. Aligning to the
+        *local* hour also lands a tick on local midnight in the half-hour-offset
+        timezones (India, Nepal), where midnight is not on a UTC hour at all.
         """
-        # Short warm-up delay so other services finish booting; still gives us
-        # an initial snapshot well before the first hour mark.
+        # Short warm-up delay so other services finish booting; still gives us an
+        # initial snapshot well before the first boundary.
         await asyncio.sleep(30)
         while True:
             try:
                 await self._capture_energy_snapshots()
             except Exception as e:
                 logger.error("Error in energy snapshot capture: %s", e)
-            await asyncio.sleep(3600)  # 1 hour
+
+            now = datetime.now(timezone.utc)
+            delay = (next_local_hour(now) - now).total_seconds()
+            await asyncio.sleep(max(delay, 60))
 
     async def _capture_energy_snapshots(self):
         """Capture one energy snapshot row per plug with a usable lifetime counter."""
-        from datetime import timezone
-
         from backend.app.core.database import async_session
         from backend.app.models.smart_plug import SmartPlug
         from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
@@ -128,7 +137,10 @@ class SmartPlugManager:
             if not plugs:
                 return
 
-            now = datetime.now(timezone.utc)
+            # Naive UTC: the column is naive, and asyncpg rejects an aware value
+            # outright (SQLite quietly drops the offset, which is why this went
+            # unnoticed — on Postgres the whole capture raised).
+            now = utcnow_naive()
             captured = 0
             for plug in plugs:
                 # MQTT plugs only publish a "today" counter that resets at midnight —
@@ -146,8 +158,9 @@ class SmartPlugManager:
                     continue
                 lifetime = energy.get("total")
                 if lifetime is None:
-                    # MQTT / REST plugs that only expose "today" can't be used for
-                    # cumulative snapshots — skip them.
+                    # The plug exposes no cumulative counter — a REST plug with only
+                    # rest_energy_path set, say. Nothing to snapshot, and its Today
+                    # comes straight from the device anyway.
                     continue
                 db.add(
                     SmartPlugEnergySnapshot(
@@ -189,7 +202,7 @@ class SmartPlugManager:
                         success = await service.turn_on(plug)
                         if success:
                             plug.last_state = "ON"
-                            plug.last_checked = datetime.now(timezone.utc)
+                            plug.last_checked = utcnow_naive()
                             self._last_schedule_check[plug.id] = f"on:{current_time}"
 
                 # Check if we should turn off
@@ -200,7 +213,7 @@ class SmartPlugManager:
                         success = await service.turn_off(plug)
                         if success:
                             plug.last_state = "OFF"
-                            plug.last_checked = datetime.now(timezone.utc)
+                            plug.last_checked = utcnow_naive()
                             self._last_schedule_check[plug.id] = f"off:{current_time}"
                             # Mark printer offline if linked
                             if plug.printer_id:
@@ -245,7 +258,7 @@ class SmartPlugManager:
 
                 if success:
                     plug.last_state = "ON"
-                    plug.last_checked = datetime.now(timezone.utc)
+                    plug.last_checked = utcnow_naive()
                     plug.auto_off_executed = False  # Reset flag when turning on
             except Exception as e:
                 logger.warning("Failed to turn on plug '%s' for printer %s: %s", plug.name, printer_id, e)
@@ -617,7 +630,7 @@ class SmartPlugManager:
                 plug = result.scalar_one_or_none()
                 if plug:
                     plug.auto_off_pending = pending
-                    plug.auto_off_pending_since = datetime.now(timezone.utc) if pending else None
+                    plug.auto_off_pending_since = utcnow_naive() if pending else None
                     await db.commit()
                     logger.debug("Marked plug %s auto_off_pending=%s", plug_id, pending)
         except Exception as e:
@@ -639,7 +652,7 @@ class SmartPlugManager:
                     plug.auto_off_pending = False  # Clear pending state
                     plug.auto_off_pending_since = None
                     plug.last_state = "OFF"
-                    plug.last_checked = datetime.now(timezone.utc)
+                    plug.last_checked = utcnow_naive()
                     await db.commit()
                     if plug.auto_off_persistent:
                         logger.info("Auto-off executed for plug %s (persistent, stays enabled)", plug_id)
@@ -685,10 +698,8 @@ class SmartPlugManager:
                 for plug in pending_plugs:
                     # Check how long it's been pending (timeout after 2 hours)
                     if plug.auto_off_pending_since:
-                        pending_since = plug.auto_off_pending_since
-                        if pending_since.tzinfo is None:
-                            pending_since = pending_since.replace(tzinfo=timezone.utc)
-                        elapsed = (datetime.now(timezone.utc) - pending_since).total_seconds()
+                        pending_since = to_naive_utc(plug.auto_off_pending_since)
+                        elapsed = (utcnow_naive() - pending_since).total_seconds()
                         if elapsed > 7200:  # 2 hours
                             logger.warning(
                                 f"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, "

+ 112 - 0
backend/app/utils/local_time.py

@@ -0,0 +1,112 @@
+"""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)

+ 159 - 0
backend/tests/unit/services/test_plug_energy_history.py

@@ -0,0 +1,159 @@
+"""Today / Yesterday derived from a plug's lifetime energy counter (#2539).
+
+The reporter's Shelly Plug S Gen3 reports one number, ``aenergy.total``, and it
+only ever climbs. Bambuddy filed that under "today", so Today never reset at
+midnight and Yesterday and Total stayed at zero forever. These tests pin the
+arithmetic that replaces it, and the two ways it can legitimately have no answer.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+
+import pytest
+
+from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
+from backend.app.services.plug_energy_history import derive_today_yesterday, fill_derived_energy
+from backend.app.utils.local_time import local_day_start, to_naive_utc
+
+pytestmark = pytest.mark.asyncio
+
+
+@pytest.fixture(autouse=True)
+def berlin(monkeypatch):
+    """The reporter's timezone. A UTC day boundary would roll his Today over at
+    02:00 wall-clock, which is the whole reason local_day_start exists.
+    """
+    monkeypatch.setenv("TZ", "Europe/Berlin")
+
+
+async def _plug(db) -> SmartPlug:
+    plug = SmartPlug(
+        name="Shelly",
+        plug_type="rest",
+        rest_energy_total_path="aenergy.total",
+        rest_energy_total_multiplier=0.001,
+    )
+    db.add(plug)
+    await db.commit()
+    await db.refresh(plug)
+    return plug
+
+
+async def _snapshot(db, plug_id: int, when: datetime, kwh: float) -> None:
+    db.add(
+        SmartPlugEnergySnapshot(
+            plug_id=plug_id,
+            recorded_at=to_naive_utc(when),
+            lifetime_kwh=kwh,
+        )
+    )
+    await db.commit()
+
+
+async def test_derives_today_and_yesterday_from_the_counter(db_session):
+    plug = await _plug(db_session)
+    now = datetime.now(timezone.utc)
+
+    await _snapshot(db_session, plug.id, local_day_start(now, days_ago=1), 100.0)
+    await _snapshot(db_session, plug.id, local_day_start(now, days_ago=0), 102.0)
+
+    today, yesterday = await derive_today_yesterday(db_session, plug.id, live_total_kwh=103.5)
+
+    assert today == pytest.approx(1.5)  # counter now, minus this midnight
+    assert yesterday == pytest.approx(2.0)  # this midnight, minus the one before
+
+
+async def test_yesterday_is_none_until_two_midnights_have_passed(db_session):
+    """A day-old install can say what today used, but has nothing to compare
+    yesterday against. Better an empty field than a fabricated one.
+    """
+    plug = await _plug(db_session)
+    now = datetime.now(timezone.utc)
+    await _snapshot(db_session, plug.id, local_day_start(now, days_ago=0), 102.0)
+
+    today, yesterday = await derive_today_yesterday(db_session, plug.id, live_total_kwh=103.5)
+
+    assert today == pytest.approx(1.5)
+    assert yesterday is None
+
+
+async def test_nothing_derivable_before_the_first_midnight(db_session):
+    plug = await _plug(db_session)
+    now = datetime.now(timezone.utc)
+    # Snapshot taken this morning, after midnight — no baseline for the day.
+    await _snapshot(db_session, plug.id, now - timedelta(minutes=30), 102.0)
+
+    today, yesterday = await derive_today_yesterday(db_session, plug.id, live_total_kwh=103.5)
+
+    assert today is None
+    assert yesterday is None
+
+
+async def test_counter_reset_reports_nothing_rather_than_a_negative(db_session):
+    """A factory reset zeroes a Shelly's aenergy.total. The delta goes negative,
+    and "-101.6 kWh used today" is worse than a blank.
+    """
+    plug = await _plug(db_session)
+    now = datetime.now(timezone.utc)
+    await _snapshot(db_session, plug.id, local_day_start(now, days_ago=1), 100.0)
+    await _snapshot(db_session, plug.id, local_day_start(now, days_ago=0), 102.0)
+
+    today, _ = await derive_today_yesterday(db_session, plug.id, live_total_kwh=0.4)
+
+    assert today is None
+
+
+async def test_snapshots_from_other_plugs_are_not_borrowed(db_session):
+    plug = await _plug(db_session)
+    other = SmartPlug(name="Other", plug_type="rest")
+    db_session.add(other)
+    await db_session.commit()
+    await db_session.refresh(other)
+
+    now = datetime.now(timezone.utc)
+    await _snapshot(db_session, other.id, local_day_start(now, days_ago=0), 50.0)
+
+    today, yesterday = await derive_today_yesterday(db_session, plug.id, live_total_kwh=103.5)
+
+    assert today is None
+    assert yesterday is None
+
+
+class TestFillDerivedEnergy:
+    async def test_fills_today_and_yesterday_for_a_lifetime_only_plug(self, db_session):
+        plug = await _plug(db_session)
+        now = datetime.now(timezone.utc)
+        await _snapshot(db_session, plug.id, local_day_start(now, days_ago=1), 100.0)
+        await _snapshot(db_session, plug.id, local_day_start(now, days_ago=0), 102.0)
+
+        energy = await fill_derived_energy(db_session, plug.id, {"power": 84.0, "total": 103.5})
+
+        assert energy["today"] == pytest.approx(1.5)
+        assert energy["yesterday"] == pytest.approx(2.0)
+        assert energy["total"] == 103.5
+
+    async def test_never_overwrites_what_the_device_reported(self, db_session):
+        """Tasmota knows its own daily figures. A device that measured the day
+        itself beats our hourly-snapshot arithmetic, so it wins.
+        """
+        plug = await _plug(db_session)
+        now = datetime.now(timezone.utc)
+        await _snapshot(db_session, plug.id, local_day_start(now, days_ago=1), 100.0)
+        await _snapshot(db_session, plug.id, local_day_start(now, days_ago=0), 102.0)
+
+        energy = await fill_derived_energy(db_session, plug.id, {"today": 9.9, "yesterday": 8.8, "total": 103.5})
+
+        assert energy["today"] == 9.9
+        assert energy["yesterday"] == 8.8
+
+    async def test_no_lifetime_counter_is_left_alone(self, db_session):
+        """A REST plug configured with only a today-path, or an MQTT plug: there
+        is nothing to derive from, and its Today already came from the device.
+        """
+        plug = await _plug(db_session)
+
+        energy = await fill_derived_energy(db_session, plug.id, {"power": 84.0, "today": 1.2})
+
+        assert energy == {"power": 84.0, "today": 1.2}

+ 90 - 0
backend/tests/unit/services/test_rest_smart_plug.py

@@ -34,6 +34,11 @@ def mock_plug():
     plug.rest_energy_url = None
     plug.rest_energy_path = "energy.today"
     plug.rest_energy_multiplier = 1.0
+    # Pinned to None rather than left as a MagicMock: an auto-created attribute is
+    # truthy, so get_energy would think a lifetime path was configured and take a
+    # branch no test meant to exercise.
+    plug.rest_energy_total_path = None
+    plug.rest_energy_total_multiplier = 1.0
     return plug
 
 
@@ -184,6 +189,91 @@ class TestGetEnergy:
         assert result["power"] == 42.5
         assert result["today"] == 1.23
 
+
+class TestGetEnergyLifetimeCounter:
+    """#2539. A Shelly Plug S Gen3 reports exactly one energy figure, and it is
+    cumulative. It has to land in ``total``, not ``today``.
+    """
+
+    # The reporter's own Switch.GetStatus payload.
+    SHELLY = {"apower": 84.0, "aenergy": {"total": 2620.197}}
+
+    @pytest.fixture
+    def shelly(self, mock_plug):
+        mock_plug.rest_power_path = "apower"
+        mock_plug.rest_power_multiplier = 1.0
+        mock_plug.rest_energy_path = None  # a Shelly has no notion of "today"
+        mock_plug.rest_energy_total_path = "aenergy.total"
+        mock_plug.rest_energy_total_multiplier = 0.001  # Wh -> kWh
+        return mock_plug
+
+    @pytest.mark.asyncio
+    async def test_lifetime_counter_lands_in_total_not_today(self, service, shelly):
+        response = MagicMock()
+        response.json.return_value = self.SHELLY
+
+        with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=response):
+            result = await service.get_energy(shelly)
+
+        assert result["power"] == 84.0
+        assert result["total"] == pytest.approx(2.620197)
+        # The bug: this used to be 2.620197, a lifetime figure wearing today's
+        # label, which then never reset at midnight.
+        assert "today" not in result
+
+    @pytest.mark.asyncio
+    async def test_a_plug_reporting_both_counters_keeps_them_apart(self, service, mock_plug):
+        """A Tasmota behind a REST bridge exposes Today and Total. Neither may
+        overwrite the other.
+        """
+        mock_plug.rest_power_path = "power"
+        mock_plug.rest_energy_path = "energy.today"
+        mock_plug.rest_energy_multiplier = 1.0
+        mock_plug.rest_energy_total_path = "energy.total"
+        mock_plug.rest_energy_total_multiplier = 1.0
+
+        response = MagicMock()
+        response.json.return_value = {"power": 42.5, "energy": {"today": 1.23, "total": 987.6}}
+
+        with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=response):
+            result = await service.get_energy(mock_plug)
+
+        assert result["today"] == 1.23
+        assert result["total"] == 987.6
+
+    @pytest.mark.asyncio
+    async def test_total_path_alone_is_enough_to_read_energy(self, service, mock_plug):
+        """No power path, no today path — only the lifetime counter. get_energy
+        used to bail out entirely, since its guard only knew about the other two.
+        """
+        mock_plug.rest_power_path = None
+        mock_plug.rest_energy_path = None
+        mock_plug.rest_energy_total_path = "aenergy.total"
+        mock_plug.rest_energy_total_multiplier = 0.001
+
+        response = MagicMock()
+        response.json.return_value = self.SHELLY
+
+        with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=response):
+            result = await service.get_energy(mock_plug)
+
+        assert result == {"total": pytest.approx(2.620197)}
+
+    @pytest.mark.asyncio
+    async def test_both_counters_share_one_fetch(self, service, shelly):
+        """Today and Total ride on the same Shelly response. Reading them must not
+        cost two HTTP round-trips against a device on the end of a wifi link.
+        """
+        shelly.rest_energy_path = "aenergy.total"  # same URL as the total path
+
+        response = MagicMock()
+        response.json.return_value = self.SHELLY
+
+        with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=response) as send:
+            await service.get_energy(shelly)
+
+        assert send.await_count == 1
+
     @pytest.mark.asyncio
     async def test_energy_no_status_url_no_separate_urls(self, service, mock_plug):
         """No URLs at all (status=None, power_url=None, energy_url=None) → None."""

+ 6 - 3
backend/tests/unit/test_local_backup.py

@@ -146,15 +146,18 @@ class TestCalculateNextRun:
         """
         from zoneinfo import ZoneInfoNotFoundError
 
-        from backend.app.services import local_backup as lb_module
+        # The resolver moved to utils/local_time in #2539, when the smart-plug
+        # energy history needed the same local day boundary. local_backup still
+        # calls it, so this still guards the behaviour local_backup depends on.
+        from backend.app.utils import local_time as tz_module
 
         monkeypatch.delenv("TZ", raising=False)
 
         def _always_missing(_key):
             raise ZoneInfoNotFoundError("no tz database on this platform")
 
-        monkeypatch.setattr(lb_module, "ZoneInfo", _always_missing)
-        assert lb_module._local_zone() is timezone.utc
+        monkeypatch.setattr(tz_module, "ZoneInfo", _always_missing)
+        assert tz_module.local_zone() is timezone.utc
 
     def test_dst_spring_forward_gap_does_not_crash(self, monkeypatch):
         """Europe/Berlin spring-forward 2026-03-29 jumps 02:00 → 03:00 local;

+ 111 - 0
backend/tests/unit/test_plug_datetimes_are_naive_utc.py

@@ -0,0 +1,111 @@
+"""Smart-plug timestamps must be naive UTC (#2539 collateral).
+
+Every ``DateTime`` column in the smart-plug tables is naive, and Bambuddy's
+convention is that a naive column holds UTC. The smart-plug code wrote *aware*
+datetimes into them anyway. SQLite tolerates that — its bind processor reads the
+datetime's fields and drops the offset — so it went unnoticed for a long time.
+
+**asyncpg does not.** It raises ``DataError: invalid input for query argument``,
+which meant that on Postgres:
+
+* every energy snapshot capture raised, so the snapshot table stayed empty and
+  the Statistics page's date-filtered energy figure was permanently zero;
+* every plug status poll raised on ``last_checked``.
+
+Postgres is the setup Bambuddy recommends for multi-printer installs, so this
+was not a corner. Both of these tests fail against the pre-#2539 code.
+"""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services import smart_plug_manager as manager_module
+from backend.app.utils.local_time import to_naive_utc, utcnow_naive
+
+
+def test_utcnow_naive_carries_no_offset():
+    now = utcnow_naive()
+    assert now.tzinfo is None
+
+
+def test_to_naive_utc_converts_rather_than_truncates():
+    """An aware datetime in another zone must be *converted* to UTC before the
+    offset is dropped, not merely stripped — stripping 02:00+02:00 would record
+    it as 02:00 UTC, an hour of energy attributed to the wrong day.
+    """
+    from datetime import datetime, timedelta, timezone
+
+    berlin_summer = timezone(timedelta(hours=2))
+    aware = datetime(2026, 7, 11, 2, 30, tzinfo=berlin_summer)
+
+    naive = to_naive_utc(aware)
+
+    assert naive.tzinfo is None
+    assert naive == datetime(2026, 7, 11, 0, 30)
+
+
+def test_to_naive_utc_passes_through_naive_and_none():
+    from datetime import datetime
+
+    already = datetime(2026, 7, 11, 12, 0)
+    assert to_naive_utc(already) is already
+    assert to_naive_utc(None) is None
+
+
+@pytest.mark.asyncio
+async def test_energy_snapshot_is_stamped_with_a_naive_datetime():
+    """The regression guard, and the one that actually reproduces the bug.
+
+    Reproducing the real failure needs a live Postgres, which CI has no reason to
+    run for this. So catch it one step earlier: intercept the row on its way into
+    the session and assert the timestamp carries no offset. An aware value here is
+    exactly what asyncpg rejects with DataError, and what SQLite quietly swallows —
+    which is why nobody noticed for so long.
+
+    A source scan was tried first and was worthless: the aware ``now`` is assigned
+    on one line and used as ``recorded_at`` on another, so grepping for the two
+    together sees nothing.
+    """
+    added: list[object] = []
+
+    class FakeSession:
+        async def execute(self, *_a, **_kw):
+            result = MagicMock()
+            result.scalars.return_value.all.return_value = [SimpleNamespace(id=1, plug_type="rest", enabled=True)]
+            return result
+
+        def add(self, obj):
+            added.append(obj)
+
+        async def commit(self):
+            pass
+
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, *_a):
+            return False
+
+    manager = manager_module.SmartPlugManager()
+
+    with (
+        patch("backend.app.core.database.async_session", FakeSession),
+        patch.object(
+            manager,
+            "get_service_for_plug",
+            new=AsyncMock(return_value=SimpleNamespace(get_energy=AsyncMock(return_value={"total": 2.62}))),
+        ),
+    ):
+        await manager._capture_energy_snapshots()
+
+    assert len(added) == 1, "expected one snapshot row to be written"
+    recorded_at = added[0].recorded_at
+    assert recorded_at.tzinfo is None, (
+        "energy snapshot stamped with a timezone-aware datetime. The column is "
+        "naive, and asyncpg raises DataError on Postgres — the whole capture "
+        "fails and the Statistics energy figure stays at zero. Use utcnow_naive()."
+    )

+ 8 - 0
frontend/src/api/client.ts

@@ -1855,6 +1855,10 @@ export interface SmartPlug {
   rest_energy_url: string | null;
   rest_energy_path: string | null;
   rest_energy_multiplier: number;
+  // Lifetime counter, separate from the daily one (#2539). A Shelly reports only
+  // this; Today and Yesterday are derived from its hourly snapshots.
+  rest_energy_total_path: string | null;
+  rest_energy_total_multiplier: number;
   printer_id: number | null;
   enabled: boolean;
   auto_on: boolean;
@@ -1929,6 +1933,8 @@ export interface SmartPlugCreate {
   rest_energy_url?: string | null;
   rest_energy_path?: string | null;
   rest_energy_multiplier?: number;
+  rest_energy_total_path?: string | null;
+  rest_energy_total_multiplier?: number;
   printer_id?: number | null;
   enabled?: boolean;
   auto_on?: boolean;
@@ -1995,6 +2001,8 @@ export interface SmartPlugUpdate {
   rest_energy_url?: string | null;
   rest_energy_path?: string | null;
   rest_energy_multiplier?: number;
+  rest_energy_total_path?: string | null;
+  rest_energy_total_multiplier?: number;
   printer_id?: number | null;
   enabled?: boolean;
   auto_on?: boolean;

+ 31 - 1
frontend/src/components/AddSmartPlugModal.tsx

@@ -58,6 +58,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const [restEnergyUrl, setRestEnergyUrl] = useState(plug?.rest_energy_url || '');
   const [restEnergyPath, setRestEnergyPath] = useState(plug?.rest_energy_path || '');
   const [restEnergyMultiplier, setRestEnergyMultiplier] = useState<string>((plug?.rest_energy_multiplier ?? 1).toString());
+  const [restEnergyTotalPath, setRestEnergyTotalPath] = useState(plug?.rest_energy_total_path || '');
+  const [restEnergyTotalMultiplier, setRestEnergyTotalMultiplier] = useState<string>((plug?.rest_energy_total_multiplier ?? 1).toString());
   // HA energy sensor entities (optional)
   const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || '');
   const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_entity || '');
@@ -381,6 +383,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       rest_energy_url: plugType === 'rest' ? (restEnergyUrl.trim() || null) : null,
       rest_energy_path: plugType === 'rest' ? (restEnergyPath.trim() || null) : null,
       rest_energy_multiplier: plugType === 'rest' ? (parseFloat(restEnergyMultiplier) || 1) : 1,
+      rest_energy_total_path: plugType === 'rest' ? (restEnergyTotalPath.trim() || null) : null,
+      rest_energy_total_multiplier: plugType === 'rest' ? (parseFloat(restEnergyTotalMultiplier) || 1) : 1,
       username: plugType === 'tasmota' ? (username.trim() || null) : null,
       password: plugType === 'tasmota' ? (password.trim() || null) : null,
       printer_id: printerId,
@@ -1312,10 +1316,36 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                     />
                   </div>
                 </div>
-
                 <p className="text-xs text-bambu-gray">
                   {t('smartPlugs.restEnergyHint')}
                 </p>
+
+                {/* Lifetime counter (#2539) — the only energy figure a Shelly has. */}
+                <div className="grid grid-cols-2 gap-3">
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restEnergyTotalPath')}</label>
+                    <input
+                      type="text"
+                      value={restEnergyTotalPath}
+                      onChange={(e) => setRestEnergyTotalPath(e.target.value)}
+                      placeholder={t('smartPlugs.restEnergyTotalPathHint')}
+                      className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                    />
+                  </div>
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restEnergyTotalMultiplier')}</label>
+                    <input
+                      type="text"
+                      value={restEnergyTotalMultiplier}
+                      onChange={(e) => setRestEnergyTotalMultiplier(e.target.value)}
+                      placeholder="1"
+                      className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                    />
+                  </div>
+                </div>
+                <p className="text-xs text-bambu-gray">
+                  {t('smartPlugs.restEnergyTotalHint')}
+                </p>
               </div>
 
               {/* Test Connection */}

+ 6 - 1
frontend/src/i18n/locales/de.ts

@@ -5349,7 +5349,12 @@ export default {
     restPowerPath: 'JSON-Pfad für Leistung',
     restPowerMultiplier: 'Power Multiplikator',
     restEnergyUrl: 'Energie URL',
-    restEnergyPath: 'JSON-Pfad für Energie',
+    restEnergyPath: 'JSON-Pfad für Energie (heute)',
+    restEnergyTotalPath: 'JSON-Pfad für Energie (Gesamtzähler)',
+    restEnergyTotalMultiplier: 'Multiplikator Gesamtzähler',
+    restEnergyTotalPathHint: 'z.B. aenergy.total',
+    restEnergyTotalHint:
+      'Viele Steckdosen — darunter alle Shellys — liefern nur einen Gesamtzähler, der nie zurückgesetzt wird. Er gehört hierher, nicht in das Feld darüber: als Tagesverbrauch gelesen würde er um Mitternacht nie zurückspringen, und Gestern und Gesamt blieben leer. Bambuddy errechnet Heute und Gestern daraus, wofür ein bis zwei Tage an Messwerten nötig sind. Ein Shelly liefert Wattstunden, also Multiplikator 0.001 verwenden.',
     restEnergyMultiplier: 'Energie Multiplikator',
     restUrlRequired: 'Mindestens eine URL (ON oder OFF) ist für REST-Steckdosen erforderlich',
     restHeadersHint: 'z. B. {"Authorization": "Bearer your-token"}',

+ 6 - 1
frontend/src/i18n/locales/en.ts

@@ -5393,7 +5393,12 @@ export default {
     restPowerPath: 'Power JSON Path',
     restPowerMultiplier: 'Power Multiplier',
     restEnergyUrl: 'Energy URL',
-    restEnergyPath: 'Energy JSON Path',
+    restEnergyPath: 'Energy JSON Path (today)',
+    restEnergyTotalPath: 'Energy JSON Path (lifetime)',
+    restEnergyTotalMultiplier: 'Lifetime Multiplier',
+    restEnergyTotalPathHint: 'e.g. aenergy.total',
+    restEnergyTotalHint:
+      "Many plugs — every Shelly among them — report only a lifetime counter that never resets. It belongs here, not in the field above: read as today's usage it would never reset at midnight, and Yesterday and Total would stay empty. Bambuddy works Today and Yesterday out from it, which takes a day or two of readings to fill in. A Shelly reports watt-hours, so use a multiplier of 0.001.",
     restEnergyMultiplier: 'Energy Multiplier',
     restUrlRequired: 'At least one URL (ON or OFF) is required for REST plugs',
     restHeadersHint: 'e.g. {"Authorization": "Bearer your-token"}',

+ 6 - 1
frontend/src/i18n/locales/es.ts

@@ -5358,7 +5358,12 @@ export default {
     restPowerPath: 'Ruta JSON de la potencia',
     restPowerMultiplier: 'Multiplicador de potencia',
     restEnergyUrl: 'URL de energía',
-    restEnergyPath: 'Ruta JSON de la energía',
+    restEnergyPath: 'Ruta JSON de energía (hoy)',
+    restEnergyTotalPath: 'Ruta JSON de energía (contador total)',
+    restEnergyTotalMultiplier: 'Multiplicador del contador total',
+    restEnergyTotalPathHint: 'p. ej. aenergy.total',
+    restEnergyTotalHint:
+      'Muchos enchufes — todos los Shelly entre ellos — solo informan de un contador acumulado que nunca se reinicia. Va aquí, no en el campo de arriba: leído como el consumo de hoy nunca se pondría a cero a medianoche, y Ayer y Total seguirían vacíos. Bambuddy deduce Hoy y Ayer a partir de él, lo que requiere uno o dos días de lecturas. Un Shelly informa en vatios-hora, así que usa un multiplicador de 0.001.',
     restEnergyMultiplier: 'Multiplicador de energía',
     restUrlRequired: 'Se requiere al menos una URL (de encendido o apagado) para los enchufes REST',
     restHeadersHint: 'p. ej. {"Authorization": "Bearer your-token"}',

+ 6 - 1
frontend/src/i18n/locales/fr.ts

@@ -5339,7 +5339,12 @@ export default {
     restPowerPath: 'Chemin JSON puissance',
     restPowerMultiplier: 'Multiplicateur de puissance',
     restEnergyUrl: 'URL d\'énergie',
-    restEnergyPath: 'Chemin JSON énergie',
+    restEnergyPath: "Chemin JSON de l'énergie (aujourd'hui)",
+    restEnergyTotalPath: "Chemin JSON de l'énergie (compteur total)",
+    restEnergyTotalMultiplier: 'Multiplicateur du compteur total',
+    restEnergyTotalPathHint: 'ex. aenergy.total',
+    restEnergyTotalHint:
+      "Beaucoup de prises — tous les Shelly notamment — ne fournissent qu'un compteur cumulé qui ne se remet jamais à zéro. Il va ici, pas dans le champ ci-dessus : lu comme la consommation du jour, il ne repartirait jamais de zéro à minuit, et Hier et Total resteraient vides. Bambuddy en déduit Aujourd'hui et Hier, ce qui demande un à deux jours de relevés. Un Shelly renvoie des wattheures : utilisez un multiplicateur de 0.001.",
     restEnergyMultiplier: 'Multiplicateur d\'énergie',
     restUrlRequired: 'Au moins une URL (ON ou OFF) est requise pour les prises REST',
     restHeadersHint: 'par ex. {"Authorization": "Bearer your-token"}',

+ 6 - 1
frontend/src/i18n/locales/it.ts

@@ -5338,7 +5338,12 @@ export default {
     restPowerPath: 'Percorso JSON potenza',
     restPowerMultiplier: 'Moltiplicatore potenza',
     restEnergyUrl: 'URL energia',
-    restEnergyPath: 'Percorso JSON energia',
+    restEnergyPath: 'Percorso JSON energia (oggi)',
+    restEnergyTotalPath: 'Percorso JSON energia (contatore totale)',
+    restEnergyTotalMultiplier: 'Moltiplicatore contatore totale',
+    restEnergyTotalPathHint: 'es. aenergy.total',
+    restEnergyTotalHint:
+      'Molte prese — tutti gli Shelly fra queste — riportano solo un contatore cumulativo che non si azzera mai. Va qui, non nel campo sopra: letto come consumo di oggi non tornerebbe mai a zero a mezzanotte, e Ieri e Totale resterebbero vuoti. Bambuddy ricava Oggi e Ieri da questo valore, il che richiede uno o due giorni di letture. Uno Shelly riporta wattora, quindi usa un moltiplicatore di 0.001.',
     restEnergyMultiplier: 'Moltiplicatore energia',
     restUrlRequired: 'È richiesto almeno un URL (ON o OFF) per le prese REST',
     restHeadersHint: 'es. {"Authorization": "Bearer your-token"}',

+ 6 - 1
frontend/src/i18n/locales/ja.ts

@@ -5350,7 +5350,12 @@ export default {
     restPowerPath: '電力JSONパス',
     restPowerMultiplier: '電力乗数',
     restEnergyUrl: 'エネルギーURL',
-    restEnergyPath: '電力量JSONパス',
+    restEnergyPath: 'エネルギーの JSON パス (今日)',
+    restEnergyTotalPath: 'エネルギーの JSON パス (積算値)',
+    restEnergyTotalMultiplier: '積算値の乗数',
+    restEnergyTotalPathHint: '例: aenergy.total',
+    restEnergyTotalHint:
+      '多くのスマートプラグ (Shelly はすべて) は、リセットされない積算カウンターしか返しません。その値は上の欄ではなく、ここに入力してください。今日の使用量として読むと深夜にリセットされず、昨日と合計は空のままになります。Bambuddy は積算値から今日と昨日を算出しますが、そのためには 1〜2 日分の測定値が必要です。Shelly はワット時で返すため、乗数には 0.001 を指定してください。',
     restEnergyMultiplier: 'エネルギー乗数',
     restUrlRequired: 'RESTプラグには少なくとも1つのURL(ONまたはOFF)が必要',
     restHeadersHint: '例: {"Authorization": "Bearer your-token"}',

+ 6 - 1
frontend/src/i18n/locales/ko.ts

@@ -5075,7 +5075,12 @@ export default {
     restPowerPath: '전력 JSON 경로',
     restPowerMultiplier: '전력 배수',
     restEnergyUrl: '에너지 URL',
-    restEnergyPath: '에너지 JSON 경로',
+    restEnergyPath: '에너지 JSON 경로 (오늘)',
+    restEnergyTotalPath: '에너지 JSON 경로 (누적값)',
+    restEnergyTotalMultiplier: '누적값 배율',
+    restEnergyTotalPathHint: '예: aenergy.total',
+    restEnergyTotalHint:
+      '많은 플러그가 — Shelly는 모두 — 초기화되지 않는 누적 카운터만 보고합니다. 그 값은 위 항목이 아니라 여기에 입력하세요. 오늘 사용량으로 읽으면 자정에 초기화되지 않고, 어제와 총계는 계속 비어 있게 됩니다. Bambuddy가 누적값에서 오늘과 어제를 계산하며, 이를 위해 하루에서 이틀 치 측정값이 필요합니다. Shelly는 와트시로 보고하므로 배율은 0.001을 사용하세요.',
     restEnergyMultiplier: '에너지 배수',
     restUrlRequired: 'REST 플러그에는 URL(ON 또는 OFF) 중 하나 이상이 필요합니다',
     restHeadersHint: '예: {"Authorization": "Bearer your-token"}',

+ 6 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -5338,7 +5338,12 @@ export default {
     restPowerPath: 'Caminho JSON de energia',
     restPowerMultiplier: 'Multiplicador de potência',
     restEnergyUrl: 'URL de energia',
-    restEnergyPath: 'Caminho JSON de energia',
+    restEnergyPath: 'Caminho JSON de energia (hoje)',
+    restEnergyTotalPath: 'Caminho JSON de energia (contador total)',
+    restEnergyTotalMultiplier: 'Multiplicador do contador total',
+    restEnergyTotalPathHint: 'ex.: aenergy.total',
+    restEnergyTotalHint:
+      'Muitas tomadas — todos os Shelly entre elas — informam apenas um contador acumulado que nunca é zerado. Ele vai aqui, não no campo acima: lido como o consumo de hoje, ele nunca voltaria a zero à meia-noite, e Ontem e Total ficariam vazios. O Bambuddy calcula Hoje e Ontem a partir dele, o que exige um ou dois dias de leituras. Um Shelly informa em watt-hora, então use um multiplicador de 0.001.',
     restEnergyMultiplier: 'Multiplicador de energia',
     restUrlRequired: 'Ao menos uma URL (ON ou OFF) é necessária para tomadas REST',
     restHeadersHint: 'ex. {"Authorization": "Bearer your-token"}',

+ 6 - 1
frontend/src/i18n/locales/tr.ts

@@ -5312,7 +5312,12 @@ export default {
     restPowerPath: 'Güç JSON Yolu',
     restPowerMultiplier: 'Güç Çarpanı',
     restEnergyUrl: 'Enerji URL\'si',
-    restEnergyPath: 'Enerji JSON Yolu',
+    restEnergyPath: 'Enerji JSON yolu (bugün)',
+    restEnergyTotalPath: 'Enerji JSON yolu (toplam sayaç)',
+    restEnergyTotalMultiplier: 'Toplam sayaç çarpanı',
+    restEnergyTotalPathHint: 'örn. aenergy.total',
+    restEnergyTotalHint:
+      "Birçok priz — tüm Shelly'ler dahil — yalnızca hiç sıfırlanmayan bir toplam sayaç bildirir. Bu değer yukarıdaki alana değil, buraya girilmelidir: bugünün tüketimi olarak okunursa gece yarısı sıfırlanmaz, Dün ve Toplam da boş kalır. Bambuddy Bugün ve Dün değerlerini bundan hesaplar; bunun için bir iki günlük ölçüm gerekir. Shelly watt-saat bildirdiğinden çarpan olarak 0.001 kullanın.",
     restEnergyMultiplier: 'Enerji Çarpanı',
     restUrlRequired: 'REST prizleri için en az bir URL (ON veya OFF) gerekli',
     restHeadersHint: 'örn. {"Authorization": "Bearer your-token"}',

+ 6 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -5338,7 +5338,12 @@ export default {
     restPowerPath: '功率 JSON 路径',
     restPowerMultiplier: '功率乘数',
     restEnergyUrl: '能耗URL',
-    restEnergyPath: '能耗 JSON 路径',
+    restEnergyPath: '能耗 JSON 路径(今日)',
+    restEnergyTotalPath: '能耗 JSON 路径(累计值)',
+    restEnergyTotalMultiplier: '累计值倍数',
+    restEnergyTotalPathHint: '例如 aenergy.total',
+    restEnergyTotalHint:
+      '许多插座(包括所有 Shelly)只提供永不归零的累计计数值。该值应填在此处,而不是上面的字段:若当作今日用量读取,它在午夜不会归零,而“昨日”和“总计”会一直为空。Bambuddy 会据此推算“今日”和“昨日”,这需要一到两天的采样数据。Shelly 以瓦时为单位,因此倍数请填 0.001。',
     restEnergyMultiplier: '能耗乘数',
     restUrlRequired: 'REST 插座至少需要一个 URL(ON 或 OFF)',
     restHeadersHint: '例如 {"Authorization": "Bearer your-token"}',

+ 6 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -5338,7 +5338,12 @@ export default {
     restPowerPath: '功率 JSON 路徑',
     restPowerMultiplier: '功率乘數',
     restEnergyUrl: '能耗URL',
-    restEnergyPath: '能耗 JSON 路徑',
+    restEnergyPath: '能耗 JSON 路徑(今日)',
+    restEnergyTotalPath: '能耗 JSON 路徑(累計值)',
+    restEnergyTotalMultiplier: '累計值倍數',
+    restEnergyTotalPathHint: '例如 aenergy.total',
+    restEnergyTotalHint:
+      '許多插座(包括所有 Shelly)只提供永不歸零的累計計數值。該值應填在此處,而非上方欄位:若當作今日用量讀取,它在午夜不會歸零,而「昨日」與「總計」會一直是空的。Bambuddy 會據此推算「今日」與「昨日」,這需要一至兩天的取樣資料。Shelly 以瓦時為單位,因此倍數請填 0.001。',
     restEnergyMultiplier: '能耗乘數',
     restUrlRequired: 'REST 插座至少需要一個 URL(ON 或 OFF)',
     restHeadersHint: '例如:{"Authorization": "Bearer your-token"}',

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-Bb3jqp6t.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-C5xd9oTZ.js"></script>
+    <script type="module" crossorigin src="/assets/index-Bb3jqp6t.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DaanvRDY.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов