mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
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.
240 lines
9.0 KiB
Python
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"}
|