"""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