printer_ha_sensor.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. """Schemas for Home Assistant entities bound to a printer (#1148, #448)."""
  2. from datetime import datetime
  3. from typing import Literal
  4. from pydantic import BaseModel, Field, model_validator
  5. from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
  6. class PrinterHASensorBase(BaseModel):
  7. printer_id: int
  8. name: str = Field(..., min_length=1, max_length=100)
  9. # max_length matches the column (String(255)). The pattern's [a-z0-9_]+ is
  10. # unbounded, so a direct API caller could send a longer id: SQLite stores
  11. # it, PostgreSQL raises DataError, and it would surface as a 500 rather
  12. # than a 422. Same bound as the location sibling.
  13. entity_id: str = Field(..., max_length=255, pattern=r"^(binary_sensor|sensor)\.[a-z0-9_]+$")
  14. kind: Literal["binary", "numeric"] = "binary"
  15. device_class: str | None = Field(default=None, max_length=32)
  16. unit: str | None = Field(default=None, max_length=16)
  17. alert_state: Literal["on", "off"] | None = None
  18. alert_above: float | None = None
  19. alert_below: float | None = None
  20. block_print: bool = False
  21. notify_on_alert: bool = False
  22. show_on_printer_card: bool = True
  23. sort_order: int = Field(default=0, ge=0, le=999)
  24. @model_validator(mode="after")
  25. def validate_kind_matches_entity(self) -> "PrinterHASensorBase":
  26. domain = self.entity_id.split(".")[0]
  27. expected = "binary" if domain == "binary_sensor" else "numeric"
  28. if self.kind != expected:
  29. raise ValueError(f"kind must be '{expected}' for a {domain} entity")
  30. # Alert fields are per-kind: a threshold on a door contact and an
  31. # on/off alert on a thermometer are both configuration the poller
  32. # would silently ignore, so reject them at the edge instead.
  33. if self.kind == "binary" and (self.alert_above is not None or self.alert_below is not None):
  34. raise ValueError("alert_above/alert_below only apply to numeric sensors")
  35. if self.kind == "numeric" and self.alert_state is not None:
  36. raise ValueError("alert_state only applies to binary sensors")
  37. if self.alert_above is not None and self.alert_below is not None and self.alert_below >= self.alert_above:
  38. raise ValueError("alert_below must be lower than alert_above")
  39. # An interlock or a notification with nothing to trigger on would never
  40. # fire — that reads as a broken feature, not as a no-op.
  41. if (self.block_print or self.notify_on_alert) and not self._has_alert_condition():
  42. raise ValueError("block_print and notify_on_alert require an alert condition")
  43. return self
  44. def _has_alert_condition(self) -> bool:
  45. return self.alert_state is not None or self.alert_above is not None or self.alert_below is not None
  46. class PrinterHASensorCreate(PrinterHASensorBase):
  47. pass
  48. class PrinterHASensorUpdate(IgnoresNullForRequiredColumns):
  49. """Partial update. Validated against the merged row in the route, because
  50. the per-kind rules above need fields this payload may not carry."""
  51. __orm_table__ = "printer_ha_sensors"
  52. name: str | None = Field(default=None, min_length=1, max_length=100)
  53. # Same column-width bound as the base schema; PATCH reaches the same row.
  54. entity_id: str | None = Field(default=None, max_length=255, pattern=r"^(binary_sensor|sensor)\.[a-z0-9_]+$")
  55. kind: Literal["binary", "numeric"] | None = None
  56. device_class: str | None = Field(default=None, max_length=32)
  57. unit: str | None = Field(default=None, max_length=16)
  58. alert_state: Literal["on", "off"] | None = None
  59. alert_above: float | None = None
  60. alert_below: float | None = None
  61. block_print: bool | None = None
  62. notify_on_alert: bool | None = None
  63. show_on_printer_card: bool | None = None
  64. sort_order: int | None = Field(default=None, ge=0, le=999)
  65. class PrinterHASensorResponse(PrinterHASensorBase):
  66. # Reads stay tolerant of what writes now reject: this feature shipped
  67. # before entity_id was bounded, and SQLite never enforced the column's 255,
  68. # so a row longer than that can genuinely exist. Inheriting the bound would
  69. # turn it into a 500 on the list route — the same failure the bound was
  70. # added to prevent, moved from the write path to the read path. The pattern
  71. # is dropped with it, for the same generation of rows. Writes are unchanged.
  72. entity_id: str
  73. id: int
  74. last_state: str | None = None
  75. last_changed: datetime | None = None
  76. last_checked: datetime | None = None
  77. created_at: datetime
  78. updated_at: datetime
  79. class Config:
  80. from_attributes = True
  81. class PrinterHASensorReading(BaseModel):
  82. """One sensor's live state, as the printer card renders it."""
  83. id: int
  84. name: str
  85. entity_id: str
  86. kind: str
  87. device_class: str | None = None
  88. unit: str | None = None
  89. # Raw HA state: "on"/"off" for binary, the numeric string for sensors.
  90. # None when the entity is unavailable or has not been polled yet.
  91. state: str | None = None
  92. value: float | None = None # numeric sensors only, parsed from state
  93. alerting: bool = False
  94. block_print: bool = False
  95. reachable: bool = True
  96. last_changed: datetime | None = None
  97. class HADisplayEntity(BaseModel):
  98. """A bindable entity, as offered by the picker."""
  99. entity_id: str
  100. friendly_name: str
  101. state: str | None = None
  102. domain: str
  103. device_class: str | None = None
  104. unit_of_measurement: str | None = None