location_ha_sensor_manager.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. import asyncio
  2. import logging
  3. from sqlalchemy import select
  4. from sqlalchemy.ext.asyncio import AsyncSession
  5. from backend.app.models.location import Location
  6. from backend.app.models.location_ha_sensor import LAST_STATE_MAX_LENGTH, LocationHASensor
  7. from backend.app.models.settings import Settings
  8. from backend.app.services.ha_sensor_manager import SensorReading, describe_state, evaluate, persistable_state
  9. from backend.app.services.homeassistant import homeassistant_service
  10. from backend.app.utils.local_time import utcnow_naive
  11. logger = logging.getLogger(__name__)
  12. POLL_INTERVAL = 120
  13. MIN_POLL_INTERVAL = 60
  14. class LocationHASensorManager:
  15. def __init__(self):
  16. self._task: asyncio.Task | None = None
  17. # sensor id -> last reading. Sensors absent from this map have not been
  18. # polled yet; callers must not read that as "not alerting" without also
  19. # checking, which is why get_reading returns None rather than a default.
  20. self._readings: dict[int, SensorReading] = {}
  21. # sensor id -> alerting, from the last reading we could actually take.
  22. # Kept apart from _readings because a dropout must not read as the
  23. # alert clearing: on -> unavailable -> on is one continuous alert, and
  24. # notifying off _readings alone would ping the user on every reconnect
  25. # of a flaky sensor. Absent means "never had a reachable reading".
  26. self._last_alerting: dict[int, bool] = {}
  27. def start(self):
  28. if self._task is None:
  29. self._task = asyncio.create_task(self._poll_loop())
  30. logger.info("Home Assistant location-sensor poller started")
  31. def stop(self):
  32. if self._task:
  33. self._task.cancel()
  34. self._task = None
  35. logger.info("Home Assistant location-sensor poller stopped")
  36. def get_reading(self, sensor_id: int) -> SensorReading | None:
  37. return self._readings.get(sensor_id)
  38. def forget(self, sensor_id: int):
  39. """Drop a deleted sensor's cached reading so its id cannot be reused
  40. by a later row and answer with the old sensor's state."""
  41. self._readings.pop(sensor_id, None)
  42. self._last_alerting.pop(sensor_id, None)
  43. async def _poll_loop(self):
  44. # Poll first, sleep after — the interval is configurable and can be
  45. # minutes long, and a restart should not leave every location's
  46. # reading blank on the card for a full interval before the first one
  47. # lands.
  48. while True:
  49. try:
  50. await self.poll_once()
  51. except asyncio.CancelledError:
  52. break
  53. except Exception as e:
  54. logger.warning("Home Assistant location-sensor poll failed: %s", e)
  55. try:
  56. await asyncio.sleep(await self._get_poll_interval())
  57. except asyncio.CancelledError:
  58. break
  59. except Exception as e:
  60. # _get_poll_interval() reads Settings, so this leg does I/O
  61. # and a transient database failure (pool exhaustion, a
  62. # restarting server) can raise here. Letting it escape ends
  63. # the task for good: stop() is what clears self._task, so a
  64. # loop that died on its own leaves it set and start() will
  65. # not revive it — location sensors would stay frozen until
  66. # the process restarts. The poll_once() call above already
  67. # survives the same error one line earlier.
  68. logger.warning("Home Assistant location-sensor poll interval lookup failed: %s", e)
  69. await asyncio.sleep(POLL_INTERVAL)
  70. async def _get_poll_interval(self) -> int:
  71. """User-configurable poll cadence, clamped to a sane floor.
  72. Falls back to the default on a missing row or a corrupted value
  73. rather than raising — a bad setting must not take the poller down.
  74. """
  75. from backend.app.core.database import async_session
  76. async with async_session() as db:
  77. result = await db.execute(select(Settings).where(Settings.key == "location_sensor_poll_interval"))
  78. row = result.scalar_one_or_none()
  79. if row is None:
  80. return POLL_INTERVAL
  81. try:
  82. return max(MIN_POLL_INTERVAL, int(row.value))
  83. except (TypeError, ValueError):
  84. return POLL_INTERVAL
  85. async def poll_once(self):
  86. """One pass over every configured sensor."""
  87. from backend.app.core.database import async_session
  88. async with async_session() as db:
  89. result = await db.execute(select(LocationHASensor))
  90. sensors = list(result.scalars().all())
  91. # Drop readings for rows that no longer exist. The delete route
  92. # calls forget(), but a location deleted with sensors attached
  93. # takes them out by cascade, and a restored backup can renumber
  94. # them — either way a stale id must not answer for a later sensor.
  95. live = {s.id for s in sensors}
  96. for stale in set(self._readings) - live:
  97. self.forget(stale)
  98. if not sensors:
  99. return
  100. if not await self._configure(db):
  101. for sensor in sensors:
  102. self._readings[sensor.id] = SensorReading(None, None, False, False)
  103. return
  104. states = await homeassistant_service.fetch_states(sorted({s.entity_id for s in sensors}))
  105. await self._apply(db, sensors, states)
  106. async def refresh_one(self, db: AsyncSession, sensor: LocationHASensor):
  107. """Read a single sensor now, on the caller's session.
  108. Used after a create or an edit so the card shows a state straight away
  109. instead of blank until the next tick. Deliberately not a full
  110. ``poll_once``: a request handler must not wait on every configured
  111. entity, and must not fire another user's notification as a side effect
  112. of this one saving a form.
  113. """
  114. self.forget(sensor.id)
  115. if not await self._configure(db):
  116. self._readings[sensor.id] = SensorReading(None, None, False, False)
  117. return
  118. states = await homeassistant_service.fetch_states([sensor.entity_id])
  119. reading = evaluate(sensor, states.get(sensor.entity_id))
  120. self._readings[sensor.id] = reading
  121. if reading.reachable:
  122. self._last_alerting[sensor.id] = reading.alerting
  123. sensor.last_checked = utcnow_naive()
  124. persisted = persistable_state(reading.state, LAST_STATE_MAX_LENGTH)
  125. if reading.reachable and sensor.last_state != persisted:
  126. sensor.last_state = persisted
  127. sensor.last_changed = sensor.last_checked
  128. await db.commit()
  129. await db.refresh(sensor)
  130. async def _configure(self, db: AsyncSession) -> bool:
  131. from backend.app.api.routes.settings import get_homeassistant_settings
  132. try:
  133. ha_settings = await get_homeassistant_settings(db)
  134. except Exception as e:
  135. logger.warning("Failed to read Home Assistant settings: %s", e)
  136. return False
  137. if not ha_settings["ha_url"] or not ha_settings["ha_token"]:
  138. return False
  139. homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
  140. return True
  141. async def _apply(self, db: AsyncSession, sensors: list[LocationHASensor], states: dict[str, dict | None]):
  142. """Fold poll results into the cache, the DB and any notifications."""
  143. from backend.app.services.notification_service import notification_service
  144. now = utcnow_naive()
  145. alerts: list[tuple[LocationHASensor, SensorReading]] = []
  146. for sensor in sensors:
  147. payload = states.get(sensor.entity_id)
  148. reading = evaluate(sensor, payload)
  149. was_alerting = self._last_alerting.get(sensor.id)
  150. self._readings[sensor.id] = reading
  151. sensor.last_checked = now
  152. if reading.reachable:
  153. persisted = persistable_state(reading.state, LAST_STATE_MAX_LENGTH)
  154. if sensor.last_state != persisted:
  155. sensor.last_state = persisted
  156. sensor.last_changed = now
  157. # Notify on the edge into alerting only. `was_alerting is None` is
  158. # a cold cache (first poll after a restart) — a drybox that was
  159. # already too humid then has not just become too humid, and
  160. # re-announcing it on every restart would train users to ignore
  161. # the alert.
  162. if sensor.notify_on_alert and reading.reachable and reading.alerting and was_alerting is False:
  163. alerts.append((sensor, reading))
  164. if reading.reachable:
  165. self._last_alerting[sensor.id] = reading.alerting
  166. await db.commit()
  167. for sensor, reading in alerts:
  168. # db.get, not sensor.location: touching the lazy relationship from
  169. # an async session raises MissingGreenlet.
  170. location = await db.get(Location, sensor.location_id)
  171. try:
  172. await notification_service.on_location_ha_sensor_alert(
  173. location_name=location.name if location else "Unknown",
  174. sensor_name=sensor.name,
  175. state=describe_state(sensor, reading),
  176. db=db,
  177. )
  178. except Exception as e:
  179. logger.warning("Failed to send HA sensor alert for '%s': %s", sensor.name, e)
  180. location_ha_sensor_manager = LocationHASensorManager()