Files
maziggy 45921b7a56 Limit groups to selected printers (issue #1727)
A group can now be limited to a set of printers. Its members see and
control only those printers. Every other printer answers 404, as if it
didn't exist.

- Groups gain restrict_printers and a group_printers table (migration
  for SQLite and Postgres). A user's printers are the union of their
  limited groups. Groups without the flag don't limit anything, a user
  in no limited group keeps every printer, and admins see all of them.
- core/printer_scope.py holds the scope. RequestPrinterScope and
  RequirePrinterPermissionIfAuthEnabled apply it to routes: printer
  routes, camera, queue and batches, archives, projects, stats, print
  log, pipeline runs, inventory and Spoolman assignments, maintenance,
  smart plugs, scheduled drying, firmware and Obico status.
- API keys, camera stream, Cam Wall, overlay and WebSocket tokens carry
  the printers of whoever created them. WebSocket broadcasts are
  filtered per connection, and the filtering fails closed.
- Scheduler: "Any <model>" jobs stay on their owner's printers. A job
  pinned to a printer its owner lost waits with a reason. Callers with
  no user identity and limited printers must queue to a specific printer.
- Group editor: new Printer access section, translated into all 15
  locales. Saving a system group no longer resends unchanged
  permissions, which the backend refused.
2026-10-01 14:47:40 +02:00

240 lines
9.0 KiB
Python

"""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, RequirePrinterPermissionIfAuthEnabled
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 = RequirePrinterPermissionIfAuthEnabled(Permission.SMART_PLUGS_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"}