Browse Source

Take the electricity price from Home Assistant and cost energy hour by hour (#1251)

Each hourly energy snapshot records the price, and the energy used until the
next one is costed at it. Prints and the Statistics energy cost follow the
price over time instead of applying one price after the fact. The price can
come from a Home Assistant sensor, read hourly and at print start and end.
Energy multipliers now accept any value above zero, for plugs that report
watt-seconds.
maziggy 2 days ago
parent
commit
b596b55456
37 changed files with 1433 additions and 41 deletions
  1. 5 0
      CHANGELOG.md
  2. 17 10
      backend/app/api/routes/archives.py
  3. 23 1
      backend/app/api/routes/settings.py
  4. 76 0
      backend/app/core/database.py
  5. 34 4
      backend/app/main.py
  6. 6 0
      backend/app/models/archive.py
  7. 9 0
      backend/app/models/smart_plug_energy_snapshot.py
  8. 15 0
      backend/app/schemas/settings.py
  9. 12 12
      backend/app/schemas/smart_plug.py
  10. 302 0
      backend/app/services/energy_price.py
  11. 8 7
      backend/app/services/smart_plug_manager.py
  12. 645 0
      backend/tests/unit/test_energy_price_1251.py
  13. 3 0
      backend/tests/unit/test_plug_datetimes_are_naive_utc.py
  14. 74 0
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  15. 4 0
      frontend/src/api/client.ts
  16. 8 0
      frontend/src/i18n/locales/de.ts
  17. 8 0
      frontend/src/i18n/locales/en.ts
  18. 8 0
      frontend/src/i18n/locales/es.ts
  19. 8 0
      frontend/src/i18n/locales/fr.ts
  20. 8 0
      frontend/src/i18n/locales/it.ts
  21. 8 0
      frontend/src/i18n/locales/ja.ts
  22. 8 0
      frontend/src/i18n/locales/ko.ts
  23. 8 0
      frontend/src/i18n/locales/nl.ts
  24. 8 0
      frontend/src/i18n/locales/pt-BR.ts
  25. 8 0
      frontend/src/i18n/locales/ru.ts
  26. 8 0
      frontend/src/i18n/locales/sv.ts
  27. 8 0
      frontend/src/i18n/locales/tr.ts
  28. 8 0
      frontend/src/i18n/locales/uk.ts
  29. 8 0
      frontend/src/i18n/locales/zh-CN.ts
  30. 8 0
      frontend/src/i18n/locales/zh-TW.ts
  31. 78 4
      frontend/src/pages/SettingsPage.tsx
  32. 0 0
      static/assets/ImagePreviewModal-Aay5Kh3s.js
  33. 0 1
      static/assets/PdfPreviewModal-YkjMPvJ5.js
  34. 0 0
      static/assets/SpreadsheetPreviewModal-Du9mvozs.js
  35. 1 1
      static/assets/index-Cl_K2DVh.js
  36. 0 0
      static/assets/pdf-B1-VD3Hl.js
  37. 1 1
      static/index.html

+ 5 - 0
CHANGELOG.md

@@ -5,6 +5,10 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 ## [1.2.6b1] - Unreleased
 
 
 ### Added
 ### Added
+- **The electricity price can come from a Home Assistant sensor, and energy is costed at the price of the hour it was used (#1251, requested by @Roving-Ronin)** — With a tariff that changes during the day, such as Amber in Australia or Nord Pool and Tibber in Europe, there was no way to get the right energy cost. Bambuddy kept one price and applied it after the fact: a print was costed at the price when it finished, and in **Total consumption** mode every kWh on the Statistics page, past months included, was multiplied by today's price. So the all-time energy cost moved every time the price did. Pushing the price in from Home Assistant through the API kept that one price current, but the arithmetic stayed wrong.
+  - **Price from Home Assistant:** under **Settings → General → Cost Tracking**, **Electricity price source** can now be set to **Home Assistant sensor** and pointed at a price sensor. Bambuddy reads it when you save, every hour, and at the start and end of each print. Prices per MWh and per Wh are converted to per kWh. If Home Assistant can't be reached, the last price read is used. The push through the API still works.
+  - **Costed hour by hour:** each hourly energy reading now records the price, and the energy used until the next reading is costed at it. A print that runs through cheap and expensive hours is costed at each, and the Statistics energy cost does the same for any date range and for all time.
+  - **A new price applies from then on:** changing the price no longer re-costs history, so a new contract doesn't change last year's figures. Readings taken before this update keep the price that was set when you upgraded.
 - **The Slice dialog can list only the printers that are online and the spools loaded in them (#3172, requested by @wolfrage76)** — The person slicing is often not at the printer, and the useful filaments are the ones already loaded, because those are what a remote print can start on. The Slice dialog listed every printer profile and every filament profile, so you had to know or walk over to check what was in the AMS. Two checkboxes above the printer profile now narrow the lists. Both are off until you turn them on, and the browser remembers them.
 - **The Slice dialog can list only the printers that are online and the spools loaded in them (#3172, requested by @wolfrage76)** — The person slicing is often not at the printer, and the useful filaments are the ones already loaded, because those are what a remote print can start on. The Slice dialog listed every printer profile and every filament profile, so you had to know or walk over to check what was in the AMS. Two checkboxes above the printer profile now narrow the lists. Both are off until you turn them on, and the browser remembers them.
   - **Only printers that are online:** the printer profiles of the models connected right now, at every nozzle size. A profile Bambuddy picked for an offline model moves to an online one. One you picked yourself stays.
   - **Only printers that are online:** the printer profiles of the models connected right now, at every nozzle size. A profile Bambuddy picked for an offline model moves to an online one. One you picked yourself stays.
   - **Only spools that are loaded:** each filament row lists the profiles of the spools in the online printers of the selected model, and its automatic pick is made from them. A profile you picked yourself stays.
   - **Only spools that are loaded:** each filament row lists the profiles of the spools in the online printers of the selected model, and its automatic pick is made from them. A profile you picked yourself stays.
@@ -99,6 +103,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 
 ### Fixed
 ### Fixed
+- **A smart plug that reports energy in watt-seconds could not be set up (reported by @CLKRUN in #1251)** — A myStrom Switch reports energy in watt-seconds, and converting that to kWh needs a multiplier of about 0.000000278. The multiplier fields refused anything below 0.0001, so saving the plug failed. Any multiplier above zero is now accepted, for MQTT and REST plugs alike.
 - **The Statistics energy total stayed at 0 for Shelly and other REST plugs with only a lifetime counter (#3232, reported and contributed by @rojosinalma in #3233)** — With **Total consumption** tracking and no date filter, the Statistics page adds up each plug's lifetime counter, but for REST plugs it added the counter for today. Since #2539, a Shelly is set up with only **Lifetime Energy JSON Path**, as it has no counter for today, so its plug added nothing and the energy total and cost showed 0, while any date range showed the right figure. REST plugs now add their lifetime counter, the same as Tasmota and Home Assistant plugs. A plug with only a counter for today still adds that one.
 - **The Statistics energy total stayed at 0 for Shelly and other REST plugs with only a lifetime counter (#3232, reported and contributed by @rojosinalma in #3233)** — With **Total consumption** tracking and no date filter, the Statistics page adds up each plug's lifetime counter, but for REST plugs it added the counter for today. Since #2539, a Shelly is set up with only **Lifetime Energy JSON Path**, as it has no counter for today, so its plug added nothing and the energy total and cost showed 0, while any date range showed the right figure. REST plugs now add their lifetime counter, the same as Tasmota and Home Assistant plugs. A plug with only a counter for today still adds that one.
 - **Running out of file descriptors could corrupt a SQLite database (#2883, reported and contributed by @M2ABRAMSTANK in #2884)** — Docker, the systemd service and most native installs start Bambuddy with a limit of 1024 open files. Once a busy instance used them all, every new database connection failed with "disk I/O error"; on the reporter's single-printer install that went on for 44 hours and ended with "database disk image is malformed" and a login outage. Part of the reason is how SQLite works in WAL mode: a database connection that closes keeps its file open until the last connection closes, so the database's share of open files stays at the most connections the pool ever reached. Bambuddy now raises its own open-file limit to the maximum the system allows when it starts, on every install, and logs what it set. The SQLite connection pool is smaller (10 + 90 instead of 20 + 200), which halves its share. A large printer farm still on SQLite that runs out of connections can raise `DB_MAX_OVERFLOW`, but is better served by PostgreSQL. The support bundle now counts open files by kind (database, sockets, pipes and so on) against the limit, so the next report shows what is holding them. Covered by backend tests.
 - **Running out of file descriptors could corrupt a SQLite database (#2883, reported and contributed by @M2ABRAMSTANK in #2884)** — Docker, the systemd service and most native installs start Bambuddy with a limit of 1024 open files. Once a busy instance used them all, every new database connection failed with "disk I/O error"; on the reporter's single-printer install that went on for 44 hours and ended with "database disk image is malformed" and a login outage. Part of the reason is how SQLite works in WAL mode: a database connection that closes keeps its file open until the last connection closes, so the database's share of open files stays at the most connections the pool ever reached. Bambuddy now raises its own open-file limit to the maximum the system allows when it starts, on every install, and logs what it set. The SQLite connection pool is smaller (10 + 90 instead of 20 + 200), which halves its share. A large printer farm still on SQLite that runs out of connections can raise `DB_MAX_OVERFLOW`, but is better served by PostgreSQL. The support bundle now counts open files by kind (database, sockets, pipes and so on) against the limit, so the next report shows what is holding them. Covered by backend tests.
 - **One model queued to several printers could print in the wrong filament (#2799, reported and contributed by @grolmus in #2803)** — An AMS mapping is a list of one printer's own tray numbers, and Bambuddy stamped the mapping it worked out for the first selected printer onto every other printer in the same dispatch. Where those printers hold the same spools in a different slot order the tray numbers still resolve, so nothing looks wrong, and each copy prints from whatever sits in that tray — in the reported case ASA where the 3MF requires PETG. An explicit mapping also bypasses the printer's own filament-type check, so nothing further down catches it. Every selected printer is now mapped against its own AMS, and a mapping already stored on a queue item is checked against the printer about to run it and recomputed when it does not fit, which covers a spool moved between queueing and dispatch as well. A tray of the right type in the wrong colour is still accepted, as it always was when nothing better is loaded. A slot you put on a tray of another material yourself in the print dialog is kept as you chose it.
 - **One model queued to several printers could print in the wrong filament (#2799, reported and contributed by @grolmus in #2803)** — An AMS mapping is a list of one printer's own tray numbers, and Bambuddy stamped the mapping it worked out for the first selected printer onto every other printer in the same dispatch. Where those printers hold the same spools in a different slot order the tray numbers still resolve, so nothing looks wrong, and each copy prints from whatever sits in that tray — in the reported case ASA where the 3MF requires PETG. An explicit mapping also bypasses the printer's own filament-type check, so nothing further down catches it. Every selected printer is now mapped against its own AMS, and a mapping already stored on a queue item is checked against the printer about to run it and recomputed when it does not fit, which covers a spool moved between queueing and dispatch as well. A tray of the right type in the wrong colour is still accepted, as it always was when nothing better is loaded. A slot you put on a tray of another material yourself in the print dialog is kept as you chose it.

+ 17 - 10
backend/app/api/routes/archives.py

@@ -1362,28 +1362,35 @@ async def get_archive_stats(
 
 
     # Energy totals - check which mode to use
     # Energy totals - check which mode to use
     from backend.app.api.routes.settings import get_setting
     from backend.app.api.routes.settings import get_setting
+    from backend.app.services.energy_price import (
+        all_time_cost,
+        cost_at_average_price,
+        snapshot_cost,
+        stored_price,
+    )
 
 
     energy_tracking_mode = await get_setting(db, "energy_tracking_mode") or "total"
     energy_tracking_mode = await get_setting(db, "energy_tracking_mode") or "total"
-    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
+    # The last price known. Not read from Home Assistant here: the snapshot
+    # loop refreshes it hourly, and it only prices the energy since then.
+    energy_cost_per_kwh = await stored_price(db)
 
 
     total_energy_kwh: float = 0.0
     total_energy_kwh: float = 0.0
     total_energy_cost: float = 0.0
     total_energy_cost: float = 0.0
     energy_data_warming_up = False
     energy_data_warming_up = False
 
 
     if energy_tracking_mode == "total" and not date_from and not date_to:
     if energy_tracking_mode == "total" and not date_from and not date_to:
-        # All-time total consumption — read live lifetime counters.
+        # All-time total consumption — read live lifetime counters, costed at
+        # the price of each hour the snapshots cover (#1251).
         total_energy_kwh = await _sum_live_plug_totals(db)
         total_energy_kwh = await _sum_live_plug_totals(db)
-        total_energy_cost = total_energy_kwh * energy_cost_per_kwh
+        total_energy_cost = await all_time_cost(db, total_energy_kwh, energy_cost_per_kwh)
     elif energy_tracking_mode == "total":
     elif energy_tracking_mode == "total":
         # Total consumption mode with a date filter (#941): use hourly snapshots
         # Total consumption mode with a date filter (#941): use hourly snapshots
         # to compute per-plug (endpoint - baseline) deltas.
         # 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
+        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_kwh, energy_data_warming_up = await _sum_snapshot_deltas(db, dt_from=dt_from, dt_to=dt_to)
+        costed = await snapshot_cost(db, fallback_price=energy_cost_per_kwh, dt_from=dt_from, dt_to=dt_to)
+        total_energy_cost = cost_at_average_price(total_energy_kwh, costed.kwh, costed.cost, energy_cost_per_kwh)
     else:
     else:
         # Per-print mode: sum the per-run energy column from PrintLogEntry.
         # Per-print mode: sum the per-run energy column from PrintLogEntry.
         energy_kwh_result = await db.execute(select(func.sum(PrintLogEntry.energy_kwh)).where(*base_conditions))
         energy_kwh_result = await db.execute(select(func.sum(PrintLogEntry.energy_kwh)).where(*base_conditions))

+ 23 - 1
backend/app/api/routes/settings.py

@@ -234,6 +234,10 @@ _FLOAT_SETTING_KEYS = frozenset(
     }
     }
 )
 )
 
 
+# String settings limited to a fixed set of values, which a stored "None"
+# would break when the settings response is built.
+_ENUM_SETTING_KEYS = frozenset({"energy_price_source"})
+
 _INT_SETTING_KEYS = frozenset(
 _INT_SETTING_KEYS = frozenset(
     {
     {
         "ams_humidity_good",
         "ams_humidity_good",
@@ -339,7 +343,7 @@ async def update_settings(
     null_keys = sorted(
     null_keys = sorted(
         key
         key
         for key, value in update_data.items()
         for key, value in update_data.items()
-        if value is None and key in (_BOOL_SETTING_KEYS | _FLOAT_SETTING_KEYS | _INT_SETTING_KEYS)
+        if value is None and key in (_BOOL_SETTING_KEYS | _FLOAT_SETTING_KEYS | _INT_SETTING_KEYS | _ENUM_SETTING_KEYS)
     )
     )
     if null_keys:
     if null_keys:
         raise HTTPException(status_code=422, detail=f"These settings cannot be null: {', '.join(null_keys)}")
         raise HTTPException(status_code=422, detail=f"These settings cannot be null: {', '.join(null_keys)}")
@@ -384,6 +388,13 @@ async def update_settings(
     }
     }
     mqtt_updated = bool(mqtt_keys & set(update_data.keys()))
     mqtt_updated = bool(mqtt_keys & set(update_data.keys()))
 
 
+    # The page saves every field it shows on each change, so compare against
+    # what is stored: only a real change of price source is worth a read.
+    price_source_changed = False
+    for key in ("energy_price_source", "energy_price_ha_entity"):
+        if key in update_data and str(update_data[key]) != (await get_setting(db, key) or ""):
+            price_source_changed = True
+
     for key, value in update_data.items():
     for key, value in update_data.items():
         # Convert value to string for storage
         # Convert value to string for storage
         if isinstance(value, bool):
         if isinstance(value, bool):
@@ -401,6 +412,17 @@ async def update_settings(
     if {"camera_light_mode", "camera_light_delay"} & set(update_data.keys()):
     if {"camera_light_mode", "camera_light_delay"} & set(update_data.keys()):
         camera_light.invalidate_settings()
         camera_light.invalidate_settings()
 
 
+    if price_source_changed:
+        # Read a newly chosen price sensor now, so the saved settings show its
+        # price instead of the old one until the next hourly read (#1251).
+        from backend.app.services.energy_price import current_price
+
+        try:
+            await current_price(db, remember=True)
+            await db.commit()
+        except Exception as e:
+            logger.warning("Could not read the electricity price after saving its source: %s", e)
+
     # Reconfigure MQTT relay if any MQTT settings changed
     # Reconfigure MQTT relay if any MQTT settings changed
     if mqtt_updated:
     if mqtt_updated:
         try:
         try:

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

@@ -5269,6 +5269,82 @@ async def run_migrations(conn):
             {"t": "camera_stream"},
             {"t": "camera_stream"},
         )
         )
 
 
+    # Migration: electricity price on each energy snapshot, and where and when a
+    # print's starting counter was read (#1251), so energy is costed at the
+    # price of the hour it was used rather than at today's price.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE smart_plug_energy_snapshots ADD COLUMN price_per_kwh REAL")
+        await _safe_execute(conn, "ALTER TABLE smart_plug_energy_snapshots ADD COLUMN kwh_to_date REAL")
+        await _safe_execute(conn, "ALTER TABLE smart_plug_energy_snapshots ADD COLUMN cost_to_date REAL")
+        await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN energy_start_price REAL")
+        await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN energy_start_at DATETIME")
+    else:
+        await _safe_execute(
+            conn, "ALTER TABLE smart_plug_energy_snapshots ADD COLUMN IF NOT EXISTS price_per_kwh DOUBLE PRECISION"
+        )
+        await _safe_execute(
+            conn, "ALTER TABLE smart_plug_energy_snapshots ADD COLUMN IF NOT EXISTS kwh_to_date DOUBLE PRECISION"
+        )
+        await _safe_execute(
+            conn, "ALTER TABLE smart_plug_energy_snapshots ADD COLUMN IF NOT EXISTS cost_to_date DOUBLE PRECISION"
+        )
+        await _safe_execute(
+            conn, "ALTER TABLE print_archives ADD COLUMN IF NOT EXISTS energy_start_price DOUBLE PRECISION"
+        )
+        await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN IF NOT EXISTS energy_start_at TIMESTAMP")
+    await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN energy_start_plug_id INTEGER")
+    await _backfill_snapshot_prices(conn)
+
+
+async def _backfill_snapshot_prices(conn) -> None:
+    """Give the energy snapshots taken before #1251 the price set at upgrade.
+
+    Until now every kWh in the Statistics was costed at the price set right
+    now, so changing it re-costed all of history. Writing today's price onto
+    the existing rows keeps those figures where they stand, and from here on a
+    new price applies only to the energy used after it was set.
+
+    The running totals start from the raw counter: at one price throughout,
+    the energy to date is the counter and its cost the counter times the price.
+    A reset in that old history then shows as a drop, which the Statistics
+    treat as nothing, exactly as they already did for the energy figure. One
+    UPDATE on both databases, however long the history is.
+
+    Gated to run once: snapshots taken later always carry a price, and a NULL
+    one after this has run would mean something else, not "before #1251".
+    """
+    from sqlalchemy import text
+
+    flag = "_backfill_1251_snapshot_prices_done"
+
+    async with conn.begin_nested():
+        already = (
+            await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
+        ).scalar_one_or_none()
+        if already:
+            return
+
+        raw = (
+            await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": "energy_cost_per_kwh"})
+        ).scalar_one_or_none()
+        try:
+            price = float(raw) if raw else 0.15
+        except (TypeError, ValueError):
+            price = 0.15
+
+        await conn.execute(
+            text(
+                "UPDATE smart_plug_energy_snapshots "
+                "SET price_per_kwh = :p, kwh_to_date = lifetime_kwh, cost_to_date = lifetime_kwh * :p "
+                "WHERE price_per_kwh IS NULL"
+            ),
+            {"p": price},
+        )
+        await conn.execute(
+            text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
+            {"k": flag, "v": "true"},
+        )
+
 
 
 async def _migrate_confirm_prompt_body_template(conn) -> None:
 async def _migrate_confirm_prompt_body_template(conn) -> None:
     """Replace the one-tap verdict URLs in the outcome prompt's body (#1898).
     """Replace the one-tap verdict URLs in the outcome prompt's body (#1898).

+ 34 - 4
backend/app/main.py

@@ -995,6 +995,18 @@ async def _record_energy_start(archive, printer_id: int, db, *, context: str = "
             return False
             return False
         plug, energy = selected
         plug, energy = selected
         archive.energy_start_kwh = float(energy["total"])
         archive.energy_start_kwh = float(energy["total"])
+        # With the plug's snapshots, what lets the cost follow the price over
+        # the print rather than take the price at its end (#1251). A price that
+        # can't be had must not cost the print its energy reading.
+        archive.energy_start_at = utcnow_naive()
+        archive.energy_start_plug_id = plug.id
+        try:
+            from backend.app.services.energy_price import current_price
+
+            archive.energy_start_price = await current_price(db)
+        except Exception as e:
+            _logger.warning("[ENERGY] Could not read the electricity price for archive %s: %s", archive.id, e)
+            archive.energy_start_price = None
         await db.commit()
         await db.commit()
         _logger.info(
         _logger.info(
             "[ENERGY] Recorded starting energy%s for archive %s from plug '%s': %s kWh",
             "[ENERGY] Recorded starting energy%s for archive %s from plug '%s': %s kWh",
@@ -7983,11 +7995,29 @@ async def on_print_complete(printer_id: int, data: dict):
                     )
                     )
                     return
                     return
 
 
-                from backend.app.api.routes.settings import get_setting
+                from backend.app.services.energy_price import current_price, print_energy_cost, stored_price
 
 
-                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_value = round(energy_used * cost_per_kwh, 3)
+                # Each hour of the print at the price of that hour (#1251). If
+                # that fails, the last price known, as before, rather than no
+                # energy at all. Read first: the session may be unusable after.
+                last_price = await stored_price(db)
+                try:
+                    energy_cost_value = round(
+                        await print_energy_cost(
+                            db,
+                            plug_id=archive.energy_start_plug_id,
+                            start_at=archive.energy_start_at,
+                            start_kwh=starting_kwh,
+                            start_price=archive.energy_start_price,
+                            end_kwh=energy["total"],
+                            end_plug_id=plug.id,
+                            price_now=await current_price(db),
+                        ),
+                        3,
+                    )
+                except Exception as e:
+                    logger.warning("[ENERGY-BG] Hour-by-hour cost failed for archive %s: %s", archive_id, e)
+                    energy_cost_value = round(energy_used * last_price, 3)
 
 
                 # First-run-only overwrite of archive.energy_kwh / energy_cost so a
                 # First-run-only overwrite of archive.energy_kwh / energy_cost so a
                 # reprint doesn't visually clobber the source archive's energy data
                 # reprint doesn't visually clobber the source archive's energy data

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

@@ -147,6 +147,12 @@ class PrintArchive(Base):
     # Plug lifetime counter captured at print start; delta at print end becomes energy_kwh.
     # 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).
     # Persisted so per-print tracking survives backend restarts mid-print (#941).
     energy_start_kwh: Mapped[float | None] = mapped_column(Float)
     energy_start_kwh: Mapped[float | None] = mapped_column(Float)
+    # When and from which plug that counter was read, and the electricity price
+    # then. With the plug's hourly snapshots in between, these let the print's
+    # cost follow a price that changes while it runs (#1251).
+    energy_start_at: Mapped[datetime | None] = mapped_column(DateTime)
+    energy_start_plug_id: Mapped[int | None] = mapped_column(Integer)
+    energy_start_price: Mapped[float | None] = mapped_column(Float)
 
 
     # Timestamps
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

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

@@ -20,3 +20,12 @@ class SmartPlugEnergySnapshot(Base):
     plug_id: Mapped[int] = mapped_column(ForeignKey("smart_plugs.id", ondelete="CASCADE"), nullable=False)
     plug_id: Mapped[int] = mapped_column(ForeignKey("smart_plugs.id", ondelete="CASCADE"), nullable=False)
     recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
     recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
     lifetime_kwh: Mapped[float] = mapped_column(Float, nullable=False)
     lifetime_kwh: Mapped[float] = mapped_column(Float, nullable=False)
+    # The electricity price when the snapshot was taken (#1251). The energy used
+    # until the next snapshot is costed at it, so a price that changes during the
+    # day is applied hour by hour instead of to all of history at once.
+    price_per_kwh: Mapped[float | None] = mapped_column(Float, nullable=True)
+    # Running totals of the energy counted and what it cost, up to this row
+    # (#1251). The cost of any span is the difference of two rows, so the
+    # Statistics never have to walk the whole history.
+    kwh_to_date: Mapped[float | None] = mapped_column(Float, nullable=True)
+    cost_to_date: Mapped[float | None] = mapped_column(Float, nullable=True)

+ 15 - 0
backend/app/schemas/settings.py

@@ -65,6 +65,19 @@ class AppSettings(BaseModel):
         default="total",
         default="total",
         description="Energy display mode on stats: 'print' shows sum of per-print energy, 'total' shows lifetime plug consumption",
         description="Energy display mode on stats: 'print' shows sum of per-print energy, 'total' shows lifetime plug consumption",
     )
     )
+    # A plain string on the way out, like energy_tracking_mode: a stray value
+    # (a restored backup) reads as "fixed" everywhere instead of failing the
+    # whole settings response. Writes are checked in AppSettingsUpdate.
+    energy_price_source: str = Field(
+        default="fixed",
+        description=(
+            "Where the electricity price comes from: 'fixed' uses energy_cost_per_kwh, "
+            "'homeassistant' reads energy_price_ha_entity hourly and at print start and end (#1251)"
+        ),
+    )
+    energy_price_ha_entity: str = Field(
+        default="", description="Home Assistant sensor holding the electricity price per kWh"
+    )
 
 
     # Spoolman integration
     # Spoolman integration
     spoolman_enabled: bool = Field(default=False, description="Enable Spoolman integration for filament tracking")
     spoolman_enabled: bool = Field(default=False, description="Enable Spoolman integration for filament tracking")
@@ -721,6 +734,8 @@ class AppSettingsUpdate(BaseModel):
     currency: str | None = None
     currency: str | None = None
     energy_cost_per_kwh: float | None = None
     energy_cost_per_kwh: float | None = None
     energy_tracking_mode: str | None = None
     energy_tracking_mode: str | None = None
+    energy_price_source: Literal["fixed", "homeassistant"] | None = None
+    energy_price_ha_entity: str | None = Field(default=None, max_length=255)
     spoolman_enabled: bool | None = None
     spoolman_enabled: bool | None = None
     spoolman_url: str | None = None
     spoolman_url: str | None = None
     spoolman_sync_mode: str | None = None
     spoolman_sync_mode: str | None = None

+ 12 - 12
backend/app/schemas/smart_plug.py

@@ -27,12 +27,12 @@ class SmartPlugBase(BaseModel):
     # Power monitoring
     # Power monitoring
     mqtt_power_topic: str | None = Field(default=None, max_length=200)  # Topic for power data
     mqtt_power_topic: str | None = Field(default=None, max_length=200)  # Topic for power data
     mqtt_power_path: str | None = Field(default=None, max_length=100)  # e.g., "power_l1" or "data.power"
     mqtt_power_path: str | None = Field(default=None, max_length=100)  # e.g., "power_l1" or "data.power"
-    mqtt_power_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)  # Unit conversion for power
+    mqtt_power_multiplier: float = Field(default=1.0, gt=0, le=10000)  # Unit conversion for power
 
 
     # Energy monitoring
     # Energy monitoring
     mqtt_energy_topic: str | None = Field(default=None, max_length=200)  # Topic for energy data
     mqtt_energy_topic: str | None = Field(default=None, max_length=200)  # Topic for energy data
     mqtt_energy_path: str | None = Field(default=None, max_length=100)  # e.g., "energy_l1"
     mqtt_energy_path: str | None = Field(default=None, max_length=100)  # e.g., "energy_l1"
-    mqtt_energy_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)  # Unit conversion for energy
+    mqtt_energy_multiplier: float = Field(default=1.0, gt=0, le=10000)  # Unit conversion for energy
 
 
     # State monitoring
     # State monitoring
     mqtt_state_topic: str | None = Field(default=None, max_length=200)  # Topic for state data
     mqtt_state_topic: str | None = Field(default=None, max_length=200)  # Topic for state data
@@ -42,7 +42,7 @@ class SmartPlugBase(BaseModel):
     )  # What value means "ON" (e.g., "ON", "true", "1")
     )  # What value means "ON" (e.g., "ON", "true", "1")
 
 
     # Legacy multiplier - kept for backward compatibility
     # Legacy multiplier - kept for backward compatibility
-    mqtt_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)  # Deprecated, use mqtt_power_multiplier
+    mqtt_multiplier: float = Field(default=1.0, gt=0, le=10000)  # Deprecated, use mqtt_power_multiplier
 
 
     # REST/Webhook fields (required when plug_type="rest")
     # REST/Webhook fields (required when plug_type="rest")
     rest_on_url: str | None = Field(default=None, max_length=500)
     rest_on_url: str | None = Field(default=None, max_length=500)
@@ -56,14 +56,14 @@ class SmartPlugBase(BaseModel):
     rest_status_on_value: str | None = Field(default=None, max_length=50)
     rest_status_on_value: str | None = Field(default=None, max_length=50)
     rest_power_url: str | None = Field(default=None, max_length=500)
     rest_power_url: str | None = Field(default=None, max_length=500)
     rest_power_path: str | None = Field(default=None, max_length=200)
     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_power_multiplier: float = Field(default=1.0, gt=0, le=10000)
     rest_energy_url: str | None = Field(default=None, max_length=500)
     rest_energy_url: str | None = Field(default=None, max_length=500)
     # Today's usage, resetting at midnight.
     # Today's usage, resetting at midnight.
     rest_energy_path: str | None = Field(default=None, max_length=200)
     rest_energy_path: str | None = Field(default=None, max_length=200)
-    rest_energy_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
+    rest_energy_multiplier: float = Field(default=1.0, gt=0, le=10000)
     # Lifetime counter that never resets (#2539) — a Shelly's `aenergy.total`.
     # 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_path: str | None = Field(default=None, max_length=200)
-    rest_energy_total_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)
+    rest_energy_total_multiplier: float = Field(default=1.0, gt=0, le=10000)
 
 
     printer_id: int | None = None
     printer_id: int | None = None
     # #2629: only a plug that really feeds the printer may mark it offline when
     # #2629: only a plug that really feeds the printer may mark it offline when
@@ -132,15 +132,15 @@ class SmartPlugUpdate(BaseModel):
     ha_energy_total_entity: str | None = None
     ha_energy_total_entity: str | None = None
     # MQTT fields (legacy)
     # MQTT fields (legacy)
     mqtt_topic: str | None = None
     mqtt_topic: str | None = None
-    mqtt_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
+    mqtt_multiplier: float | None = Field(default=None, gt=0, le=10000)
     # MQTT power fields
     # MQTT power fields
     mqtt_power_topic: str | None = None
     mqtt_power_topic: str | None = None
     mqtt_power_path: str | None = None
     mqtt_power_path: str | None = None
-    mqtt_power_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
+    mqtt_power_multiplier: float | None = Field(default=None, gt=0, le=10000)
     # MQTT energy fields
     # MQTT energy fields
     mqtt_energy_topic: str | None = None
     mqtt_energy_topic: str | None = None
     mqtt_energy_path: str | None = None
     mqtt_energy_path: str | None = None
-    mqtt_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
+    mqtt_energy_multiplier: float | None = Field(default=None, gt=0, le=10000)
     # MQTT state fields
     # MQTT state fields
     mqtt_state_topic: str | None = None
     mqtt_state_topic: str | None = None
     mqtt_state_path: str | None = None
     mqtt_state_path: str | None = None
@@ -157,12 +157,12 @@ class SmartPlugUpdate(BaseModel):
     rest_status_on_value: str | None = None
     rest_status_on_value: str | None = None
     rest_power_url: str | None = None
     rest_power_url: str | None = None
     rest_power_path: str | None = None
     rest_power_path: str | None = None
-    rest_power_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
+    rest_power_multiplier: float | None = Field(default=None, gt=0, le=10000)
     rest_energy_url: str | None = None
     rest_energy_url: str | None = None
     rest_energy_path: str | None = None
     rest_energy_path: str | None = None
-    rest_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
+    rest_energy_multiplier: float | None = Field(default=None, gt=0, le=10000)
     rest_energy_total_path: str | None = None
     rest_energy_total_path: str | None = None
-    rest_energy_total_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
+    rest_energy_total_multiplier: float | None = Field(default=None, gt=0, le=10000)
     printer_id: int | None = None
     printer_id: int | None = None
     # #2629: see SmartPlugBase.controls_printer_power.
     # #2629: see SmartPlugBase.controls_printer_power.
     controls_printer_power: bool | None = None
     controls_printer_power: bool | None = None

+ 302 - 0
backend/app/services/energy_price.py

@@ -0,0 +1,302 @@
+"""The electricity price energy is costed at, and the cost of energy over time (#1251).
+
+The price is either the fixed ``energy_cost_per_kwh`` setting or, with
+``energy_price_source = "homeassistant"``, a Home Assistant sensor. A sensor
+reading is written back into ``energy_cost_per_kwh``, so that setting always
+holds the last price known: the Settings page shows it, and it is what is used
+whenever Home Assistant can't be reached.
+
+Energy is costed at the price of the hour it was used. Every hourly energy
+snapshot carries the price at that moment, and the energy a plug used between
+two snapshots is costed at the price of the first. Each snapshot also keeps a
+running total of the energy and cost to date, so the cost of any span is the
+difference of two rows, like the energy itself. A print or a date range
+then costs the sum of its hours, instead of all of it at whatever the price
+happens to be when it ends, or today.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from datetime import datetime
+
+import httpx
+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 to_naive_utc
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_PRICE = 0.15
+_HA_TIMEOUT = 5.0
+PRICE_SOURCE_FIXED = "fixed"
+PRICE_SOURCE_HOMEASSISTANT = "homeassistant"
+
+# A sensor's unit, after the currency, scaled to per kWh. Day-ahead market
+# sensors (Nord Pool, ENTSO-E) are often per MWh.
+_ENERGY_UNIT_SCALE = {"kwh": 1.0, "mwh": 0.001, "wh": 1000.0}
+
+
+async def stored_price(db: AsyncSession) -> float:
+    """The ``energy_cost_per_kwh`` setting: the fixed price, or the last one read."""
+    from backend.app.api.routes.settings import get_setting
+
+    raw = await get_setting(db, "energy_cost_per_kwh")
+    try:
+        return float(raw) if raw else DEFAULT_PRICE
+    except (TypeError, ValueError):
+        return DEFAULT_PRICE
+
+
+def price_from_state(state: dict | None) -> float | None:
+    """A per-kWh price from a Home Assistant state, or None when it has none.
+
+    Negative prices are paid out in some markets; they are costed as free,
+    as a cost below zero is not something the rest of Bambuddy expects.
+    """
+    if not state:
+        return None
+    try:
+        value = float(state.get("state"))
+    except (TypeError, ValueError):
+        return None
+    unit = str((state.get("attributes") or {}).get("unit_of_measurement") or "")
+    per = unit.rsplit("/", 1)[-1].strip().lower() if "/" in unit else ""
+    value *= _ENERGY_UNIT_SCALE.get(per, 1.0)
+    if value != value or value in (float("inf"), float("-inf")):
+        return None
+    return max(0.0, value)
+
+
+async def current_price(db: AsyncSession, *, remember: bool = False) -> float:
+    """The electricity price now.
+
+    With Home Assistant as the source, reads the sensor; falls back to the last
+    price remembered when it can't be read. ``remember`` writes a new reading
+    into ``energy_cost_per_kwh`` (the caller commits). Only the hourly loop and
+    the settings save do, so the print paths never write a setting in the same
+    transaction as the energy reading they exist to save.
+    """
+    from backend.app.api.routes.settings import get_homeassistant_settings, get_setting, set_setting
+
+    fallback = await stored_price(db)
+    if (await get_setting(db, "energy_price_source") or PRICE_SOURCE_FIXED) != PRICE_SOURCE_HOMEASSISTANT:
+        return fallback
+    entity_id = (await get_setting(db, "energy_price_ha_entity") or "").strip()
+    if not entity_id:
+        return fallback
+
+    ha = await get_homeassistant_settings(db)
+    if not ha["ha_enabled"] or not ha["ha_url"] or not ha["ha_token"]:
+        return fallback
+
+    price = price_from_state(await _read_state(ha["ha_url"], ha["ha_token"], entity_id))
+    if price is None:
+        logger.info("Electricity price sensor %s has no usable value; keeping %s", entity_id, fallback)
+        return fallback
+    if remember and price != fallback:
+        await set_setting(db, "energy_cost_per_kwh", str(price))
+    return price
+
+
+async def _read_state(url: str, token: str, entity_id: str) -> dict | None:
+    """One entity's state, or None. Its own short timeout: this runs at print
+    start and end, which an unreachable Home Assistant must not hold up."""
+    try:
+        async with httpx.AsyncClient(timeout=_HA_TIMEOUT) as client:
+            response = await client.get(
+                f"{url.rstrip('/')}/api/states/{entity_id}",
+                headers={"Authorization": f"Bearer {token}"},
+            )
+            response.raise_for_status()
+            return response.json()
+    except Exception as e:
+        logger.debug("Could not read electricity price from %s: %s", entity_id, e)
+        return None
+
+
+def cost_of_readings(readings: list[tuple[float, float | None]], fallback_price: float) -> tuple[float, float]:
+    """``(kwh, cost)`` over consecutive ``(lifetime_kwh, price)`` readings.
+
+    Each step is costed at the price of the reading it starts from. A step
+    where the counter went backwards (a device reset) counts as nothing.
+    """
+    kwh = 0.0
+    cost = 0.0
+    for (start, price), (end, _) in zip(readings, readings[1:], strict=False):
+        delta = end - start
+        if delta <= 0:
+            continue
+        kwh += delta
+        cost += delta * (price if price is not None else fallback_price)
+    return kwh, cost
+
+
+def cost_at_average_price(kwh: float, costed_kwh: float, costed: float, fallback_price: float) -> float:
+    """Cost ``kwh`` at the average price of the energy that could be costed.
+
+    The energy figure shown and the energy summed hour by hour are the same
+    unless a counter was reset along the way; this keeps cost and energy
+    consistent either way.
+    """
+    if costed_kwh > 0:
+        return kwh * (costed / costed_kwh)
+    return kwh * fallback_price
+
+
+async def print_energy_cost(
+    db: AsyncSession,
+    *,
+    plug_id: int | None,
+    start_at: datetime | None,
+    start_kwh: float,
+    start_price: float | None,
+    end_kwh: float,
+    end_plug_id: int,
+    price_now: float,
+) -> float:
+    """What the energy a print used cost, hour by hour.
+
+    Falls back to the price now for a print whose start predates #1251, or
+    whose end reading came from a different plug than its start.
+    """
+    energy_used = end_kwh - start_kwh
+    if start_at is None or plug_id is None or plug_id != end_plug_id:
+        return energy_used * price_now
+
+    result = await db.execute(
+        select(SmartPlugEnergySnapshot.lifetime_kwh, SmartPlugEnergySnapshot.price_per_kwh)
+        .where(
+            SmartPlugEnergySnapshot.plug_id == plug_id,
+            SmartPlugEnergySnapshot.recorded_at > to_naive_utc(start_at),
+        )
+        .order_by(SmartPlugEnergySnapshot.recorded_at, SmartPlugEnergySnapshot.id)
+    )
+    readings: list[tuple[float, float | None]] = [(start_kwh, start_price)]
+    readings.extend((row[0], row[1]) for row in result.all())
+    readings.append((end_kwh, None))
+    costed_kwh, costed = cost_of_readings(readings, price_now)
+    return cost_at_average_price(energy_used, costed_kwh, costed, price_now)
+
+
+@dataclass
+class SnapshotCost:
+    """Energy and cost between two points of the snapshot history, summed over plugs.
+
+    ``kwh``/``cost`` come from the running totals and are what is costed;
+    ``last_lifetime_kwh`` is the sum of each plug's latest lifetime counter,
+    which only the all-time figure needs.
+    """
+
+    kwh: float = 0.0
+    cost: float = 0.0
+    last_lifetime_kwh: float = 0.0
+
+
+def _running_totals(row: SmartPlugEnergySnapshot, fallback_price: float) -> tuple[float, float]:
+    """A snapshot's energy and cost to date. A row without them (none should be
+    left after the upgrade backfill) counts its whole counter at its price."""
+    kwh = row.kwh_to_date if row.kwh_to_date is not None else row.lifetime_kwh
+    if row.cost_to_date is not None:
+        return kwh, row.cost_to_date
+    price = row.price_per_kwh if row.price_per_kwh is not None else fallback_price
+    return kwh, row.lifetime_kwh * price
+
+
+async def new_snapshot(
+    db: AsyncSession, *, plug_id: int, recorded_at: datetime, lifetime_kwh: float, price: float
+) -> SmartPlugEnergySnapshot:
+    """The next snapshot row for a plug, its running totals carried forward.
+
+    The energy since the previous snapshot is added at the previous snapshot's
+    price, so each hour is costed at the price that held during it. A counter
+    that went backwards (a device reset) adds nothing. The first snapshot of a
+    plug counts what its counter had already reached at the price then.
+    """
+    s = SmartPlugEnergySnapshot
+    prev = (
+        await db.execute(select(s).where(s.plug_id == plug_id).order_by(s.recorded_at.desc(), s.id.desc()).limit(1))
+    ).scalar_one_or_none()
+    if prev is None:
+        kwh_to_date, cost_to_date = lifetime_kwh, lifetime_kwh * price
+    else:
+        prev_kwh, prev_cost = _running_totals(prev, price)
+        prev_price = prev.price_per_kwh if prev.price_per_kwh is not None else price
+        step = max(0.0, lifetime_kwh - prev.lifetime_kwh)
+        kwh_to_date, cost_to_date = prev_kwh + step, prev_cost + step * prev_price
+    return s(
+        plug_id=plug_id,
+        recorded_at=recorded_at,
+        lifetime_kwh=lifetime_kwh,
+        price_per_kwh=price,
+        kwh_to_date=kwh_to_date,
+        cost_to_date=cost_to_date,
+    )
+
+
+async def _snapshot_at_or_before(
+    db: AsyncSession, plug_id: int, moment: datetime | None
+) -> SmartPlugEnergySnapshot | None:
+    s = SmartPlugEnergySnapshot
+    query = select(s).where(s.plug_id == plug_id)
+    if moment is not None:
+        query = query.where(s.recorded_at <= moment)
+    return (await db.execute(query.order_by(s.recorded_at.desc(), s.id.desc()).limit(1))).scalar_one_or_none()
+
+
+async def snapshot_cost(
+    db: AsyncSession,
+    *,
+    fallback_price: float,
+    dt_from: datetime | None = None,
+    dt_to: datetime | None = None,
+) -> SnapshotCost:
+    """Energy and cost over the same span ``_sum_snapshot_deltas`` measures.
+
+    Per plug: from the last snapshot at or before ``dt_from`` (or its first
+    snapshot) to the last at or before ``dt_to``, as the difference of their
+    running totals. Without ``dt_from``, from zero: the whole history. Two
+    indexed lookups per plug, however long the history is.
+    """
+    from backend.app.models.smart_plug import SmartPlug
+
+    s = SmartPlugEnergySnapshot
+    dt_from = to_naive_utc(dt_from)
+    dt_to = to_naive_utc(dt_to)
+    result = SnapshotCost()
+    for plug_id in (await db.execute(select(SmartPlug.id))).scalars().all():
+        end = await _snapshot_at_or_before(db, plug_id, dt_to)
+        if end is None:
+            continue
+        end_kwh, end_cost = _running_totals(end, fallback_price)
+        result.last_lifetime_kwh += end.lifetime_kwh
+        base_kwh = base_cost = 0.0
+        if dt_from is not None:
+            base = await _snapshot_at_or_before(db, plug_id, dt_from)
+            if base is None:
+                base = (
+                    await db.execute(select(s).where(s.plug_id == plug_id).order_by(s.recorded_at, s.id).limit(1))
+                ).scalar_one_or_none()
+            if base is not None:
+                base_kwh, base_cost = _running_totals(base, fallback_price)
+        kwh, cost = end_kwh - base_kwh, end_cost - base_cost
+        # Negative only across a reset in rows from before the upgrade, whose
+        # totals are the raw counter; the energy figure clamps those to 0 too.
+        if kwh > 0 and cost >= 0:
+            result.kwh += kwh
+            result.cost += cost
+    return result
+
+
+async def all_time_cost(db: AsyncSession, live_total_kwh: float, price_now: float) -> float:
+    """What the plugs' lifetime energy cost, hour by hour where it can be.
+
+    Energy since each plug's latest snapshot, and every plug with no snapshots
+    (an MQTT plug), is costed at the price now.
+    """
+    hist = await snapshot_cost(db, fallback_price=price_now)
+    tail = max(0.0, live_total_kwh - hist.last_lifetime_kwh)
+    return cost_at_average_price(live_total_kwh, hist.kwh + tail, hist.cost + tail * price_now, price_now)

+ 8 - 7
backend/app/services/smart_plug_manager.py

@@ -129,7 +129,6 @@ class SmartPlugManager:
         """Capture one energy snapshot row per plug with a usable lifetime counter."""
         """Capture one energy snapshot row per plug with a usable lifetime counter."""
         from backend.app.core.database import async_session
         from backend.app.core.database import async_session
         from backend.app.models.smart_plug import SmartPlug
         from backend.app.models.smart_plug import SmartPlug
-        from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
 
 
         async with async_session() as db:
         async with async_session() as db:
             plugs_result = await db.execute(select(SmartPlug).where(SmartPlug.enabled.is_(True)))
             plugs_result = await db.execute(select(SmartPlug).where(SmartPlug.enabled.is_(True)))
@@ -141,6 +140,11 @@ class SmartPlugManager:
             # outright (SQLite quietly drops the offset, which is why this went
             # outright (SQLite quietly drops the offset, which is why this went
             # unnoticed — on Postgres the whole capture raised).
             # unnoticed — on Postgres the whole capture raised).
             now = utcnow_naive()
             now = utcnow_naive()
+            # One price for every row of this tick: read from Home Assistant
+            # when that's the source, so it is refreshed hourly (#1251).
+            from backend.app.services.energy_price import current_price, new_snapshot
+
+            price = await current_price(db, remember=True)
             captured = 0
             captured = 0
             for plug in plugs:
             for plug in plugs:
                 # MQTT plugs only publish a "today" counter that resets at midnight —
                 # MQTT plugs only publish a "today" counter that resets at midnight —
@@ -163,16 +167,13 @@ class SmartPlugManager:
                     # comes straight from the device anyway.
                     # comes straight from the device anyway.
                     continue
                     continue
                 db.add(
                 db.add(
-                    SmartPlugEnergySnapshot(
-                        plug_id=plug.id,
-                        recorded_at=now,
-                        lifetime_kwh=float(lifetime),
-                    )
+                    await new_snapshot(db, plug_id=plug.id, recorded_at=now, lifetime_kwh=float(lifetime), price=price)
                 )
                 )
                 captured += 1
                 captured += 1
 
 
+            # Commits the price read above too, even with nothing captured.
+            await db.commit()
             if captured:
             if captured:
-                await db.commit()
                 logger.info("Captured %d energy snapshot(s)", captured)
                 logger.info("Captured %d energy snapshot(s)", captured)
 
 
     async def _check_schedules(self):
     async def _check_schedules(self):

+ 645 - 0
backend/tests/unit/test_energy_price_1251.py

@@ -0,0 +1,645 @@
+"""Tests for #1251 — electricity price from Home Assistant, costed hour by hour.
+
+Covers:
+- reading a price sensor: units, unusable states, negative prices, fallback,
+  and remembering the reading in ``energy_cost_per_kwh``
+- the cost arithmetic: each step at the price it starts from, resets skipped
+- Statistics: date-range and all-time cost follow the snapshot prices instead
+  of multiplying everything by today's price
+- per-print cost across a price change, and the fallbacks for prints that
+  started before the upgrade or ended on another plug
+- the start of a print recording when, from which plug and at what price
+- the one-shot backfill of prices onto existing snapshots
+"""
+
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from sqlalchemy import select, text
+
+from backend.app.api.routes.archives import _sum_snapshot_deltas
+from backend.app.api.routes.settings import get_setting, set_setting
+from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
+from backend.app.services import energy_price
+from backend.app.services.energy_price import (
+    all_time_cost,
+    cost_at_average_price,
+    cost_of_readings,
+    current_price,
+    new_snapshot,
+    price_from_state,
+    print_energy_cost,
+    snapshot_cost,
+)
+
+T0 = datetime(2026, 9, 1, 0, 0)  # naive UTC, as the column stores it
+
+
+def _snap(plug_id: int, hours: float, kwh: float, price: float | None) -> SmartPlugEnergySnapshot:
+    return SmartPlugEnergySnapshot(
+        plug_id=plug_id, recorded_at=T0 + timedelta(hours=hours), lifetime_kwh=kwh, price_per_kwh=price
+    )
+
+
+def _state(value, unit: str | None = "AUD/kWh") -> dict:
+    return {"state": value, "attributes": {"unit_of_measurement": unit} if unit else {}}
+
+
+class TestPriceFromState:
+    def test_per_kwh_is_taken_as_is(self):
+        assert price_from_state(_state("0.31")) == pytest.approx(0.31)
+
+    def test_per_mwh_is_scaled(self):
+        # Nord Pool / ENTSO-E day-ahead sensors are often per MWh.
+        assert price_from_state(_state("120.5", "EUR/MWh")) == pytest.approx(0.1205)
+
+    def test_per_wh_is_scaled(self):
+        assert price_from_state(_state("0.0003", "€/Wh")) == pytest.approx(0.3)
+
+    def test_no_unit_is_taken_as_per_kwh(self):
+        assert price_from_state(_state("0.2", None)) == pytest.approx(0.2)
+
+    @pytest.mark.parametrize("value", ["unknown", "unavailable", "", None, "nan", "inf"])
+    def test_unusable_states_give_none(self, value):
+        assert price_from_state(_state(value)) is None
+
+    def test_no_state_gives_none(self):
+        assert price_from_state(None) is None
+
+    def test_negative_price_is_costed_as_free(self):
+        assert price_from_state(_state("-0.05")) == 0.0
+
+
+class TestCostArithmetic:
+    def test_each_step_at_the_price_it_starts_from(self):
+        # 2 kWh at 0.10, then 3 kWh at 0.50; the end reading's price is unused.
+        kwh, cost = cost_of_readings([(10.0, 0.10), (12.0, 0.50), (15.0, 9.99)], fallback_price=1.0)
+        assert kwh == pytest.approx(5.0)
+        assert cost == pytest.approx(0.2 + 1.5)
+
+    def test_missing_price_uses_fallback(self):
+        kwh, cost = cost_of_readings([(0.0, None), (2.0, None)], fallback_price=0.25)
+        assert (kwh, cost) == (pytest.approx(2.0), pytest.approx(0.5))
+
+    def test_counter_reset_step_counts_nothing(self):
+        kwh, cost = cost_of_readings([(100.0, 0.1), (2.0, 0.1), (5.0, 0.1)], fallback_price=1.0)
+        assert kwh == pytest.approx(3.0)
+        assert cost == pytest.approx(0.3)
+
+    def test_average_price_scales_to_the_energy_shown(self):
+        assert cost_at_average_price(10.0, 5.0, 2.0, fallback_price=9.0) == pytest.approx(4.0)
+
+    def test_average_price_without_costed_energy_uses_fallback(self):
+        assert cost_at_average_price(10.0, 0.0, 0.0, fallback_price=0.3) == pytest.approx(3.0)
+
+
+class TestCurrentPrice:
+    async def _configure_ha(self, db, entity="sensor.amber_price"):
+        await set_setting(db, "ha_enabled", "true")
+        await set_setting(db, "ha_url", "http://ha.local:8123")
+        await set_setting(db, "ha_token", "token")  # pragma: allowlist secret
+        await set_setting(db, "energy_price_source", "homeassistant")
+        await set_setting(db, "energy_price_ha_entity", entity)
+        await set_setting(db, "energy_cost_per_kwh", "0.2")
+        await db.commit()
+
+    @pytest.mark.asyncio
+    async def test_fixed_source_uses_the_setting(self, db_session):
+        await set_setting(db_session, "energy_cost_per_kwh", "0.27")
+        await db_session.commit()
+        with patch.object(energy_price, "_read_state", AsyncMock()) as read:
+            assert await current_price(db_session) == pytest.approx(0.27)
+        read.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_default_when_nothing_is_set(self, db_session):
+        assert await current_price(db_session) == pytest.approx(0.15)
+
+    @pytest.mark.asyncio
+    async def test_reads_the_sensor_and_remembers_it(self, db_session, monkeypatch):
+        monkeypatch.delenv("HA_URL", raising=False)
+        monkeypatch.delenv("HA_TOKEN", raising=False)
+        await self._configure_ha(db_session)
+        with patch.object(energy_price, "_read_state", AsyncMock(return_value=_state("0.42"))) as read:
+            assert await current_price(db_session, remember=True) == pytest.approx(0.42)
+        read.assert_awaited_once_with("http://ha.local:8123", "token", "sensor.amber_price")
+        assert await get_setting(db_session, "energy_cost_per_kwh") == "0.42"
+
+    @pytest.mark.asyncio
+    async def test_print_paths_read_without_writing_a_setting(self, db_session, monkeypatch):
+        # A setting written in the print-start transaction could cost the
+        # print its energy reading if that write failed.
+        monkeypatch.delenv("HA_URL", raising=False)
+        monkeypatch.delenv("HA_TOKEN", raising=False)
+        await self._configure_ha(db_session)
+        with patch.object(energy_price, "_read_state", AsyncMock(return_value=_state("0.42"))):
+            assert await current_price(db_session) == pytest.approx(0.42)
+        assert await get_setting(db_session, "energy_cost_per_kwh") == "0.2"
+
+    @pytest.mark.asyncio
+    async def test_unreadable_sensor_keeps_the_last_price(self, db_session, monkeypatch):
+        monkeypatch.delenv("HA_URL", raising=False)
+        monkeypatch.delenv("HA_TOKEN", raising=False)
+        await self._configure_ha(db_session)
+        with patch.object(energy_price, "_read_state", AsyncMock(return_value=None)):
+            assert await current_price(db_session) == pytest.approx(0.2)
+        assert await get_setting(db_session, "energy_cost_per_kwh") == "0.2"
+
+    @pytest.mark.asyncio
+    async def test_no_entity_does_not_read(self, db_session, monkeypatch):
+        monkeypatch.delenv("HA_URL", raising=False)
+        monkeypatch.delenv("HA_TOKEN", raising=False)
+        await self._configure_ha(db_session, entity="")
+        with patch.object(energy_price, "_read_state", AsyncMock()) as read:
+            assert await current_price(db_session) == pytest.approx(0.2)
+        read.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_home_assistant_switched_off_does_not_read(self, db_session, monkeypatch):
+        monkeypatch.delenv("HA_URL", raising=False)
+        monkeypatch.delenv("HA_TOKEN", raising=False)
+        await self._configure_ha(db_session)
+        await set_setting(db_session, "ha_enabled", "false")
+        await db_session.commit()
+        with patch.object(energy_price, "_read_state", AsyncMock()) as read:
+            assert await current_price(db_session) == pytest.approx(0.2)
+        read.assert_not_called()
+
+
+async def _record(db, plug_id: int, hours: float, kwh: float, price: float) -> SmartPlugEnergySnapshot:
+    """Add a snapshot the way the hourly loop does, running totals and all."""
+    row = await new_snapshot(
+        db, plug_id=plug_id, recorded_at=T0 + timedelta(hours=hours), lifetime_kwh=kwh, price=price
+    )
+    db.add(row)
+    await db.flush()
+    return row
+
+
+class TestNewSnapshot:
+    @pytest.mark.asyncio
+    async def test_first_snapshot_counts_the_counter_at_its_price(self, db_session, smart_plug_factory):
+        plug = await smart_plug_factory(name="A")
+        row = await _record(db_session, plug.id, 0, 100.0, 0.10)
+        assert (row.kwh_to_date, row.cost_to_date) == (pytest.approx(100.0), pytest.approx(10.0))
+
+    @pytest.mark.asyncio
+    async def test_each_step_at_the_previous_snapshots_price(self, db_session, smart_plug_factory):
+        plug = await smart_plug_factory(name="A")
+        await _record(db_session, plug.id, 0, 100.0, 0.10)
+        await _record(db_session, plug.id, 1, 102.0, 0.50)
+        row = await _record(db_session, plug.id, 2, 105.0, 0.20)
+        assert row.kwh_to_date == pytest.approx(105.0)
+        assert row.cost_to_date == pytest.approx(10.0 + 2 * 0.10 + 3 * 0.50)
+        assert row.price_per_kwh == pytest.approx(0.20)
+
+    @pytest.mark.asyncio
+    async def test_counter_reset_adds_nothing_and_the_total_carries_on(self, db_session, smart_plug_factory):
+        plug = await smart_plug_factory(name="A")
+        await _record(db_session, plug.id, 0, 100.0, 0.10)
+        await _record(db_session, plug.id, 1, 2.0, 0.10)  # device reset
+        row = await _record(db_session, plug.id, 2, 5.0, 0.10)
+        assert row.kwh_to_date == pytest.approx(103.0)
+        assert row.cost_to_date == pytest.approx(10.3)
+
+    @pytest.mark.asyncio
+    async def test_continues_from_a_backfilled_row(self, db_session, smart_plug_factory):
+        plug = await smart_plug_factory(name="A")
+        # What the upgrade backfill leaves: totals are the counter at one price.
+        db_session.add(
+            SmartPlugEnergySnapshot(
+                plug_id=plug.id,
+                recorded_at=T0,
+                lifetime_kwh=50.0,
+                price_per_kwh=0.2,
+                kwh_to_date=50.0,
+                cost_to_date=10.0,
+            )
+        )
+        await db_session.flush()
+        row = await _record(db_session, plug.id, 1, 53.0, 0.9)
+        assert row.kwh_to_date == pytest.approx(53.0)
+        assert row.cost_to_date == pytest.approx(10.0 + 3 * 0.2)
+
+    @pytest.mark.asyncio
+    async def test_plugs_keep_their_own_totals(self, db_session, smart_plug_factory):
+        a = await smart_plug_factory(name="A")
+        b = await smart_plug_factory(name="B")
+        await _record(db_session, a.id, 0, 10.0, 0.10)
+        await _record(db_session, b.id, 0, 500.0, 1.00)
+        row = await _record(db_session, a.id, 1, 11.0, 0.10)
+        assert row.kwh_to_date == pytest.approx(11.0)
+        assert row.cost_to_date == pytest.approx(1.1)
+
+
+class TestSnapshotCost:
+    @pytest.mark.asyncio
+    async def test_empty(self, db_session):
+        result = await snapshot_cost(db_session, fallback_price=0.3)
+        assert result.kwh == 0.0
+        assert result.cost == 0.0
+
+    @pytest.mark.asyncio
+    async def test_whole_history(self, db_session, smart_plug_factory):
+        plug = await smart_plug_factory(name="A")
+        await _record(db_session, plug.id, 0, 100.0, 0.10)
+        await _record(db_session, plug.id, 1, 102.0, 0.50)
+        await _record(db_session, plug.id, 2, 105.0, 0.20)
+        await db_session.commit()
+
+        result = await snapshot_cost(db_session, fallback_price=9.0)
+        assert result.kwh == pytest.approx(105.0)
+        assert result.cost == pytest.approx(100 * 0.10 + 2 * 0.10 + 3 * 0.50)
+        assert result.last_lifetime_kwh == pytest.approx(105.0)
+
+    @pytest.mark.asyncio
+    async def test_range_covers_the_span_the_energy_figure_counts(self, db_session, smart_plug_factory):
+        plug = await smart_plug_factory(name="A")
+        await _record(db_session, plug.id, 0, 100.0, 0.10)  # baseline: last at or before dt_from
+        await _record(db_session, plug.id, 2, 104.0, 0.50)
+        await _record(db_session, plug.id, 4, 110.0, 0.20)  # endpoint: last at or before dt_to
+        await _record(db_session, plug.id, 6, 150.0, 0.90)
+        await db_session.commit()
+
+        dt_from = (T0 + timedelta(hours=1)).replace(tzinfo=timezone.utc)
+        dt_to = (T0 + timedelta(hours=5)).replace(tzinfo=timezone.utc)
+        result = await snapshot_cost(db_session, fallback_price=9.0, dt_from=dt_from, dt_to=dt_to)
+        energy, _ = await _sum_snapshot_deltas(db_session, dt_from=dt_from, dt_to=dt_to)
+        assert result.kwh == pytest.approx(energy) == pytest.approx(10.0)
+        assert result.cost == pytest.approx(4 * 0.10 + 6 * 0.50)
+
+    @pytest.mark.asyncio
+    async def test_range_without_a_baseline_starts_at_the_first_snapshot(self, db_session, smart_plug_factory):
+        plug = await smart_plug_factory(name="A")
+        await _record(db_session, plug.id, 2, 100.0, 0.10)
+        await _record(db_session, plug.id, 3, 104.0, 0.50)
+        await db_session.commit()
+
+        result = await snapshot_cost(
+            db_session, fallback_price=9.0, dt_from=T0.replace(tzinfo=timezone.utc), dt_to=None
+        )
+        assert result.kwh == pytest.approx(4.0)
+        assert result.cost == pytest.approx(0.4)
+
+    @pytest.mark.asyncio
+    async def test_plugs_are_summed(self, db_session, smart_plug_factory):
+        a = await smart_plug_factory(name="A")
+        b = await smart_plug_factory(name="B")
+        for plug_id, start, price in ((a.id, 10.0, 0.10), (b.id, 500.0, 1.00)):
+            await _record(db_session, plug_id, 0, start, price)
+            await _record(db_session, plug_id, 1, start + 1 + (plug_id == b.id), price)
+        await db_session.commit()
+
+        result = await snapshot_cost(db_session, fallback_price=9.0, dt_from=T0.replace(tzinfo=timezone.utc))
+        assert result.kwh == pytest.approx(3.0)
+        assert result.cost == pytest.approx(1 * 0.10 + 2 * 1.00)
+
+    @pytest.mark.asyncio
+    async def test_reset_in_pre_upgrade_history_counts_nothing(self, db_session, smart_plug_factory):
+        """Backfilled totals are the raw counter, so a reset shows as a drop."""
+        plug = await smart_plug_factory(name="A")
+        for hours, kwh in ((0, 1000.0), (2, 5.0)):
+            db_session.add(
+                SmartPlugEnergySnapshot(
+                    plug_id=plug.id,
+                    recorded_at=T0 + timedelta(hours=hours),
+                    lifetime_kwh=kwh,
+                    price_per_kwh=0.2,
+                    kwh_to_date=kwh,
+                    cost_to_date=kwh * 0.2,
+                )
+            )
+        await db_session.commit()
+
+        result = await snapshot_cost(db_session, fallback_price=9.0, dt_from=(T0 + timedelta(hours=1)))
+        assert (result.kwh, result.cost) == (0.0, 0.0)
+
+    @pytest.mark.asyncio
+    async def test_row_without_totals_falls_back_to_its_counter(self, db_session, smart_plug_factory):
+        plug = await smart_plug_factory(name="A")
+        db_session.add(SmartPlugEnergySnapshot(plug_id=plug.id, recorded_at=T0, lifetime_kwh=4.0))
+        await db_session.commit()
+
+        result = await snapshot_cost(db_session, fallback_price=0.25)
+        assert (result.kwh, result.cost) == (pytest.approx(4.0), pytest.approx(1.0))
+
+
+class TestAllTimeCost:
+    @pytest.mark.asyncio
+    async def test_history_keeps_its_price_when_the_price_changes(self, db_session, smart_plug_factory):
+        """The reported problem: today's price must not re-cost past energy."""
+        plug = await smart_plug_factory(name="A")
+        await _record(db_session, plug.id, 0, 100.0, 0.10)
+        await _record(db_session, plug.id, 1, 110.0, 0.10)
+        await db_session.commit()
+
+        # Live counter 112: 110 recorded at 0.10, 2 since the last snapshot at
+        # today's 0.50.
+        cost = await all_time_cost(db_session, live_total_kwh=112.0, price_now=0.50)
+        assert cost == pytest.approx(110 * 0.10 + 2 * 0.50)
+
+    @pytest.mark.asyncio
+    async def test_no_snapshots_uses_the_price_now(self, db_session):
+        assert await all_time_cost(db_session, live_total_kwh=20.0, price_now=0.3) == pytest.approx(6.0)
+
+    @pytest.mark.asyncio
+    async def test_live_counter_below_the_history_is_costed_at_its_average_price(self, db_session, smart_plug_factory):
+        # The counter was reset since the last snapshot: the energy shown is
+        # the live counter, at the average price of the history.
+        plug = await smart_plug_factory(name="A")
+        await _record(db_session, plug.id, 0, 100.0, 0.10)
+        await _record(db_session, plug.id, 1, 200.0, 0.30)
+        await db_session.commit()
+
+        cost = await all_time_cost(db_session, live_total_kwh=50.0, price_now=9.0)
+        assert cost == pytest.approx(50 * (10.0 + 10.0) / 200.0)
+
+
+class TestPrintEnergyCost:
+    @pytest.mark.asyncio
+    async def test_price_change_during_the_print(self, db_session, smart_plug_factory):
+        plug = await smart_plug_factory(name="A")
+        # Started at 50 kWh / 0.10 half an hour before the 1 h snapshot.
+        db_session.add_all([_snap(plug.id, 1, 51.0, 0.40), _snap(plug.id, 2, 53.0, 0.05)])
+        # Snapshots from before the print must not count.
+        db_session.add(_snap(plug.id, 0, 49.0, 9.0))
+        await db_session.commit()
+
+        cost = await print_energy_cost(
+            db_session,
+            plug_id=plug.id,
+            start_at=T0 + timedelta(minutes=30),
+            start_kwh=50.0,
+            start_price=0.10,
+            end_kwh=54.0,
+            end_plug_id=plug.id,
+            price_now=0.05,
+        )
+        # 1 kWh at 0.10, 2 at 0.40, 1 at 0.05 — not 4 kWh at the end price 0.05.
+        assert cost == pytest.approx(0.10 + 0.80 + 0.05)
+
+    @pytest.mark.asyncio
+    async def test_short_print_without_snapshots_uses_the_start_price(self, db_session, smart_plug_factory):
+        plug = await smart_plug_factory(name="A")
+        cost = await print_energy_cost(
+            db_session,
+            plug_id=plug.id,
+            start_at=T0,
+            start_kwh=10.0,
+            start_price=0.30,
+            end_kwh=10.5,
+            end_plug_id=plug.id,
+            price_now=0.90,
+        )
+        assert cost == pytest.approx(0.15)
+
+    @pytest.mark.asyncio
+    async def test_print_started_before_the_upgrade_uses_the_price_now(self, db_session):
+        cost = await print_energy_cost(
+            db_session,
+            plug_id=None,
+            start_at=None,
+            start_kwh=10.0,
+            start_price=None,
+            end_kwh=12.0,
+            end_plug_id=1,
+            price_now=0.25,
+        )
+        assert cost == pytest.approx(0.5)
+
+    @pytest.mark.asyncio
+    async def test_end_reading_from_another_plug_uses_the_price_now(self, db_session):
+        cost = await print_energy_cost(
+            db_session,
+            plug_id=1,
+            start_at=T0,
+            start_kwh=10.0,
+            start_price=0.10,
+            end_kwh=12.0,
+            end_plug_id=2,
+            price_now=0.25,
+        )
+        assert cost == pytest.approx(0.5)
+
+
+class TestStatsEndpoint:
+    @pytest.mark.asyncio
+    async def test_date_range_cost_uses_snapshot_prices(self, async_client, db_session, smart_plug_factory):
+        plug = await smart_plug_factory(name="A")
+        # T0 is 2026-09-01 00:00 UTC.
+        await _record(db_session, plug.id, 23, 100.0, 0.10)  # 2026-09-01 23:00, baseline
+        await _record(db_session, plug.id, 30, 104.0, 0.50)
+        await _record(db_session, plug.id, 36, 106.0, 0.50)
+        await set_setting(db_session, "energy_tracking_mode", "total")
+        await set_setting(db_session, "energy_cost_per_kwh", "3.0")  # today's price, must not apply
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/archives/stats?date_from=2026-09-02&date_to=2026-09-02")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["total_energy_kwh"] == pytest.approx(6.0)
+        assert data["total_energy_cost"] == pytest.approx(4 * 0.10 + 2 * 0.50)
+
+
+class TestRecordEnergyStart:
+    @pytest.mark.asyncio
+    async def test_records_when_where_and_at_what_price(
+        self, db_session, printer_factory, smart_plug_factory, archive_factory
+    ):
+        printer = await printer_factory()
+        plug = await smart_plug_factory(name="Power", printer_id=printer.id)
+        archive = await archive_factory(printer.id)
+        await set_setting(db_session, "energy_cost_per_kwh", "0.33")
+        await db_session.commit()
+
+        from backend.app.main import _record_energy_start
+
+        with patch("backend.app.main._get_plug_energy", AsyncMock(return_value={"total": 7.5})):
+            assert await _record_energy_start(archive, printer.id, db_session) is True
+
+        assert archive.energy_start_kwh == 7.5
+        assert archive.energy_start_plug_id == plug.id
+        assert archive.energy_start_price == pytest.approx(0.33)
+        assert archive.energy_start_at is not None
+        assert archive.energy_start_at.tzinfo is None
+
+
+class TestSnapshotPriceBackfill:
+    @pytest.mark.asyncio
+    async def test_fills_existing_rows_once(self, test_engine, db_session, smart_plug_factory):
+        from backend.app.core.database import _backfill_snapshot_prices
+
+        plug_id = (await smart_plug_factory(name="A")).id
+        db_session.add_all([_snap(plug_id, 0, 1.0, None), _snap(plug_id, 1, 2.0, None)])
+        await set_setting(db_session, "energy_cost_per_kwh", "0.28")
+        await db_session.commit()
+
+        async with test_engine.begin() as conn:
+            await _backfill_snapshot_prices(conn)
+
+        db_session.expire_all()
+        rows = (
+            await db_session.execute(
+                select(
+                    SmartPlugEnergySnapshot.price_per_kwh,
+                    SmartPlugEnergySnapshot.kwh_to_date,
+                    SmartPlugEnergySnapshot.cost_to_date,
+                ).order_by(SmartPlugEnergySnapshot.recorded_at)
+            )
+        ).all()
+        assert [tuple(r) for r in rows] == [
+            (pytest.approx(0.28), pytest.approx(1.0), pytest.approx(0.28)),
+            (pytest.approx(0.28), pytest.approx(2.0), pytest.approx(0.56)),
+        ]
+
+        # A second run (next boot) leaves a NULL alone: the flag is set.
+        db_session.add(_snap(plug_id, 2, 3.0, None))
+        await db_session.commit()
+        async with test_engine.begin() as conn:
+            await _backfill_snapshot_prices(conn)
+            nulls = (
+                await conn.execute(text("SELECT COUNT(*) FROM smart_plug_energy_snapshots WHERE price_per_kwh IS NULL"))
+            ).scalar_one()
+        assert nulls == 1
+
+
+class TestSettingsRoute:
+    @pytest.mark.asyncio
+    async def test_saves_and_returns_the_price_source(self, async_client):
+        response = await async_client.put(
+            "/api/v1/settings/",
+            json={"energy_price_source": "homeassistant", "energy_price_ha_entity": "sensor.amber_price"},
+        )
+        assert response.status_code == 200
+        assert response.json()["energy_price_source"] == "homeassistant"
+        assert response.json()["energy_price_ha_entity"] == "sensor.amber_price"
+
+    @pytest.mark.asyncio
+    async def test_defaults_to_fixed(self, async_client):
+        response = await async_client.get("/api/v1/settings/")
+        assert response.json()["energy_price_source"] == "fixed"
+        assert response.json()["energy_price_ha_entity"] == ""
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("value", ["tibber", None])
+    async def test_refuses_an_unknown_or_null_source(self, async_client, value):
+        response = await async_client.put("/api/v1/settings/", json={"energy_price_source": value})
+        assert response.status_code == 422
+        # A stored "None" would have broken every later settings read.
+        assert (await async_client.get("/api/v1/settings/")).status_code == 200
+
+    @pytest.mark.asyncio
+    async def test_reads_the_price_only_when_its_source_changes(self, async_client):
+        body = {"energy_price_source": "homeassistant", "energy_price_ha_entity": "sensor.p", "currency": "AUD"}
+        with patch("backend.app.services.energy_price.current_price", AsyncMock(return_value=0.3)) as read:
+            await async_client.put("/api/v1/settings/", json=body)
+            assert read.await_count == 1
+            # The page saves every field on each change; same source, no read.
+            await async_client.put("/api/v1/settings/", json={**body, "currency": "EUR"})
+            assert read.await_count == 1
+            await async_client.put("/api/v1/settings/", json={**body, "energy_price_ha_entity": "sensor.q"})
+            assert read.await_count == 2
+
+
+class TestSnapshotCapture:
+    @pytest.mark.asyncio
+    async def test_every_row_of_a_tick_carries_the_price(self):
+        from types import SimpleNamespace
+        from unittest.mock import MagicMock
+
+        from backend.app.services import smart_plug_manager as manager_module
+
+        added: list[SmartPlugEnergySnapshot] = []
+
+        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),
+                    SimpleNamespace(id=2, plug_type="tasmota", enabled=True),
+                ]
+                # No earlier snapshot for either plug.
+                result.scalar_one_or_none.return_value = None
+                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("backend.app.services.energy_price.current_price", AsyncMock(return_value=0.37)) as read,
+            patch.object(
+                manager,
+                "get_service_for_plug",
+                new=AsyncMock(return_value=SimpleNamespace(get_energy=AsyncMock(return_value={"total": 5.0}))),
+            ),
+        ):
+            await manager._capture_energy_snapshots()
+
+        read.assert_awaited_once()
+        assert read.await_args.kwargs == {"remember": True}
+        assert [row.price_per_kwh for row in added] == [0.37, 0.37]
+        assert [row.cost_to_date for row in added] == [pytest.approx(5.0 * 0.37)] * 2
+
+
+class TestEnergyMultiplierRange:
+    """myStrom reports watt-seconds: 1 Ws = 1/3,600,000 kWh (#1251 thread)."""
+
+    WS_TO_KWH = 1 / 3_600_000
+
+    @pytest.mark.parametrize(
+        "field",
+        [
+            "mqtt_power_multiplier",
+            "mqtt_energy_multiplier",
+            "rest_power_multiplier",
+            "rest_energy_multiplier",
+            "rest_energy_total_multiplier",
+        ],
+    )
+    def test_watt_seconds_conversion_is_accepted(self, field):
+        from backend.app.schemas.smart_plug import SmartPlugCreate, SmartPlugUpdate
+
+        plug = SmartPlugCreate(
+            name="myStrom", plug_type="rest", rest_on_url="http://plug/relay?state=1", **{field: self.WS_TO_KWH}
+        )
+        assert getattr(plug, field) == pytest.approx(self.WS_TO_KWH)
+        assert getattr(SmartPlugUpdate(**{field: self.WS_TO_KWH}), field) == pytest.approx(self.WS_TO_KWH)
+
+    def test_zero_is_still_refused(self):
+        from pydantic import ValidationError
+
+        from backend.app.schemas.smart_plug import SmartPlugCreate
+
+        with pytest.raises(ValidationError):
+            SmartPlugCreate(
+                name="myStrom",
+                plug_type="rest",
+                rest_on_url="http://plug/relay?state=1",
+                rest_energy_total_multiplier=0,
+            )
+
+
+class TestStrayStoredSource:
+    @pytest.mark.asyncio
+    async def test_settings_still_load_and_the_price_is_fixed(self, async_client, db_session):
+        # Settings restored from a backup are written as stored, unchecked.
+        await set_setting(db_session, "energy_price_source", "tibber")
+        await set_setting(db_session, "energy_cost_per_kwh", "0.2")
+        await db_session.commit()
+
+        assert (await async_client.get("/api/v1/settings/")).status_code == 200
+        with patch.object(energy_price, "_read_state", AsyncMock()) as read:
+            assert await current_price(db_session) == pytest.approx(0.2)
+        read.assert_not_called()

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

@@ -76,6 +76,9 @@ async def test_energy_snapshot_is_stamped_with_a_naive_datetime():
         async def execute(self, *_a, **_kw):
         async def execute(self, *_a, **_kw):
             result = MagicMock()
             result = MagicMock()
             result.scalars.return_value.all.return_value = [SimpleNamespace(id=1, plug_type="rest", enabled=True)]
             result.scalars.return_value.all.return_value = [SimpleNamespace(id=1, plug_type="rest", enabled=True)]
+            # The plug's previous snapshot, which a new one carries its running
+            # totals on from (#1251): none yet.
+            result.scalar_one_or_none.return_value = None
             return result
             return result
 
 
         def add(self, obj):
         def add(self, obj):

+ 74 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -176,6 +176,80 @@ describe('SettingsPage', () => {
     });
     });
   });
   });
 
 
+  describe('electricity price source (#1251)', () => {
+    const haSettings = {
+      ...mockSettings,
+      ha_enabled: true,
+      energy_cost_per_kwh: 0.42,
+      energy_price_source: 'homeassistant',
+      energy_price_ha_entity: 'sensor.amber_general_price',
+    };
+
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/ha-sensors/entities', () =>
+          HttpResponse.json([
+            { entity_id: 'sensor.amber_general_price', friendly_name: 'Amber price', state: '0.42', domain: 'sensor', device_class: null, unit_of_measurement: 'AUD/kWh' },
+            { entity_id: 'sensor.room_temperature', friendly_name: 'Room', state: '21', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C' },
+          ])
+        )
+      );
+    });
+
+    it('offers a fixed price by default, with no sensor field', async () => {
+      render(<SettingsPage />);
+
+      const input = (await screen.findByText('Electricity cost per kWh')).parentElement!.querySelector('input')!;
+      expect(input).not.toBeDisabled();
+      expect(screen.queryByPlaceholderText('sensor.electricity_price')).not.toBeInTheDocument();
+    });
+
+    it('shows the sensor and the last price read, read-only, with Home Assistant as the source', async () => {
+      server.use(http.get('/api/v1/settings/', () => HttpResponse.json(haSettings)));
+      render(<SettingsPage />);
+
+      expect(await screen.findByPlaceholderText('sensor.electricity_price')).toHaveValue('sensor.amber_general_price');
+      const input = screen.getByText('Last price read per kWh').parentElement!.querySelector('input')!;
+      expect(input).toBeDisabled();
+      expect(input).toHaveValue(0.42);
+      // Only price sensors are suggested, not every sensor in Home Assistant.
+      await waitFor(() => {
+        const options = document.querySelectorAll('#ha-price-entities option');
+        expect(Array.from(options).map((o) => o.getAttribute('value'))).toEqual(['sensor.amber_general_price']);
+      });
+    });
+
+    it('warns when Home Assistant is not connected', async () => {
+      server.use(http.get('/api/v1/settings/', () => HttpResponse.json({ ...haSettings, ha_enabled: false })));
+      render(<SettingsPage />);
+
+      expect(await screen.findByText(/Home Assistant is not set up/)).toBeInTheDocument();
+    });
+
+    it('does not send its stale copy of a price read from Home Assistant', async () => {
+      let saved: Record<string, unknown> | null = null;
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json(haSettings)),
+        http.put('/api/v1/settings/', async ({ request }) => {
+          saved = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ ...haSettings, ...saved });
+        })
+      );
+      render(<SettingsPage />);
+
+      const label = await screen.findByText('Restore plate for finish photo');
+      await new Promise((resolve) => setTimeout(resolve, 200));
+      await userEvent.click(within(label.closest('div')!.parentElement!).getByRole('checkbox'));
+
+      await waitFor(() => {
+        expect(saved).not.toBeNull();
+      }, { timeout: 3000 });
+      expect(saved!).not.toHaveProperty('energy_cost_per_kwh');
+      expect(saved!.energy_price_source).toBe('homeassistant');
+      expect(saved!.energy_price_ha_entity).toBe('sensor.amber_general_price');
+    });
+  });
+
   describe('general settings', () => {
   describe('general settings', () => {
     it('shows date format setting', async () => {
     it('shows date format setting', async () => {
       render(<SettingsPage />);
       render(<SettingsPage />);

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

@@ -1399,6 +1399,10 @@ export interface AppSettings {
   currency: string;
   currency: string;
   energy_cost_per_kwh: number;
   energy_cost_per_kwh: number;
   energy_tracking_mode: 'print' | 'total';
   energy_tracking_mode: 'print' | 'total';
+  // Where the electricity price comes from (#1251). With 'homeassistant',
+  // energy_cost_per_kwh holds the sensor's last reading and is read-only.
+  energy_price_source: 'fixed' | 'homeassistant';
+  energy_price_ha_entity: string;
   check_updates: boolean;
   check_updates: boolean;
   check_printer_firmware: boolean;
   check_printer_firmware: boolean;
   include_beta_updates: boolean;
   include_beta_updates: boolean;

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

@@ -2850,6 +2850,14 @@ export default {
     currency: 'Währung',
     currency: 'Währung',
     defaultFilamentCost: 'Standard-Filamentkosten (pro kg)',
     defaultFilamentCost: 'Standard-Filamentkosten (pro kg)',
     electricityCost: 'Stromkosten pro kWh',
     electricityCost: 'Stromkosten pro kWh',
+    energyPriceSource: 'Quelle des Strompreises',
+    energyPriceSourceFixed: 'Fester Preis',
+    energyPriceSourceHomeAssistant: 'Home-Assistant-Sensor',
+    energyPriceHaEntity: 'Preissensor',
+    energyPriceLastRead: 'Zuletzt gelesener Preis pro kWh',
+    energyPriceFixedHint: 'Ein neuer Preis gilt für den Strom, der ab dann verbraucht wird; bereits verbrauchter Strom behält den Preis, zu dem er verbraucht wurde.',
+    energyPriceHaHint: 'Wird stündlich sowie zu Beginn und Ende jedes Drucks gelesen, und jede Stunde Verbrauch wird mit ihrem eigenen Preis berechnet. Der Sensor muss den Preis pro kWh liefern (pro MWh und pro Wh wird umgerechnet).',
+    energyPriceHaNotConfigured: 'Home Assistant ist nicht eingerichtet. Verbinde es unter Einstellungen → Netzwerk → Home Assistant.',
     energyDisplayMode: 'Energieanzeige-Modus',
     energyDisplayMode: 'Energieanzeige-Modus',
     energyModePrintDescription: 'Dashboard zeigt Summe der während Drucken verbrauchten Energie',
     energyModePrintDescription: 'Dashboard zeigt Summe der während Drucken verbrauchten Energie',
     energyModeTotalDescription: 'Dashboard zeigt Gesamtenergie der Smart Plugs',
     energyModeTotalDescription: 'Dashboard zeigt Gesamtenergie der Smart Plugs',

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

@@ -2872,6 +2872,14 @@ export default {
     currency: 'Currency',
     currency: 'Currency',
     defaultFilamentCost: 'Default filament cost (per kg)',
     defaultFilamentCost: 'Default filament cost (per kg)',
     electricityCost: 'Electricity cost per kWh',
     electricityCost: 'Electricity cost per kWh',
+    energyPriceSource: 'Electricity price source',
+    energyPriceSourceFixed: 'Fixed price',
+    energyPriceSourceHomeAssistant: 'Home Assistant sensor',
+    energyPriceHaEntity: 'Price sensor',
+    energyPriceLastRead: 'Last price read per kWh',
+    energyPriceFixedHint: 'A new price applies to the energy used from then on; energy already used keeps the price it was used at.',
+    energyPriceHaHint: 'Read every hour and at the start and end of each print, and each hour of energy is costed at its own price. The sensor must give the price per kWh (per MWh and per Wh are converted).',
+    energyPriceHaNotConfigured: 'Home Assistant is not set up. Connect it under Settings → Network → Home Assistant.',
     energyDisplayMode: 'Energy display mode',
     energyDisplayMode: 'Energy display mode',
     energyModePrintDescription: 'Dashboard shows sum of energy used during prints',
     energyModePrintDescription: 'Dashboard shows sum of energy used during prints',
     energyModeTotalDescription: 'Dashboard shows lifetime energy from smart plugs',
     energyModeTotalDescription: 'Dashboard shows lifetime energy from smart plugs',

+ 8 - 0
frontend/src/i18n/locales/es.ts

@@ -2852,6 +2852,14 @@ export default {
     currency: 'Moneda',
     currency: 'Moneda',
     defaultFilamentCost: 'Coste predeterminado del filamento (por kg)',
     defaultFilamentCost: 'Coste predeterminado del filamento (por kg)',
     electricityCost: 'Coste de la electricidad por kWh',
     electricityCost: 'Coste de la electricidad por kWh',
+    energyPriceSource: 'Origen del precio de la electricidad',
+    energyPriceSourceFixed: 'Precio fijo',
+    energyPriceSourceHomeAssistant: 'Sensor de Home Assistant',
+    energyPriceHaEntity: 'Sensor de precio',
+    energyPriceLastRead: 'Último precio leído por kWh',
+    energyPriceFixedHint: 'Un precio nuevo se aplica a la energía consumida a partir de entonces; la energía ya consumida conserva el precio al que se consumió.',
+    energyPriceHaHint: 'Se lee cada hora y al inicio y al final de cada impresión, y cada hora de energía se calcula a su propio precio. El sensor debe dar el precio por kWh (por MWh y por Wh se convierten).',
+    energyPriceHaNotConfigured: 'Home Assistant no está configurado. Conéctalo en Ajustes → Red → Home Assistant.',
     energyDisplayMode: 'Modo de visualización de la energía',
     energyDisplayMode: 'Modo de visualización de la energía',
     energyModePrintDescription: 'El panel muestra la suma de la energía usada durante las impresiones',
     energyModePrintDescription: 'El panel muestra la suma de la energía usada durante las impresiones',
     energyModeTotalDescription: 'El panel muestra la energía total de los enchufes inteligentes',
     energyModeTotalDescription: 'El panel muestra la energía total de los enchufes inteligentes',

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

@@ -2801,6 +2801,14 @@ export default {
     currency: 'Devise',
     currency: 'Devise',
     defaultFilamentCost: 'Coût filament par défaut (par kg)',
     defaultFilamentCost: 'Coût filament par défaut (par kg)',
     electricityCost: 'Coût électricité par kWh',
     electricityCost: 'Coût électricité par kWh',
+    energyPriceSource: "Source du prix de l'électricité",
+    energyPriceSourceFixed: 'Prix fixe',
+    energyPriceSourceHomeAssistant: 'Capteur Home Assistant',
+    energyPriceHaEntity: 'Capteur de prix',
+    energyPriceLastRead: 'Dernier prix lu par kWh',
+    energyPriceFixedHint: "Un nouveau prix s'applique à l'énergie consommée à partir de ce moment ; l'énergie déjà consommée garde le prix auquel elle a été consommée.",
+    energyPriceHaHint: "Lu toutes les heures ainsi qu'au début et à la fin de chaque impression, et chaque heure d'énergie est comptée à son propre prix. Le capteur doit donner le prix par kWh (par MWh et par Wh sont convertis).",
+    energyPriceHaNotConfigured: "Home Assistant n'est pas configuré. Connectez-le dans Paramètres → Réseau → Home Assistant.",
     energyDisplayMode: 'Mode d\'affichage énergie',
     energyDisplayMode: 'Mode d\'affichage énergie',
     energyModePrintDescription: 'Le tableau de bord affiche la somme de l\'énergie utilisée pendant les impressions',
     energyModePrintDescription: 'Le tableau de bord affiche la somme de l\'énergie utilisée pendant les impressions',
     energyModeTotalDescription: 'Le tableau de bord affiche l\'énergie totale des prises connectées',
     energyModeTotalDescription: 'Le tableau de bord affiche l\'énergie totale des prises connectées',

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

@@ -2800,6 +2800,14 @@ export default {
     currency: 'Valuta',
     currency: 'Valuta',
     defaultFilamentCost: 'Costo filamento predefinito (per kg)',
     defaultFilamentCost: 'Costo filamento predefinito (per kg)',
     electricityCost: 'Costo elettricità per kWh',
     electricityCost: 'Costo elettricità per kWh',
+    energyPriceSource: "Origine del prezzo dell'elettricità",
+    energyPriceSourceFixed: 'Prezzo fisso',
+    energyPriceSourceHomeAssistant: 'Sensore Home Assistant',
+    energyPriceHaEntity: 'Sensore del prezzo',
+    energyPriceLastRead: 'Ultimo prezzo letto per kWh',
+    energyPriceFixedHint: "Un nuovo prezzo si applica all'energia consumata da quel momento; l'energia già consumata mantiene il prezzo a cui è stata consumata.",
+    energyPriceHaHint: "Letto ogni ora e all'inizio e alla fine di ogni stampa, e ogni ora di energia è calcolata al proprio prezzo. Il sensore deve fornire il prezzo per kWh (per MWh e per Wh vengono convertiti).",
+    energyPriceHaNotConfigured: 'Home Assistant non è configurato. Collegalo in Impostazioni → Rete → Home Assistant.',
     energyDisplayMode: 'Modalità visualizzazione energia',
     energyDisplayMode: 'Modalità visualizzazione energia',
     energyModePrintDescription: 'La dashboard mostra la somma dell\'energia usata durante le stampe',
     energyModePrintDescription: 'La dashboard mostra la somma dell\'energia usata durante le stampe',
     energyModeTotalDescription: 'La dashboard mostra l\'energia totale dalle prese smart',
     energyModeTotalDescription: 'La dashboard mostra l\'energia totale dalle prese smart',

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

@@ -2849,6 +2849,14 @@ export default {
     currency: '通貨',
     currency: '通貨',
     defaultFilamentCost: 'デフォルトフィラメントコスト(kg単価)',
     defaultFilamentCost: 'デフォルトフィラメントコスト(kg単価)',
     electricityCost: '電気料金(kWh単価)',
     electricityCost: '電気料金(kWh単価)',
+    energyPriceSource: '電気料金の取得元',
+    energyPriceSourceFixed: '固定料金',
+    energyPriceSourceHomeAssistant: 'Home Assistant センサー',
+    energyPriceHaEntity: '料金センサー',
+    energyPriceLastRead: '最後に読み取った kWh 単価',
+    energyPriceFixedHint: '新しい料金はそれ以降に使用した電力に適用されます。使用済みの電力は使用時の料金のままです。',
+    energyPriceHaHint: '1 時間ごとと各印刷の開始時・終了時に読み取り、電力は使用した時間帯ごとの料金で計算されます。センサーは kWh 単価を返す必要があります(MWh 単価と Wh 単価は換算されます)。',
+    energyPriceHaNotConfigured: 'Home Assistant が設定されていません。設定 → ネットワーク → Home Assistant で接続してください。',
     energyDisplayMode: 'エネルギー表示モード',
     energyDisplayMode: 'エネルギー表示モード',
     energyModePrintDescription: 'ダッシュボードに印刷中の消費エネルギーの合計を表示',
     energyModePrintDescription: 'ダッシュボードに印刷中の消費エネルギーの合計を表示',
     energyModeTotalDescription: 'ダッシュボードにスマートプラグの累計エネルギーを表示',
     energyModeTotalDescription: 'ダッシュボードにスマートプラグの累計エネルギーを表示',

+ 8 - 0
frontend/src/i18n/locales/ko.ts

@@ -2711,6 +2711,14 @@ export default {
     currency: '통화',
     currency: '통화',
     defaultFilamentCost: '기본 필라멘트 비용 (kg당)',
     defaultFilamentCost: '기본 필라멘트 비용 (kg당)',
     electricityCost: 'kWh당 전기 비용',
     electricityCost: 'kWh당 전기 비용',
+    energyPriceSource: '전기 요금 출처',
+    energyPriceSourceFixed: '고정 요금',
+    energyPriceSourceHomeAssistant: 'Home Assistant 센서',
+    energyPriceHaEntity: '요금 센서',
+    energyPriceLastRead: '마지막으로 읽은 kWh당 요금',
+    energyPriceFixedHint: '새 요금은 그 이후 사용한 전력에 적용되며, 이미 사용한 전력은 사용 당시의 요금을 유지합니다.',
+    energyPriceHaHint: '매시간 그리고 각 출력의 시작과 끝에 읽으며, 전력은 사용한 시간대의 요금으로 계산됩니다. 센서는 kWh당 요금을 제공해야 합니다(MWh당, Wh당 요금은 변환됩니다).',
+    energyPriceHaNotConfigured: 'Home Assistant가 설정되지 않았습니다. 설정 → 네트워크 → Home Assistant에서 연결하세요.',
     energyDisplayMode: '에너지 표시 모드',
     energyDisplayMode: '에너지 표시 모드',
     energyModePrintDescription: '대시보드가 인쇄 중 사용된 에너지 합계를 표시',
     energyModePrintDescription: '대시보드가 인쇄 중 사용된 에너지 합계를 표시',
     energyModeTotalDescription: '대시보드가 스마트 플러그의 전체 에너지를 표시',
     energyModeTotalDescription: '대시보드가 스마트 플러그의 전체 에너지를 표시',

+ 8 - 0
frontend/src/i18n/locales/nl.ts

@@ -2870,6 +2870,14 @@ export default {
     currency: 'Valuta',
     currency: 'Valuta',
     defaultFilamentCost: 'Standaard filamentkosten (per kg)',
     defaultFilamentCost: 'Standaard filamentkosten (per kg)',
     electricityCost: 'Elektriciteitskosten per kWh',
     electricityCost: 'Elektriciteitskosten per kWh',
+    energyPriceSource: 'Bron van de elektriciteitsprijs',
+    energyPriceSourceFixed: 'Vaste prijs',
+    energyPriceSourceHomeAssistant: 'Home Assistant-sensor',
+    energyPriceHaEntity: 'Prijssensor',
+    energyPriceLastRead: 'Laatst gelezen prijs per kWh',
+    energyPriceFixedHint: 'Een nieuwe prijs geldt voor de energie die vanaf dan wordt verbruikt; al verbruikte energie houdt de prijs waartegen ze werd verbruikt.',
+    energyPriceHaHint: 'Wordt elk uur en bij het begin en einde van elke print gelezen, en elk uur energie wordt tegen zijn eigen prijs berekend. De sensor moet de prijs per kWh geven (per MWh en per Wh wordt omgerekend).',
+    energyPriceHaNotConfigured: 'Home Assistant is niet ingesteld. Verbind het onder Instellingen → Netwerk → Home Assistant.',
     energyDisplayMode: 'Weergavemodus energie',
     energyDisplayMode: 'Weergavemodus energie',
     energyModePrintDescription: 'Dashboard toont de som van energie die tijdens afdrukken is gebruikt',
     energyModePrintDescription: 'Dashboard toont de som van energie die tijdens afdrukken is gebruikt',
     energyModeTotalDescription: 'Dashboard toont het totale energieverbruik van slimme stekkers over de hele levensduur',
     energyModeTotalDescription: 'Dashboard toont het totale energieverbruik van slimme stekkers over de hele levensduur',

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

@@ -2800,6 +2800,14 @@ export default {
     currency: 'Moeda',
     currency: 'Moeda',
     defaultFilamentCost: 'Custo padrão do filamento (por kg)',
     defaultFilamentCost: 'Custo padrão do filamento (por kg)',
     electricityCost: 'Custo da eletricidade por kWh',
     electricityCost: 'Custo da eletricidade por kWh',
+    energyPriceSource: 'Origem do preço da eletricidade',
+    energyPriceSourceFixed: 'Preço fixo',
+    energyPriceSourceHomeAssistant: 'Sensor do Home Assistant',
+    energyPriceHaEntity: 'Sensor de preço',
+    energyPriceLastRead: 'Último preço lido por kWh',
+    energyPriceFixedHint: 'Um novo preço vale para a energia consumida a partir de então; a energia já consumida mantém o preço em que foi consumida.',
+    energyPriceHaHint: 'Lido a cada hora e no início e no fim de cada impressão, e cada hora de energia é calculada pelo seu próprio preço. O sensor deve informar o preço por kWh (por MWh e por Wh são convertidos).',
+    energyPriceHaNotConfigured: 'O Home Assistant não está configurado. Conecte-o em Configurações → Rede → Home Assistant.',
     energyDisplayMode: 'Modo de exibição de energia',
     energyDisplayMode: 'Modo de exibição de energia',
     energyModePrintDescription: 'O painel mostra a soma da energia usada durante as impressões',
     energyModePrintDescription: 'O painel mostra a soma da energia usada durante as impressões',
     energyModeTotalDescription: 'O painel mostra a energia total dos plugues inteligentes',
     energyModeTotalDescription: 'O painel mostra a energia total dos plugues inteligentes',

+ 8 - 0
frontend/src/i18n/locales/ru.ts

@@ -2715,6 +2715,14 @@ export default {
     currency: "Валюта",
     currency: "Валюта",
     defaultFilamentCost: "Стоимость филамента по умолчанию (за кг)",
     defaultFilamentCost: "Стоимость филамента по умолчанию (за кг)",
     electricityCost: "Стоимость электроэнергии за кВт·ч",
     electricityCost: "Стоимость электроэнергии за кВт·ч",
+    energyPriceSource: 'Источник цены электроэнергии',
+    energyPriceSourceFixed: 'Фиксированная цена',
+    energyPriceSourceHomeAssistant: 'Датчик Home Assistant',
+    energyPriceHaEntity: 'Датчик цены',
+    energyPriceLastRead: 'Последняя прочитанная цена за кВт·ч',
+    energyPriceFixedHint: 'Новая цена применяется к энергии, потреблённой после её установки; уже потреблённая энергия сохраняет цену на момент потребления.',
+    energyPriceHaHint: 'Считывается каждый час, а также в начале и в конце каждой печати, и каждый час потребления рассчитывается по своей цене. Датчик должен давать цену за кВт·ч (цена за МВт·ч и за Вт·ч пересчитывается).',
+    energyPriceHaNotConfigured: 'Home Assistant не настроен. Подключите его в Настройки → Сеть → Home Assistant.',
     energyDisplayMode: "Режим отображения энергии",
     energyDisplayMode: "Режим отображения энергии",
     energyModePrintDescription: "На панели показывается сумма энергии, израсходованной во время печати",
     energyModePrintDescription: "На панели показывается сумма энергии, израсходованной во время печати",
     energyModeTotalDescription: "На панели показывается полный расход энергии по данным умных розеток",
     energyModeTotalDescription: "На панели показывается полный расход энергии по данным умных розеток",

+ 8 - 0
frontend/src/i18n/locales/sv.ts

@@ -2869,6 +2869,14 @@ export default {
     currency: 'Valuta',
     currency: 'Valuta',
     defaultFilamentCost: 'Standardfilamentkostnad (per kg)',
     defaultFilamentCost: 'Standardfilamentkostnad (per kg)',
     electricityCost: 'Elkostnad per kWh',
     electricityCost: 'Elkostnad per kWh',
+    energyPriceSource: 'Källa för elpriset',
+    energyPriceSourceFixed: 'Fast pris',
+    energyPriceSourceHomeAssistant: 'Home Assistant-sensor',
+    energyPriceHaEntity: 'Prissensor',
+    energyPriceLastRead: 'Senast lästa pris per kWh',
+    energyPriceFixedHint: 'Ett nytt pris gäller för energin som används från och med då; energi som redan använts behåller priset den användes till.',
+    energyPriceHaHint: 'Läses varje timme samt vid start och slut av varje utskrift, och varje timmes energi räknas till sitt eget pris. Sensorn måste ge priset per kWh (per MWh och per Wh räknas om).',
+    energyPriceHaNotConfigured: 'Home Assistant är inte konfigurerat. Anslut det under Inställningar → Nätverk → Home Assistant.',
     energyDisplayMode: 'Energivisningsläge',
     energyDisplayMode: 'Energivisningsläge',
     energyModePrintDescription: 'Instrumentpanelen visar summan av energin som använts under utskrifter',
     energyModePrintDescription: 'Instrumentpanelen visar summan av energin som använts under utskrifter',
     energyModeTotalDescription: 'Instrumentpanelen visar livstidsenergi från smarta kontakter',
     energyModeTotalDescription: 'Instrumentpanelen visar livstidsenergi från smarta kontakter',

+ 8 - 0
frontend/src/i18n/locales/tr.ts

@@ -2852,6 +2852,14 @@ export default {
     currency: 'Para Birimi',
     currency: 'Para Birimi',
     defaultFilamentCost: 'Varsayılan filament maliyeti (kg başına)',
     defaultFilamentCost: 'Varsayılan filament maliyeti (kg başına)',
     electricityCost: 'kWh başına elektrik maliyeti',
     electricityCost: 'kWh başına elektrik maliyeti',
+    energyPriceSource: 'Elektrik fiyatı kaynağı',
+    energyPriceSourceFixed: 'Sabit fiyat',
+    energyPriceSourceHomeAssistant: 'Home Assistant sensörü',
+    energyPriceHaEntity: 'Fiyat sensörü',
+    energyPriceLastRead: 'Son okunan kWh başına fiyat',
+    energyPriceFixedHint: 'Yeni fiyat, o andan itibaren kullanılan enerjiye uygulanır; önceden kullanılmış enerji, kullanıldığı fiyatı korur.',
+    energyPriceHaHint: 'Her saat ve her baskının başında ve sonunda okunur; her saatin enerjisi kendi fiyatıyla hesaplanır. Sensör kWh başına fiyatı vermelidir (MWh ve Wh başına fiyatlar dönüştürülür).',
+    energyPriceHaNotConfigured: 'Home Assistant ayarlanmamış. Ayarlar → Ağ → Home Assistant bölümünden bağlayın.',
     energyDisplayMode: 'Enerji görüntüleme modu',
     energyDisplayMode: 'Enerji görüntüleme modu',
     energyModePrintDescription: 'Gösterge paneli, baskılar sırasında kullanılan enerji toplamını gösterir',
     energyModePrintDescription: 'Gösterge paneli, baskılar sırasında kullanılan enerji toplamını gösterir',
     energyModeTotalDescription: 'Gösterge paneli, akıllı prizlerden ömür boyu enerjiyi gösterir',
     energyModeTotalDescription: 'Gösterge paneli, akıllı prizlerden ömür boyu enerjiyi gösterir',

+ 8 - 0
frontend/src/i18n/locales/uk.ts

@@ -2867,6 +2867,14 @@ export default {
     currency: "Валюта",
     currency: "Валюта",
     defaultFilamentCost: "Вартість філаменту за замовчуванням (за кг)",
     defaultFilamentCost: "Вартість філаменту за замовчуванням (за кг)",
     electricityCost: "Вартість електроенергії за kWh",
     electricityCost: "Вартість електроенергії за kWh",
+    energyPriceSource: 'Джерело ціни електроенергії',
+    energyPriceSourceFixed: 'Фіксована ціна',
+    energyPriceSourceHomeAssistant: 'Датчик Home Assistant',
+    energyPriceHaEntity: 'Датчик ціни',
+    energyPriceLastRead: 'Остання прочитана ціна за kWh',
+    energyPriceFixedHint: 'Нова ціна застосовується до енергії, спожитої після її встановлення; вже спожита енергія зберігає ціну на момент споживання.',
+    energyPriceHaHint: 'Зчитується щогодини, а також на початку і в кінці кожного друку, і кожна година споживання рахується за власною ціною. Датчик має давати ціну за kWh (ціна за MWh і за Wh перераховується).',
+    energyPriceHaNotConfigured: 'Home Assistant не налаштовано. Підключіть його в Налаштування → Мережа → Home Assistant.',
     energyDisplayMode: "Режим відображення енергії",
     energyDisplayMode: "Режим відображення енергії",
     energyModePrintDescription: "Інформаційна панель показує суму енергії, використаної під час друку",
     energyModePrintDescription: "Інформаційна панель показує суму енергії, використаної під час друку",
     energyModeTotalDescription: "На приладовій панелі показано енергію від розумних розеток протягом усього терміну служби",
     energyModeTotalDescription: "На приладовій панелі показано енергію від розумних розеток протягом усього терміну служби",

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

@@ -2845,6 +2845,14 @@ export default {
     currency: '货币',
     currency: '货币',
     defaultFilamentCost: '默认耗材成本(每公斤)',
     defaultFilamentCost: '默认耗材成本(每公斤)',
     electricityCost: '每千瓦时电费',
     electricityCost: '每千瓦时电费',
+    energyPriceSource: '电价来源',
+    energyPriceSourceFixed: '固定电价',
+    energyPriceSourceHomeAssistant: 'Home Assistant 传感器',
+    energyPriceHaEntity: '电价传感器',
+    energyPriceLastRead: '最近读取的每千瓦时电价',
+    energyPriceFixedHint: '新电价只用于此后消耗的电量;已消耗的电量保留其消耗时的电价。',
+    energyPriceHaHint: '每小时以及每次打印开始和结束时读取,每小时的耗电按该小时的电价计算。传感器必须提供每千瓦时电价(每兆瓦时和每瓦时电价会自动换算)。',
+    energyPriceHaNotConfigured: '尚未配置 Home Assistant。请在 设置 → 网络 → Home Assistant 中连接。',
     energyDisplayMode: '能源显示模式',
     energyDisplayMode: '能源显示模式',
     energyModePrintDescription: '仪表板显示打印期间使用的能源总和',
     energyModePrintDescription: '仪表板显示打印期间使用的能源总和',
     energyModeTotalDescription: '仪表板显示智能插座的累计能源',
     energyModeTotalDescription: '仪表板显示智能插座的累计能源',

+ 8 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -2845,6 +2845,14 @@ export default {
     currency: '貨幣',
     currency: '貨幣',
     defaultFilamentCost: '預設耗材成本(每公斤)',
     defaultFilamentCost: '預設耗材成本(每公斤)',
     electricityCost: '每千瓦時電費',
     electricityCost: '每千瓦時電費',
+    energyPriceSource: '電價來源',
+    energyPriceSourceFixed: '固定電價',
+    energyPriceSourceHomeAssistant: 'Home Assistant 感測器',
+    energyPriceHaEntity: '電價感測器',
+    energyPriceLastRead: '最近讀取的每千瓦時電價',
+    energyPriceFixedHint: '新電價只用於此後消耗的電量;已消耗的電量保留其消耗時的電價。',
+    energyPriceHaHint: '每小時以及每次列印開始和結束時讀取,每小時的耗電按該小時的電價計算。感測器必須提供每千瓦時電價(每百萬瓦時和每瓦時電價會自動換算)。',
+    energyPriceHaNotConfigured: '尚未設定 Home Assistant。請在 設定 → 網路 → Home Assistant 中連接。',
     energyDisplayMode: '能源顯示模式',
     energyDisplayMode: '能源顯示模式',
     energyModePrintDescription: '儀表板顯示列印期間使用的能源總和',
     energyModePrintDescription: '儀表板顯示列印期間使用的能源總和',
     energyModeTotalDescription: '儀表板顯示智慧插座的累計能源',
     energyModeTotalDescription: '儀表板顯示智慧插座的累計能源',

+ 78 - 4
frontend/src/pages/SettingsPage.tsx

@@ -438,6 +438,19 @@ export function SettingsPage() {
     refetchOnReconnect: false,
     refetchOnReconnect: false,
   });
   });
 
 
+  // Price sensors to suggest when the electricity price comes from Home
+  // Assistant (#1251): anything with a unit per kWh, MWh or Wh.
+  const { data: haPriceEntities } = useQuery({
+    queryKey: ['ha-price-entities'],
+    queryFn: async () =>
+      (await api.getBindableHAEntities()).filter(
+        (e) => e.domain === 'sensor' && /\/\s*[km]?wh$/i.test(e.unit_of_measurement ?? ''),
+      ),
+    enabled: activeTab === 'general' && settings?.energy_price_source === 'homeassistant' && !!settings?.ha_enabled,
+    staleTime: 60_000,
+    retry: false,
+  });
+
   const handleStorageUsageRefresh = async () => {
   const handleStorageUsageRefresh = async () => {
     setStorageUsageRefreshing(true);
     setStorageUsageRefreshing(true);
     try {
     try {
@@ -1138,6 +1151,10 @@ export function SettingsPage() {
       // the baseline never lags behind a save regardless.
       // the baseline never lags behind a save regardless.
       serverBaselineRef.current = data;
       serverBaselineRef.current = data;
       queryClient.setQueryData(['settings'], data);
       queryClient.setQueryData(['settings'], data);
+      // Choosing a price sensor reads it on save; show that price (#1251).
+      if (data.energy_price_source === 'homeassistant') {
+        setLocalSettings(prev => (prev ? { ...prev, energy_cost_per_kwh: data.energy_cost_per_kwh } : prev));
+      }
       // Don't call setLocalSettings(data) here — it would overwrite in-progress
       // Don't call setLocalSettings(data) here — it would overwrite in-progress
       // user input (e.g. typing a hostname) with the stale saved snapshot,
       // user input (e.g. typing a hostname) with the stale saved snapshot,
       // causing the text field to reset mid-typing. Instead, let the useEffect
       // causing the text field to reset mid-typing. Instead, let the useEffect
@@ -1205,8 +1222,11 @@ export function SettingsPage() {
       (baseline.finish_photo_restore_plate ?? true) !== (localSettings.finish_photo_restore_plate ?? true) ||
       (baseline.finish_photo_restore_plate ?? true) !== (localSettings.finish_photo_restore_plate ?? true) ||
       baseline.default_filament_cost !== localSettings.default_filament_cost ||
       baseline.default_filament_cost !== localSettings.default_filament_cost ||
       baseline.currency !== localSettings.currency ||
       baseline.currency !== localSettings.currency ||
-      baseline.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
+      (localSettings.energy_price_source !== 'homeassistant' &&
+        baseline.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh) ||
       baseline.energy_tracking_mode !== localSettings.energy_tracking_mode ||
       baseline.energy_tracking_mode !== localSettings.energy_tracking_mode ||
+      (baseline.energy_price_source ?? 'fixed') !== (localSettings.energy_price_source ?? 'fixed') ||
+      (baseline.energy_price_ha_entity ?? '') !== (localSettings.energy_price_ha_entity ?? '') ||
       baseline.check_updates !== localSettings.check_updates ||
       baseline.check_updates !== localSettings.check_updates ||
       (baseline.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
       (baseline.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
       (baseline.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) ||
       (baseline.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) ||
@@ -1325,8 +1345,16 @@ export function SettingsPage() {
         finish_photo_restore_plate: localSettings.finish_photo_restore_plate ?? true,
         finish_photo_restore_plate: localSettings.finish_photo_restore_plate ?? true,
         default_filament_cost: localSettings.default_filament_cost,
         default_filament_cost: localSettings.default_filament_cost,
         currency: localSettings.currency,
         currency: localSettings.currency,
-        energy_cost_per_kwh: localSettings.energy_cost_per_kwh,
+        // A price read from Home Assistant is the server's to set; the copy
+        // here can be an hour old and would overwrite a newer reading (#1251).
+        ...(localSettings.energy_price_source === 'homeassistant'
+          ? {}
+          : { energy_cost_per_kwh: localSettings.energy_cost_per_kwh }),
         energy_tracking_mode: localSettings.energy_tracking_mode,
         energy_tracking_mode: localSettings.energy_tracking_mode,
+        // Normalised: anything but 'homeassistant' (a value restored from a
+        // backup) is fixed, and the backend refuses unknown values outright.
+        energy_price_source: localSettings.energy_price_source === 'homeassistant' ? 'homeassistant' : 'fixed',
+        energy_price_ha_entity: localSettings.energy_price_ha_entity ?? '',
         check_updates: localSettings.check_updates,
         check_updates: localSettings.check_updates,
         check_printer_firmware: localSettings.check_printer_firmware,
         check_printer_firmware: localSettings.check_printer_firmware,
         include_beta_updates: localSettings.include_beta_updates,
         include_beta_updates: localSettings.include_beta_updates,
@@ -2229,7 +2257,47 @@ export function SettingsPage() {
               </div>
               </div>
               <div>
               <div>
                 <label className="block text-sm text-bambu-gray mb-1">
                 <label className="block text-sm text-bambu-gray mb-1">
-                  {t('settings.electricityCost')}
+                  {t('settings.energyPriceSource')}
+                </label>
+                <select
+                  value={localSettings.energy_price_source === 'homeassistant' ? 'homeassistant' : 'fixed'}
+                  onChange={(e) => updateSetting('energy_price_source', e.target.value as 'fixed' | 'homeassistant')}
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                >
+                  <option value="fixed">{t('settings.energyPriceSourceFixed')}</option>
+                  <option value="homeassistant">{t('settings.energyPriceSourceHomeAssistant')}</option>
+                </select>
+                {localSettings.energy_price_source === 'homeassistant' && !localSettings.ha_enabled && (
+                  <p className="text-xs text-yellow-500 mt-1">{t('settings.energyPriceHaNotConfigured')}</p>
+                )}
+              </div>
+              {localSettings.energy_price_source === 'homeassistant' && (
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">
+                    {t('settings.energyPriceHaEntity')}
+                  </label>
+                  <input
+                    type="text"
+                    list="ha-price-entities"
+                    value={localSettings.energy_price_ha_entity ?? ''}
+                    onChange={(e) => updateSetting('energy_price_ha_entity', e.target.value.trim())}
+                    placeholder="sensor.electricity_price"
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                  />
+                  <datalist id="ha-price-entities">
+                    {(haPriceEntities ?? []).map((e) => (
+                      <option key={e.entity_id} value={e.entity_id}>
+                        {`${e.friendly_name} (${e.state ?? '?'} ${e.unit_of_measurement ?? ''})`}
+                      </option>
+                    ))}
+                  </datalist>
+                </div>
+              )}
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">
+                  {localSettings.energy_price_source === 'homeassistant'
+                    ? t('settings.energyPriceLastRead')
+                    : t('settings.electricityCost')}
                 </label>
                 </label>
                 <div className="relative">
                 <div className="relative">
                   <span className="absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray text-sm pointer-events-none">
                   <span className="absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray text-sm pointer-events-none">
@@ -2242,10 +2310,16 @@ export function SettingsPage() {
                     onChange={(v) => updateSetting('energy_cost_per_kwh', v)}
                     onChange={(v) => updateSetting('energy_cost_per_kwh', v)}
                     integer={false}
                     integer={false}
                     fallback={0}
                     fallback={0}
+                    disabled={localSettings.energy_price_source === 'homeassistant'}
                     style={{ paddingLeft: `${Math.max(2, getCurrencySymbol(localSettings.currency).length * 0.6 + 1)}rem` }}
                     style={{ paddingLeft: `${Math.max(2, getCurrencySymbol(localSettings.currency).length * 0.6 + 1)}rem` }}
-                    className="w-full pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    className="w-full pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-60"
                   />
                   />
                 </div>
                 </div>
+                <p className="text-xs text-bambu-gray mt-1">
+                  {localSettings.energy_price_source === 'homeassistant'
+                    ? t('settings.energyPriceHaHint')
+                    : t('settings.energyPriceFixedHint')}
+                </p>
               </div>
               </div>
               <div>
               <div>
                 <label className="block text-sm text-bambu-gray mb-1">
                 <label className="block text-sm text-bambu-gray mb-1">

File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-Aay5Kh3s.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-YkjMPvJ5.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpreadsheetPreviewModal-Du9mvozs.js


File diff suppressed because it is too large
+ 1 - 1
static/assets/index-Cl_K2DVh.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-B1-VD3Hl.js


+ 1 - 1
static/index.html

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

Some files were not shown because too many files changed in this diff