ha_sensor_manager.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. """Polls the Home Assistant entities bound to printers (#1148, #448).
  2. One background loop reads every configured entity on a fixed cadence and keeps
  3. the result in memory. Three things consume it:
  4. * the printer card, which reads the cache instead of hitting Home Assistant
  5. once per card per refresh;
  6. * notifications, fired on a transition *into* the alert state, never on every
  7. poll while it persists;
  8. * the print interlock, which holds queued jobs for a printer while one of its
  9. sensors is alerting.
  10. Everything degrades to "no opinion" when Home Assistant cannot be reached: an
  11. unreadable sensor never alerts, never notifies, and never holds a print. A
  12. door contact that stops responding must not strand the queue.
  13. """
  14. import asyncio
  15. import logging
  16. from dataclasses import dataclass
  17. from sqlalchemy import select
  18. from sqlalchemy.ext.asyncio import AsyncSession
  19. from backend.app.models.printer import Printer
  20. from backend.app.models.printer_ha_sensor import PrinterHASensor
  21. from backend.app.services.homeassistant import as_float, homeassistant_service
  22. from backend.app.utils.local_time import utcnow_naive
  23. logger = logging.getLogger(__name__)
  24. # Fast enough that an enclosure door reads as live, slow enough that a handful
  25. # of tiny LAN requests stays background noise.
  26. POLL_INTERVAL = 15
  27. @dataclass
  28. class SensorReading:
  29. """The last thing we managed to read for one sensor."""
  30. state: str | None # raw HA state, None when unreadable
  31. value: float | None # parsed number for numeric sensors
  32. alerting: bool
  33. reachable: bool
  34. class HASensorManager:
  35. def __init__(self):
  36. self._task: asyncio.Task | None = None
  37. # sensor id -> last reading. Sensors absent from this map have not been
  38. # polled yet; callers must not read that as "not alerting" without also
  39. # checking, which is why get_reading returns None rather than a default.
  40. self._readings: dict[int, SensorReading] = {}
  41. # sensor id -> alerting, from the last reading we could actually take.
  42. # Kept apart from _readings because a dropout must not read as the
  43. # alert clearing: on -> unavailable -> on is one continuous alert, and
  44. # notifying off _readings alone would ping the user on every reconnect
  45. # of a flaky contact. Absent means "never had a reachable reading".
  46. self._last_alerting: dict[int, bool] = {}
  47. # -- lifecycle ---------------------------------------------------------
  48. def start(self):
  49. if self._task is None:
  50. self._task = asyncio.create_task(self._poll_loop())
  51. logger.info("Home Assistant sensor poller started")
  52. def stop(self):
  53. if self._task:
  54. self._task.cancel()
  55. self._task = None
  56. logger.info("Home Assistant sensor poller stopped")
  57. # -- cache access ------------------------------------------------------
  58. def get_reading(self, sensor_id: int) -> SensorReading | None:
  59. return self._readings.get(sensor_id)
  60. def forget(self, sensor_id: int):
  61. """Drop a deleted sensor's cached reading so its id cannot be reused
  62. by a later row and answer with the old sensor's state."""
  63. self._readings.pop(sensor_id, None)
  64. self._last_alerting.pop(sensor_id, None)
  65. async def blocked_printers(self, db: AsyncSession) -> dict[int, str]:
  66. """Printers currently held by an interlock, mapped to the sensor names.
  67. A sensor counts only when it is configured to block, *and* was read
  68. successfully, *and* is in its alert state. Anything we could not read
  69. is omitted, so the queue keeps moving when Home Assistant is down.
  70. One query for the whole fleet — the scheduler calls this on every pass,
  71. and per-printer lookups would put a query per printer in that loop.
  72. """
  73. result = await db.execute(select(PrinterHASensor).where(PrinterHASensor.block_print.is_(True)))
  74. blocked: dict[int, list[str]] = {}
  75. for sensor in result.scalars().all():
  76. reading = self._readings.get(sensor.id)
  77. if reading and reading.reachable and reading.alerting:
  78. blocked.setdefault(sensor.printer_id, []).append(sensor.name)
  79. return {printer_id: ", ".join(names) for printer_id, names in blocked.items()}
  80. # -- polling -----------------------------------------------------------
  81. async def _poll_loop(self):
  82. while True:
  83. try:
  84. await asyncio.sleep(POLL_INTERVAL)
  85. await self.poll_once()
  86. except asyncio.CancelledError:
  87. break
  88. except Exception as e:
  89. logger.warning("Home Assistant sensor poll failed: %s", e)
  90. async def poll_once(self):
  91. """One pass over every configured sensor."""
  92. from backend.app.core.database import async_session
  93. async with async_session() as db:
  94. result = await db.execute(select(PrinterHASensor))
  95. sensors = list(result.scalars().all())
  96. # Drop readings for rows that no longer exist. The delete route
  97. # calls forget(), but a printer deleted with sensors attached takes
  98. # them out by cascade, and a restored backup can renumber them —
  99. # either way a stale id must not answer for a later sensor.
  100. live = {s.id for s in sensors}
  101. for stale in set(self._readings) - live:
  102. self.forget(stale)
  103. if not sensors:
  104. return
  105. if not await self._configure(db):
  106. # Not configured is not a failure to report every 15 seconds,
  107. # but the readings must not go stale-but-confident either.
  108. for sensor in sensors:
  109. self._readings[sensor.id] = SensorReading(None, None, False, False)
  110. return
  111. states = await homeassistant_service.fetch_states(sorted({s.entity_id for s in sensors}))
  112. await self._apply(db, sensors, states)
  113. async def refresh_one(self, db: AsyncSession, sensor: PrinterHASensor):
  114. """Read a single sensor now, on the caller's session.
  115. Used after a create or an edit so the card shows a state straight away
  116. instead of blank until the next tick. Deliberately not a full
  117. ``poll_once``: a request handler must not wait on every configured
  118. entity, and must not fire another user's notification as a side effect
  119. of this one saving a form.
  120. """
  121. self.forget(sensor.id)
  122. if not await self._configure(db):
  123. self._readings[sensor.id] = SensorReading(None, None, False, False)
  124. return
  125. states = await homeassistant_service.fetch_states([sensor.entity_id])
  126. reading = evaluate(sensor, states.get(sensor.entity_id))
  127. self._readings[sensor.id] = reading
  128. if reading.reachable:
  129. self._last_alerting[sensor.id] = reading.alerting
  130. sensor.last_checked = utcnow_naive()
  131. if reading.reachable and sensor.last_state != reading.state:
  132. sensor.last_state = reading.state
  133. sensor.last_changed = sensor.last_checked
  134. await db.commit()
  135. await db.refresh(sensor)
  136. async def _configure(self, db: AsyncSession) -> bool:
  137. from backend.app.api.routes.settings import get_homeassistant_settings
  138. try:
  139. ha_settings = await get_homeassistant_settings(db)
  140. except Exception as e:
  141. logger.warning("Failed to read Home Assistant settings: %s", e)
  142. return False
  143. if not ha_settings["ha_url"] or not ha_settings["ha_token"]:
  144. return False
  145. homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
  146. return True
  147. async def _apply(self, db: AsyncSession, sensors: list[PrinterHASensor], states: dict[str, dict | None]):
  148. """Fold poll results into the cache, the DB and any notifications."""
  149. from backend.app.services.notification_service import notification_service
  150. now = utcnow_naive()
  151. alerts: list[tuple[PrinterHASensor, SensorReading]] = []
  152. for sensor in sensors:
  153. payload = states.get(sensor.entity_id)
  154. reading = evaluate(sensor, payload)
  155. was_alerting = self._last_alerting.get(sensor.id)
  156. self._readings[sensor.id] = reading
  157. sensor.last_checked = now
  158. if reading.reachable:
  159. if sensor.last_state != reading.state:
  160. sensor.last_state = reading.state
  161. sensor.last_changed = now
  162. # Notify on the edge into alerting only. `was_alerting is None` is
  163. # a cold cache (first poll after a restart) — a door that was
  164. # already open then has not just been opened, and re-announcing it
  165. # on every restart would train users to ignore the alert.
  166. if sensor.notify_on_alert and reading.reachable and reading.alerting and was_alerting is False:
  167. alerts.append((sensor, reading))
  168. if reading.reachable:
  169. self._last_alerting[sensor.id] = reading.alerting
  170. await db.commit()
  171. for sensor, reading in alerts:
  172. # db.get, not sensor.printer: touching the lazy relationship from
  173. # an async session raises MissingGreenlet.
  174. printer = await db.get(Printer, sensor.printer_id)
  175. try:
  176. await notification_service.on_ha_sensor_alert(
  177. printer_id=sensor.printer_id,
  178. printer_name=printer.name if printer else "Unknown",
  179. sensor_name=sensor.name,
  180. state=describe_state(sensor, reading),
  181. db=db,
  182. )
  183. except Exception as e:
  184. logger.warning("Failed to send HA sensor alert for '%s': %s", sensor.name, e)
  185. def evaluate(sensor: PrinterHASensor, payload: dict | None) -> SensorReading:
  186. """Turn one HA state payload into a reading.
  187. Split out from the manager so the alert rules can be tested without a
  188. poller, a database or a Home Assistant.
  189. """
  190. if payload is None:
  191. return SensorReading(state=None, value=None, alerting=False, reachable=False)
  192. state = payload.get("state")
  193. # HA reports these two for entities whose integration is down. Treating
  194. # them as a state would make "unavailable" a value the card renders and
  195. # the thresholds compare against.
  196. if state in (None, "unknown", "unavailable"):
  197. return SensorReading(state=None, value=None, alerting=False, reachable=False)
  198. state = str(state)
  199. if sensor.kind == "numeric":
  200. value = as_float(state)
  201. if value is None:
  202. # A sensor that used to report numbers and now reports text is
  203. # not a reading we can place against a threshold.
  204. return SensorReading(state=state, value=None, alerting=False, reachable=True)
  205. alerting = (sensor.alert_above is not None and value > sensor.alert_above) or (
  206. sensor.alert_below is not None and value < sensor.alert_below
  207. )
  208. return SensorReading(state=state, value=value, alerting=alerting, reachable=True)
  209. normalized = state.lower()
  210. alerting = sensor.alert_state is not None and normalized == sensor.alert_state
  211. return SensorReading(state=normalized, value=None, alerting=alerting, reachable=True)
  212. def describe_state(sensor: PrinterHASensor, reading: SensorReading) -> str:
  213. """Human-readable state for a notification body ("open", "31.4 °C")."""
  214. if sensor.kind == "numeric" and reading.value is not None:
  215. return f"{reading.value:g} {sensor.unit}".strip() if sensor.unit else f"{reading.value:g}"
  216. return reading.state or "unknown"
  217. ha_sensor_manager = HASensorManager()