ha_sensors.py 8.9 KB

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