| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239 |
- """API routes for Home Assistant sensors bound to a printer (#1148, #448)."""
- import logging
- from fastapi import APIRouter, Depends, HTTPException
- from sqlalchemy import select
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.core.auth import RequirePermissionIfAuthEnabled
- from backend.app.core.database import get_db
- from backend.app.core.permissions import Permission
- from backend.app.models.printer import Printer
- from backend.app.models.printer_ha_sensor import PrinterHASensor
- from backend.app.models.user import User
- from backend.app.schemas.printer_ha_sensor import (
- HADisplayEntity,
- PrinterHASensorCreate,
- PrinterHASensorReading,
- PrinterHASensorResponse,
- PrinterHASensorUpdate,
- )
- from backend.app.services.ha_sensor_manager import ha_sensor_manager
- from backend.app.services.homeassistant import homeassistant_service
- logger = logging.getLogger(__name__)
- router = APIRouter(prefix="/ha-sensors", tags=["ha-sensors"])
- # These reuse the smart-plug permissions rather than introducing their own.
- # Both surfaces are "the Home Assistant integration", and a brand-new
- # permission would be missing from every existing custom role — users who can
- # manage plugs today would silently lose access to the sensors next to them.
- _READ = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ)
- _CREATE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE)
- _UPDATE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE)
- _DELETE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE)
- async def _refresh_quietly(sensor: PrinterHASensor, db: AsyncSession) -> None:
- """Take a first reading without letting it fail the write that preceded it.
- The sensor row is committed before this runs. A failure here costs the card
- one poll interval of blank state, which is not worth turning a successful
- save into an error response.
- """
- try:
- await ha_sensor_manager.refresh_one(db, sensor)
- except Exception as e:
- logger.warning("Could not read %s right after saving it: %s", sensor.entity_id, e)
- @router.get("/", response_model=list[PrinterHASensorResponse])
- async def list_ha_sensors(
- printer_id: int | None = None,
- db: AsyncSession = Depends(get_db),
- _: User | None = _READ,
- ):
- """List configured sensors, grouped by printer and in display order."""
- query = select(PrinterHASensor)
- if printer_id is not None:
- query = query.where(PrinterHASensor.printer_id == printer_id)
- result = await db.execute(query.order_by(PrinterHASensor.printer_id, PrinterHASensor.sort_order))
- return list(result.scalars().all())
- # Must precede /{sensor_id} so "entities" is not parsed as an id.
- @router.get("/entities", response_model=list[HADisplayEntity])
- async def list_bindable_entities(
- search: str | None = None,
- db: AsyncSession = Depends(get_db),
- _: User | None = _READ,
- ):
- """List the Home Assistant entities that can be bound to a printer."""
- from backend.app.api.routes.settings import get_homeassistant_settings
- ha_settings = await get_homeassistant_settings(db)
- if not ha_settings["ha_url"] or not ha_settings["ha_token"]:
- raise HTTPException(
- 400,
- "Home Assistant not configured. Please set HA URL and token in Settings → Network → Home Assistant.",
- )
- entities = await homeassistant_service.list_display_entities(ha_settings["ha_url"], ha_settings["ha_token"], search)
- return [HADisplayEntity(**e) for e in entities]
- @router.get("/by-printer/{printer_id}/readings", response_model=list[PrinterHASensorReading])
- async def get_printer_sensor_readings(
- printer_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = _READ,
- ):
- """Live state of a printer's card-visible sensors.
- Served from the poller's cache, so a page full of printer cards costs
- Home Assistant nothing. A sensor the poller has not reached yet falls back
- to its last persisted state, marked unreachable, rather than vanishing
- from the card on every restart.
- """
- result = await db.execute(
- select(PrinterHASensor)
- .where(
- PrinterHASensor.printer_id == printer_id,
- PrinterHASensor.show_on_printer_card.is_(True),
- )
- .order_by(PrinterHASensor.sort_order, PrinterHASensor.id)
- )
- readings = []
- for sensor in result.scalars().all():
- cached = ha_sensor_manager.get_reading(sensor.id)
- readings.append(
- PrinterHASensorReading(
- id=sensor.id,
- name=sensor.name,
- entity_id=sensor.entity_id,
- kind=sensor.kind,
- device_class=sensor.device_class,
- unit=sensor.unit,
- state=cached.state if cached else sensor.last_state,
- value=cached.value if cached else None,
- alerting=cached.alerting if cached else False,
- block_print=sensor.block_print,
- reachable=cached.reachable if cached else False,
- last_changed=sensor.last_changed,
- )
- )
- return readings
- @router.post("/", response_model=PrinterHASensorResponse)
- async def create_ha_sensor(
- data: PrinterHASensorCreate,
- db: AsyncSession = Depends(get_db),
- _: User | None = _CREATE,
- ):
- """Bind a Home Assistant entity to a printer."""
- printer = await db.get(Printer, data.printer_id)
- if not printer:
- raise HTTPException(404, "Printer not found")
- existing = await db.execute(
- select(PrinterHASensor).where(
- PrinterHASensor.printer_id == data.printer_id,
- PrinterHASensor.entity_id == data.entity_id,
- )
- )
- if existing.scalar_one_or_none():
- raise HTTPException(400, f"{data.entity_id} is already bound to this printer")
- sensor = PrinterHASensor(**data.model_dump())
- db.add(sensor)
- await db.commit()
- await db.refresh(sensor)
- logger.info("Bound HA entity %s to printer %s as '%s'", sensor.entity_id, sensor.printer_id, sensor.name)
- # Read it once now so the card shows a state immediately instead of after
- # the next poll tick. Best-effort: the row is already committed, so letting
- # a Home Assistant hiccup 500 the request would report a failure for work
- # that succeeded — and the retry would come back "already bound".
- await _refresh_quietly(sensor, db)
- return sensor
- @router.get("/{sensor_id}", response_model=PrinterHASensorResponse)
- async def get_ha_sensor(
- sensor_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = _READ,
- ):
- sensor = await db.get(PrinterHASensor, sensor_id)
- if not sensor:
- raise HTTPException(404, "Sensor not found")
- return sensor
- @router.patch("/{sensor_id}", response_model=PrinterHASensorResponse)
- async def update_ha_sensor(
- sensor_id: int,
- data: PrinterHASensorUpdate,
- db: AsyncSession = Depends(get_db),
- _: User | None = _UPDATE,
- ):
- sensor = await db.get(PrinterHASensor, sensor_id)
- if not sensor:
- raise HTTPException(404, "Sensor not found")
- updates = data.model_dump(exclude_unset=True)
- # Re-run the create-time rules against the merged row. A PATCH that only
- # sets block_print has no entity_id or alert_state in its payload, so the
- # schema alone cannot tell whether the result is coherent.
- merged = {field: getattr(sensor, field) for field in PrinterHASensorCreate.model_fields}
- merged.update(updates)
- try:
- PrinterHASensorCreate(**merged)
- except ValueError as e:
- raise HTTPException(422, str(e)) from e
- # Same uniqueness rule as create: repointing a sensor at an entity the
- # printer already has would leave two rows fighting over one pill.
- new_entity = updates.get("entity_id")
- if new_entity and new_entity != sensor.entity_id:
- clash = await db.execute(
- select(PrinterHASensor).where(
- PrinterHASensor.printer_id == sensor.printer_id,
- PrinterHASensor.entity_id == new_entity,
- PrinterHASensor.id != sensor.id,
- )
- )
- if clash.scalar_one_or_none():
- raise HTTPException(400, f"{new_entity} is already bound to this printer")
- for field, value in updates.items():
- setattr(sensor, field, value)
- await db.commit()
- await db.refresh(sensor)
- # The entity or its alert rule may have changed under the cached reading.
- await _refresh_quietly(sensor, db)
- return sensor
- @router.delete("/{sensor_id}")
- async def delete_ha_sensor(
- sensor_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = _DELETE,
- ):
- sensor = await db.get(PrinterHASensor, sensor_id)
- if not sensor:
- raise HTTPException(404, "Sensor not found")
- name = sensor.name
- await db.delete(sensor)
- await db.commit()
- ha_sensor_manager.forget(sensor_id)
- logger.info("Removed HA sensor '%s'", name)
- return {"message": f"Sensor '{name}' removed"}
|