mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
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.
105 lines
4.8 KiB
Python
105 lines
4.8 KiB
Python
"""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
|