mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
Binds binary_sensor and reading-carrying sensor entities to a printer and renders their state on its card, worded by Home Assistant's device_class. Optional per-sensor alert condition drives a notification on the transition into the alert state and an opt-in interlock that holds queued prints while alerting -- a hold with a readable waiting_reason, never a failure, and only ever on a sensor that was read successfully. Sensors get their own table rather than a wider entity pattern on SmartPlug: get_smart_plug_by_printer would otherwise hand the card's power button a door contact to switch. The hold is passed to the model matcher directly rather than merged into busy_printers: _check_auto_drying reads that set as "is currently printing" and would put an idle-but-held printer down the mid-print drying path. The notification_providers migration spells its default FALSE, not 0 -- Postgres rejects an integer default for a boolean and _safe_execute swallows the error.
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
"""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"}
|
||||
Reference in New Issue
Block a user