ha_sensors.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. """API routes for Home Assistant sensors bound to a printer (#1148, #448)."""
  2. import logging
  3. from fastapi import APIRouter, Depends, HTTPException
  4. from sqlalchemy import select
  5. from sqlalchemy.ext.asyncio import AsyncSession
  6. from backend.app.core.auth import (
  7. RequestPrinterScope,
  8. RequirePermissionIfAuthEnabled,
  9. RequirePrinterPermissionIfAuthEnabled,
  10. )
  11. from backend.app.core.database import get_db
  12. from backend.app.core.permissions import Permission
  13. from backend.app.core.printer_scope import PrinterScope
  14. from backend.app.models.printer import Printer
  15. from backend.app.models.printer_ha_sensor import PrinterHASensor
  16. from backend.app.models.user import User
  17. from backend.app.schemas.printer_ha_sensor import (
  18. HADisplayEntity,
  19. PrinterHASensorCreate,
  20. PrinterHASensorReading,
  21. PrinterHASensorResponse,
  22. PrinterHASensorUpdate,
  23. )
  24. from backend.app.services.ha_sensor_manager import ha_sensor_manager
  25. from backend.app.services.homeassistant import homeassistant_service
  26. logger = logging.getLogger(__name__)
  27. router = APIRouter(prefix="/ha-sensors", tags=["ha-sensors"])
  28. # These reuse the smart-plug permissions rather than introducing their own.
  29. # Both surfaces are "the Home Assistant integration", and a brand-new
  30. # permission would be missing from every existing custom role — users who can
  31. # manage plugs today would silently lose access to the sensors next to them.
  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. async def _refresh_quietly(sensor: PrinterHASensor, db: AsyncSession) -> None:
  37. """Take a first reading without letting it fail the write that preceded it.
  38. The sensor row is committed before this runs. A failure here costs the card
  39. one poll interval of blank state, which is not worth turning a successful
  40. save into an error response.
  41. """
  42. try:
  43. await ha_sensor_manager.refresh_one(db, sensor)
  44. except Exception as e:
  45. logger.warning("Could not read %s right after saving it: %s", sensor.entity_id, e)
  46. async def _load_sensor(db: AsyncSession, sensor_id: int, printer_scope: PrinterScope) -> PrinterHASensor:
  47. """The sensor, if its printer is one the caller may use; otherwise the 404 of a missing one."""
  48. sensor = await db.get(PrinterHASensor, sensor_id)
  49. if not sensor or not printer_scope.allows(sensor.printer_id):
  50. raise HTTPException(404, "Sensor not found")
  51. return sensor
  52. @router.get("/", response_model=list[PrinterHASensorResponse])
  53. async def list_ha_sensors(
  54. printer_id: int | None = None,
  55. db: AsyncSession = Depends(get_db),
  56. _: User | None = _READ,
  57. printer_scope: PrinterScope = RequestPrinterScope,
  58. ):
  59. """List configured sensors, grouped by printer and in display order."""
  60. query = select(PrinterHASensor)
  61. if (scope_clause := printer_scope.where(PrinterHASensor.printer_id)) is not None:
  62. query = query.where(scope_clause)
  63. if printer_id is not None:
  64. query = query.where(PrinterHASensor.printer_id == printer_id)
  65. result = await db.execute(query.order_by(PrinterHASensor.printer_id, PrinterHASensor.sort_order))
  66. return list(result.scalars().all())
  67. # Must precede /{sensor_id} so "entities" is not parsed as an id.
  68. @router.get("/entities", response_model=list[HADisplayEntity])
  69. async def list_bindable_entities(
  70. search: str | None = None,
  71. db: AsyncSession = Depends(get_db),
  72. _: User | None = _READ,
  73. ):
  74. """List the Home Assistant entities that can be bound to a printer."""
  75. from backend.app.api.routes.settings import get_homeassistant_settings
  76. ha_settings = await get_homeassistant_settings(db)
  77. if not ha_settings["ha_url"] or not ha_settings["ha_token"]:
  78. raise HTTPException(
  79. 400,
  80. "Home Assistant not configured. Please set HA URL and token in Settings → Network → Home Assistant.",
  81. )
  82. entities = await homeassistant_service.list_display_entities(ha_settings["ha_url"], ha_settings["ha_token"], search)
  83. return [HADisplayEntity(**e) for e in entities]
  84. @router.get("/by-printer/{printer_id}/readings", response_model=list[PrinterHASensorReading])
  85. async def get_printer_sensor_readings(
  86. printer_id: int,
  87. db: AsyncSession = Depends(get_db),
  88. _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
  89. ):
  90. """Live state of a printer's card-visible sensors.
  91. Served from the poller's cache, so a page full of printer cards costs
  92. Home Assistant nothing. A sensor the poller has not reached yet falls back
  93. to its last persisted state, marked unreachable, rather than vanishing
  94. from the card on every restart.
  95. """
  96. result = await db.execute(
  97. select(PrinterHASensor)
  98. .where(
  99. PrinterHASensor.printer_id == printer_id,
  100. PrinterHASensor.show_on_printer_card.is_(True),
  101. )
  102. .order_by(PrinterHASensor.sort_order, PrinterHASensor.id)
  103. )
  104. readings = []
  105. for sensor in result.scalars().all():
  106. cached = ha_sensor_manager.get_reading(sensor.id)
  107. readings.append(
  108. PrinterHASensorReading(
  109. id=sensor.id,
  110. name=sensor.name,
  111. entity_id=sensor.entity_id,
  112. kind=sensor.kind,
  113. device_class=sensor.device_class,
  114. unit=sensor.unit,
  115. state=cached.state if cached else sensor.last_state,
  116. value=cached.value if cached else None,
  117. alerting=cached.alerting if cached else False,
  118. block_print=sensor.block_print,
  119. reachable=cached.reachable if cached else False,
  120. last_changed=sensor.last_changed,
  121. )
  122. )
  123. return readings
  124. @router.post("/", response_model=PrinterHASensorResponse)
  125. async def create_ha_sensor(
  126. data: PrinterHASensorCreate,
  127. db: AsyncSession = Depends(get_db),
  128. _: User | None = _CREATE,
  129. printer_scope: PrinterScope = RequestPrinterScope,
  130. ):
  131. """Bind a Home Assistant entity to a printer."""
  132. printer_scope.ensure(data.printer_id)
  133. printer = await db.get(Printer, data.printer_id)
  134. if not printer:
  135. raise HTTPException(404, "Printer not found")
  136. existing = await db.execute(
  137. select(PrinterHASensor).where(
  138. PrinterHASensor.printer_id == data.printer_id,
  139. PrinterHASensor.entity_id == data.entity_id,
  140. )
  141. )
  142. if existing.scalar_one_or_none():
  143. raise HTTPException(400, f"{data.entity_id} is already bound to this printer")
  144. sensor = PrinterHASensor(**data.model_dump())
  145. db.add(sensor)
  146. await db.commit()
  147. await db.refresh(sensor)
  148. logger.info("Bound HA entity %s to printer %s as '%s'", sensor.entity_id, sensor.printer_id, sensor.name)
  149. # Read it once now so the card shows a state immediately instead of after
  150. # the next poll tick. Best-effort: the row is already committed, so letting
  151. # a Home Assistant hiccup 500 the request would report a failure for work
  152. # that succeeded — and the retry would come back "already bound".
  153. await _refresh_quietly(sensor, db)
  154. return sensor
  155. @router.get("/{sensor_id}", response_model=PrinterHASensorResponse)
  156. async def get_ha_sensor(
  157. sensor_id: int,
  158. db: AsyncSession = Depends(get_db),
  159. _: User | None = _READ,
  160. printer_scope: PrinterScope = RequestPrinterScope,
  161. ):
  162. return await _load_sensor(db, sensor_id, printer_scope)
  163. @router.patch("/{sensor_id}", response_model=PrinterHASensorResponse)
  164. async def update_ha_sensor(
  165. sensor_id: int,
  166. data: PrinterHASensorUpdate,
  167. db: AsyncSession = Depends(get_db),
  168. _: User | None = _UPDATE,
  169. printer_scope: PrinterScope = RequestPrinterScope,
  170. ):
  171. sensor = await _load_sensor(db, sensor_id, printer_scope)
  172. updates = data.model_dump(exclude_unset=True)
  173. # Re-run the create-time rules against the merged row. A PATCH that only
  174. # sets block_print has no entity_id or alert_state in its payload, so the
  175. # schema alone cannot tell whether the result is coherent.
  176. merged = {field: getattr(sensor, field) for field in PrinterHASensorCreate.model_fields}
  177. merged.update(updates)
  178. try:
  179. PrinterHASensorCreate(**merged)
  180. except ValueError as e:
  181. raise HTTPException(422, str(e)) from e
  182. # Same uniqueness rule as create: repointing a sensor at an entity the
  183. # printer already has would leave two rows fighting over one pill.
  184. new_entity = updates.get("entity_id")
  185. if new_entity and new_entity != sensor.entity_id:
  186. clash = await db.execute(
  187. select(PrinterHASensor).where(
  188. PrinterHASensor.printer_id == sensor.printer_id,
  189. PrinterHASensor.entity_id == new_entity,
  190. PrinterHASensor.id != sensor.id,
  191. )
  192. )
  193. if clash.scalar_one_or_none():
  194. raise HTTPException(400, f"{new_entity} is already bound to this printer")
  195. for field, value in updates.items():
  196. setattr(sensor, field, value)
  197. await db.commit()
  198. await db.refresh(sensor)
  199. # The entity or its alert rule may have changed under the cached reading.
  200. await _refresh_quietly(sensor, db)
  201. return sensor
  202. @router.delete("/{sensor_id}")
  203. async def delete_ha_sensor(
  204. sensor_id: int,
  205. db: AsyncSession = Depends(get_db),
  206. _: User | None = _DELETE,
  207. printer_scope: PrinterScope = RequestPrinterScope,
  208. ):
  209. sensor = await _load_sensor(db, sensor_id, printer_scope)
  210. name = sensor.name
  211. await db.delete(sensor)
  212. await db.commit()
  213. ha_sensor_manager.forget(sensor_id)
  214. logger.info("Removed HA sensor '%s'", name)
  215. return {"message": f"Sensor '{name}' removed"}