Parcourir la source

fix(energy): date-range energy in total mode + restart-resilient per-print tracking (#941)

  The Statistics page reported "Gesamt" (All Time) kWh correctly but showed
  zero for Today/Week/Month in total-consumption mode. Two bugs drove it:

  1. The starting plug counter was kept in an in-memory dict
     `_print_energy_start` that was lost on any backend restart mid-print, so
     the per-print `energy_kwh` delta silently never got computed. The stats
     endpoint's fallback path `SUM(PrintArchive.energy_kwh)` therefore summed
     to zero for users running in total mode.
  2. Total-consumption mode has no per-print delta by design — it includes
     idle/preheat/standby — so the fallback to archive rows was the wrong
     strategy even when the data existed.

  Fix, in two parts:

  - Persist `energy_start_kwh` on the archive row and read it back from a
    fresh session at print end. Deletes `_print_energy_start` and its 5
    call sites, replacing them with a single `_record_energy_start()` helper.
    Per-print tracking is now restart-resilient regardless of tracking mode.
  - Add hourly `smart_plug_energy_snapshots` table + `_snapshot_loop()` in
    SmartPlugManager. Rewrote the `/archives/stats` energy branch as
    `_sum_snapshot_deltas()` which computes per-plug
    `max(0, last-in-range - baseline)` where baseline is the latest snapshot
    at or before the range start, falling back to the earliest-ever snapshot
    and signalling `energy_data_warming_up` when no pre-range baseline
    exists (fresh upgrade). MQTT plugs are skipped from snapshots since they
    only report "today" and have no lifetime counter.

  Frontend: QuickStatsWidget renders an AlertTriangle next to Energy Used /
  Energy Cost with a tooltip when `energy_data_warming_up` is true, so the
  "low values right after upgrading" situation is explained in-product.
  Fully localised across 7 UI languages.

  Tests: new backend unit tests cover the snapshot delta arithmetic
  (baseline/endpoint, counter reset clamp, multi-plug, warming-up fallback,
  endpoint windowing), per-print restart resilience via expunge_all, and the
  snapshot task lifecycle (start idempotent, stop cancels). Frontend tests
  assert the warning icon appears only when the flag is set and only on the
  energy tiles.

  Docs: updated `CHANGELOG.md`, `README.md`, wiki `features/energy.md`,
  wiki `features/statistics.md`, and website `features.html` with the new
  behaviour and warming-up explanation.
maziggy il y a 1 mois
Parent
commit
8266d225d2

+ 1 - 0
CHANGELOG.md

@@ -14,6 +14,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **LDAP Default Fallback Group** — Settings → Authentication → LDAP → Advanced now has a "Default group" selector. When an LDAP user authenticates but is not listed in any mapped LDAP group, they are automatically assigned to this fallback group instead of being left without permissions. Previously such users could log in successfully but landed on empty pages because every permission check failed. Leave the setting empty to preserve the old behavior. A warning is logged each time the fallback is applied so administrators can spot missing group assignments.
 
 ### Fixed
+- **Energy Statistics Empty for Week/Month/Day in Total Consumption Mode** ([#941](https://github.com/maziggy/bambuddy/issues/941)) — With "Total consumption" selected as the energy tracking mode, the Statistics page showed the correct kWh total for All Time but zero for every time-filtered range (Today, This Week, This Month, …). The backend fell back to summing per-print archive energy whenever a date filter was active, but in total-consumption mode the per-print column was often empty for two reasons: (1) the starting-kWh value was held in an in-memory dict (`_print_energy_start`) that was lost on any backend restart mid-print, so prints that spanned a restart never got an energy delta computed; (2) historical prints from before a smart plug was added had no value at all. The fix replaces the in-memory dict with a persisted `energy_start_kwh` column on the archive row, and adds an hourly snapshot loop (`smart_plug_energy_snapshots` table) that captures each plug's lifetime counter. The `/archives/stats` endpoint now computes date-range totals via per-plug `(last-in-range − baseline)` deltas from those snapshots, clamping counter resets to zero. A warming-up flag is returned (and rendered as a tooltip next to the Energy stats on StatsPage) when the query runs on incomplete snapshot history — e.g. right after upgrade, before the hourly loop has built up a baseline before the selected range — so the "low" values during the first hours after upgrading are explained in-product rather than misread as a bug. Fully localized across all 7 UI languages. Per-print energy tracking is now restart-resilient in all modes as a side-effect. Thanks to Mike (@TheMadMike23) for reporting.
 - **Virtual Printer "Synchronizing device information" Times Out in Orca** ([#927](https://github.com/maziggy/bambuddy/issues/927)) — OrcaSlicer's "Send job" flow sat on "Synchronizing device information…" until it gave up, even though the FTP upload itself worked when the user clicked "Send job anyway". The virtual printer's MQTT server gated all incoming command handling on `f"device/{self.serial}/request" in topic` — if the slicer's cached serial for the VP didn't exactly equal the VP's computed `self.serial` (which depends on model prefix + per-VP `serial_suffix`), every `get_version`, `pushall`, and `project_file` publish was silently dropped. Nothing was logged past the initial "MQTT publish to …" line, so the slicer never received a `push_status` or `get_version` response on its subscribed `device/{serial}/report` topic and hit its sync timeout. Status pushes, version responses, and project_file acknowledgments were *also* being published on `device/{self.serial}/report`, so even when the incoming check happened to pass, replies targeted a topic the slicer wasn't listening on if its serial had drifted. Both directions are now serial-adaptive: the handler accepts any authenticated publish on a `device/*/request` topic, extracts the serial the slicer is actually using from the topic, stores it per-connection, and uses it for every outgoing status report, version response, print acknowledgment, and periodic push so responses always land on the topic the slicer subscribed to. The client's serial is cleared when the connection closes and when the server stops. Regression tests cover the mismatched-serial publish path, the non-request-topic rejection path, the pushall→status_report routing, and the client-serial lifecycle.
 - **External Sidebar Link Icon Not Showing** ([#878](https://github.com/maziggy/bambuddy/issues/878)) — Custom icons uploaded for external sidebar links rendered correctly in the edit dialog but were missing from the sidebar itself, and opening the icon URL directly returned `{"detail":"Valid camera stream token required..."}`. The sidebar `<img>` tag in `Layout.tsx` used a raw `/api/v1/external-links/{id}/icon` URL, but that endpoint is protected by a query-string stream token (the same mechanism used for camera streams and archive thumbnails, because `<img>` tags cannot send Authorization headers). The edit dialog already routed through `api.getExternalLinkIconUrl()`, which wraps the URL via `withStreamToken()`; the sidebar now does the same, so icons appear when auth is enabled.
 - **Shortest Job First Toggle Disappears After Clicking** ([#879](https://github.com/maziggy/bambuddy/issues/879)) — The SJF toggle badge on the queue page was rendered inside the Pending Queue section header, which is only shown when there is at least one pending item and the list view is active. Clicking the toggle often coincided with the scheduler starting the only pending print, at which point the Pending section unmounted and the toggle vanished along with it — making it look like the button had disappeared after clicking. The toggle has been moved to the top of the queue page, next to the list/timeline view switcher, so it stays reachable regardless of pending-item count, active filters, or the selected view mode.

+ 2 - 1
README.md

@@ -125,7 +125,8 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Smart plug integration (Tasmota, Home Assistant, MQTT, REST/Webhook)
 - REST smart plugs: Control any device with an HTTP API (openHAB, ioBroker, FHEM, Node-RED) with separate power/energy URLs and unit multipliers
 - MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring
-- Energy consumption tracking (per-print kWh and cost)
+- Energy consumption tracking (per-print kWh and cost) — restart-resilient: mid-print backend restarts no longer lose per-print energy
+- Energy statistics by date range (Today / Week / Month / …) in total-consumption mode via hourly lifetime-counter snapshots
 - HA energy sensor support (for plugs with separate power/energy sensors)
 - Auto power-on before print
 - Auto power-off after cooldown

+ 138 - 41
backend/app/api/routes/archives.py

@@ -798,49 +798,25 @@ async def get_archive_stats(
     energy_cost_per_kwh_str = await get_setting(db, "energy_cost_per_kwh")
     energy_cost_per_kwh = float(energy_cost_per_kwh_str) if energy_cost_per_kwh_str else 0.15
 
-    # When date filters are active, smart plug lifetime totals can't be
-    # filtered by date range — fall back to per-print archive data instead.
+    total_energy_kwh: float = 0.0
+    total_energy_cost: float = 0.0
+    energy_data_warming_up = False
+
     if energy_tracking_mode == "total" and not date_from and not date_to:
-        # Total mode: sum up 'total' counter from all smart plugs (lifetime consumption)
-        from backend.app.models.smart_plug import SmartPlug
-        from backend.app.services.homeassistant import homeassistant_service
-        from backend.app.services.mqtt_relay import mqtt_relay
-        from backend.app.services.tasmota import tasmota_service
-
-        plugs_result = await db.execute(select(SmartPlug))
-        plugs = list(plugs_result.scalars().all())
-
-        # Configure HA service once (needed for homeassistant-type plugs)
-        ha_url = await get_setting(db, "ha_url") or ""
-        ha_token = await get_setting(db, "ha_token") or ""
-        homeassistant_service.configure(ha_url, ha_token)
-
-        total_energy_kwh = 0.0
-        for plug in plugs:
-            if plug.plug_type == "tasmota":
-                energy = await tasmota_service.get_energy(plug)
-                if energy and energy.get("total") is not None:
-                    total_energy_kwh += energy["total"]
-            elif plug.plug_type == "homeassistant":
-                energy = await homeassistant_service.get_energy(plug)
-                if energy and energy.get("total") is not None:
-                    total_energy_kwh += energy["total"]
-            elif plug.plug_type == "mqtt":
-                # MQTT plugs report "today" energy, not lifetime total
-                mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id)
-                if mqtt_data and mqtt_data.energy is not None:
-                    total_energy_kwh += mqtt_data.energy
-            elif plug.plug_type == "rest":
-                from backend.app.services.rest_smart_plug import rest_smart_plug_service
-
-                energy = await rest_smart_plug_service.get_energy(plug)
-                if energy and energy.get("today") is not None:
-                    total_energy_kwh += energy["today"]
-
-        total_energy_kwh = round(total_energy_kwh, 3)
-        total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 3)
+        # All-time total consumption — read live lifetime counters.
+        total_energy_kwh = await _sum_live_plug_totals(db)
+        total_energy_cost = total_energy_kwh * energy_cost_per_kwh
+    elif energy_tracking_mode == "total":
+        # Total consumption mode with a date filter (#941): use hourly snapshots
+        # to compute per-plug (endpoint - baseline) deltas.
+        total_energy_kwh, energy_data_warming_up = await _sum_snapshot_deltas(
+            db,
+            dt_from=(datetime.combine(date_from, time.min, tzinfo=timezone.utc) if date_from else None),
+            dt_to=(datetime.combine(date_to, time.max, tzinfo=timezone.utc) if date_to else None),
+        )
+        total_energy_cost = total_energy_kwh * energy_cost_per_kwh
     else:
-        # Print mode: sum up per-print energy from archives
+        # Per-print mode: sum the per-print energy column directly.
         energy_kwh_result = await db.execute(select(func.sum(PrintArchive.energy_kwh)).where(*base_conditions))
         total_energy_kwh = energy_kwh_result.scalar() or 0
 
@@ -860,9 +836,130 @@ async def get_archive_stats(
         time_accuracy_by_printer=accuracy_by_printer if accuracy_by_printer else None,
         total_energy_kwh=round(total_energy_kwh, 3),
         total_energy_cost=round(total_energy_cost, 3),
+        energy_data_warming_up=energy_data_warming_up,
     )
 
 
+async def _sum_live_plug_totals(db: AsyncSession) -> float:
+    """Sum the live lifetime counter from every smart plug.
+
+    Used for all-time "total consumption" mode. Only the current value is
+    available so this can't be date-filtered — use `_sum_snapshot_deltas` for
+    that case.
+    """
+    from backend.app.api.routes.settings import get_setting
+    from backend.app.models.smart_plug import SmartPlug
+    from backend.app.services.homeassistant import homeassistant_service
+    from backend.app.services.mqtt_relay import mqtt_relay
+    from backend.app.services.rest_smart_plug import rest_smart_plug_service
+    from backend.app.services.tasmota import tasmota_service
+
+    plugs_result = await db.execute(select(SmartPlug))
+    plugs = list(plugs_result.scalars().all())
+
+    ha_url = await get_setting(db, "ha_url") or ""
+    ha_token = await get_setting(db, "ha_token") or ""
+    homeassistant_service.configure(ha_url, ha_token)
+
+    total = 0.0
+    for plug in plugs:
+        if plug.plug_type == "tasmota":
+            energy = await tasmota_service.get_energy(plug)
+            if energy and energy.get("total") is not None:
+                total += energy["total"]
+        elif plug.plug_type == "homeassistant":
+            energy = await homeassistant_service.get_energy(plug)
+            if energy and energy.get("total") is not None:
+                total += energy["total"]
+        elif plug.plug_type == "mqtt":
+            # MQTT plugs only expose today's counter, not lifetime.
+            mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id)
+            if mqtt_data and mqtt_data.energy is not None:
+                total += mqtt_data.energy
+        elif plug.plug_type == "rest":
+            energy = await rest_smart_plug_service.get_energy(plug)
+            if energy and energy.get("today") is not None:
+                total += energy["today"]
+    return total
+
+
+async def _sum_snapshot_deltas(
+    db: AsyncSession,
+    *,
+    dt_from: datetime | None,
+    dt_to: datetime | None,
+) -> tuple[float, bool]:
+    """Sum per-plug energy consumption over a date range using hourly snapshots.
+
+    For each plug:
+      * baseline  = last snapshot at or before `dt_from` (ideal)
+                    — if missing, fall back to the earliest snapshot ever
+                      recorded for the plug and flag the result as warming up.
+      * endpoint  = last snapshot at or before `dt_to` (or most recent overall)
+      * delta     = max(0, endpoint - baseline)  — clamp counter resets to 0.
+
+    Returns (total_kwh, warming_up). `warming_up = True` means at least one plug
+    had no baseline before `dt_from` (fresh install or fresh upgrade), so the
+    result undercounts the beginning of the range.
+    """
+    from backend.app.models.smart_plug import SmartPlug
+    from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
+
+    plug_ids_result = await db.execute(select(SmartPlug.id))
+    plug_ids = [row[0] for row in plug_ids_result.all()]
+    if not plug_ids:
+        return 0.0, False
+
+    total = 0.0
+    warming_up = False
+    for plug_id in plug_ids:
+        baseline: float | None = None
+        if dt_from is not None:
+            baseline_q = await db.execute(
+                select(SmartPlugEnergySnapshot.lifetime_kwh)
+                .where(
+                    SmartPlugEnergySnapshot.plug_id == plug_id,
+                    SmartPlugEnergySnapshot.recorded_at <= dt_from,
+                )
+                .order_by(SmartPlugEnergySnapshot.recorded_at.desc())
+                .limit(1)
+            )
+            baseline = baseline_q.scalar()
+        if baseline is None:
+            # No snapshot before range start — fall back to the earliest
+            # snapshot ever recorded. Result undercounts the pre-first-snapshot
+            # portion of the range; signal that to the frontend.
+            earliest_q = await db.execute(
+                select(SmartPlugEnergySnapshot.lifetime_kwh)
+                .where(SmartPlugEnergySnapshot.plug_id == plug_id)
+                .order_by(SmartPlugEnergySnapshot.recorded_at.asc())
+                .limit(1)
+            )
+            baseline = earliest_q.scalar()
+            if baseline is None:
+                # No snapshots at all for this plug yet.
+                warming_up = True
+                continue
+            warming_up = True
+
+        endpoint_conditions = [SmartPlugEnergySnapshot.plug_id == plug_id]
+        if dt_to is not None:
+            endpoint_conditions.append(SmartPlugEnergySnapshot.recorded_at <= dt_to)
+        endpoint_q = await db.execute(
+            select(SmartPlugEnergySnapshot.lifetime_kwh)
+            .where(*endpoint_conditions)
+            .order_by(SmartPlugEnergySnapshot.recorded_at.desc())
+            .limit(1)
+        )
+        endpoint = endpoint_q.scalar()
+        if endpoint is None:
+            continue
+
+        total += max(0.0, endpoint - baseline)
+
+    return total, warming_up
+
+
 @router.get("/tags")
 async def get_all_tags(
     db: AsyncSession = Depends(get_db),

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

@@ -179,6 +179,7 @@ async def init_db():
         settings,
         slot_preset,
         smart_plug,
+        smart_plug_energy_snapshot,
         spool,
         spool_assignment,
         spool_catalog,
@@ -1374,6 +1375,40 @@ async def run_migrations(conn):
     if not is_sqlite():
         await _safe_execute(conn, "ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL")
 
+    # Migration: Add energy_start_kwh to print_archives (#941)
+    # Persists the smart plug lifetime counter captured at print start, so per-print
+    # energy tracking survives a backend restart mid-print.
+    await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN energy_start_kwh REAL")
+
+    # Migration: Create smart_plug_energy_snapshots table (#941)
+    # Hourly snapshots of each plug's lifetime counter, so date-range queries in
+    # "total consumption" energy mode can compute (last - first) deltas.
+    await _safe_execute(
+        conn,
+        """
+        CREATE TABLE IF NOT EXISTS smart_plug_energy_snapshots (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            plug_id INTEGER NOT NULL REFERENCES smart_plugs(id) ON DELETE CASCADE,
+            recorded_at DATETIME NOT NULL,
+            lifetime_kwh REAL NOT NULL
+        )
+        """
+        if is_sqlite()
+        else """
+        CREATE TABLE IF NOT EXISTS smart_plug_energy_snapshots (
+            id SERIAL PRIMARY KEY,
+            plug_id INTEGER NOT NULL REFERENCES smart_plugs(id) ON DELETE CASCADE,
+            recorded_at TIMESTAMP NOT NULL,
+            lifetime_kwh REAL NOT NULL
+        )
+        """,
+    )
+    await _safe_execute(
+        conn,
+        "CREATE INDEX IF NOT EXISTS ix_plug_energy_snapshots_plug_time "
+        "ON smart_plug_energy_snapshots(plug_id, recorded_at)",
+    )
+
     # Seed default settings keys that must exist on fresh install
     default_settings = [
         ("advanced_auth_enabled", "false"),

+ 83 - 99
backend/app/main.py

@@ -255,9 +255,6 @@ _active_prints: dict[tuple[int, str], int] = {}
 # {(printer_id, filename): archive_id}
 _expected_prints: dict[tuple[int, str], int] = {}
 
-# Track starting energy for prints: {archive_id: starting_kwh}
-_print_energy_start: dict[int, float] = {}
-
 # Track AMS mapping for prints: {archive_id: [global_tray_id_per_slot]}
 # Used by usage tracker to map 3MF slots to physical AMS trays
 _print_ams_mappings: dict[int, list[int]] = {}
@@ -341,6 +338,39 @@ async def _get_plug_energy(plug, db) -> dict | None:
         return await tasmota_service.get_energy(plug)
 
 
+async def _record_energy_start(archive, printer_id: int, db, *, context: str = "") -> bool:
+    """Capture the smart plug lifetime counter on the archive at print start.
+
+    Persists `energy_start_kwh` on the archive row (#941) so per-print energy
+    tracking survives a backend restart mid-print. The print-end handler reads
+    this value back from the DB and computes the delta against the current
+    plug counter.
+    """
+    _logger = logging.getLogger(__name__)
+    try:
+        plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
+        plug = plug_result.scalar_one_or_none()
+        if not plug:
+            _logger.info("[ENERGY] No smart plug for printer %s (archive %s)", printer_id, archive.id)
+            return False
+        energy = await _get_plug_energy(plug, db)
+        if not energy or energy.get("total") is None:
+            _logger.warning("[ENERGY] No 'total' in energy response for archive %s", archive.id)
+            return False
+        archive.energy_start_kwh = float(energy["total"])
+        await db.commit()
+        _logger.info(
+            "[ENERGY] Recorded starting energy%s for archive %s: %s kWh",
+            f" ({context})" if context else "",
+            archive.id,
+            energy["total"],
+        )
+        return True
+    except Exception as e:
+        _logger.warning("[ENERGY] Failed to record starting energy for archive %s: %s", archive.id, e)
+        return False
+
+
 def register_expected_print(
     printer_id: int,
     filename: str,
@@ -1534,27 +1564,8 @@ async def on_print_start(printer_id: int, data: dict):
                     except Exception:
                         pass
 
-                # Set up energy tracking
-                try:
-                    plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-                    plug = plug_result.scalar_one_or_none()
-                    logger.info(
-                        f"[ENERGY] Print start - archive {archive.id}, printer {printer_id}, plug found: {plug is not None}"
-                    )
-                    if plug:
-                        energy = await _get_plug_energy(plug, db)
-                        logger.info("[ENERGY] Energy response from plug: %s", energy)
-                        if energy and energy.get("total") is not None:
-                            _print_energy_start[archive.id] = energy["total"]
-                            logger.info(
-                                f"[ENERGY] Recorded starting energy for archive {archive.id}: {energy['total']} kWh"
-                            )
-                        else:
-                            logger.warning("[ENERGY] No 'total' in energy response for archive %s", archive.id)
-                    else:
-                        logger.info("[ENERGY] No smart plug found for printer %s", printer_id)
-                except Exception as e:
-                    logger.warning("Failed to record starting energy: %s", e)
+                # Set up energy tracking (#941: persist start on archive row)
+                await _record_energy_start(archive, printer_id, db, context="expected-print")
 
                 await ws_manager.send_archive_updated(
                     {
@@ -1642,20 +1653,9 @@ async def on_print_start(printer_id: int, data: dict):
                 )
                 # Track this as the active print
                 _active_prints[(printer_id, existing_archive.filename)] = existing_archive.id
-                # Also set up energy tracking if not already tracked
-                if existing_archive.id not in _print_energy_start:
-                    try:
-                        plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-                        plug = plug_result.scalar_one_or_none()
-                        if plug:
-                            energy = await _get_plug_energy(plug, db)
-                            if energy and energy.get("total") is not None:
-                                _print_energy_start[existing_archive.id] = energy["total"]
-                                logger.info(
-                                    f"Recorded starting energy for existing archive {existing_archive.id}: {energy['total']} kWh"
-                                )
-                    except Exception as e:
-                        logger.warning("Failed to record starting energy for existing archive: %s", e)
+                # Also set up energy tracking if not already tracked (#941: persisted column)
+                if existing_archive.energy_start_kwh is None:
+                    await _record_energy_start(existing_archive, printer_id, db, context="existing-printing")
                 # Send notification with archive data (existing archive)
                 if not notification_sent:
                     archive_data = {
@@ -1884,19 +1884,8 @@ async def on_print_start(printer_id: int, data: dict):
                     _active_prints[(printer_id, f"{subtask_name}.3mf")] = fallback_archive.id
                     _active_prints[(printer_id, subtask_name)] = fallback_archive.id
 
-                # Record starting energy if smart plug available
-                try:
-                    plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-                    plug = plug_result.scalar_one_or_none()
-                    if plug:
-                        energy = await _get_plug_energy(plug, db)
-                        if energy and energy.get("total") is not None:
-                            _print_energy_start[fallback_archive.id] = energy["total"]
-                            logger.info(
-                                f"[ENERGY] Recorded starting energy for fallback archive {fallback_archive.id}: {energy['total']} kWh"
-                            )
-                except Exception as e:
-                    logger.warning("Failed to record starting energy for fallback: %s", e)
+                # Record starting energy if smart plug available (#941: persisted column)
+                await _record_energy_start(fallback_archive, printer_id, db, context="fallback")
 
                 # Send WebSocket notification
                 await ws_manager.send_archive_created(
@@ -1975,27 +1964,8 @@ async def on_print_start(printer_id: int, data: dict):
                     )
                     logger.info("Started layer timelapse for printer %s, archive %s", printer_id, archive.id)
 
-                # Record starting energy from smart plug if available
-                try:
-                    plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-                    plug = plug_result.scalar_one_or_none()
-                    logger.info(
-                        f"[ENERGY] Auto-archive print start - archive {archive.id}, printer {printer_id}, plug found: {plug is not None}"
-                    )
-                    if plug:
-                        energy = await _get_plug_energy(plug, db)
-                        logger.info("[ENERGY] Auto-archive energy response: %s", energy)
-                        if energy and energy.get("total") is not None:
-                            _print_energy_start[archive.id] = energy["total"]
-                            logger.info(
-                                f"[ENERGY] Recorded starting energy for archive {archive.id}: {energy['total']} kWh"
-                            )
-                        else:
-                            logger.warning("[ENERGY] No 'total' in energy response for archive %s", archive.id)
-                    else:
-                        logger.info("[ENERGY] No smart plug found for printer %s", printer_id)
-                except Exception as e:
-                    logger.warning("Failed to record starting energy: %s", e)
+                # Record starting energy from smart plug if available (#941: persisted column)
+                await _record_energy_start(archive, printer_id, db, context="auto-archive")
 
                 await ws_manager.send_archive_created(
                     {
@@ -2913,44 +2883,58 @@ async def on_print_complete(printer_id: int, data: dict):
 
     # Run slow operations as background tasks to avoid blocking the event loop
     # These operations can take 5-10+ seconds and would freeze the UI if awaited
-    starting_kwh = _print_energy_start.pop(archive_id, None)
 
     async def _background_energy_calculation():
-        """Calculate and save energy usage in background."""
+        """Calculate and save energy usage in background.
+
+        Reads the starting kWh from the archive row (#941: persisted so a mid-print
+        backend restart no longer loses per-print energy data).
+        """
         try:
             logger.info("[ENERGY-BG] Starting energy calculation for archive %s", archive_id)
             async with async_session() as db:
-                plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-                plug = plug_result.scalar_one_or_none()
+                from backend.app.models.archive import PrintArchive
 
-                if plug:
-                    energy = await _get_plug_energy(plug, db)
-                    logger.info("[ENERGY-BG] Energy response: %s", energy)
+                archive = await db.get(PrintArchive, archive_id)
+                if archive is None:
+                    logger.warning("[ENERGY-BG] Archive %s no longer exists", archive_id)
+                    return
+                starting_kwh = archive.energy_start_kwh
+                if starting_kwh is None:
+                    logger.info("[ENERGY-BG] No start kWh recorded for archive %s", archive_id)
+                    return
 
-                    energy_used = None
-                    if starting_kwh is not None and energy and energy.get("total") is not None:
-                        ending_kwh = energy["total"]
-                        energy_used = round(ending_kwh - starting_kwh, 4)
-                        logger.info("[ENERGY-BG] Per-print energy: %s kWh", energy_used)
+                plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
+                plug = plug_result.scalar_one_or_none()
+                if plug is None:
+                    logger.info("[ENERGY-BG] No smart plug for printer %s", printer_id)
+                    return
 
-                    if energy_used is not None and energy_used >= 0:
-                        from backend.app.api.routes.settings import get_setting
+                energy = await _get_plug_energy(plug, db)
+                logger.info("[ENERGY-BG] Energy response: %s", energy)
+                if not energy or energy.get("total") is None:
+                    logger.warning("[ENERGY-BG] No 'total' in energy response")
+                    return
 
-                        energy_cost_per_kwh = await get_setting(db, "energy_cost_per_kwh")
-                        cost_per_kwh = float(energy_cost_per_kwh) if energy_cost_per_kwh else 0.15
-                        energy_cost = round(energy_used * cost_per_kwh, 3)
+                energy_used = round(energy["total"] - starting_kwh, 4)
+                logger.info("[ENERGY-BG] Per-print energy: %s kWh", energy_used)
+                if energy_used < 0:
+                    logger.warning(
+                        "[ENERGY-BG] Negative energy delta for archive %s (start=%s, end=%s) — counter reset?",
+                        archive_id,
+                        starting_kwh,
+                        energy["total"],
+                    )
+                    return
 
-                        from backend.app.models.archive import PrintArchive
+                from backend.app.api.routes.settings import get_setting
 
-                        result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-                        archive = result.scalar_one_or_none()
-                        if archive:
-                            archive.energy_kwh = energy_used
-                            archive.energy_cost = energy_cost
-                            await db.commit()
-                            logger.info("[ENERGY-BG] Saved: %s kWh, cost=%s", energy_used, energy_cost)
-                else:
-                    logger.info("[ENERGY-BG] No smart plug for printer %s", printer_id)
+                energy_cost_per_kwh = await get_setting(db, "energy_cost_per_kwh")
+                cost_per_kwh = float(energy_cost_per_kwh) if energy_cost_per_kwh else 0.15
+                archive.energy_kwh = energy_used
+                archive.energy_cost = round(energy_used * cost_per_kwh, 3)
+                await db.commit()
+                logger.info("[ENERGY-BG] Saved: %s kWh, cost=%s", energy_used, archive.energy_cost)
         except Exception as e:
             logger.warning("[ENERGY-BG] Failed: %s", e)
 

+ 2 - 0
backend/app/models/__init__.py

@@ -19,6 +19,7 @@ from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_catalog import SpoolCatalogEntry
@@ -34,6 +35,7 @@ __all__ = [
     "Filament",
     "Settings",
     "SmartPlug",
+    "SmartPlugEnergySnapshot",
     "MaintenanceType",
     "PrinterMaintenance",
     "MaintenanceHistory",

+ 3 - 0
backend/app/models/archive.py

@@ -65,6 +65,9 @@ class PrintArchive(Base):
     # Energy tracking
     energy_kwh: Mapped[float | None] = mapped_column(Float)  # Energy consumed in kWh
     energy_cost: Mapped[float | None] = mapped_column(Float)  # Cost of energy consumed
+    # Plug lifetime counter captured at print start; delta at print end becomes energy_kwh.
+    # Persisted so per-print tracking survives backend restarts mid-print (#941).
+    energy_start_kwh: Mapped[float | None] = mapped_column(Float)
 
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 22 - 0
backend/app/models/smart_plug_energy_snapshot.py

@@ -0,0 +1,22 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class SmartPlugEnergySnapshot(Base):
+    """Hourly snapshot of a smart plug's lifetime energy counter.
+
+    Powers date-range queries in "total consumption" energy mode. For a given
+    range we sum `(last_snapshot_in_range - last_snapshot_before_range)` per plug.
+    """
+
+    __tablename__ = "smart_plug_energy_snapshots"
+    __table_args__ = (Index("ix_plug_energy_snapshots_plug_time", "plug_id", "recorded_at"),)
+
+    id: Mapped[int] = mapped_column(Integer, primary_key=True)
+    plug_id: Mapped[int] = mapped_column(ForeignKey("smart_plugs.id", ondelete="CASCADE"), nullable=False)
+    recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
+    lifetime_kwh: Mapped[float] = mapped_column(Float, nullable=False)

+ 4 - 0
backend/app/schemas/archive.py

@@ -149,6 +149,10 @@ class ArchiveStats(BaseModel):
     # Energy stats
     total_energy_kwh: float = 0.0
     total_energy_cost: float = 0.0
+    # Set when the date-range query in "total consumption" mode is running on
+    # incomplete snapshot history — e.g. right after a fresh upgrade before the
+    # hourly snapshot loop has built up a baseline. Frontend shows a tooltip.
+    energy_data_warming_up: bool = False
 
 
 class ProjectPageImage(BaseModel):

+ 73 - 0
backend/app/services/smart_plug_manager.py

@@ -26,6 +26,7 @@ class SmartPlugManager:
         self._pending_off: dict[int, asyncio.Task] = {}  # plug_id -> task
         self._loop: asyncio.AbstractEventLoop | None = None
         self._scheduler_task: asyncio.Task | None = None
+        self._snapshot_task: asyncio.Task | None = None
         self._last_schedule_check: dict[int, str] = {}  # plug_id -> "HH:MM" last executed
 
     async def get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None):
@@ -69,6 +70,9 @@ class SmartPlugManager:
         if self._scheduler_task is None:
             self._scheduler_task = asyncio.create_task(self._schedule_loop())
             logger.info("Smart plug scheduler started")
+        if self._snapshot_task is None:
+            self._snapshot_task = asyncio.create_task(self._snapshot_loop())
+            logger.info("Smart plug energy snapshot loop started")
 
     def stop_scheduler(self):
         """Stop the background scheduler."""
@@ -76,6 +80,10 @@ class SmartPlugManager:
             self._scheduler_task.cancel()
             self._scheduler_task = None
             logger.info("Smart plug scheduler stopped")
+        if self._snapshot_task:
+            self._snapshot_task.cancel()
+            self._snapshot_task = None
+            logger.info("Smart plug energy snapshot loop stopped")
 
     async def _schedule_loop(self):
         """Background loop that checks scheduled on/off times every minute."""
@@ -88,6 +96,71 @@ class SmartPlugManager:
             # Wait until the next minute
             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.
+        """
+        # Short warm-up delay so other services finish booting; still gives us
+        # an initial snapshot well before the first hour mark.
+        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
+
+    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
+
+        async with async_session() as db:
+            plugs_result = await db.execute(select(SmartPlug).where(SmartPlug.enabled.is_(True)))
+            plugs = list(plugs_result.scalars().all())
+            if not plugs:
+                return
+
+            now = datetime.now(timezone.utc)
+            captured = 0
+            for plug in plugs:
+                # MQTT plugs only publish a "today" counter that resets at midnight —
+                # they can never feed cumulative snapshots, so skip them outright to
+                # avoid a noisy tasmota-service fallback attempt on an IP-less plug.
+                if plug.plug_type == "mqtt":
+                    continue
+                try:
+                    service = await self.get_service_for_plug(plug, db)
+                    energy = await service.get_energy(plug)
+                except Exception as e:
+                    logger.debug("Snapshot: failed to read energy from plug %s: %s", plug.id, e)
+                    continue
+                if not energy:
+                    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.
+                    continue
+                db.add(
+                    SmartPlugEnergySnapshot(
+                        plug_id=plug.id,
+                        recorded_at=now,
+                        lifetime_kwh=float(lifetime),
+                    )
+                )
+                captured += 1
+
+            if captured:
+                await db.commit()
+                logger.info("Captured %d energy snapshot(s)", captured)
+
     async def _check_schedules(self):
         """Check all plugs for scheduled on/off times."""
         from backend.app.core.database import async_session

+ 1 - 0
backend/tests/conftest.py

@@ -85,6 +85,7 @@ async def test_engine():
         settings,
         slot_preset,
         smart_plug,
+        smart_plug_energy_snapshot,  # noqa: F401
         spool,
         spool_assignment,
         spool_catalog,

+ 27 - 6
backend/tests/unit/services/test_smart_plug_manager.py

@@ -314,15 +314,36 @@ class TestSmartPlugManager:
 
     def test_start_scheduler_idempotent(self, manager):
         """Verify starting scheduler twice doesn't create multiple tasks."""
-        mock_task = MagicMock()
-        manager._scheduler_task = mock_task
+        mock_schedule_task = MagicMock()
+        mock_snapshot_task = MagicMock()
+        manager._scheduler_task = mock_schedule_task
+        manager._snapshot_task = mock_snapshot_task
 
-        # Mock _schedule_loop to avoid unawaited coroutine warning (in case it's called)
-        with patch.object(manager, "_schedule_loop") as mock_loop, patch("asyncio.create_task") as mock_create:
+        # Mock the loop coroutines to avoid unawaited coroutine warnings
+        with (
+            patch.object(manager, "_schedule_loop") as mock_loop,
+            patch.object(manager, "_snapshot_loop") as mock_snapshot,
+            patch("asyncio.create_task") as mock_create,
+        ):
             manager.start_scheduler()
 
-            mock_create.assert_not_called()  # Should not create new task
-            mock_loop.assert_not_called()  # Should not call _schedule_loop
+            mock_create.assert_not_called()  # Should not create new tasks
+            mock_loop.assert_not_called()
+            mock_snapshot.assert_not_called()
+
+    def test_stop_scheduler_cancels_snapshot_task(self, manager):
+        """Verify stopping scheduler also cancels the snapshot loop (#941)."""
+        mock_schedule_task = MagicMock()
+        mock_snapshot_task = MagicMock()
+        manager._scheduler_task = mock_schedule_task
+        manager._snapshot_task = mock_snapshot_task
+
+        manager.stop_scheduler()
+
+        mock_schedule_task.cancel.assert_called_once()
+        mock_snapshot_task.cancel.assert_called_once()
+        assert manager._scheduler_task is None
+        assert manager._snapshot_task is None
 
 
 class TestGetPlugsForPrinter:

+ 199 - 0
backend/tests/unit/test_energy_snapshots.py

@@ -0,0 +1,199 @@
+"""Tests for #941 — date-range energy in total consumption mode + restart-resilient per-print tracking.
+
+Covers:
+- `_sum_snapshot_deltas()`: correct (endpoint - baseline) arithmetic
+- Counter-reset clamp, warming-up flag, missing-endpoint handling
+- Restart resilience: per-print `energy_start_kwh` persists across a
+  "simulated restart" (new session/process), so the print-end handler can
+  still compute the delta.
+"""
+
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.api.routes.archives import _sum_snapshot_deltas
+from backend.app.models.archive import PrintArchive
+from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
+
+
+def _snap(plug_id: int, recorded_at: datetime, kwh: float) -> SmartPlugEnergySnapshot:
+    return SmartPlugEnergySnapshot(plug_id=plug_id, recorded_at=recorded_at, lifetime_kwh=kwh)
+
+
+class TestSumSnapshotDeltas:
+    @pytest.mark.asyncio
+    async def test_returns_zero_when_no_plugs(self, db_session):
+        total, warming = await _sum_snapshot_deltas(db_session, dt_from=None, dt_to=None)
+        assert total == 0.0
+        assert warming is False
+
+    @pytest.mark.asyncio
+    async def test_simple_delta_with_baseline_and_endpoint(self, db_session, smart_plug_factory):
+        plug = await smart_plug_factory(name="A")
+        # Baseline sits before the range, endpoint inside the range.
+        t0 = datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc)
+        db_session.add(_snap(plug.id, t0, 100.0))  # baseline
+        db_session.add(_snap(plug.id, t0 + timedelta(days=2), 115.0))  # endpoint
+        await db_session.commit()
+
+        range_start = t0 + timedelta(days=1)
+        range_end = t0 + timedelta(days=3)
+        total, warming = await _sum_snapshot_deltas(db_session, dt_from=range_start, dt_to=range_end)
+
+        assert total == pytest.approx(15.0)
+        assert warming is False
+
+    @pytest.mark.asyncio
+    async def test_warming_up_when_no_baseline_before_range(self, db_session, smart_plug_factory):
+        plug = await smart_plug_factory(name="A")
+        # All snapshots happen AFTER range_start — simulates fresh upgrade.
+        t0 = datetime(2026, 4, 10, 12, 0, tzinfo=timezone.utc)
+        db_session.add(_snap(plug.id, t0, 500.0))  # first snapshot ever (fallback baseline)
+        db_session.add(_snap(plug.id, t0 + timedelta(hours=6), 502.0))  # endpoint
+        await db_session.commit()
+
+        range_start = datetime(2026, 4, 10, 0, 0, tzinfo=timezone.utc)  # before any snapshot
+        range_end = datetime(2026, 4, 10, 23, 59, tzinfo=timezone.utc)
+
+        total, warming = await _sum_snapshot_deltas(db_session, dt_from=range_start, dt_to=range_end)
+
+        assert total == pytest.approx(2.0)  # 502 - 500
+        assert warming is True
+
+    @pytest.mark.asyncio
+    async def test_counter_reset_is_clamped_to_zero(self, db_session, smart_plug_factory):
+        plug = await smart_plug_factory(name="A")
+        t0 = datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc)
+        db_session.add(_snap(plug.id, t0, 1000.0))  # baseline
+        # Counter reset — endpoint is lower than baseline (plug replaced, firmware reset, ...)
+        db_session.add(_snap(plug.id, t0 + timedelta(days=2), 5.0))
+        await db_session.commit()
+
+        total, warming = await _sum_snapshot_deltas(
+            db_session,
+            dt_from=t0 + timedelta(days=1),
+            dt_to=t0 + timedelta(days=3),
+        )
+
+        assert total == 0.0
+        assert warming is False
+
+    @pytest.mark.asyncio
+    async def test_multiple_plugs_are_summed(self, db_session, smart_plug_factory):
+        plug1 = await smart_plug_factory(name="A", ip_address="10.0.0.1")
+        plug2 = await smart_plug_factory(name="B", ip_address="10.0.0.2")
+        t0 = datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc)
+        # plug1: 100 -> 110  (delta 10)
+        db_session.add(_snap(plug1.id, t0, 100.0))
+        db_session.add(_snap(plug1.id, t0 + timedelta(days=2), 110.0))
+        # plug2:  50 ->  55  (delta 5)
+        db_session.add(_snap(plug2.id, t0, 50.0))
+        db_session.add(_snap(plug2.id, t0 + timedelta(days=2), 55.0))
+        await db_session.commit()
+
+        total, warming = await _sum_snapshot_deltas(
+            db_session,
+            dt_from=t0 + timedelta(days=1),
+            dt_to=t0 + timedelta(days=3),
+        )
+
+        assert total == pytest.approx(15.0)
+        assert warming is False
+
+    @pytest.mark.asyncio
+    async def test_plug_with_no_snapshots_signals_warming(self, db_session, smart_plug_factory):
+        # Plug exists but never snapshotted (yet).
+        await smart_plug_factory(name="A")
+        t0 = datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc)
+
+        total, warming = await _sum_snapshot_deltas(
+            db_session,
+            dt_from=t0,
+            dt_to=t0 + timedelta(days=1),
+        )
+
+        assert total == 0.0
+        assert warming is True
+
+    @pytest.mark.asyncio
+    async def test_endpoint_picks_last_snapshot_at_or_before_range_end(self, db_session, smart_plug_factory):
+        plug = await smart_plug_factory(name="A")
+        t0 = datetime(2026, 4, 1, 0, 0, tzinfo=timezone.utc)
+        db_session.add(_snap(plug.id, t0, 100.0))  # baseline
+        db_session.add(_snap(plug.id, t0 + timedelta(days=1), 105.0))  # inside range
+        db_session.add(_snap(plug.id, t0 + timedelta(days=5), 130.0))  # AFTER range_end — must be ignored
+        await db_session.commit()
+
+        total, _warming = await _sum_snapshot_deltas(
+            db_session,
+            dt_from=t0 + timedelta(hours=12),
+            dt_to=t0 + timedelta(days=2),
+        )
+
+        # Baseline is last snapshot <= range_start → the t0 one at 100
+        # Endpoint is last snapshot <= range_end → the day-1 one at 105
+        assert total == pytest.approx(5.0)
+
+
+class TestPerPrintRestartResilience:
+    """#941: per-print energy tracking survives a mid-print backend restart.
+
+    The critical change: `energy_start_kwh` is stored on the archive row, not
+    in an in-memory dict. A new DB session should still be able to read it.
+    """
+
+    @pytest.mark.asyncio
+    async def test_energy_start_kwh_persists_to_db(self, db_session, printer_factory):
+        printer = await printer_factory()
+        archive = PrintArchive(
+            printer_id=printer.id,
+            filename="resilience.gcode.3mf",
+            print_name="Resilience",
+            file_path="archives/test/resilience.gcode.3mf",
+            file_size=1000,
+            status="printing",
+            energy_start_kwh=123.456,
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        archive_id = archive.id
+
+        # Drop the ORM reference and re-fetch, simulating a fresh session
+        # (the situation we'd be in after a backend restart).
+        db_session.expunge_all()
+        result = await db_session.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+        reloaded = result.scalar_one()
+
+        assert reloaded.energy_start_kwh == pytest.approx(123.456)
+
+    @pytest.mark.asyncio
+    async def test_energy_kwh_delta_computes_from_persisted_start(self, db_session, printer_factory):
+        """Simulates the background energy calc reading from DB instead of a dict."""
+        printer = await printer_factory()
+        archive = PrintArchive(
+            printer_id=printer.id,
+            filename="delta.gcode.3mf",
+            print_name="Delta",
+            file_path="archives/test/delta.gcode.3mf",
+            file_size=1000,
+            status="completed",
+            energy_start_kwh=200.0,
+        )
+        db_session.add(archive)
+        await db_session.commit()
+
+        # Emulate the end-of-print calculation: plug currently reads 203.4 kWh
+        ending_kwh = 203.4
+        assert archive.energy_start_kwh is not None
+        archive.energy_kwh = round(ending_kwh - archive.energy_start_kwh, 4)
+        archive.energy_cost = round(archive.energy_kwh * 0.30, 3)
+        await db_session.commit()
+
+        # Re-read and verify
+        db_session.expunge_all()
+        result = await db_session.execute(select(PrintArchive).where(PrintArchive.id == archive.id))
+        reloaded = result.scalar_one()
+        assert reloaded.energy_kwh == pytest.approx(3.4)
+        assert reloaded.energy_cost == pytest.approx(1.02)

+ 51 - 0
frontend/src/__tests__/pages/StatsPage.test.tsx

@@ -411,4 +411,55 @@ describe('StatsPage', () => {
       expect(screen.queryByText('All Users')).not.toBeInTheDocument();
     });
   });
+
+  describe('energy warming-up indicator (#941)', () => {
+    it('does not show a warning icon when energy data is available', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Energy Used')).toBeInTheDocument();
+      });
+
+      const energyLabel = screen.getByText('Energy Used').closest('div');
+      expect(energyLabel?.querySelector('svg[aria-label]')).toBeNull();
+    });
+
+    it('shows a warning icon with tooltip next to energy stats when warming up', async () => {
+      server.use(
+        http.get('/api/v1/archives/stats', () => {
+          return HttpResponse.json({ ...mockStats, energy_data_warming_up: true });
+        })
+      );
+
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Energy Used')).toBeInTheDocument();
+      });
+
+      // Both Energy Used and Energy Cost labels get a warning icon with the
+      // tooltip accessible via aria-label.
+      const icons = await screen.findAllByLabelText(/still collecting hourly snapshots/i);
+      expect(icons.length).toBe(2);
+    });
+
+    it('does not decorate other stats with the energy warming-up warning', async () => {
+      server.use(
+        http.get('/api/v1/archives/stats', () => {
+          return HttpResponse.json({ ...mockStats, energy_data_warming_up: true });
+        })
+      );
+
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Total Prints')).toBeInTheDocument();
+      });
+
+      const totalPrints = screen.getByText('Total Prints').closest('div');
+      expect(totalPrints?.querySelector('svg[aria-label]')).toBeNull();
+      const printTime = screen.getByText('Print Time').closest('div');
+      expect(printTime?.querySelector('svg[aria-label]')).toBeNull();
+    });
+  });
 });

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

@@ -460,6 +460,10 @@ export interface ArchiveStats {
   time_accuracy_by_printer: Record<string, number> | null;
   total_energy_kwh: number;
   total_energy_cost: number;
+  // True when a date-filtered total-consumption query is running on incomplete
+  // snapshot history (e.g. right after upgrade, before hourly snapshots have
+  // a baseline). UI should explain why the number may undercount.
+  energy_data_warming_up?: boolean;
 }
 
 export interface TagInfo {

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

@@ -1084,6 +1084,7 @@ export default {
     totalCost: 'Gesamtkosten',
     energyUsed: 'Energieverbrauch',
     energyCost: 'Energiekosten',
+    energyWarmingUpTooltip: 'Die Energieerfassung sammelt noch stündliche Snapshots. Zeitraumwerte werden genau, sobald vor dem gewählten Bereich mindestens ein Snapshot vorliegt. Frühe Werte können zu niedrig sein.',
     averagePrintTime: 'Durchschnittliche Druckzeit',
     printsPerDay: 'Drucke pro Tag',
     byPrinter: 'Nach Drucker',

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

@@ -1084,6 +1084,7 @@ export default {
     totalCost: 'Total Cost',
     energyUsed: 'Energy Used',
     energyCost: 'Energy Cost',
+    energyWarmingUpTooltip: 'Energy tracking is still collecting hourly snapshots. Date-range totals will become accurate once at least one snapshot exists before the selected range. Early values may undercount.',
     averagePrintTime: 'Average Print Time',
     printsPerDay: 'Prints per Day',
     byPrinter: 'By Printer',

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

@@ -1084,6 +1084,7 @@ export default {
     totalCost: 'Coût total',
     energyUsed: 'Énergie consommée',
     energyCost: 'Coût énergie',
+    energyWarmingUpTooltip: 'Le suivi énergétique collecte encore des instantanés horaires. Les totaux par période deviendront précis dès qu’un instantané existera avant la plage sélectionnée. Les premières valeurs peuvent être sous-estimées.',
     averagePrintTime: 'Temps moyen par impression',
     printsPerDay: 'Impressions par jour',
     byPrinter: 'Par imprimante',

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

@@ -1084,6 +1084,7 @@ export default {
     totalCost: 'Costo totale',
     energyUsed: 'Energia usata',
     energyCost: 'Costo energia',
+    energyWarmingUpTooltip: 'Il tracciamento energia sta ancora raccogliendo snapshot orari. I totali per intervallo diventeranno accurati quando esisterà almeno uno snapshot prima dell’intervallo selezionato. I primi valori potrebbero essere sottostimati.',
     averagePrintTime: 'Tempo medio di stampa',
     printsPerDay: 'Stampe al giorno',
     byPrinter: 'Per stampante',

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

@@ -1083,6 +1083,7 @@ export default {
     totalCost: '総コスト',
     energyUsed: 'エネルギー使用量',
     energyCost: 'エネルギーコスト',
+    energyWarmingUpTooltip: 'エネルギー追跡は毎時スナップショットを収集中です。選択範囲の前に少なくとも1つのスナップショットが存在すると、期間合計が正確になります。初期値は過小になる場合があります。',
     averagePrintTime: '平均印刷時間',
     printsPerDay: '1日あたりの印刷数',
     byPrinter: 'プリンター別',

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

@@ -1084,6 +1084,7 @@ export default {
     totalCost: 'Custo Total',
     energyUsed: 'Energia Utilizada',
     energyCost: 'Custo da Energia',
+    energyWarmingUpTooltip: 'O monitoramento de energia ainda está coletando snapshots por hora. Os totais por período ficarão precisos quando houver pelo menos um snapshot antes do intervalo selecionado. Valores iniciais podem ser subestimados.',
     averagePrintTime: 'Tempo Médio de Impressão',
     printsPerDay: 'Impressões por Dia',
     byPrinter: 'Por Impressora',

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

@@ -1084,6 +1084,7 @@ export default {
     totalCost: '总成本',
     energyUsed: '能耗',
     energyCost: '能源成本',
+    energyWarmingUpTooltip: '能耗追踪正在收集每小时快照。当所选范围之前至少存在一个快照时,时间段合计将变得准确。早期数值可能偏低。',
     averagePrintTime: '平均打印时间',
     printsPerDay: '每日打印次数',
     byPrinter: '按打印机',

+ 25 - 4
frontend/src/pages/StatsPage.tsx

@@ -129,29 +129,50 @@ function QuickStatsWidget({
     total_cost: number;
     total_energy_kwh: number;
     total_energy_cost: number;
+    energy_data_warming_up?: boolean;
   } | undefined;
   currency: string;
 }) {
   const { t } = useTranslation();
 
+  const warmingUp = stats?.energy_data_warming_up === true;
+  const warmingUpTooltip = warmingUp ? t('stats.energyWarmingUpTooltip') : undefined;
+
   const items = [
     { icon: Package, color: 'text-bambu-green', label: t('stats.totalPrints'), value: `${stats?.total_prints || 0}` },
     { icon: Clock, color: 'text-blue-400', label: t('stats.printTime'), value: `${stats?.total_print_time_hours?.toFixed(1) ?? '0'}h` },
     { icon: Package, color: 'text-orange-400', label: t('stats.filamentUsed'), value: formatWeight(stats?.total_filament_grams || 0) },
     { icon: DollarSign, color: 'text-green-400', label: t('stats.filamentCost'), value: `${currency} ${stats?.total_cost?.toFixed(2) ?? '0.00'}` },
-    { icon: Zap, color: 'text-yellow-400', label: t('stats.energyUsed'), value: `${stats?.total_energy_kwh?.toFixed(3) ?? '0.000'} kWh` },
-    { icon: DollarSign, color: 'text-yellow-500', label: t('stats.energyCost'), value: `${currency} ${stats?.total_energy_cost?.toFixed(2) ?? '0.00'}` },
+    {
+      icon: Zap,
+      color: 'text-yellow-400',
+      label: t('stats.energyUsed'),
+      value: `${stats?.total_energy_kwh?.toFixed(3) ?? '0.000'} kWh`,
+      warning: warmingUp,
+      tooltip: warmingUpTooltip,
+    },
+    {
+      icon: DollarSign,
+      color: 'text-yellow-500',
+      label: t('stats.energyCost'),
+      value: `${currency} ${stats?.total_energy_cost?.toFixed(2) ?? '0.00'}`,
+      warning: warmingUp,
+      tooltip: warmingUpTooltip,
+    },
   ];
 
   return (
     <div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
       {items.map((item) => (
-        <div key={item.label} className="flex items-start gap-3">
+        <div key={item.label} className="flex items-start gap-3" title={item.tooltip}>
           <div className={`p-2 rounded-lg bg-bambu-dark ${item.color}`}>
             <item.icon className="w-5 h-5" />
           </div>
           <div>
-            <p className="text-xs text-bambu-gray">{item.label}</p>
+            <p className="text-xs text-bambu-gray flex items-center gap-1">
+              {item.label}
+              {item.warning && <AlertTriangle className="w-3 h-3 text-yellow-400" aria-label={item.tooltip} />}
+            </p>
             <p className="text-xl font-bold text-white">{item.value}</p>
           </div>
         </div>

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-nOTxWdVv.js


+ 1 - 1
static/index.html

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

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff