location_ha_sensor.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. """Schemas for Home Assistant entities bound to a storage location (#2824)."""
  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. from backend.app.schemas.printer_ha_sensor import HADisplayEntity # noqa: F401
  7. class LocationHASensorBase(BaseModel):
  8. location_id: int
  9. name: str = Field(..., min_length=1, max_length=100)
  10. # max_length matches the column (String(255)). The pattern's [a-z0-9_]+ is
  11. # unbounded, so a direct API caller — the picker only ever offers real
  12. # Home Assistant ids — could send a longer one: SQLite stores it, but
  13. # PostgreSQL raises DataError, and the create/update routes only map
  14. # IntegrityError, so it would surface as a 500 instead of a 422.
  15. entity_id: str = Field(..., max_length=255, pattern=r"^(binary_sensor|sensor)\.[a-z0-9_]+$")
  16. kind: Literal["binary", "numeric"] = "binary"
  17. device_class: str | None = Field(default=None, max_length=32)
  18. unit: str | None = Field(default=None, max_length=16)
  19. alert_state: Literal["on", "off"] | None = None
  20. # allow_inf_nan=False: pydantic's lax mode coerces the strings "nan"/"inf"
  21. # into real NaN/Infinity floats. A NaN threshold satisfies the "notify
  22. # needs an alert condition" rule below yet every comparison against it is
  23. # False — a notification that can never fire — and it skips the
  24. # below-vs-above ordering check the same way. Responses serialize NaN as
  25. # null, so the UI would show an empty field over a poisoned row.
  26. alert_above: float | None = Field(default=None, allow_inf_nan=False)
  27. alert_below: float | None = Field(default=None, allow_inf_nan=False)
  28. notify_on_alert: bool = False
  29. show_on_card: bool = True
  30. sort_order: int = Field(default=0, ge=0, le=999)
  31. @model_validator(mode="after")
  32. def validate_kind_matches_entity(self) -> "LocationHASensorBase":
  33. domain = self.entity_id.split(".")[0]
  34. expected = "binary" if domain == "binary_sensor" else "numeric"
  35. if self.kind != expected:
  36. raise ValueError(f"kind must be '{expected}' for a {domain} entity")
  37. # Alert fields are per-kind: a threshold on a battery sensor and an
  38. # on/off alert on a temperature reading are both configuration the
  39. # poller would silently ignore, so reject them at the edge instead.
  40. if self.kind == "binary" and (self.alert_above is not None or self.alert_below is not None):
  41. raise ValueError("alert_above/alert_below only apply to numeric sensors")
  42. if self.kind == "numeric" and self.alert_state is not None:
  43. raise ValueError("alert_state only applies to binary sensors")
  44. if self.alert_above is not None and self.alert_below is not None and self.alert_below >= self.alert_above:
  45. raise ValueError("alert_below must be lower than alert_above")
  46. # A notification with nothing to trigger on would never fire — that
  47. # reads as a broken feature, not as a no-op.
  48. if self.notify_on_alert and not self._has_alert_condition():
  49. raise ValueError("notify_on_alert requires an alert condition")
  50. return self
  51. def _has_alert_condition(self) -> bool:
  52. return self.alert_state is not None or self.alert_above is not None or self.alert_below is not None
  53. class LocationHASensorCreate(LocationHASensorBase):
  54. pass
  55. class LocationHASensorUpdate(IgnoresNullForRequiredColumns):
  56. """Partial update. Validated against the merged row in the route, because
  57. the per-kind rules above need fields this payload may not carry."""
  58. __orm_table__ = "location_ha_sensors"
  59. name: str | None = Field(default=None, min_length=1, max_length=100)
  60. # Same column-width bound as the base schema; PATCH reaches the same row.
  61. entity_id: str | None = Field(default=None, max_length=255, pattern=r"^(binary_sensor|sensor)\.[a-z0-9_]+$")
  62. kind: Literal["binary", "numeric"] | None = None
  63. device_class: str | None = Field(default=None, max_length=32)
  64. unit: str | None = Field(default=None, max_length=16)
  65. alert_state: Literal["on", "off"] | None = None
  66. # Same allow_inf_nan story as the base schema. The route's merged-row
  67. # re-validation would catch these too, but rejecting them here keeps the
  68. # error attached to the offending field.
  69. alert_above: float | None = Field(default=None, allow_inf_nan=False)
  70. alert_below: float | None = Field(default=None, allow_inf_nan=False)
  71. notify_on_alert: bool | None = None
  72. show_on_card: bool | None = None
  73. sort_order: int | None = Field(default=None, ge=0, le=999)
  74. class LocationHASensorResponse(LocationHASensorBase):
  75. # Reads must tolerate what writes now reject, or one legacy row 500s the
  76. # whole list. Three constraints are relaxed here on purpose:
  77. #
  78. # * the NaN/inf thresholds a row could carry before allow_inf_nan landed —
  79. # serialization turns them into null, which is also what the edit form
  80. # should show;
  81. # * the entity_id length bound, for a row created before max_length existed
  82. # (SQLite never enforced the column's 255, so those rows are real);
  83. # * the entity_id pattern, which the same generation of rows predates.
  84. #
  85. # Every one of them is still rejected on the way in, so this widens what
  86. # can be read back, never what can be stored.
  87. alert_above: float | None = None
  88. alert_below: float | None = None
  89. entity_id: str
  90. id: int
  91. last_state: str | None = None
  92. last_changed: datetime | None = None
  93. last_checked: datetime | None = None
  94. created_at: datetime
  95. updated_at: datetime
  96. class Config:
  97. from_attributes = True
  98. class LocationHASensorReading(BaseModel):
  99. """One sensor's live state, as the filament card and inventory table render it."""
  100. id: int
  101. name: str
  102. entity_id: str
  103. kind: str
  104. device_class: str | None = None
  105. unit: str | None = None
  106. # Raw HA state: "on"/"off" for binary, the numeric string for sensors.
  107. # None when the entity is unavailable or has not been polled yet.
  108. state: str | None = None
  109. value: float | None = None # numeric sensors only, parsed from state
  110. alerting: bool = False
  111. reachable: bool = True
  112. alert_state: str | None = None
  113. alert_above: float | None = None
  114. alert_below: float | None = None
  115. last_changed: datetime | None = None
  116. # Lets a consumer that fetched the unfiltered (show_on_card=False) list
  117. # still pick out the card-visible subset itself, instead of issuing a
  118. # second request for the same location.
  119. show_on_card: bool = True