energy_plug.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. """Which of a printer's plugs measures its energy? (#2859)
  2. Per-print energy is the delta of one plug's lifetime counter between print start
  3. and print end, so both readings have to come from the same plug. The two call
  4. sites used to assume a printer had exactly one: they selected every plug with
  5. ``SmartPlug.printer_id == printer_id`` and then called ``scalar_one_or_none()``.
  6. Nothing enforces that assumption. The plug API rejects a second *Tasmota* plug
  7. on a printer and deliberately allows any number of Home Assistant entities --
  8. "allow multiple per printer (for different automations)" -- which is how a
  9. filter fan, a dry box or a lights script ends up linked beside the printer's own
  10. plug. On those installs the query returned two rows, ``scalar_one_or_none()``
  11. raised, the print-start handler caught it as just another failure and logged a
  12. warning, and no archive on that printer ever carried an energy figure again. It
  13. was silent because the print-end handler then reports "no start kWh recorded",
  14. which reads exactly like "this printer has no plug".
  15. The rule below picks the printer's own plug without asking the user to nominate
  16. one. ``controls_printer_power`` (#2629) already means "this plug really feeds
  17. the printer" rather than an accessory that merely follows the print cycle, so it
  18. ranks above one that does not, and the id breaks ties so the start and end
  19. readings agree on the answer. The decisive test in practice is the last one: a
  20. candidate has to actually report a lifetime counter to be chosen, and accessory
  21. plugs are usually switch-only, so they drop out with nothing configured.
  22. Deliberately *not* enforced: one plug per printer. Bambuddy dropped the UNIQUE
  23. constraint on ``smart_plugs.printer_id`` on purpose, and the flag defaults to on
  24. for every existing plug, so clearing it to make it unique would change which
  25. plugs may mark a printer offline on auto-off (#2629) -- not this module's
  26. business.
  27. Equally deliberate: nothing is excluded, only ranked. A printer with one linked
  28. plug used it whatever it was, and must keep doing so, so a disabled row or a
  29. script still gets its turn once the plausible candidates have declined.
  30. """
  31. from __future__ import annotations
  32. from collections.abc import Awaitable, Callable
  33. from sqlalchemy import select
  34. from sqlalchemy.ext.asyncio import AsyncSession
  35. from backend.app.models.smart_plug import SmartPlug
  36. # Reads a plug's energy dict, or None when the device did not answer. Injected
  37. # rather than imported so this module stays independent of the plug-type
  38. # dispatch that lives with the callers.
  39. EnergyReader = Callable[[SmartPlug, AsyncSession], Awaitable[dict | None]]
  40. def _is_script_entity(plug: SmartPlug) -> bool:
  41. """A Home Assistant ``script.*`` entity linked to a printer for automation.
  42. Stored as plugs so they can follow the print cycle (see
  43. ``trigger_associated_scripts``), but a script has nothing to meter.
  44. """
  45. return bool(plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."))
  46. def _rank(plug: SmartPlug) -> tuple:
  47. """Sort key: least surprising source of a printer's meter first.
  48. Ranking rather than filtering, deliberately. A printer with exactly one
  49. linked row behaved the same before this module existed whatever that row
  50. was -- disabled, a script, an accessory -- and it has to keep behaving that
  51. way, so nothing is excluded outright and every rejection is left to the one
  52. test that cannot be wrong: does it actually report a counter.
  53. """
  54. return (
  55. _is_script_entity(plug),
  56. not plug.enabled,
  57. not plug.controls_printer_power,
  58. plug.id,
  59. )
  60. async def energy_plug_candidates(db: AsyncSession, printer_id: int | None) -> list[SmartPlug]:
  61. """Plugs on *printer_id* that could supply its energy counter, best first."""
  62. if printer_id is None:
  63. # `printer_id == None` compiles to `IS NULL`, which would return every
  64. # plug linked to no printer at all and bill a print against whichever
  65. # one happened to answer. Callers are typed `int`, so this is a guard
  66. # against a future one rather than a live path.
  67. return []
  68. result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
  69. return sorted(result.scalars().all(), key=_rank)
  70. async def select_energy_reading(
  71. candidates: list[SmartPlug],
  72. read_energy: EnergyReader,
  73. db: AsyncSession,
  74. ) -> tuple[SmartPlug, dict] | None:
  75. """First candidate that actually reports a lifetime counter, with its reading.
  76. Returns the reading alongside the plug so the caller does not poll twice --
  77. the value that decided the choice is the value it needs.
  78. """
  79. for plug in candidates:
  80. energy = await read_energy(plug, db)
  81. if energy and energy.get("total") is not None:
  82. return plug, energy
  83. return None