printer_ha_sensor.py 5.2 KB

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