ha_sensor_manager.py 13 KB

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