Files
bambuddy/backend/app/schemas/printer_ha_sensor.py
T
maziggy cd004df817 Show Home Assistant sensors on the printer card (#1148, #448)
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.
2026-08-05 14:26:38 +02:00

115 lines
4.4 KiB
Python

"""Schemas for Home Assistant entities bound to a printer (#1148, #448)."""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, model_validator
class PrinterHASensorBase(BaseModel):
printer_id: int
name: str = Field(..., min_length=1, max_length=100)
entity_id: str = Field(..., pattern=r"^(binary_sensor|sensor)\.[a-z0-9_]+$")
kind: Literal["binary", "numeric"] = "binary"
device_class: str | None = Field(default=None, max_length=32)
unit: str | None = Field(default=None, max_length=16)
alert_state: Literal["on", "off"] | None = None
alert_above: float | None = None
alert_below: float | None = None
block_print: bool = False
notify_on_alert: bool = False
show_on_printer_card: bool = True
sort_order: int = Field(default=0, ge=0, le=999)
@model_validator(mode="after")
def validate_kind_matches_entity(self) -> "PrinterHASensorBase":
domain = self.entity_id.split(".")[0]
expected = "binary" if domain == "binary_sensor" else "numeric"
if self.kind != expected:
raise ValueError(f"kind must be '{expected}' for a {domain} entity")
# Alert fields are per-kind: a threshold on a door contact and an
# on/off alert on a thermometer are both configuration the poller
# would silently ignore, so reject them at the edge instead.
if self.kind == "binary" and (self.alert_above is not None or self.alert_below is not None):
raise ValueError("alert_above/alert_below only apply to numeric sensors")
if self.kind == "numeric" and self.alert_state is not None:
raise ValueError("alert_state only applies to binary sensors")
if self.alert_above is not None and self.alert_below is not None and self.alert_below >= self.alert_above:
raise ValueError("alert_below must be lower than alert_above")
# An interlock or a notification with nothing to trigger on would never
# fire — that reads as a broken feature, not as a no-op.
if (self.block_print or self.notify_on_alert) and not self._has_alert_condition():
raise ValueError("block_print and notify_on_alert require an alert condition")
return self
def _has_alert_condition(self) -> bool:
return self.alert_state is not None or self.alert_above is not None or self.alert_below is not None
class PrinterHASensorCreate(PrinterHASensorBase):
pass
class PrinterHASensorUpdate(BaseModel):
"""Partial update. Validated against the merged row in the route, because
the per-kind rules above need fields this payload may not carry."""
name: str | None = Field(default=None, min_length=1, max_length=100)
entity_id: str | None = Field(default=None, pattern=r"^(binary_sensor|sensor)\.[a-z0-9_]+$")
kind: Literal["binary", "numeric"] | None = None
device_class: str | None = Field(default=None, max_length=32)
unit: str | None = Field(default=None, max_length=16)
alert_state: Literal["on", "off"] | None = None
alert_above: float | None = None
alert_below: float | None = None
block_print: bool | None = None
notify_on_alert: bool | None = None
show_on_printer_card: bool | None = None
sort_order: int | None = Field(default=None, ge=0, le=999)
class PrinterHASensorResponse(PrinterHASensorBase):
id: int
last_state: str | None = None
last_changed: datetime | None = None
last_checked: datetime | None = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class PrinterHASensorReading(BaseModel):
"""One sensor's live state, as the printer card renders it."""
id: int
name: str
entity_id: str
kind: str
device_class: str | None = None
unit: str | None = None
# Raw HA state: "on"/"off" for binary, the numeric string for sensors.
# None when the entity is unavailable or has not been polled yet.
state: str | None = None
value: float | None = None # numeric sensors only, parsed from state
alerting: bool = False
block_print: bool = False
reachable: bool = True
last_changed: datetime | None = None
class HADisplayEntity(BaseModel):
"""A bindable entity, as offered by the picker."""
entity_id: str
friendly_name: str
state: str | None = None
domain: str
device_class: str | None = None
unit_of_measurement: str | None = None