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

Use the printer's own plug for energy when several are linked (issue #2859)

Per-print energy is one plug's meter read at the start of a print and
again at the end. Both readings asked for "the plug on this printer"
with scalar_one_or_none(), which raises on two rows. Linking a second
plug to a printer - a dry box, a filter fan, a lights script - therefore
stopped energy tracking on that printer outright, and did it silently:
the print-start handler logged the exception as an ordinary failure and
the print-end handler then reported "no start kWh recorded", which is
also what it says for a printer with nothing linked to it.

The assumption was never enforced anywhere else. The plug API rejects a
second Tasmota plug and deliberately allows any number of Home Assistant
entities, the UNIQUE constraint on smart_plugs.printer_id was dropped on
purpose, and every other consumer reads a list. These two call sites were
the last ones left from before that.

Energy now ranks a printer's plugs - the one that powers it first, then
by id so the start and end readings agree - and takes the first that
actually reports a counter, so accessories drop out with nothing
configured. Ranking rather than filtering: a printer whose only linked
row is disabled, or a script, used it before and still does. When none
of them measures anything the log names the ones it tried, so that stops
reading like "no plug configured".

Also: the plug page counted an online plug as offline unless it reported
energy, so a switch with no power sensor showed as offline for as long
as it stayed linked.

Existing archives cannot be backfilled - the starting reading was never
taken, so there is nothing to compute a delta from.
maziggy 2 недель назад
Родитель
Сommit
5a05e03c8e

+ 2 - 0
CHANGELOG.md

@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Fixed
+- **A printer with more than one smart plug never recorded energy or energy cost (#2859, reported by @sn8key)** — Linking a second plug to a printer — a dry box, a filter fan, a chamber light you want to switch from the printer card — stopped energy tracking on that printer completely, from the moment the second plug was linked. Per-print energy is the change in one plug's meter between the start and the end of a print, and both readings asked for "the plug on this printer" in a way that accepts one answer and fails on two. The failure was indistinguishable from having no plug at all: the print-end log said no starting reading had been taken, which is also what it says for a printer with nothing linked to it. Bambuddy now picks the printer's own plug — the one marked as supplying its power, and of those the one that actually reports a meter, so accessories drop out on their own — and names the plugs it tried when none of them measures anything. Linking several plugs to a printer is supported and always was; only energy assumed otherwise. Past prints cannot be recovered, since the starting reading was never taken. Covered by backend tests.
+- **The smart plug page counted an online plug as offline unless it reported energy (#2859)** — A plug with no power sensor is still online, but "N/M plugs online" only counted the ones sending energy figures, so a working switch showed as offline for as long as it stayed linked. The count now reflects whether the plug answers.
 - **Reprints of a file already on the printer archived without their thumbnail or filament data (#2780 regression)** — Printing a file that is already on the printer — from the printer's own screen, from Handy, or after sending it to storage from a slicer and pressing print — reports the file by its path rather than by how it got there. Bambuddy read anything that was not a fresh upload as "the printer kept this on internal storage", stopped looking, and archived the print with only its name and timing. The file was on the card the whole time: on the machine this was measured on, `/media/usb0/foobar.gcode.3mf` was listable and downloadable over FTP at the moment Bambuddy decided it was unreachable. This affected every model, not only the H2 series and P2S the original change was about, and it arrived with that change on 2026-08-14 — before it, those prints archived normally. Bambuddy now reads the path: the printer's own internal model cache is still skipped, since nothing there is reachable, and everything else is looked for as it always was. Covered by backend tests.
 - **Prints sent from a slicer were sometimes logged as though Bambuddy had sent them (#2843 follow-up)** — Bambuddy records the dispatch behind every print so a support bundle shows where the sliced file went, and it told its own dispatches apart from a slicer's by a sequence number it believed was unique to it. It is not: that number is the slicer convention Bambuddy adopted, and measured on the wire OrcaSlicer counts from it while Bambu Studio counts from the same base a few higher. Whichever dispatch happened to land on the shared value was filed as Bambuddy's own and never recorded — after a slicer restart, that is the first print you send. Bambuddy now recognises its own dispatch by the job it actually sent. Nothing about printing or archiving changed; the entry was diagnostic, but it is the entry that tells you whether a printer stores your files somewhere Bambuddy can read them. Covered by backend tests.
 - **A print with no 3MF could take its filament figures from an unrelated model (#2843, reported by @gyrene2083)** — Bambu Studio sends a sliced file to the printer's internal storage on H2-series and P2S, which Bambuddy cannot read, so those prints archive without a 3MF ([BambuStudio#10481](https://github.com/bambulab/BambuStudio/issues/10481) tracks that default upstream). Bambuddy then looks for the same model in your Library or among earlier prints, which is how a reprint still gets its filament accounted for. The name it searched on was the wrong one. A running print reports the file it is executing — always `Metadata/plate_1.gcode` — and with no 3MF to correct it, that path became the archive's name and `plate_1` became the search term. Every Bambu print has a plate 1, so the search matched on nothing meaningful and took whatever came back: on the maintainer's H2D a 1.6 g Cube was costed from a 207 g four-colour ABS print whose file happened to be named `lid_plate_1.3mf`. The match now uses the model name the printer reports alongside the plate path, a plate name on its own is refused rather than searched for, and a name must match a whole filename instead of merely appearing inside one. A print that cannot be identified is left untracked, which is the honest answer — the previous behaviour was to charge your spools for a model you did not print. Covered by backend tests, including the exact collision measured on the H2D.

+ 30 - 15
backend/app/main.py

@@ -83,7 +83,6 @@ from backend.app.core.config import APP_VERSION, settings as app_settings
 from backend.app.core.database import async_session, engine, init_db
 from backend.app.core.tasks import spawn_background_task
 from backend.app.core.websocket import ws_manager
-from backend.app.models.smart_plug import SmartPlug
 from backend.app.services import print_dispatch_context
 from backend.app.services.archive import ArchiveService, peek_plate_index_in_3mf, swap_plate_suffix
 from backend.app.services.archive_purge import archive_purge_service
@@ -98,6 +97,7 @@ from backend.app.services.bambu_ftp import (
     with_ftp_retry,
 )
 from backend.app.services.bambu_mqtt import PrinterState
+from backend.app.services.energy_plug import energy_plug_candidates, select_energy_reading
 from backend.app.services.github_backup import github_backup_service
 from backend.app.services.ha_sensor_manager import ha_sensor_manager
 from backend.app.services.homeassistant import homeassistant_service
@@ -896,21 +896,30 @@ async def _record_energy_start(archive, printer_id: int, db, *, context: str = "
     """
     _logger = logging.getLogger(__name__)
     try:
-        plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-        plug = plug_result.scalar_one_or_none()
-        if not plug:
+        candidates = await energy_plug_candidates(db, printer_id)
+        if not candidates:
             _logger.info("[ENERGY] No smart plug for printer %s (archive %s)", printer_id, archive.id)
             return False
-        energy = await _get_plug_energy(plug, db)
-        if not energy or energy.get("total") is None:
-            _logger.warning("[ENERGY] No 'total' in energy response for archive %s", archive.id)
+        selected = await select_energy_reading(candidates, _get_plug_energy, db)
+        if selected is None:
+            # Naming the plugs matters here: with several linked to one printer
+            # this is the difference between "the meter is offline" and "you
+            # linked only accessories" (#2859).
+            _logger.warning(
+                "[ENERGY] No plug on printer %s reports a lifetime energy counter for archive %s (tried: %s)",
+                printer_id,
+                archive.id,
+                ", ".join(plug.name for plug in candidates),
+            )
             return False
+        plug, energy = selected
         archive.energy_start_kwh = float(energy["total"])
         await db.commit()
         _logger.info(
-            "[ENERGY] Recorded starting energy%s for archive %s: %s kWh",
+            "[ENERGY] Recorded starting energy%s for archive %s from plug '%s': %s kWh",
             f" ({context})" if context else "",
             archive.id,
+            plug.name,
             energy["total"],
         )
         return True
@@ -6345,17 +6354,23 @@ async def on_print_complete(printer_id: int, data: dict):
                     logger.info("[ENERGY-BG] No start kWh recorded for archive %s", archive_id)
                     return
 
-                plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-                plug = plug_result.scalar_one_or_none()
-                if plug is None:
+                candidates = await energy_plug_candidates(db, printer_id)
+                if not candidates:
                     logger.info("[ENERGY-BG] No smart plug for printer %s", printer_id)
                     return
 
-                energy = await _get_plug_energy(plug, db)
-                logger.info("[ENERGY-BG] Energy response: %s", energy)
-                if not energy or energy.get("total") is None:
-                    logger.warning("[ENERGY-BG] No 'total' in energy response")
+                # Same ordering as the start reading, so the delta below is
+                # against the counter that produced `starting_kwh` (#2859).
+                selected = await select_energy_reading(candidates, _get_plug_energy, db)
+                if selected is None:
+                    logger.warning(
+                        "[ENERGY-BG] No plug on printer %s reports a lifetime energy counter (tried: %s)",
+                        printer_id,
+                        ", ".join(plug.name for plug in candidates),
+                    )
                     return
+                plug, energy = selected
+                logger.info("[ENERGY-BG] Energy response from plug '%s': %s", plug.name, energy)
 
                 energy_used = round(energy["total"] - starting_kwh, 4)
                 logger.info("[ENERGY-BG] Per-print energy: %s kWh", energy_used)

+ 104 - 0
backend/app/services/energy_plug.py

@@ -0,0 +1,104 @@
+"""Which of a printer's plugs measures its energy? (#2859)
+
+Per-print energy is the delta of one plug's lifetime counter between print start
+and print end, so both readings have to come from the same plug. The two call
+sites used to assume a printer had exactly one: they selected every plug with
+``SmartPlug.printer_id == printer_id`` and then called ``scalar_one_or_none()``.
+
+Nothing enforces that assumption. The plug API rejects a second *Tasmota* plug
+on a printer and deliberately allows any number of Home Assistant entities --
+"allow multiple per printer (for different automations)" -- which is how a
+filter fan, a dry box or a lights script ends up linked beside the printer's own
+plug. On those installs the query returned two rows, ``scalar_one_or_none()``
+raised, the print-start handler caught it as just another failure and logged a
+warning, and no archive on that printer ever carried an energy figure again. It
+was silent because the print-end handler then reports "no start kWh recorded",
+which reads exactly like "this printer has no plug".
+
+The rule below picks the printer's own plug without asking the user to nominate
+one. ``controls_printer_power`` (#2629) already means "this plug really feeds
+the printer" rather than an accessory that merely follows the print cycle, so it
+ranks above one that does not, and the id breaks ties so the start and end
+readings agree on the answer. The decisive test in practice is the last one: a
+candidate has to actually report a lifetime counter to be chosen, and accessory
+plugs are usually switch-only, so they drop out with nothing configured.
+
+Deliberately *not* enforced: one plug per printer. Bambuddy dropped the UNIQUE
+constraint on ``smart_plugs.printer_id`` on purpose, and the flag defaults to on
+for every existing plug, so clearing it to make it unique would change which
+plugs may mark a printer offline on auto-off (#2629) -- not this module's
+business.
+
+Equally deliberate: nothing is excluded, only ranked. A printer with one linked
+plug used it whatever it was, and must keep doing so, so a disabled row or a
+script still gets its turn once the plausible candidates have declined.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.smart_plug import SmartPlug
+
+# Reads a plug's energy dict, or None when the device did not answer. Injected
+# rather than imported so this module stays independent of the plug-type
+# dispatch that lives with the callers.
+EnergyReader = Callable[[SmartPlug, AsyncSession], Awaitable[dict | None]]
+
+
+def _is_script_entity(plug: SmartPlug) -> bool:
+    """A Home Assistant ``script.*`` entity linked to a printer for automation.
+
+    Stored as plugs so they can follow the print cycle (see
+    ``trigger_associated_scripts``), but a script has nothing to meter.
+    """
+    return bool(plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."))
+
+
+def _rank(plug: SmartPlug) -> tuple:
+    """Sort key: least surprising source of a printer's meter first.
+
+    Ranking rather than filtering, deliberately. A printer with exactly one
+    linked row behaved the same before this module existed whatever that row
+    was -- disabled, a script, an accessory -- and it has to keep behaving that
+    way, so nothing is excluded outright and every rejection is left to the one
+    test that cannot be wrong: does it actually report a counter.
+    """
+    return (
+        _is_script_entity(plug),
+        not plug.enabled,
+        not plug.controls_printer_power,
+        plug.id,
+    )
+
+
+async def energy_plug_candidates(db: AsyncSession, printer_id: int | None) -> list[SmartPlug]:
+    """Plugs on *printer_id* that could supply its energy counter, best first."""
+    if printer_id is None:
+        # `printer_id == None` compiles to `IS NULL`, which would return every
+        # plug linked to no printer at all and bill a print against whichever
+        # one happened to answer. Callers are typed `int`, so this is a guard
+        # against a future one rather than a live path.
+        return []
+    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
+    return sorted(result.scalars().all(), key=_rank)
+
+
+async def select_energy_reading(
+    candidates: list[SmartPlug],
+    read_energy: EnergyReader,
+    db: AsyncSession,
+) -> tuple[SmartPlug, dict] | None:
+    """First candidate that actually reports a lifetime counter, with its reading.
+
+    Returns the reading alongside the plug so the caller does not poll twice --
+    the value that decided the choice is the value it needs.
+    """
+    for plug in candidates:
+        energy = await read_energy(plug, db)
+        if energy and energy.get("total") is not None:
+            return plug, energy
+    return None

+ 306 - 0
backend/tests/unit/test_energy_plug_selection_2859.py

@@ -0,0 +1,306 @@
+"""Energy tracking with more than one plug linked to a printer (#2859).
+
+The reporter had two Home Assistant plugs on a P1S -- its own plug plus a dry
+box he wanted to switch from the printer card -- and no archive on that printer
+ever carried an energy figure, while his single-plug X2D was fine. Both energy
+call sites selected every plug for the printer and then called
+``scalar_one_or_none()``, which raises on two rows; the print-start handler
+caught that as an ordinary failure, so ``energy_start_kwh`` was never written
+and the print-end handler reported "no start kWh recorded" -- indistinguishable
+from having no plug at all.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.services.energy_plug import energy_plug_candidates, select_energy_reading
+
+
+def _plug(plug_id: int, name: str, *, power: bool = True, ha_entity_id: str | None = None) -> SimpleNamespace:
+    return SimpleNamespace(
+        id=plug_id,
+        name=name,
+        controls_printer_power=power,
+        plug_type="homeassistant" if ha_entity_id else "tasmota",
+        ha_entity_id=ha_entity_id,
+    )
+
+
+class TestCandidateOrdering:
+    """Ordering has to be stable: the print-end delta is only meaningful if it
+    reads the same counter the print-start reading came from."""
+
+    @pytest.mark.asyncio
+    async def test_no_plugs_for_printer(self, db_session, printer_factory):
+        printer = await printer_factory()
+
+        assert await energy_plug_candidates(db_session, printer.id) == []
+
+    @pytest.mark.asyncio
+    async def test_single_plug_is_the_candidate(self, db_session, printer_factory, smart_plug_factory):
+        printer = await printer_factory()
+        plug = await smart_plug_factory(name="P1S Power", printer_id=printer.id)
+
+        candidates = await energy_plug_candidates(db_session, printer.id)
+
+        assert [c.id for c in candidates] == [plug.id]
+
+    @pytest.mark.asyncio
+    async def test_power_plug_sorts_ahead_of_earlier_accessory(self, db_session, printer_factory, smart_plug_factory):
+        printer = await printer_factory()
+        accessory = await smart_plug_factory(
+            name="Dry Box",
+            plug_type="homeassistant",
+            printer_id=printer.id,
+            controls_printer_power=False,
+        )
+        power = await smart_plug_factory(
+            name="P1S Power",
+            plug_type="homeassistant",
+            printer_id=printer.id,
+            controls_printer_power=True,
+        )
+
+        candidates = await energy_plug_candidates(db_session, printer.id)
+
+        assert [c.id for c in candidates] == [power.id, accessory.id]
+
+    @pytest.mark.asyncio
+    async def test_ties_break_by_id(self, db_session, printer_factory, smart_plug_factory):
+        """`controls_printer_power` defaults to on, so two plugs claiming it is
+        the normal case rather than a misconfiguration."""
+        printer = await printer_factory()
+        first = await smart_plug_factory(name="First", plug_type="homeassistant", printer_id=printer.id)
+        second = await smart_plug_factory(name="Second", plug_type="homeassistant", printer_id=printer.id)
+
+        candidates = await energy_plug_candidates(db_session, printer.id)
+
+        assert [c.id for c in candidates] == [first.id, second.id]
+
+    @pytest.mark.asyncio
+    async def test_other_printers_plugs_are_not_candidates(self, db_session, printer_factory, smart_plug_factory):
+        mine = await printer_factory(name="P1S")
+        theirs = await printer_factory(name="X2D")
+        plug = await smart_plug_factory(name="P1S Power", printer_id=mine.id)
+        await smart_plug_factory(name="X2D Power", printer_id=theirs.id)
+
+        candidates = await energy_plug_candidates(db_session, mine.id)
+
+        assert [c.id for c in candidates] == [plug.id]
+
+    @pytest.mark.asyncio
+    async def test_unlinked_plug_is_not_a_candidate(self, db_session, printer_factory, smart_plug_factory):
+        printer = await printer_factory()
+        await smart_plug_factory(name="Bench Plug", printer_id=None)
+
+        assert await energy_plug_candidates(db_session, printer.id) == []
+
+    @pytest.mark.asyncio
+    async def test_no_printer_matches_nothing(self, db_session, smart_plug_factory):
+        """`printer_id == None` would compile to `IS NULL` and hand back every
+        unlinked plug, so a print could be billed against a bench plug."""
+        await smart_plug_factory(name="Bench Plug", printer_id=None)
+
+        assert await energy_plug_candidates(db_session, None) == []
+
+    @pytest.mark.asyncio
+    async def test_disabled_plug_sorts_last(self, db_session, printer_factory, smart_plug_factory):
+        printer = await printer_factory()
+        retired = await smart_plug_factory(
+            name="Retired Plug",
+            plug_type="homeassistant",
+            printer_id=printer.id,
+            enabled=False,
+        )
+        live = await smart_plug_factory(name="P1S Power", plug_type="homeassistant", printer_id=printer.id)
+
+        candidates = await energy_plug_candidates(db_session, printer.id)
+
+        assert [c.id for c in candidates] == [live.id, retired.id]
+
+    @pytest.mark.asyncio
+    async def test_only_plug_is_used_even_when_disabled(self, db_session, printer_factory, smart_plug_factory):
+        """Ranking, not filtering: a printer whose single plug is disabled
+        tracked energy before this module existed and has to keep doing so."""
+        printer = await printer_factory()
+        retired = await smart_plug_factory(name="Retired Plug", printer_id=printer.id, enabled=False)
+
+        candidates = await energy_plug_candidates(db_session, printer.id)
+
+        assert [c.id for c in candidates] == [retired.id]
+
+    @pytest.mark.asyncio
+    async def test_home_assistant_script_sorts_last(self, db_session, printer_factory, smart_plug_factory):
+        """A `script.*` entity is linked for the automation triggers and has
+        nothing to meter, so it is asked only if nothing else answers."""
+        printer = await printer_factory()
+        script = await smart_plug_factory(
+            name="Notify Script",
+            plug_type="homeassistant",
+            ha_entity_id="script.notify_done",
+            printer_id=printer.id,
+        )
+        plug = await smart_plug_factory(
+            name="P1S Power",
+            plug_type="homeassistant",
+            ha_entity_id="switch.p1s",
+            printer_id=printer.id,
+            enabled=False,
+        )
+
+        candidates = await energy_plug_candidates(db_session, printer.id)
+
+        # Even disabled, a real switch outranks a script.
+        assert [c.id for c in candidates] == [plug.id, script.id]
+
+
+class TestSelectEnergyReading:
+    @pytest.mark.asyncio
+    async def test_picks_the_plug_that_reports_a_counter(self):
+        """The decisive test in practice: an accessory is usually switch-only,
+        so it drops out without the user configuring anything."""
+        dry_box = _plug(1, "Dry Box")
+        printer_plug = _plug(2, "P1S Power")
+
+        async def read(plug, _db):
+            return {"power": 3.0} if plug is dry_box else {"power": 120.0, "total": 41.5}
+
+        selected = await select_energy_reading([dry_box, printer_plug], read, db=None)
+
+        assert selected is not None
+        plug, energy = selected
+        assert plug is printer_plug
+        assert energy["total"] == 41.5
+
+    @pytest.mark.asyncio
+    async def test_stops_at_the_first_usable_reading(self):
+        first = _plug(1, "P1S Power")
+        second = _plug(2, "Dry Box")
+        seen = []
+
+        async def read(plug, _db):
+            seen.append(plug.name)
+            return {"total": 1.0}
+
+        selected = await select_energy_reading([first, second], read, db=None)
+
+        assert selected[0] is first
+        assert seen == ["P1S Power"]
+
+    @pytest.mark.asyncio
+    async def test_unreachable_plug_does_not_end_the_search(self):
+        offline = _plug(1, "Offline")
+        printer_plug = _plug(2, "P1S Power")
+
+        async def read(plug, _db):
+            return None if plug is offline else {"total": 7.0}
+
+        selected = await select_energy_reading([offline, printer_plug], read, db=None)
+
+        assert selected[0] is printer_plug
+
+    @pytest.mark.asyncio
+    async def test_zero_is_a_reading(self):
+        """A freshly reset counter is a perfectly good baseline; treating 0 as
+        missing would drop the first print after a plug replacement."""
+        plug = _plug(1, "P1S Power")
+
+        selected = await select_energy_reading([plug], AsyncMock(return_value={"total": 0.0}), db=None)
+
+        assert selected is not None
+        assert selected[1]["total"] == 0.0
+
+    @pytest.mark.asyncio
+    async def test_none_when_nothing_reports_a_counter(self):
+        async def read(_plug, _db):
+            return {"power": 3.0, "total": None}
+
+        assert await select_energy_reading([_plug(1, "Dry Box")], read, db=None) is None
+
+    @pytest.mark.asyncio
+    async def test_none_for_an_empty_candidate_list(self):
+        assert await select_energy_reading([], AsyncMock(), db=None) is None
+
+
+class TestRecordEnergyStart:
+    """The reported failure, end to end."""
+
+    @pytest.mark.asyncio
+    async def test_two_plugs_no_longer_lose_the_start_reading(
+        self, db_session, printer_factory, smart_plug_factory, archive_factory
+    ):
+        printer = await printer_factory()
+        await smart_plug_factory(
+            name="Dry Box",
+            plug_type="homeassistant",
+            printer_id=printer.id,
+            controls_printer_power=False,
+        )
+        await smart_plug_factory(
+            name="P1S Power",
+            plug_type="homeassistant",
+            printer_id=printer.id,
+            controls_printer_power=True,
+        )
+        archive = await archive_factory(printer.id)
+
+        from backend.app.main import _record_energy_start
+
+        async def read(plug, _db):
+            return {"power": 120.0, "total": 41.5} if plug.name == "P1S Power" else {"power": 2.0}
+
+        with patch("backend.app.main._get_plug_energy", side_effect=read):
+            recorded = await _record_energy_start(archive, printer.id, db_session)
+
+        assert recorded is True
+        assert archive.energy_start_kwh == 41.5
+
+    @pytest.mark.asyncio
+    async def test_single_plug_is_unchanged(self, db_session, printer_factory, smart_plug_factory, archive_factory):
+        printer = await printer_factory()
+        await smart_plug_factory(name="X2D Power", printer_id=printer.id)
+        archive = await archive_factory(printer.id)
+
+        from backend.app.main import _record_energy_start
+
+        with patch("backend.app.main._get_plug_energy", AsyncMock(return_value={"total": 12.25})):
+            recorded = await _record_energy_start(archive, printer.id, db_session)
+
+        assert recorded is True
+        assert archive.energy_start_kwh == 12.25
+
+    @pytest.mark.asyncio
+    async def test_no_plug_records_nothing(self, db_session, printer_factory, archive_factory):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+
+        from backend.app.main import _record_energy_start
+
+        recorded = await _record_energy_start(archive, printer.id, db_session)
+
+        assert recorded is False
+        assert archive.energy_start_kwh is None
+
+    @pytest.mark.asyncio
+    async def test_names_the_plugs_it_tried_when_none_measures(
+        self, db_session, printer_factory, smart_plug_factory, archive_factory, capture_logs
+    ):
+        """ "No plug reports energy" and "no plug at all" used to log the same
+        way, which is what made this invisible for the reporter."""
+        printer = await printer_factory()
+        await smart_plug_factory(name="Dry Box", plug_type="homeassistant", printer_id=printer.id)
+        await smart_plug_factory(name="Chamber Light", plug_type="homeassistant", printer_id=printer.id)
+        archive = await archive_factory(printer.id)
+
+        from backend.app.main import _record_energy_start
+
+        with patch("backend.app.main._get_plug_energy", AsyncMock(return_value={"power": 1.0})):
+            recorded = await _record_energy_start(archive, printer.id, db_session)
+
+        assert recorded is False
+        assert archive.energy_start_kwh is None
+        logged = "\n".join(record.getMessage() for record in capture_logs.get_warnings())
+        assert "Dry Box" in logged
+        assert "Chamber Light" in logged

+ 10 - 5
frontend/src/pages/SettingsPage.tsx

@@ -378,14 +378,19 @@ export function SettingsPage() {
       for (const { plug, status } of statuses) {
         // For MQTT plugs, consider reachable if we have power data
         const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power != null);
-        const isReachable = (status?.reachable || hasMqttData) && status?.energy;
+        // Reachability is about the device answering, not about it measuring
+        // anything. Requiring `status.energy` here counted a perfectly online
+        // switch-only plug -- no power sensor, so the backend leaves `energy`
+        // null -- as offline, and the counter labelled "plugs online" quietly
+        // reported "plugs reporting energy" instead (#2859).
+        const isReachable = status?.reachable || hasMqttData;
 
         if (isReachable) {
           reachableCount++;
-          if (status.energy?.power != null) totalPower += status.energy.power;
-          if (status.energy?.today != null) totalToday += status.energy.today;
-          if (status.energy?.yesterday != null) totalYesterday += status.energy.yesterday;
-          if (status.energy?.total != null) totalLifetime += status.energy.total;
+          if (status?.energy?.power != null) totalPower += status.energy.power;
+          if (status?.energy?.today != null) totalToday += status.energy.today;
+          if (status?.energy?.yesterday != null) totalYesterday += status.energy.yesterday;
+          if (status?.energy?.total != null) totalLifetime += status.energy.total;
         }
       }
 

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


+ 1 - 1
static/index.html

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

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