location_ha_sensors.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. """API routes for Home Assistant sensors bound to a storage location (#2824)."""
  2. import logging
  3. from fastapi import APIRouter, Depends, HTTPException
  4. from sqlalchemy import select
  5. from sqlalchemy.exc import IntegrityError
  6. from sqlalchemy.ext.asyncio import AsyncSession
  7. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  8. from backend.app.core.database import get_db
  9. from backend.app.core.permissions import Permission
  10. from backend.app.models.location import Location
  11. from backend.app.models.location_ha_sensor import LocationHASensor
  12. from backend.app.models.user import User
  13. from backend.app.schemas.location_ha_sensor import (
  14. HADisplayEntity,
  15. LocationHASensorCreate,
  16. LocationHASensorReading,
  17. LocationHASensorResponse,
  18. LocationHASensorUpdate,
  19. )
  20. from backend.app.services.homeassistant import homeassistant_service
  21. from backend.app.services.location_ha_sensor_manager import location_ha_sensor_manager
  22. logger = logging.getLogger(__name__)
  23. router = APIRouter(prefix="/location-ha-sensors", tags=["location-ha-sensors"])
  24. # Reuse the smart-plug permissions, same as ha_sensors.py: both surfaces are
  25. # "the Home Assistant integration", just scoped to a location instead of a
  26. # printer. INVENTORY_* would put HA entity bindings behind
  27. # can_manage_inventory, which defaults to on for API keys (see auth.py) —
  28. # an inventory-scoped key (e.g. a SpoolBuddy kiosk) would then be able to
  29. # create, edit and delete HA sensor bindings, a capability the printer
  30. # sibling deliberately keeps admin-only by leaving SMART_PLUGS_CREATE/
  31. # UPDATE/DELETE off the API-key allowlist entirely.
  32. _READ = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ)
  33. _CREATE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE)
  34. _UPDATE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE)
  35. _DELETE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE)
  36. # Mirrors categoryFor() in LocationHASensorModal.tsx, which also gates that
  37. # dialog's entity picker. A device class outside these three has no category
  38. # and is not subject to the one-per-location rule below.
  39. #
  40. # "moisture" is deliberately not mapped to humidity: it is Home Assistant's
  41. # binary wet/dry class, so a leak detector would otherwise block a real
  42. # hygrometer on the same location, and it could not carry the category's
  43. # thresholds anyway — the schema rejects alert_above/alert_below for
  44. # kind="binary".
  45. _CATEGORY_BY_DEVICE_CLASS = {
  46. "temperature": "temperature",
  47. "humidity": "humidity",
  48. "battery": "battery",
  49. }
  50. def _category_for(device_class: str | None) -> str | None:
  51. return _CATEGORY_BY_DEVICE_CLASS.get(device_class) if device_class else None
  52. async def _reject_duplicate_category(
  53. db: AsyncSession,
  54. location_id: int,
  55. device_class: str | None,
  56. exclude_sensor_id: int | None = None,
  57. ) -> None:
  58. """One sensor per category per location, enforced here and not only in the UI.
  59. The inventory column and the card footer both pick their reading with a
  60. single ``find`` over the location's sensors, so a second temperature
  61. sensor does not show up alongside the first — it silently shadows it
  62. depending on row order. The modal already prompts to replace rather than
  63. add, so this closes the same rule for direct API callers instead of
  64. leaving the guarantee resting on the client.
  65. """
  66. category = _category_for(device_class)
  67. if category is None:
  68. return
  69. query = select(LocationHASensor).where(LocationHASensor.location_id == location_id)
  70. if exclude_sensor_id is not None:
  71. query = query.where(LocationHASensor.id != exclude_sensor_id)
  72. result = await db.execute(query)
  73. for other in result.scalars().all():
  74. if _category_for(other.device_class) == category:
  75. raise HTTPException(
  76. 400,
  77. f"This location already has a {category} sensor ({other.entity_id}). "
  78. "Edit that sensor to point at a different entity instead.",
  79. )
  80. async def _refresh_quietly(sensor: LocationHASensor, db: AsyncSession) -> None:
  81. """Take a first reading without letting it fail the write that preceded it.
  82. The sensor row is committed before this runs. A failure here costs the
  83. card one poll interval of blank state, which is not worth turning a
  84. successful save into an error response.
  85. """
  86. try:
  87. await location_ha_sensor_manager.refresh_one(db, sensor)
  88. except Exception as e:
  89. logger.warning("Could not read %s right after saving it: %s", sensor.entity_id, e)
  90. @router.get("/", response_model=list[LocationHASensorResponse])
  91. async def list_location_ha_sensors(
  92. location_id: int | None = None,
  93. db: AsyncSession = Depends(get_db),
  94. _: User | None = _READ,
  95. ):
  96. """List configured sensors, grouped by location and in display order."""
  97. query = select(LocationHASensor)
  98. if location_id is not None:
  99. query = query.where(LocationHASensor.location_id == location_id)
  100. result = await db.execute(query.order_by(LocationHASensor.location_id, LocationHASensor.sort_order))
  101. return list(result.scalars().all())
  102. # Must precede /{sensor_id} so "entities" is not parsed as an id.
  103. @router.get("/entities", response_model=list[HADisplayEntity])
  104. async def list_bindable_entities(
  105. search: str | None = None,
  106. db: AsyncSession = Depends(get_db),
  107. _: User | None = _READ,
  108. ):
  109. """List the Home Assistant entities that can be bound to a storage location."""
  110. from backend.app.api.routes.settings import get_homeassistant_settings
  111. ha_settings = await get_homeassistant_settings(db)
  112. if not ha_settings["ha_url"] or not ha_settings["ha_token"]:
  113. raise HTTPException(
  114. 400,
  115. "Home Assistant not configured. Please set HA URL and token in Settings → Network → Home Assistant.",
  116. )
  117. entities = await homeassistant_service.list_display_entities(ha_settings["ha_url"], ha_settings["ha_token"], search)
  118. return [HADisplayEntity(**e) for e in entities]
  119. @router.get("/by-location/{location_id}/readings", response_model=list[LocationHASensorReading])
  120. async def get_location_sensor_readings(
  121. location_id: int,
  122. show_on_card: bool = True,
  123. db: AsyncSession = Depends(get_db),
  124. _: User | None = _READ,
  125. ):
  126. """Live state of a location's card-visible sensors.
  127. Served from the poller's cache, so a page full of filament cards costs
  128. Home Assistant nothing. A sensor the poller has not reached yet falls
  129. back to its last persisted state, marked unreachable, rather than
  130. vanishing from the card on every restart.
  131. """
  132. conditions = [LocationHASensor.location_id == location_id]
  133. if show_on_card:
  134. conditions.append(LocationHASensor.show_on_card.is_(True))
  135. result = await db.execute(
  136. select(LocationHASensor).where(*conditions).order_by(LocationHASensor.sort_order, LocationHASensor.id)
  137. )
  138. readings = []
  139. for sensor in result.scalars().all():
  140. cached = location_ha_sensor_manager.get_reading(sensor.id)
  141. readings.append(
  142. LocationHASensorReading(
  143. id=sensor.id,
  144. name=sensor.name,
  145. entity_id=sensor.entity_id,
  146. kind=sensor.kind,
  147. device_class=sensor.device_class,
  148. unit=sensor.unit,
  149. state=cached.state if cached else sensor.last_state,
  150. value=cached.value if cached else None,
  151. alerting=cached.alerting if cached else False,
  152. reachable=cached.reachable if cached else False,
  153. alert_state=sensor.alert_state,
  154. alert_above=sensor.alert_above,
  155. alert_below=sensor.alert_below,
  156. last_changed=sensor.last_changed,
  157. show_on_card=sensor.show_on_card,
  158. )
  159. )
  160. return readings
  161. @router.post("/", response_model=LocationHASensorResponse)
  162. async def create_location_ha_sensor(
  163. data: LocationHASensorCreate,
  164. db: AsyncSession = Depends(get_db),
  165. _: User | None = _CREATE,
  166. ):
  167. """Bind a Home Assistant entity to a storage location."""
  168. location = await db.get(Location, data.location_id)
  169. if not location:
  170. raise HTTPException(404, "Location not found")
  171. existing = await db.execute(
  172. select(LocationHASensor).where(
  173. LocationHASensor.location_id == data.location_id,
  174. LocationHASensor.entity_id == data.entity_id,
  175. )
  176. )
  177. if existing.scalar_one_or_none():
  178. raise HTTPException(400, f"{data.entity_id} is already bound to this location")
  179. await _reject_duplicate_category(db, data.location_id, data.device_class)
  180. sensor = LocationHASensor(**data.model_dump())
  181. db.add(sensor)
  182. try:
  183. await db.commit()
  184. except IntegrityError:
  185. # The duplicate check above is read-then-insert, so a concurrent
  186. # create for the same (location, entity) can get past it — the unique
  187. # index is the backstop, and its loser should read like the pre-check.
  188. await db.rollback()
  189. raise HTTPException(400, f"{data.entity_id} is already bound to this location") from None
  190. await db.refresh(sensor)
  191. logger.info("Bound HA entity %s to location %s as '%s'", sensor.entity_id, sensor.location_id, sensor.name)
  192. # Read it once now so the card shows a state immediately instead of after
  193. # the next poll tick. Best-effort: the row is already committed, so
  194. # letting a Home Assistant hiccup 500 the request would report a failure
  195. # for work that succeeded — and the retry would come back "already bound".
  196. await _refresh_quietly(sensor, db)
  197. return sensor
  198. @router.get("/{sensor_id}", response_model=LocationHASensorResponse)
  199. async def get_location_ha_sensor(
  200. sensor_id: int,
  201. db: AsyncSession = Depends(get_db),
  202. _: User | None = _READ,
  203. ):
  204. sensor = await db.get(LocationHASensor, sensor_id)
  205. if not sensor:
  206. raise HTTPException(404, "Sensor not found")
  207. return sensor
  208. @router.patch("/{sensor_id}", response_model=LocationHASensorResponse)
  209. async def update_location_ha_sensor(
  210. sensor_id: int,
  211. data: LocationHASensorUpdate,
  212. db: AsyncSession = Depends(get_db),
  213. _: User | None = _UPDATE,
  214. ):
  215. sensor = await db.get(LocationHASensor, sensor_id)
  216. if not sensor:
  217. raise HTTPException(404, "Sensor not found")
  218. updates = data.model_dump(exclude_unset=True)
  219. # Re-run the create-time rules against the merged row. A PATCH that only
  220. # sets show_on_card has no entity_id or alert_state in its payload, so the
  221. # schema alone cannot tell whether the result is coherent.
  222. merged = {field: getattr(sensor, field) for field in LocationHASensorCreate.model_fields}
  223. merged.update(updates)
  224. try:
  225. LocationHASensorCreate(**merged)
  226. except ValueError as e:
  227. raise HTTPException(422, str(e)) from e
  228. # Same uniqueness rule as create: repointing a sensor at an entity the
  229. # location already has would leave two rows fighting over one reading.
  230. new_entity = updates.get("entity_id")
  231. if new_entity and new_entity != sensor.entity_id:
  232. clash = await db.execute(
  233. select(LocationHASensor).where(
  234. LocationHASensor.location_id == sensor.location_id,
  235. LocationHASensor.entity_id == new_entity,
  236. LocationHASensor.id != sensor.id,
  237. )
  238. )
  239. if clash.scalar_one_or_none():
  240. raise HTTPException(400, f"{new_entity} is already bound to this location")
  241. # Same one-per-category rule as create, against the merged row and
  242. # excluding this sensor — repointing a sensor within its own category
  243. # (the modal's replace flow) stays allowed.
  244. if "device_class" in updates:
  245. await _reject_duplicate_category(db, sensor.location_id, merged["device_class"], exclude_sensor_id=sensor.id)
  246. for field, value in updates.items():
  247. setattr(sensor, field, value)
  248. # Read before commit: after a rollback the instance is expired, and
  249. # touching its attributes from async code raises MissingGreenlet.
  250. entity_id = sensor.entity_id
  251. try:
  252. await db.commit()
  253. except IntegrityError:
  254. # Same backstop as create: the clash check above races a concurrent
  255. # write, and the unique index decides who loses.
  256. await db.rollback()
  257. raise HTTPException(400, f"{entity_id} is already bound to this location") from None
  258. await db.refresh(sensor)
  259. # The entity or its alert rule may have changed under the cached reading.
  260. await _refresh_quietly(sensor, db)
  261. return sensor
  262. @router.delete("/{sensor_id}")
  263. async def delete_location_ha_sensor(
  264. sensor_id: int,
  265. db: AsyncSession = Depends(get_db),
  266. _: User | None = _DELETE,
  267. ):
  268. sensor = await db.get(LocationHASensor, sensor_id)
  269. if not sensor:
  270. raise HTTPException(404, "Sensor not found")
  271. name = sensor.name
  272. await db.delete(sensor)
  273. await db.commit()
  274. location_ha_sensor_manager.forget(sensor_id)
  275. logger.info("Removed location HA sensor '%s'", name)
  276. return {"message": f"Sensor '{name}' removed"}