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.
This commit is contained in:
maziggy
2026-08-17 15:51:03 +02:00
6 changed files with 452 additions and 22 deletions
+30 -15
View File
@@ -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
View File
@@ -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
@@ -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
View File
@@ -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;
}
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>