diff --git a/backend/app/api/routes/ha_sensors.py b/backend/app/api/routes/ha_sensors.py new file mode 100644 index 000000000..6f3261ad5 --- /dev/null +++ b/backend/app/api/routes/ha_sensors.py @@ -0,0 +1,239 @@ +"""API routes for Home Assistant sensors bound to a printer (#1148, #448).""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.core.auth import RequirePermissionIfAuthEnabled +from backend.app.core.database import get_db +from backend.app.core.permissions import Permission +from backend.app.models.printer import Printer +from backend.app.models.printer_ha_sensor import PrinterHASensor +from backend.app.models.user import User +from backend.app.schemas.printer_ha_sensor import ( + HADisplayEntity, + PrinterHASensorCreate, + PrinterHASensorReading, + PrinterHASensorResponse, + PrinterHASensorUpdate, +) +from backend.app.services.ha_sensor_manager import ha_sensor_manager +from backend.app.services.homeassistant import homeassistant_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/ha-sensors", tags=["ha-sensors"]) + +# These reuse the smart-plug permissions rather than introducing their own. +# Both surfaces are "the Home Assistant integration", and a brand-new +# permission would be missing from every existing custom role — users who can +# manage plugs today would silently lose access to the sensors next to them. +_READ = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ) +_CREATE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE) +_UPDATE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE) +_DELETE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE) + + +async def _refresh_quietly(sensor: PrinterHASensor, db: AsyncSession) -> None: + """Take a first reading without letting it fail the write that preceded it. + + The sensor row is committed before this runs. A failure here costs the card + one poll interval of blank state, which is not worth turning a successful + save into an error response. + """ + try: + await ha_sensor_manager.refresh_one(db, sensor) + except Exception as e: + logger.warning("Could not read %s right after saving it: %s", sensor.entity_id, e) + + +@router.get("/", response_model=list[PrinterHASensorResponse]) +async def list_ha_sensors( + printer_id: int | None = None, + db: AsyncSession = Depends(get_db), + _: User | None = _READ, +): + """List configured sensors, grouped by printer and in display order.""" + query = select(PrinterHASensor) + if printer_id is not None: + query = query.where(PrinterHASensor.printer_id == printer_id) + result = await db.execute(query.order_by(PrinterHASensor.printer_id, PrinterHASensor.sort_order)) + return list(result.scalars().all()) + + +# Must precede /{sensor_id} so "entities" is not parsed as an id. +@router.get("/entities", response_model=list[HADisplayEntity]) +async def list_bindable_entities( + search: str | None = None, + db: AsyncSession = Depends(get_db), + _: User | None = _READ, +): + """List the Home Assistant entities that can be bound to a printer.""" + from backend.app.api.routes.settings import get_homeassistant_settings + + ha_settings = await get_homeassistant_settings(db) + if not ha_settings["ha_url"] or not ha_settings["ha_token"]: + raise HTTPException( + 400, + "Home Assistant not configured. Please set HA URL and token in Settings → Network → Home Assistant.", + ) + + entities = await homeassistant_service.list_display_entities(ha_settings["ha_url"], ha_settings["ha_token"], search) + return [HADisplayEntity(**e) for e in entities] + + +@router.get("/by-printer/{printer_id}/readings", response_model=list[PrinterHASensorReading]) +async def get_printer_sensor_readings( + printer_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = _READ, +): + """Live state of a printer's card-visible sensors. + + Served from the poller's cache, so a page full of printer cards costs + Home Assistant nothing. A sensor the poller has not reached yet falls back + to its last persisted state, marked unreachable, rather than vanishing + from the card on every restart. + """ + result = await db.execute( + select(PrinterHASensor) + .where( + PrinterHASensor.printer_id == printer_id, + PrinterHASensor.show_on_printer_card.is_(True), + ) + .order_by(PrinterHASensor.sort_order, PrinterHASensor.id) + ) + + readings = [] + for sensor in result.scalars().all(): + cached = ha_sensor_manager.get_reading(sensor.id) + readings.append( + PrinterHASensorReading( + id=sensor.id, + name=sensor.name, + entity_id=sensor.entity_id, + kind=sensor.kind, + device_class=sensor.device_class, + unit=sensor.unit, + state=cached.state if cached else sensor.last_state, + value=cached.value if cached else None, + alerting=cached.alerting if cached else False, + block_print=sensor.block_print, + reachable=cached.reachable if cached else False, + last_changed=sensor.last_changed, + ) + ) + return readings + + +@router.post("/", response_model=PrinterHASensorResponse) +async def create_ha_sensor( + data: PrinterHASensorCreate, + db: AsyncSession = Depends(get_db), + _: User | None = _CREATE, +): + """Bind a Home Assistant entity to a printer.""" + printer = await db.get(Printer, data.printer_id) + if not printer: + raise HTTPException(404, "Printer not found") + + existing = await db.execute( + select(PrinterHASensor).where( + PrinterHASensor.printer_id == data.printer_id, + PrinterHASensor.entity_id == data.entity_id, + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(400, f"{data.entity_id} is already bound to this printer") + + sensor = PrinterHASensor(**data.model_dump()) + db.add(sensor) + await db.commit() + await db.refresh(sensor) + logger.info("Bound HA entity %s to printer %s as '%s'", sensor.entity_id, sensor.printer_id, sensor.name) + + # Read it once now so the card shows a state immediately instead of after + # the next poll tick. Best-effort: the row is already committed, so letting + # a Home Assistant hiccup 500 the request would report a failure for work + # that succeeded — and the retry would come back "already bound". + await _refresh_quietly(sensor, db) + return sensor + + +@router.get("/{sensor_id}", response_model=PrinterHASensorResponse) +async def get_ha_sensor( + sensor_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = _READ, +): + sensor = await db.get(PrinterHASensor, sensor_id) + if not sensor: + raise HTTPException(404, "Sensor not found") + return sensor + + +@router.patch("/{sensor_id}", response_model=PrinterHASensorResponse) +async def update_ha_sensor( + sensor_id: int, + data: PrinterHASensorUpdate, + db: AsyncSession = Depends(get_db), + _: User | None = _UPDATE, +): + sensor = await db.get(PrinterHASensor, sensor_id) + if not sensor: + raise HTTPException(404, "Sensor not found") + + updates = data.model_dump(exclude_unset=True) + + # Re-run the create-time rules against the merged row. A PATCH that only + # sets block_print has no entity_id or alert_state in its payload, so the + # schema alone cannot tell whether the result is coherent. + merged = {field: getattr(sensor, field) for field in PrinterHASensorCreate.model_fields} + merged.update(updates) + try: + PrinterHASensorCreate(**merged) + except ValueError as e: + raise HTTPException(422, str(e)) from e + + # Same uniqueness rule as create: repointing a sensor at an entity the + # printer already has would leave two rows fighting over one pill. + new_entity = updates.get("entity_id") + if new_entity and new_entity != sensor.entity_id: + clash = await db.execute( + select(PrinterHASensor).where( + PrinterHASensor.printer_id == sensor.printer_id, + PrinterHASensor.entity_id == new_entity, + PrinterHASensor.id != sensor.id, + ) + ) + if clash.scalar_one_or_none(): + raise HTTPException(400, f"{new_entity} is already bound to this printer") + + for field, value in updates.items(): + setattr(sensor, field, value) + await db.commit() + await db.refresh(sensor) + + # The entity or its alert rule may have changed under the cached reading. + await _refresh_quietly(sensor, db) + return sensor + + +@router.delete("/{sensor_id}") +async def delete_ha_sensor( + sensor_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = _DELETE, +): + sensor = await db.get(PrinterHASensor, sensor_id) + if not sensor: + raise HTTPException(404, "Sensor not found") + + name = sensor.name + await db.delete(sensor) + await db.commit() + ha_sensor_manager.forget(sensor_id) + logger.info("Removed HA sensor '%s'", name) + return {"message": f"Sensor '{name}' removed"} diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 0f9b25465..d30282799 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -279,6 +279,7 @@ async def init_db(): print_log, print_queue, printer, + printer_ha_sensor, printer_sensor_history, project, project_bom, @@ -3996,6 +3997,18 @@ async def run_migrations(conn): ) await _migrate_backfill_variant_groups(conn) + # Migration: Home Assistant sensor alerts (#1148). The printer_ha_sensors + # table itself is new, so create_all() builds it; only the provider opt-in + # column needs adding to existing databases. + # + # DEFAULT FALSE, not DEFAULT 0: Postgres will not take an integer default + # for a boolean column, and _safe_execute swallows the DatatypeMismatchError + # — so the older "BOOLEAN DEFAULT 0" migrations above quietly do nothing on + # Postgres and only work there because create_all() builds the column on a + # fresh install. SQLite has understood FALSE since 3.23, so this spelling + # is the one that actually applies on both. + await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_ha_sensor_alert BOOLEAN DEFAULT FALSE") + async def _migrate_backfill_variant_groups(conn) -> None: """Build variant groups from the slice provenance already on disk (#671 / #2570). diff --git a/backend/app/main.py b/backend/app/main.py index 4e5de2a52..1460b1414 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -33,6 +33,7 @@ from backend.app.api.routes import ( firmware, github_backup, groups, + ha_sensors, inventory, kprofiles, labels, @@ -96,6 +97,7 @@ from backend.app.services.bambu_ftp import ( ) from backend.app.services.bambu_mqtt import PrinterState from backend.app.services.github_backup import github_backup_service +from backend.app.services.ha_sensor_manager import ha_sensor_manager from backend.app.services.homeassistant import homeassistant_service from backend.app.services.library_trash import library_trash_service from backend.app.services.local_backup import local_backup_service @@ -7344,6 +7346,9 @@ async def lifespan(app: FastAPI): # Start the smart plug scheduler for time-based on/off smart_plug_manager.start_scheduler() + # Start the Home Assistant sensor poller (#1148) + ha_sensor_manager.start() + # Resume any pending auto-offs that were interrupted by restart await smart_plug_manager.resume_pending_auto_offs() @@ -7422,6 +7427,7 @@ async def lifespan(app: FastAPI): # Shutdown print_scheduler.stop() smart_plug_manager.stop_scheduler() + ha_sensor_manager.stop() notification_service.stop_digest_scheduler() github_backup_service.stop_scheduler() local_backup_service.stop_scheduler() @@ -7922,6 +7928,7 @@ app.include_router(cloud.router, prefix=app_settings.api_prefix) app.include_router(orca_cloud.router, prefix=app_settings.api_prefix) app.include_router(local_presets.router, prefix=app_settings.api_prefix) app.include_router(smart_plugs.router, prefix=app_settings.api_prefix) +app.include_router(ha_sensors.router, prefix=app_settings.api_prefix) app.include_router(print_log.router, prefix=app_settings.api_prefix) app.include_router(print_queue.router, prefix=app_settings.api_prefix) app.include_router(kprofiles.router, prefix=app_settings.api_prefix) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index fcb298f0f..a62d2afe2 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -21,6 +21,7 @@ from backend.app.models.pending_upload import PendingUpload from backend.app.models.pipeline_run import PipelineJob, PipelineRun from backend.app.models.print_batch import PrintBatch, PrintBatchPlate from backend.app.models.printer import Printer +from backend.app.models.printer_ha_sensor import PrinterHASensor from backend.app.models.printer_sensor_history import PrinterSensorHistory from backend.app.models.project import Project from backend.app.models.settings import Settings @@ -56,6 +57,7 @@ __all__ = [ "APIKey", "AMSSensorHistory", "PrinterSensorHistory", + "PrinterHASensor", "AmsLabel", "PendingUpload", "PrintBatch", diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py index 343121003..1efcec13f 100644 --- a/backend/app/models/notification.py +++ b/backend/app/models/notification.py @@ -82,6 +82,9 @@ class NotificationProvider(Base): on_ams_ht_humidity_high = Column(Boolean, default=False) # AMS-HT humidity above threshold on_ams_ht_temperature_high = Column(Boolean, default=False) # AMS-HT temperature above threshold + # Event triggers - Home Assistant sensors bound to a printer (#1148) + on_ha_sensor_alert = Column(Boolean, default=False) # Bound HA sensor entered its alert state + # Event triggers - Build plate detection on_plate_not_empty = Column(Boolean, default=True) # Objects detected on plate before print # Off by default: fires after every print, alongside the print-complete alert (#2525) diff --git a/backend/app/models/notification_template.py b/backend/app/models/notification_template.py index ad13f0c3a..cba32156d 100644 --- a/backend/app/models/notification_template.py +++ b/backend/app/models/notification_template.py @@ -121,6 +121,12 @@ DEFAULT_TEMPLATES = [ "title_template": "Bed Cooled", "body_template": "{printer}: Bed cooled to {bed_temp}°C (threshold: {threshold}°C)", }, + { + "event_type": "ha_sensor_alert", + "name": "Home Assistant Sensor Alert", + "title_template": "Sensor Alert", + "body_template": "{printer}: {sensor} is {state}", + }, { "event_type": "first_layer_complete", "name": "First Layer Complete", diff --git a/backend/app/models/printer.py b/backend/app/models/printer.py index aaef4c8b5..1059a373e 100644 --- a/backend/app/models/printer.py +++ b/backend/app/models/printer.py @@ -61,6 +61,7 @@ class Printer(Base): sensor_history: Mapped[list["PrinterSensorHistory"]] = relationship( back_populates="printer", cascade="all, delete-orphan" ) + ha_sensors: Mapped[list["PrinterHASensor"]] = relationship(back_populates="printer", cascade="all, delete-orphan") from backend.app.models.ams_history import AMSSensorHistory # noqa: E402 @@ -68,5 +69,6 @@ from backend.app.models.archive import PrintArchive # noqa: E402 from backend.app.models.kprofile_note import KProfileNote # noqa: E402 from backend.app.models.maintenance import PrinterMaintenance # noqa: E402 from backend.app.models.notification import NotificationProvider # noqa: E402 +from backend.app.models.printer_ha_sensor import PrinterHASensor # noqa: E402 from backend.app.models.printer_sensor_history import PrinterSensorHistory # noqa: E402 from backend.app.models.smart_plug import SmartPlug # noqa: E402 diff --git a/backend/app/models/printer_ha_sensor.py b/backend/app/models/printer_ha_sensor.py new file mode 100644 index 000000000..ccb1ea416 --- /dev/null +++ b/backend/app/models/printer_ha_sensor.py @@ -0,0 +1,72 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from backend.app.core.database import Base + + +class PrinterHASensor(Base): + """A read-only Home Assistant entity bound to a printer (#1148, #448). + + Deliberately *not* a ``SmartPlug`` row with a wider entity pattern. A plug + carries auto-on/auto-off, schedules, power alerts, energy snapshots and + ``controls_printer_power``; none of that means anything for a door contact, + and ``get_smart_plug_by_printer`` would hand the card's power button a + sensor to switch. Sensors get their own table and their own read-only + routes instead. + + Not to be confused with ``PrinterSensorHistory``, which stores the + printer's *own* heater readings. + """ + + __tablename__ = "printer_ha_sensors" + + id: Mapped[int] = mapped_column(primary_key=True) + printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), index=True) + + name: Mapped[str] = mapped_column(String(100)) + entity_id: Mapped[str] = mapped_column(String(255)) + + # "binary" for binary_sensor.*, "numeric" for sensor.*. Decides how the + # state is rendered and which alert fields apply. + kind: Mapped[str] = mapped_column(String(16), default="binary") + + # HA's own device_class, snapshotted when the entity is bound. Drives the + # on/off wording (door -> Open/Closed, motion -> Detected/Clear) and the + # icon, so the card doesn't have to say "On" for an open door. + device_class: Mapped[str | None] = mapped_column(String(32), nullable=True) + # Numeric only: "°C", "%", "ppm", ... shown next to the value. + unit: Mapped[str | None] = mapped_column(String(16), nullable=True) + + # What counts as needing attention. One notion, three consumers: the pill + # colour on the card, the notification, and the print interlock. + # Binary sensors use alert_state ("on"/"off"/None), numeric ones the + # thresholds. All None means "just show the value". + alert_state: Mapped[str | None] = mapped_column(String(8), nullable=True) + alert_above: Mapped[float | None] = mapped_column(Float, nullable=True) + alert_below: Mapped[float | None] = mapped_column(Float, nullable=True) + + # Hold queued prints for this printer while the sensor is in its alert + # state — the enclosure-door case this feature was asked for. Opt-in, and + # only ever a *hold*: the item stays pending with a waiting_reason and + # dispatches by itself once the door closes. + block_print: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0") + notify_on_alert: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0") + + show_on_printer_card: Mapped[bool] = mapped_column(Boolean, default=True, server_default="1") + sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + + # Last poll result. Persisted so a restart doesn't blank the card until the + # first poll lands, and so notifications only fire on a real transition. + last_state: Mapped[str | None] = mapped_column(String(64), nullable=True) + last_changed: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) + + printer: Mapped["Printer"] = relationship(back_populates="ha_sensors") + + +from backend.app.models.printer import Printer # noqa: E402 diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py index 5e46caf18..77c5b3602 100644 --- a/backend/app/schemas/notification.py +++ b/backend/app/schemas/notification.py @@ -61,6 +61,11 @@ class NotificationProviderBase(BaseModel): default=False, description="Notify when AMS-HT temperature exceeds threshold" ) + # Event triggers - Home Assistant sensors (#1148) + on_ha_sensor_alert: bool = Field( + default=False, description="Notify when a bound Home Assistant sensor enters its alert state" + ) + # Event triggers - Build plate detection on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print") on_plate_clear_required: bool = Field( @@ -148,6 +153,9 @@ class NotificationProviderUpdate(BaseModel): on_ams_ht_humidity_high: bool | None = None on_ams_ht_temperature_high: bool | None = None + # Event triggers - Home Assistant sensors (#1148) + on_ha_sensor_alert: bool | None = None + # Event triggers - Build plate detection on_plate_not_empty: bool | None = None on_plate_clear_required: bool | None = None diff --git a/backend/app/schemas/notification_template.py b/backend/app/schemas/notification_template.py index 85dc54841..676017b08 100644 --- a/backend/app/schemas/notification_template.py +++ b/backend/app/schemas/notification_template.py @@ -23,6 +23,7 @@ class EventType(StrEnum): AMS_HUMIDITY_HIGH = "ams_humidity_high" AMS_TEMPERATURE_HIGH = "ams_temperature_high" BED_COOLED = "bed_cooled" + HA_SENSOR_ALERT = "ha_sensor_alert" TEST = "test" @@ -77,6 +78,7 @@ EVENT_VARIABLES: dict[str, list[str]] = { "ams_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"], "ams_temperature_high": ["printer", "ams_label", "temperature", "threshold", "timestamp", "app_name"], "bed_cooled": ["printer", "bed_temp", "threshold", "filename", "timestamp", "app_name"], + "ha_sensor_alert": ["printer", "sensor", "state", "timestamp", "app_name"], "test": ["app_name", "timestamp"], # Queue notifications "queue_job_added": ["job_name", "target", "timestamp", "app_name"], @@ -205,6 +207,13 @@ SAMPLE_DATA: dict[str, dict[str, str]] = { "timestamp": "2024-01-15 14:30", "app_name": "Bambuddy", }, + "ha_sensor_alert": { + "printer": "Bambu X1C", + "sensor": "Enclosure Door", + "state": "open", + "timestamp": "2024-01-15 14:30", + "app_name": "Bambuddy", + }, "test": { "app_name": "Bambuddy", "timestamp": "2024-01-15 14:30", diff --git a/backend/app/schemas/printer_ha_sensor.py b/backend/app/schemas/printer_ha_sensor.py new file mode 100644 index 000000000..4e16b802b --- /dev/null +++ b/backend/app/schemas/printer_ha_sensor.py @@ -0,0 +1,114 @@ +"""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 diff --git a/backend/app/services/ha_sensor_manager.py b/backend/app/services/ha_sensor_manager.py new file mode 100644 index 000000000..db6d33d21 --- /dev/null +++ b/backend/app/services/ha_sensor_manager.py @@ -0,0 +1,271 @@ +"""Polls the Home Assistant entities bound to printers (#1148, #448). + +One background loop reads every configured entity on a fixed cadence and keeps +the result in memory. Three things consume it: + +* the printer card, which reads the cache instead of hitting Home Assistant + once per card per refresh; +* notifications, fired on a transition *into* the alert state, never on every + poll while it persists; +* the print interlock, which holds queued jobs for a printer while one of its + sensors is alerting. + +Everything degrades to "no opinion" when Home Assistant cannot be reached: an +unreadable sensor never alerts, never notifies, and never holds a print. A +door contact that stops responding must not strand the queue. +""" + +import asyncio +import logging +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.models.printer import Printer +from backend.app.models.printer_ha_sensor import PrinterHASensor +from backend.app.services.homeassistant import as_float, homeassistant_service +from backend.app.utils.local_time import utcnow_naive + +logger = logging.getLogger(__name__) + +# Fast enough that an enclosure door reads as live, slow enough that a handful +# of tiny LAN requests stays background noise. +POLL_INTERVAL = 15 + + +@dataclass +class SensorReading: + """The last thing we managed to read for one sensor.""" + + state: str | None # raw HA state, None when unreadable + value: float | None # parsed number for numeric sensors + alerting: bool + reachable: bool + + +class HASensorManager: + def __init__(self): + self._task: asyncio.Task | None = None + # sensor id -> last reading. Sensors absent from this map have not been + # polled yet; callers must not read that as "not alerting" without also + # checking, which is why get_reading returns None rather than a default. + self._readings: dict[int, SensorReading] = {} + # sensor id -> alerting, from the last reading we could actually take. + # Kept apart from _readings because a dropout must not read as the + # alert clearing: on -> unavailable -> on is one continuous alert, and + # notifying off _readings alone would ping the user on every reconnect + # of a flaky contact. Absent means "never had a reachable reading". + self._last_alerting: dict[int, bool] = {} + + # -- lifecycle --------------------------------------------------------- + + def start(self): + if self._task is None: + self._task = asyncio.create_task(self._poll_loop()) + logger.info("Home Assistant sensor poller started") + + def stop(self): + if self._task: + self._task.cancel() + self._task = None + logger.info("Home Assistant sensor poller stopped") + + # -- cache access ------------------------------------------------------ + + def get_reading(self, sensor_id: int) -> SensorReading | None: + return self._readings.get(sensor_id) + + def forget(self, sensor_id: int): + """Drop a deleted sensor's cached reading so its id cannot be reused + by a later row and answer with the old sensor's state.""" + self._readings.pop(sensor_id, None) + self._last_alerting.pop(sensor_id, None) + + async def blocked_printers(self, db: AsyncSession) -> dict[int, str]: + """Printers currently held by an interlock, mapped to the sensor names. + + A sensor counts only when it is configured to block, *and* was read + successfully, *and* is in its alert state. Anything we could not read + is omitted, so the queue keeps moving when Home Assistant is down. + + One query for the whole fleet — the scheduler calls this on every pass, + and per-printer lookups would put a query per printer in that loop. + """ + result = await db.execute(select(PrinterHASensor).where(PrinterHASensor.block_print.is_(True))) + blocked: dict[int, list[str]] = {} + for sensor in result.scalars().all(): + reading = self._readings.get(sensor.id) + if reading and reading.reachable and reading.alerting: + blocked.setdefault(sensor.printer_id, []).append(sensor.name) + return {printer_id: ", ".join(names) for printer_id, names in blocked.items()} + + # -- polling ----------------------------------------------------------- + + async def _poll_loop(self): + while True: + try: + await asyncio.sleep(POLL_INTERVAL) + await self.poll_once() + except asyncio.CancelledError: + break + except Exception as e: + logger.warning("Home Assistant sensor poll failed: %s", e) + + async def poll_once(self): + """One pass over every configured sensor.""" + from backend.app.core.database import async_session + + async with async_session() as db: + result = await db.execute(select(PrinterHASensor)) + sensors = list(result.scalars().all()) + + # Drop readings for rows that no longer exist. The delete route + # calls forget(), but a printer deleted with sensors attached takes + # them out by cascade, and a restored backup can renumber them — + # either way a stale id must not answer for a later sensor. + live = {s.id for s in sensors} + for stale in set(self._readings) - live: + self.forget(stale) + + if not sensors: + return + + if not await self._configure(db): + # Not configured is not a failure to report every 15 seconds, + # but the readings must not go stale-but-confident either. + for sensor in sensors: + self._readings[sensor.id] = SensorReading(None, None, False, False) + return + + states = await homeassistant_service.fetch_states(sorted({s.entity_id for s in sensors})) + await self._apply(db, sensors, states) + + async def refresh_one(self, db: AsyncSession, sensor: PrinterHASensor): + """Read a single sensor now, on the caller's session. + + Used after a create or an edit so the card shows a state straight away + instead of blank until the next tick. Deliberately not a full + ``poll_once``: a request handler must not wait on every configured + entity, and must not fire another user's notification as a side effect + of this one saving a form. + """ + self.forget(sensor.id) + if not await self._configure(db): + self._readings[sensor.id] = SensorReading(None, None, False, False) + return + + states = await homeassistant_service.fetch_states([sensor.entity_id]) + reading = evaluate(sensor, states.get(sensor.entity_id)) + self._readings[sensor.id] = reading + if reading.reachable: + self._last_alerting[sensor.id] = reading.alerting + + sensor.last_checked = utcnow_naive() + if reading.reachable and sensor.last_state != reading.state: + sensor.last_state = reading.state + sensor.last_changed = sensor.last_checked + await db.commit() + await db.refresh(sensor) + + async def _configure(self, db: AsyncSession) -> bool: + from backend.app.api.routes.settings import get_homeassistant_settings + + try: + ha_settings = await get_homeassistant_settings(db) + except Exception as e: + logger.warning("Failed to read Home Assistant settings: %s", e) + return False + if not ha_settings["ha_url"] or not ha_settings["ha_token"]: + return False + homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"]) + return True + + async def _apply(self, db: AsyncSession, sensors: list[PrinterHASensor], states: dict[str, dict | None]): + """Fold poll results into the cache, the DB and any notifications.""" + from backend.app.services.notification_service import notification_service + + now = utcnow_naive() + alerts: list[tuple[PrinterHASensor, SensorReading]] = [] + + for sensor in sensors: + payload = states.get(sensor.entity_id) + reading = evaluate(sensor, payload) + was_alerting = self._last_alerting.get(sensor.id) + self._readings[sensor.id] = reading + + sensor.last_checked = now + if reading.reachable: + if sensor.last_state != reading.state: + sensor.last_state = reading.state + sensor.last_changed = now + + # Notify on the edge into alerting only. `was_alerting is None` is + # a cold cache (first poll after a restart) — a door that was + # already open then has not just been opened, and re-announcing it + # on every restart would train users to ignore the alert. + if sensor.notify_on_alert and reading.reachable and reading.alerting and was_alerting is False: + alerts.append((sensor, reading)) + + if reading.reachable: + self._last_alerting[sensor.id] = reading.alerting + + await db.commit() + + for sensor, reading in alerts: + # db.get, not sensor.printer: touching the lazy relationship from + # an async session raises MissingGreenlet. + printer = await db.get(Printer, sensor.printer_id) + try: + await notification_service.on_ha_sensor_alert( + printer_id=sensor.printer_id, + printer_name=printer.name if printer else "Unknown", + sensor_name=sensor.name, + state=describe_state(sensor, reading), + db=db, + ) + except Exception as e: + logger.warning("Failed to send HA sensor alert for '%s': %s", sensor.name, e) + + +def evaluate(sensor: PrinterHASensor, payload: dict | None) -> SensorReading: + """Turn one HA state payload into a reading. + + Split out from the manager so the alert rules can be tested without a + poller, a database or a Home Assistant. + """ + if payload is None: + return SensorReading(state=None, value=None, alerting=False, reachable=False) + + state = payload.get("state") + # HA reports these two for entities whose integration is down. Treating + # them as a state would make "unavailable" a value the card renders and + # the thresholds compare against. + if state in (None, "unknown", "unavailable"): + return SensorReading(state=None, value=None, alerting=False, reachable=False) + + state = str(state) + if sensor.kind == "numeric": + value = as_float(state) + if value is None: + # A sensor that used to report numbers and now reports text is + # not a reading we can place against a threshold. + return SensorReading(state=state, value=None, alerting=False, reachable=True) + alerting = (sensor.alert_above is not None and value > sensor.alert_above) or ( + sensor.alert_below is not None and value < sensor.alert_below + ) + return SensorReading(state=state, value=value, alerting=alerting, reachable=True) + + normalized = state.lower() + alerting = sensor.alert_state is not None and normalized == sensor.alert_state + return SensorReading(state=normalized, value=None, alerting=alerting, reachable=True) + + +def describe_state(sensor: PrinterHASensor, reading: SensorReading) -> str: + """Human-readable state for a notification body ("open", "31.4 °C").""" + if sensor.kind == "numeric" and reading.value is not None: + return f"{reading.value:g} {sensor.unit}".strip() if sensor.unit else f"{reading.value:g}" + return reading.state or "unknown" + + +ha_sensor_manager = HASensorManager() diff --git a/backend/app/services/homeassistant.py b/backend/app/services/homeassistant.py index 24d3925c3..f85dac47b 100644 --- a/backend/app/services/homeassistant.py +++ b/backend/app/services/homeassistant.py @@ -1,5 +1,6 @@ """Service for communicating with Home Assistant via REST API.""" +import asyncio import logging from typing import TYPE_CHECKING from urllib.parse import urlparse @@ -364,6 +365,107 @@ class HomeAssistantService: logger.warning("Failed to list HA sensor entities: %s", e) return [] + async def list_display_entities(self, url: str, token: str, search: str | None = None) -> list[dict]: + """List entities that can be bound to a printer for display (#1148, #448). + + Covers every ``binary_sensor.*`` plus the ``sensor.*`` entities that + carry a reading. Distinct from ``list_sensor_entities``, which exists + for a plug's energy monitoring and therefore only admits power/energy + units — an enclosure thermometer is exactly what that one filters out. + + A ``sensor.*`` qualifies when it has a unit or its state parses as a + number. That drops the text sensors (``sensor.washing_machine_status``) + that the card has no way to render as a value. + """ + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get( + f"{url.rstrip('/')}/api/states", + headers={"Authorization": f"Bearer {token}"}, + ) + response.raise_for_status() + + entities = [] + search_lower = search.lower().strip() if search else None + + for entity in response.json(): + entity_id = entity.get("entity_id", "") + domain = entity_id.split(".")[0] if "." in entity_id else "" + if domain not in ("binary_sensor", "sensor"): + continue + + attrs = entity.get("attributes", {}) + unit = attrs.get("unit_of_measurement") + state = entity.get("state") + + if domain == "sensor" and not unit and as_float(state) is None: + continue + + friendly_name = attrs.get("friendly_name") or entity_id + if search_lower and ( + search_lower not in entity_id.lower() and search_lower not in friendly_name.lower() + ): + continue + + entities.append( + { + "entity_id": entity_id, + "friendly_name": friendly_name, + "state": state, + "domain": domain, + "device_class": attrs.get("device_class"), + "unit_of_measurement": unit, + } + ) + + return sorted(entities, key=lambda x: x["friendly_name"].lower()) + except Exception as e: + logger.warning("Failed to list HA display entities: %s", e) + return [] + + async def fetch_states(self, entity_ids: list[str]) -> dict[str, dict | None]: + """Read several entities in one pass, keyed by entity_id. + + One GET per entity over a shared client rather than a single + ``/api/states`` sweep: the poller only ever wants a handful of bound + entities, and pulling every state in the user's Home Assistant on a + 15-second cadence is a lot of payload to throw away. + + A ``None`` value means that entity could not be read — the callers + treat that as "no opinion" rather than as a state, so an unreachable + Home Assistant never trips an alert or holds a print. + """ + if not entity_ids: + return {} + if not self.base_url or not self.token: + return dict.fromkeys(entity_ids) + + async with httpx.AsyncClient(timeout=self.timeout) as client: + + async def _one(entity_id: str) -> tuple[str, dict | None]: + try: + response = await client.get( + f"{self.base_url}/api/states/{entity_id}", + headers=self._headers(), + ) + response.raise_for_status() + return entity_id, response.json() + except Exception as e: + logger.debug("Failed to read HA entity %s: %s", entity_id, e) + return entity_id, None + + results = await asyncio.gather(*(_one(e) for e in entity_ids)) + + return dict(results) + + +def as_float(value) -> float | None: + """Parse a HA state to a number, or None for "unknown"/"unavailable"/text.""" + try: + return float(value) + except (TypeError, ValueError): + return None + # Singleton instance homeassistant_service = HomeAssistantService() diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 8cf8eef79..1c95a9396 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -1729,6 +1729,43 @@ class NotificationService: providers, title, message, db, "bed_cooled", printer_id, printer_name, variables=variables ) + async def on_ha_sensor_alert( + self, + printer_id: int, + printer_name: str, + sensor_name: str, + state: str, + db: AsyncSession, + ): + """A Home Assistant sensor bound to a printer entered its alert state (#1148). + + Sent immediately rather than folded into a digest: the case this exists + for is an enclosure door left open, which is only worth telling someone + about while they can still act on it. + """ + providers = await self._get_providers_for_event(db, "on_ha_sensor_alert", printer_id) + if not providers: + return + + variables = { + "printer": printer_name, + "sensor": sensor_name, + "state": state, + } + + title, message = await self._build_message_from_template(db, "ha_sensor_alert", variables) + await self._send_to_providers( + providers, + title, + message, + db, + "ha_sensor_alert", + printer_id, + printer_name, + force_immediate=True, + variables=variables, + ) + async def on_first_layer_complete( self, printer_id: int, diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 82506dd42..e81d2b3c7 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -35,6 +35,7 @@ from backend.app.services.bambu_ftp import ( ) from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED from backend.app.services.filament_deficit import compute_deficit_for_queue_item +from backend.app.services.ha_sensor_manager import ha_sensor_manager from backend.app.services.notification_service import notification_service from backend.app.services.printer_manager import ( printer_manager, @@ -700,6 +701,31 @@ class PrintScheduler: if inflight_pid is not None: busy_printers.add(inflight_pid) + # Printers held by a Home Assistant sensor interlock (#1148) — an + # enclosure door left open, say. The fixed-printer branch turns + # this into a waiting_reason the user can act on; the model-based + # branch hides these printers from the matcher so an "Any " + # job runs on a sibling instead of queueing behind the held one. + # + # Deliberately NOT merged into busy_printers, even though that set + # already means "unavailable this pass". _check_auto_drying reads + # it as "is currently printing" and would put an idle-but-held + # printer down the mid-print drying path, which caps the drying + # temperature and skips the queue-only gating. A held printer is + # idle; it should dry exactly as it did before. + # + # Only sensors we actually read and found alerting appear here; see + # ha_sensor_manager.blocked_printers. A Home Assistant that is down + # holds nothing. + interlocked: dict[int, str] = {} + try: + interlocked = await ha_sensor_manager.blocked_printers(db) + except Exception as e: + # Never let the interlock stop the queue running. A broken + # lookup means no holds, not no dispatches. + logger.warning("Home Assistant interlock check failed: %s", e) + interlocked = {} + # Log skip reasons once per queue check (not per item) skip_reasons: dict[str, int] = {} @@ -759,6 +785,28 @@ class PrintScheduler: continue if item.printer_id: + # Held by a sensor interlock (#1148). Checked before the + # busy_printers test that would otherwise swallow it + # silently — "waiting for a printer" and "waiting for you + # to shut the enclosure" need to read differently, and only + # one of them is something the user can fix. + # + # The interlock is the only thing that writes a + # waiting_reason on this branch — the model-based branch + # nulls it at the moment it assigns a printer — so any + # reason still standing once the hold lifts is stale and is + # cleared here. Doing it at dispatch instead would leave a + # shut door reading "Waiting on Enclosure Door" for as long + # as the printer stayed busy with something else. + interlock_reason = interlocked.get(item.printer_id) + reason = f"Waiting on {interlock_reason}" if interlock_reason else None + if item.waiting_reason != reason: + item.waiting_reason = reason + await db.commit() + if interlock_reason: + skip_reasons["sensor_interlock"] = skip_reasons.get("sensor_interlock", 0) + 1 + continue + # Specific printer assignment (existing behavior) if item.printer_id in busy_printers: continue @@ -957,7 +1005,10 @@ class PrintScheduler: match_id, match_reason = await self._find_idle_printer_for_model( db, candidate.target_model, - busy_printers, + # Sensor-held printers are unavailable to the + # matcher but stay out of busy_printers itself + # (#1148) — see where `interlocked` is built. + busy_printers | interlocked.keys(), effective_types, item.target_location, filament_overrides=filament_overrides, diff --git a/backend/tests/integration/test_ha_sensors_api_1148.py b/backend/tests/integration/test_ha_sensors_api_1148.py new file mode 100644 index 000000000..538845439 --- /dev/null +++ b/backend/tests/integration/test_ha_sensors_api_1148.py @@ -0,0 +1,317 @@ +"""Integration tests for the printer-bound Home Assistant sensor API (#1148).""" + +from unittest.mock import AsyncMock, patch + +import pytest +from httpx import AsyncClient + +from backend.app.services.ha_sensor_manager import SensorReading, ha_sensor_manager + +DOOR = { + "name": "Enclosure Door", + "entity_id": "binary_sensor.enclosure_door", + "kind": "binary", + "device_class": "door", + "alert_state": "on", +} +TEMP = { + "name": "Enclosure Temp", + "entity_id": "sensor.enclosure_temp", + "kind": "numeric", + "device_class": "temperature", + "unit": "°C", +} + + +@pytest.fixture(autouse=True) +def _no_live_ha(): + """Creating or editing a sensor reads it once; keep that off the network.""" + with patch.object(ha_sensor_manager, "refresh_one", AsyncMock()): + yield + + +@pytest.fixture(autouse=True) +def _clean_cache(): + yield + ha_sensor_manager._readings.clear() + ha_sensor_manager._last_alerting.clear() + + +class TestCrud: + @pytest.mark.asyncio + @pytest.mark.integration + async def test_bind_a_door_contact(self, async_client: AsyncClient, printer_factory): + printer = await printer_factory() + + response = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id}) + + assert response.status_code == 200 + body = response.json() + assert body["entity_id"] == "binary_sensor.enclosure_door" + assert body["kind"] == "binary" + assert body["show_on_printer_card"] is True + # Display-only until the user opts in. + assert body["block_print"] is False + assert body["notify_on_alert"] is False + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_rejects_a_switch(self, async_client: AsyncClient, printer_factory): + """Switches are smart plugs; this table is read-only sensors.""" + printer = await printer_factory() + + response = await async_client.post( + "/api/v1/ha-sensors/", + json={**DOOR, "printer_id": printer.id, "entity_id": "switch.printer_plug"}, + ) + + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_rejects_a_kind_that_contradicts_the_entity(self, async_client: AsyncClient, printer_factory): + printer = await printer_factory() + + response = await async_client.post( + "/api/v1/ha-sensors/", + json={**TEMP, "printer_id": printer.id, "kind": "binary"}, + ) + + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_rejects_an_interlock_with_nothing_to_trigger_on(self, async_client: AsyncClient, printer_factory): + """block_print without an alert condition would never fire — that reads + as a broken setting, not as a no-op.""" + printer = await printer_factory() + + response = await async_client.post( + "/api/v1/ha-sensors/", + json={**DOOR, "printer_id": printer.id, "alert_state": None, "block_print": True}, + ) + + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_rejects_a_duplicate_binding(self, async_client: AsyncClient, printer_factory): + printer = await printer_factory() + payload = {**DOOR, "printer_id": printer.id} + await async_client.post("/api/v1/ha-sensors/", json=payload) + + response = await async_client.post("/api/v1/ha-sensors/", json=payload) + + assert response.status_code == 400 + assert "already bound" in response.json()["detail"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_rejects_an_unknown_printer(self, async_client: AsyncClient): + response = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": 9999}) + + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_patch_revalidates_against_the_stored_row(self, async_client: AsyncClient, printer_factory): + """The payload carries only block_print, so the coherence rule has to be + re-run against the merged row, not against the patch alone.""" + printer = await printer_factory() + created = await async_client.post( + "/api/v1/ha-sensors/", + json={**DOOR, "printer_id": printer.id, "alert_state": None}, + ) + sensor_id = created.json()["id"] + + response = await async_client.patch(f"/api/v1/ha-sensors/{sensor_id}", json={"block_print": True}) + + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_patch_accepts_a_coherent_change(self, async_client: AsyncClient, printer_factory): + printer = await printer_factory() + created = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id}) + sensor_id = created.json()["id"] + + response = await async_client.patch( + f"/api/v1/ha-sensors/{sensor_id}", + json={"block_print": True, "notify_on_alert": True, "name": "Front Door"}, + ) + + assert response.status_code == 200 + assert response.json()["block_print"] is True + assert response.json()["name"] == "Front Door" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_delete_drops_the_cached_reading(self, async_client: AsyncClient, printer_factory): + """Otherwise a later sensor reusing the id inherits this one's state.""" + printer = await printer_factory() + created = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id}) + sensor_id = created.json()["id"] + ha_sensor_manager._readings[sensor_id] = SensorReading("on", None, True, True) + + response = await async_client.delete(f"/api/v1/ha-sensors/{sensor_id}") + + assert response.status_code == 200 + assert ha_sensor_manager.get_reading(sensor_id) is None + + +class TestReadings: + @pytest.mark.asyncio + @pytest.mark.integration + async def test_serves_the_cached_reading(self, async_client: AsyncClient, printer_factory): + printer = await printer_factory() + created = await async_client.post( + "/api/v1/ha-sensors/", + json={**TEMP, "printer_id": printer.id, "alert_above": 35}, + ) + sensor_id = created.json()["id"] + ha_sensor_manager._readings[sensor_id] = SensorReading("41.2", 41.2, True, True) + + response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings") + + assert response.status_code == 200 + reading = response.json()[0] + assert reading["value"] == 41.2 + assert reading["alerting"] is True + assert reading["unit"] == "°C" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_unpolled_sensor_reports_unreachable_not_missing(self, async_client: AsyncClient, printer_factory): + """Right after a restart the card should still list the sensor, greyed + out — not drop it and reflow the layout.""" + printer = await printer_factory() + await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id}) + + response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings") + + assert len(response.json()) == 1 + assert response.json()[0]["reachable"] is False + assert response.json()[0]["alerting"] is False + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_hidden_sensors_stay_off_the_card(self, async_client: AsyncClient, printer_factory): + """An interlock the user does not want cluttering the card still works.""" + printer = await printer_factory() + await async_client.post( + "/api/v1/ha-sensors/", + json={**DOOR, "printer_id": printer.id, "show_on_printer_card": False}, + ) + + response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings") + + assert response.json() == [] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_readings_follow_sort_order(self, async_client: AsyncClient, printer_factory): + printer = await printer_factory() + await async_client.post( + "/api/v1/ha-sensors/", + json={**TEMP, "printer_id": printer.id, "sort_order": 2}, + ) + await async_client.post( + "/api/v1/ha-sensors/", + json={**DOOR, "printer_id": printer.id, "sort_order": 1}, + ) + + response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings") + + assert [r["name"] for r in response.json()] == ["Enclosure Door", "Enclosure Temp"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_other_printers_sensors_are_not_listed(self, async_client: AsyncClient, printer_factory): + one = await printer_factory() + two = await printer_factory(serial_number="OTHER123", name="Second") + await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": one.id}) + + response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{two.id}/readings") + + assert response.json() == [] + + +class TestEntityPicker: + @pytest.mark.asyncio + @pytest.mark.integration + async def test_explains_itself_when_ha_is_not_configured(self, async_client: AsyncClient): + response = await async_client.get("/api/v1/ha-sensors/entities") + + assert response.status_code == 400 + assert "Home Assistant not configured" in response.json()["detail"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_entities_is_not_parsed_as_a_sensor_id(self, async_client: AsyncClient): + """Route ordering regression: /entities must not hit /{sensor_id}.""" + response = await async_client.get("/api/v1/ha-sensors/entities") + + assert response.status_code != 404 + + +class TestCascadeAndUniqueness: + @pytest.mark.asyncio + @pytest.mark.integration + async def test_patch_cannot_create_a_duplicate_binding(self, async_client: AsyncClient, printer_factory): + printer = await printer_factory() + await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id}) + second = await async_client.post("/api/v1/ha-sensors/", json={**TEMP, "printer_id": printer.id}) + + response = await async_client.patch( + f"/api/v1/ha-sensors/{second.json()['id']}", + json={"entity_id": DOOR["entity_id"], "kind": "binary"}, + ) + + assert response.status_code == 400 + assert "already bound" in response.json()["detail"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_patch_to_the_same_entity_is_not_a_clash_with_itself( + self, async_client: AsyncClient, printer_factory + ): + printer = await printer_factory() + created = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id}) + + response = await async_client.patch( + f"/api/v1/ha-sensors/{created.json()['id']}", + json={"entity_id": DOOR["entity_id"], "name": "Front Door"}, + ) + + assert response.status_code == 200 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_deleting_a_printer_takes_its_sensors(self, async_client: AsyncClient, printer_factory): + """The relationship cascades, so no orphan row is left holding a + printer_id that no longer resolves.""" + printer = await printer_factory() + await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id}) + + deleted = await async_client.delete(f"/api/v1/printers/{printer.id}") + + assert deleted.status_code == 200 + listed = await async_client.get("/api/v1/ha-sensors/") + assert listed.json() == [] + + +class TestSaveSurvivesHomeAssistant: + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_succeeds_even_if_the_first_read_blows_up(self, async_client: AsyncClient, printer_factory): + """The row is committed before the read. Reporting a failure for work + that succeeded would send the user into a retry that 400s on the + duplicate they just created.""" + printer = await printer_factory() + + with patch.object(ha_sensor_manager, "refresh_one", AsyncMock(side_effect=RuntimeError("HA said no"))): + response = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id}) + + assert response.status_code == 200 + listed = await async_client.get(f"/api/v1/ha-sensors/?printer_id={printer.id}") + assert len(listed.json()) == 1 diff --git a/backend/tests/unit/test_ha_sensor_manager_1148.py b/backend/tests/unit/test_ha_sensor_manager_1148.py new file mode 100644 index 000000000..53aabe370 --- /dev/null +++ b/backend/tests/unit/test_ha_sensor_manager_1148.py @@ -0,0 +1,281 @@ +"""Unit tests for Home Assistant sensors bound to a printer (#1148, #448). + +The alert rules decide three separate things — the pill colour on the card, a +notification, and whether the queue holds — so they are tested directly rather +than through any one of those consumers. + +The recurring theme is that "we could not read it" must never be mistaken for +a reading. A door contact whose integration has dropped out reports +"unavailable", not "closed", and treating that as closed would let a print +start into an open enclosure; treating it as *open* would strand the queue. +Neither: it is not a reading at all. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from backend.app.services.ha_sensor_manager import ( + HASensorManager, + SensorReading, + describe_state, + evaluate, +) + + +def _sensor(**overrides): + """A sensor row as the poller sees it, without touching the DB.""" + base = { + "id": 1, + "printer_id": 4, + "name": "Enclosure Door", + "entity_id": "binary_sensor.enclosure_door", + "kind": "binary", + "device_class": "door", + "unit": None, + "alert_state": "on", + "alert_above": None, + "alert_below": None, + "block_print": False, + "notify_on_alert": False, + "last_state": None, + } + base.update(overrides) + return SimpleNamespace(**base) + + +def _numeric(**overrides): + base = { + "entity_id": "sensor.enclosure_temp", + "kind": "numeric", + "device_class": "temperature", + "unit": "\u00b0C", + "alert_state": None, + "name": "Enclosure Temp", + } + base.update(overrides) + return _sensor(**base) + + +class TestEvaluateBinary: + def test_alerts_in_the_configured_state(self): + reading = evaluate(_sensor(), {"state": "on"}) + + assert reading == SensorReading(state="on", value=None, alerting=True, reachable=True) + + def test_quiet_in_the_other_state(self): + assert evaluate(_sensor(), {"state": "off"}).alerting is False + + def test_alert_state_off_inverts_the_rule(self): + """A "fan running" contact alarms when it stops, not when it starts.""" + sensor = _sensor(alert_state="off", name="Exhaust Fan") + + assert evaluate(sensor, {"state": "off"}).alerting is True + assert evaluate(sensor, {"state": "on"}).alerting is False + + def test_no_alert_state_never_alerts(self): + """Display-only sensors are the default — they just show a state.""" + sensor = _sensor(alert_state=None) + + assert evaluate(sensor, {"state": "on"}).alerting is False + assert evaluate(sensor, {"state": "on"}).reachable is True + + def test_state_is_normalised_to_lower_case(self): + """Some integrations report "ON"; the alert rule stores "on".""" + assert evaluate(_sensor(), {"state": "ON"}).state == "on" + assert evaluate(_sensor(), {"state": "ON"}).alerting is True + + +class TestEvaluateNumeric: + def test_above_threshold_alerts(self): + assert evaluate(_numeric(alert_above=35), {"state": "41.2"}).alerting is True + + def test_below_threshold_alerts(self): + assert evaluate(_numeric(alert_below=15), {"state": "12"}).alerting is True + + def test_inside_the_band_is_quiet(self): + reading = evaluate(_numeric(alert_above=35, alert_below=15), {"state": "22.5"}) + + assert reading.alerting is False + assert reading.value == 22.5 + + def test_exactly_on_the_threshold_is_not_an_alert(self): + """Strict comparison, so a 35 °C limit does not alarm at exactly 35.""" + assert evaluate(_numeric(alert_above=35), {"state": "35"}).alerting is False + + def test_a_sensor_that_stops_reporting_numbers_does_not_alert(self): + """Reachable, but no value to compare — so no verdict either way.""" + reading = evaluate(_numeric(alert_above=35), {"state": "calibrating"}) + + assert reading.reachable is True + assert reading.value is None + assert reading.alerting is False + + +class TestUnreadable: + @pytest.mark.parametrize("state", ["unavailable", "unknown", None]) + def test_ha_non_states_are_not_readings(self, state): + reading = evaluate(_sensor(), {"state": state}) + + assert reading.reachable is False + assert reading.alerting is False + assert reading.state is None + + def test_a_failed_fetch_is_not_a_reading(self): + """fetch_states maps an entity it could not read to None.""" + reading = evaluate(_sensor(), None) + + assert reading == SensorReading(state=None, value=None, alerting=False, reachable=False) + + +class TestDescribeState: + def test_binary_uses_the_raw_state(self): + assert describe_state(_sensor(), evaluate(_sensor(), {"state": "on"})) == "on" + + def test_numeric_carries_its_unit(self): + sensor = _numeric() + + assert describe_state(sensor, evaluate(sensor, {"state": "41.20"})) == "41.2 °C" + + def test_numeric_without_a_unit_is_bare(self): + sensor = _numeric(unit=None) + + assert describe_state(sensor, evaluate(sensor, {"state": "7"})) == "7" + + +class TestBlockedPrinters: + """The interlock only ever reports a positive, current finding.""" + + def _manager_with(self, sensors, readings): + manager = HASensorManager() + manager._readings = readings + db = AsyncMock() + db.execute.return_value = SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: sensors)) + return manager, db + + @pytest.mark.asyncio + async def test_reports_an_alerting_blocking_sensor(self): + sensor = _sensor(block_print=True) + manager, db = self._manager_with([sensor], {1: SensorReading("on", None, True, True)}) + + assert await manager.blocked_printers(db) == {4: "Enclosure Door"} + + @pytest.mark.asyncio + async def test_silent_when_not_alerting(self): + sensor = _sensor(block_print=True) + manager, db = self._manager_with([sensor], {1: SensorReading("off", None, False, True)}) + + assert await manager.blocked_printers(db) == {} + + @pytest.mark.asyncio + async def test_silent_when_home_assistant_is_unreachable(self): + """The queue must keep running when HA is down, not seize up.""" + sensor = _sensor(block_print=True) + manager, db = self._manager_with([sensor], {1: SensorReading(None, None, False, False)}) + + assert await manager.blocked_printers(db) == {} + + @pytest.mark.asyncio + async def test_silent_before_the_first_poll(self): + """A cold cache is not evidence the door is open.""" + sensor = _sensor(block_print=True) + manager, db = self._manager_with([sensor], {}) + + assert await manager.blocked_printers(db) == {} + + @pytest.mark.asyncio + async def test_names_every_blocking_sensor_on_a_printer(self): + sensors = [ + _sensor(id=1, block_print=True, name="Front Door"), + _sensor(id=2, block_print=True, name="Side Panel"), + ] + manager, db = self._manager_with( + sensors, + { + 1: SensorReading("on", None, True, True), + 2: SensorReading("on", None, True, True), + }, + ) + + assert await manager.blocked_printers(db) == {4: "Front Door, Side Panel"} + + +class TestNotificationEdge: + """Alerts fire on the transition into the alert state, not while it lasts.""" + + async def _apply(self, manager, sensor, states, notify): + db = AsyncMock() + db.get.return_value = SimpleNamespace(name="X1C-1") + with patch("backend.app.services.notification_service.notification_service", notify): + await manager._apply(db, [sensor], states) + + @pytest.mark.asyncio + async def test_fires_once_on_the_way_in(self): + manager = HASensorManager() + sensor = _sensor(notify_on_alert=True) + notify = AsyncMock() + + # First poll seeds the cache; a door already open at startup has not + # just been opened. + await self._apply(manager, sensor, {sensor.entity_id: {"state": "off"}}, notify) + assert notify.on_ha_sensor_alert.await_count == 0 + + await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify) + assert notify.on_ha_sensor_alert.await_count == 1 + + # Still open on the next pass — no second alert. + await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify) + assert notify.on_ha_sensor_alert.await_count == 1 + + @pytest.mark.asyncio + async def test_silent_on_the_first_poll_after_a_restart(self): + """Cold cache. Re-announcing every pre-existing alert on every restart + is how users learn to ignore the alert.""" + manager = HASensorManager() + sensor = _sensor(notify_on_alert=True) + notify = AsyncMock() + + await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify) + + assert notify.on_ha_sensor_alert.await_count == 0 + assert manager.get_reading(sensor.id).alerting is True + + @pytest.mark.asyncio + async def test_silent_when_the_sensor_opts_out(self): + manager = HASensorManager() + sensor = _sensor(notify_on_alert=False) + notify = AsyncMock() + + await self._apply(manager, sensor, {sensor.entity_id: {"state": "off"}}, notify) + await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify) + + assert notify.on_ha_sensor_alert.await_count == 0 + + @pytest.mark.asyncio + async def test_re_arms_after_the_alert_clears(self): + manager = HASensorManager() + sensor = _sensor(notify_on_alert=True) + notify = AsyncMock() + + for state in ("off", "on", "off", "on"): + await self._apply(manager, sensor, {sensor.entity_id: {"state": state}}, notify) + + assert notify.on_ha_sensor_alert.await_count == 2 + + @pytest.mark.asyncio + async def test_a_dropout_does_not_count_as_the_alert_clearing(self): + """on -> unavailable -> on is one continuous alert, not two. + + Without this, a flaky Zigbee contact would notify on every reconnect. + """ + manager = HASensorManager() + sensor = _sensor(notify_on_alert=True) + notify = AsyncMock() + + await self._apply(manager, sensor, {sensor.entity_id: {"state": "off"}}, notify) + await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify) + await self._apply(manager, sensor, {sensor.entity_id: None}, notify) + await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify) + + assert notify.on_ha_sensor_alert.await_count == 1 diff --git a/backend/tests/unit/test_scheduler_ha_interlock_1148.py b/backend/tests/unit/test_scheduler_ha_interlock_1148.py new file mode 100644 index 000000000..fa2cc4049 --- /dev/null +++ b/backend/tests/unit/test_scheduler_ha_interlock_1148.py @@ -0,0 +1,306 @@ +"""The Home Assistant sensor interlock, seen from the scheduler (#1148). + +The feature exists because the reporter wanted to know his enclosure was shut +before starting a print remotely. Displaying the door state only helps if he +looks; the interlock is what makes it act on its own. + +It is a *hold*, never a failure: the item stays pending and dispatches by +itself once the door closes. And it holds only on a positive, freshly read +finding — a Home Assistant that is unreachable holds nothing, because a queue +that stops whenever an unrelated service goes down is worse than the problem. +""" + +from contextlib import ExitStack +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +import backend.app.models # noqa: F401 - populate Base.metadata +from backend.app.core.database import Base +from backend.app.models.library import LibraryFile +from backend.app.models.print_queue import PrintQueueItem +from backend.app.models.printer import Printer +from backend.app.services.print_scheduler import PrintScheduler + + +@pytest.fixture +async def queue_db(): + """Two X1Cs, so a model-based job has somewhere else to go.""" + engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + session_maker = async_sessionmaker(engine, expire_on_commit=False) + + async with session_maker() as db: + db.add_all( + [ + Printer( + id=1, + name="X1C-1", + serial_number="X1C0001", + ip_address="10.0.0.1", + access_code="x", + model="X1C", + is_active=True, + ), + Printer( + id=2, + name="X1C-2", + serial_number="X1C0002", + ip_address="10.0.0.2", + access_code="x", + model="X1C", + is_active=True, + ), + ] + ) + await db.commit() + + try: + yield SimpleNamespace(session_maker=session_maker) + finally: + await engine.dispose() + + +async def _add_item(ctx, *, printer_id=None, target_model=None): + async with ctx.session_maker() as db: + lib = LibraryFile( + filename="job.gcode.3mf", + file_path="/library/job.gcode.3mf", + file_size=10, + file_type="gcode.3mf", + file_metadata={"sliced_for_model": "X1C"}, + ) + db.add(lib) + await db.flush() + item = PrintQueueItem( + status="pending", + position=1, + printer_id=printer_id, + target_model=target_model, + library_file_id=lib.id, + ) + db.add(item) + await db.commit() + return item.id + + +async def _run(ctx, scheduler, blocked, launched, finder=None, idle=True, drying=None): + patches = [ + patch("backend.app.services.print_scheduler.async_session", ctx.session_maker), + patch("backend.app.core.database.async_session", ctx.session_maker), + patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)), + patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)), + patch( + "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers", + AsyncMock(return_value=blocked), + ), + patch( + "backend.app.services.notification_service.notification_service.on_queue_job_waiting", + AsyncMock(), + ), + patch( + "backend.app.services.notification_service.notification_service.on_queue_job_assigned", + AsyncMock(), + ), + patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=idle)), + patch.object(scheduler, "_check_auto_drying", drying or AsyncMock()), + patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)), + patch.object(scheduler, "_block_on_filament_deficit", AsyncMock(return_value=False)), + patch.object(scheduler, "_launch_uploads", launched), + ] + if finder is not None: + patches.append(patch.object(scheduler, "_find_idle_printer_for_model", finder)) + with ExitStack() as stack: + for p in patches: + stack.enter_context(p) + return await scheduler.check_queue() + + +async def _get_item(ctx, item_id): + async with ctx.session_maker() as db: + return (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one() + + +class TestFixedPrinter: + @pytest.mark.asyncio + async def test_holds_the_item_and_says_why(self, queue_db): + item_id = await _add_item(queue_db, printer_id=1) + launched = MagicMock() + + await _run(queue_db, PrintScheduler(), {1: "Enclosure Door"}, launched) + + launched.assert_not_called() + item = await _get_item(queue_db, item_id) + assert item.status == "pending" + assert item.waiting_reason == "Waiting on Enclosure Door" + + @pytest.mark.asyncio + async def test_holding_is_not_failing(self, queue_db): + """The door being open is a thing the user fixes in five seconds. The + job must be there waiting when they do, not failed.""" + item_id = await _add_item(queue_db, printer_id=1) + + await _run(queue_db, PrintScheduler(), {1: "Enclosure Door"}, MagicMock()) + + item = await _get_item(queue_db, item_id) + assert item.status == "pending" + assert item.error_message is None + assert item.completed_at is None + + @pytest.mark.asyncio + async def test_dispatches_once_the_hold_clears(self, queue_db): + item_id = await _add_item(queue_db, printer_id=1) + scheduler = PrintScheduler() + await _run(queue_db, scheduler, {1: "Enclosure Door"}, MagicMock()) + + launched = MagicMock() + await _run(queue_db, scheduler, {}, launched) + + launched.assert_called_once() + assert launched.call_args[0][0] == [item_id] + + @pytest.mark.asyncio + async def test_the_stale_reason_is_cleared_on_dispatch(self, queue_db): + """Otherwise the queue shows "Waiting on Enclosure Door" against a job + that is already printing.""" + item_id = await _add_item(queue_db, printer_id=1) + scheduler = PrintScheduler() + await _run(queue_db, scheduler, {1: "Enclosure Door"}, MagicMock()) + + await _run(queue_db, scheduler, {}, MagicMock()) + + assert (await _get_item(queue_db, item_id)).waiting_reason is None + + @pytest.mark.asyncio + async def test_the_reason_clears_even_when_the_printer_is_still_busy(self, queue_db): + """You shut the door, but the printer is midway through something else. + + The hold has lifted and the queue must say so. Clearing the reason only + at dispatch would leave a shut door reading "Waiting on Enclosure Door" + for the rest of a ten-hour print. + """ + item_id = await _add_item(queue_db, printer_id=1) + scheduler = PrintScheduler() + await _run(queue_db, scheduler, {1: "Enclosure Door"}, MagicMock()) + + launched = MagicMock() + await _run(queue_db, scheduler, {}, launched, idle=False) + + launched.assert_not_called() + assert (await _get_item(queue_db, item_id)).waiting_reason is None + + @pytest.mark.asyncio + async def test_another_printers_sensor_does_not_hold_this_one(self, queue_db): + item_id = await _add_item(queue_db, printer_id=1) + launched = MagicMock() + + await _run(queue_db, PrintScheduler(), {2: "Enclosure Door"}, launched) + + launched.assert_called_once() + assert launched.call_args[0][0] == [item_id] + + @pytest.mark.asyncio + async def test_nothing_blocked_dispatches_as_before(self, queue_db): + item_id = await _add_item(queue_db, printer_id=1) + launched = MagicMock() + + await _run(queue_db, PrintScheduler(), {}, launched) + + launched.assert_called_once() + assert launched.call_args[0][0] == [item_id] + + @pytest.mark.asyncio + async def test_a_held_printer_is_not_reported_as_busy(self, queue_db): + """A held printer is idle, not printing, and the rest of the scheduler + must keep seeing it that way. + + busy_printers looks like the obvious place to put a hold, but + _check_auto_drying reads that set as "is currently printing" and would + route an idle-but-held printer down the mid-print drying path — capped + temperature, and past the queue-only gating. Auto-drying must see this + printer exactly as it did before the interlock existed. + """ + await _add_item(queue_db, printer_id=1) + scheduler = PrintScheduler() + drying = AsyncMock() + + await _run(queue_db, scheduler, {1: "Enclosure Door"}, MagicMock(), drying=drying) + + busy_printers = drying.await_args[0][2] + assert 1 not in busy_printers + + @pytest.mark.asyncio + async def test_a_failing_interlock_lookup_never_stops_the_queue(self, queue_db): + """If the check itself breaks, the answer is "no holds", not "no prints".""" + item_id = await _add_item(queue_db, printer_id=1) + launched = MagicMock() + broken = AsyncMock(side_effect=RuntimeError("HA sensor table is on fire")) + + with patch( + "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers", + broken, + ): + await _run(queue_db, PrintScheduler(), {}, launched) + + launched.assert_called_once() + assert launched.call_args[0][0] == [item_id] + + +class TestModelBased: + def _finder(self): + """Stand-in matcher that respects busy_printers, as the real one does. + + That is the whole contract under test here: the interlock folds held + printers into busy_printers, so a matcher that honours the set honours + the interlock without knowing it exists. + """ + + async def finder(db, model, busy, *args, **kwargs): + for printer_id in (1, 2): + if printer_id not in busy: + return printer_id, None + return None, "All printers busy" + + return finder + + @pytest.mark.asyncio + async def test_an_interlocked_printer_is_passed_over_for_its_sibling(self, queue_db): + """The whole reason the hold is folded into busy_printers: an "Any X1C" + job should run on the printer whose door is shut, not queue behind the + one whose door is open.""" + item_id = await _add_item(queue_db, target_model="X1C") + launched = MagicMock() + + await _run( + queue_db, + PrintScheduler(), + {1: "Enclosure Door"}, + launched, + finder=self._finder(), + ) + + launched.assert_called_once() + assert launched.call_args[0][0] == [item_id] + assert (await _get_item(queue_db, item_id)).printer_id == 2 + + @pytest.mark.asyncio + async def test_every_printer_held_leaves_the_job_waiting(self, queue_db): + item_id = await _add_item(queue_db, target_model="X1C") + launched = MagicMock() + + await _run( + queue_db, + PrintScheduler(), + {1: "Enclosure Door", 2: "Enclosure Door"}, + launched, + finder=self._finder(), + ) + + launched.assert_not_called() + item = await _get_item(queue_db, item_id) + assert item.status == "pending" + assert item.printer_id is None diff --git a/frontend/src/__tests__/components/HASensorModal.test.tsx b/frontend/src/__tests__/components/HASensorModal.test.tsx new file mode 100644 index 000000000..9311feb4d --- /dev/null +++ b/frontend/src/__tests__/components/HASensorModal.test.tsx @@ -0,0 +1,91 @@ +/** + * The Home Assistant sensor modal (#1148, #448). + * + * Focused on the unconfigured-Home-Assistant path, which is the state a first + * time user is actually in: the entity picker can only ever come back empty + * there, and an empty list reads as "I have no sensors" rather than as "you + * have not connected Home Assistant yet". + */ + +import { screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { HASensorModal } from '../../components/HASensorModal'; +import { api } from '../../api/client'; +import { render } from '../utils'; + +vi.mock('../../api/client', async () => { + const actual = await vi.importActual('../../api/client'); + return { + ...actual, + api: { ...actual.api, getSettings: vi.fn(), getBindableHAEntities: vi.fn() }, + }; +}); + +const getSettings = vi.mocked(api.getSettings); +const getEntities = vi.mocked(api.getBindableHAEntities); + +const PRINTERS = [{ id: 4, name: 'X1C-1' }] as never; + +function settings(overrides = {}) { + return { + ha_enabled: true, + ha_url: 'http://homeassistant.local:8123', + ha_token: 'token', + ...overrides, + } as never; +} + +describe('HASensorModal', () => { + beforeEach(() => { + getSettings.mockReset(); + getEntities.mockReset(); + getEntities.mockResolvedValue([]); + }); + + it('warns when Home Assistant is not configured at all', async () => { + getSettings.mockResolvedValue(settings({ ha_enabled: false, ha_url: '', ha_token: '' })); + + render( {}} />); + + expect( + await screen.findByText(/Home Assistant is not configured/) + ).toBeInTheDocument(); + expect(screen.getByText('Settings → Network → Home Assistant')).toBeInTheDocument(); + }); + + it('warns when the integration is configured but switched off', async () => { + getSettings.mockResolvedValue(settings({ ha_enabled: false })); + + render( {}} />); + + expect(await screen.findByText(/Home Assistant is not configured/)).toBeInTheDocument(); + }); + + it('does not ask Home Assistant for entities it cannot reach', async () => { + getSettings.mockResolvedValue(settings({ ha_token: '' })); + + render( {}} />); + + await screen.findByText(/Home Assistant is not configured/); + expect(getEntities).not.toHaveBeenCalled(); + }); + + it('blocks saving a new sensor while unconfigured', async () => { + getSettings.mockResolvedValue(settings({ ha_enabled: false })); + + render( {}} />); + + await screen.findByText(/Home Assistant is not configured/); + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + }); + + it('shows no warning once Home Assistant is configured', async () => { + getSettings.mockResolvedValue(settings()); + + render( {}} />); + + await waitFor(() => expect(getEntities).toHaveBeenCalled()); + expect(screen.queryByText(/Home Assistant is not configured/)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/components/PrinterHASensorRow.test.tsx b/frontend/src/__tests__/components/PrinterHASensorRow.test.tsx new file mode 100644 index 000000000..216845246 --- /dev/null +++ b/frontend/src/__tests__/components/PrinterHASensorRow.test.tsx @@ -0,0 +1,161 @@ +/** + * The Home Assistant sensor row on the printer card (#1148, #448). + * + * The reporter's whole ask is a glance-and-know row: "is the enclosure shut?" + * So the assertions are about what the pill actually says. "on" is not an + * answer to that question — "Open" is, and only Home Assistant's device_class + * tells us which word to use. + */ + +import { screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PrinterHASensorRow } from '../../components/PrinterHASensorRow'; +import { api } from '../../api/client'; +import { render } from '../utils'; + +vi.mock('../../api/client', async () => { + const actual = await vi.importActual('../../api/client'); + return { ...actual, api: { ...actual.api, getHASensorReadings: vi.fn() } }; +}); + +const getReadings = vi.mocked(api.getHASensorReadings); + +function reading(overrides = {}) { + return { + id: 1, + name: 'Enclosure Door', + entity_id: 'binary_sensor.enclosure_door', + kind: 'binary' as const, + device_class: 'door', + unit: null, + state: 'off', + value: null, + alerting: false, + block_print: false, + reachable: true, + last_changed: null, + ...overrides, + }; +} + +describe('PrinterHASensorRow', () => { + beforeEach(() => { + getReadings.mockReset(); + }); + + it('renders nothing when the printer has no sensors', async () => { + getReadings.mockResolvedValue([]); + + render(); + + await waitFor(() => expect(getReadings).toHaveBeenCalledWith(4)); + // No label, no divider, no empty-state placeholder — the row must not + // reserve space on every card that has no sensors configured. + expect(screen.queryByText('Sensors')).not.toBeInTheDocument(); + }); + + it('names a door state by its device class, not by on/off', async () => { + getReadings.mockResolvedValue([reading({ state: 'off' })]); + + render(); + + expect(await screen.findByText('Closed')).toBeInTheDocument(); + expect(screen.getByText('Enclosure Door')).toBeInTheDocument(); + }); + + it('says Open for the same sensor in the other state', async () => { + getReadings.mockResolvedValue([reading({ state: 'on', alerting: true })]); + + render(); + + expect(await screen.findByText('Open')).toBeInTheDocument(); + }); + + it('falls back to on/off for a class it has no wording for', async () => { + getReadings.mockResolvedValue([reading({ device_class: null, state: 'on' })]); + + render(); + + expect(await screen.findByText('On')).toBeInTheDocument(); + }); + + it('shows a numeric reading with its unit', async () => { + getReadings.mockResolvedValue([ + reading({ + kind: 'numeric', + device_class: 'temperature', + entity_id: 'sensor.enclosure_temp', + name: 'Enclosure Temp', + unit: '°C', + state: '41.2', + value: 41.2, + }), + ]); + + render(); + + expect(await screen.findByText('41.2 °C')).toBeInTheDocument(); + }); + + it('reports an unreachable sensor as unavailable rather than as a state', async () => { + // The pill must not read "Closed" for a door contact that dropped off the + // network — that is the one wrong answer with real consequences. + getReadings.mockResolvedValue([reading({ state: null, reachable: false })]); + + render(); + + expect(await screen.findByText('Unavailable')).toBeInTheDocument(); + expect(screen.queryByText('Closed')).not.toBeInTheDocument(); + }); + + it('marks an alerting sensor for the eye', async () => { + getReadings.mockResolvedValue([reading({ state: 'on', alerting: true })]); + + render(); + + const pill = (await screen.findByText('Open')).closest('span[title]'); + expect(pill?.className).toContain('red'); + }); + + it('does not colour a quiet sensor as an alert', async () => { + getReadings.mockResolvedValue([reading({ state: 'on', alerting: false })]); + + render(); + + const pill = (await screen.findByText('Open')).closest('span[title]'); + expect(pill?.className).not.toContain('red'); + }); + + it('says so in the tooltip when a sensor holds prints', async () => { + getReadings.mockResolvedValue([reading({ block_print: true })]); + + render(); + + await screen.findByText('Closed'); + expect( + screen.getByTitle('binary_sensor.enclosure_door — holds prints while alerting') + ).toBeInTheDocument(); + }); + + it('lists every sensor bound to the printer', async () => { + getReadings.mockResolvedValue([ + reading(), + reading({ + id: 2, + kind: 'numeric', + name: 'Enclosure Temp', + entity_id: 'sensor.enclosure_temp', + device_class: 'temperature', + unit: '°C', + state: '22', + value: 22, + }), + ]); + + render(); + + expect(await screen.findByText('Enclosure Door')).toBeInTheDocument(); + expect(screen.getByText('Enclosure Temp')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4f2b1955c..29f8db389 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2167,6 +2167,75 @@ export interface HATestConnectionResult { error: string | null; } +// A Home Assistant entity bound to a printer for display on its card (#1148, #448). +// Read-only: unlike a SmartPlug there is nothing here to switch. +export interface PrinterHASensor { + id: number; + printer_id: number; + name: string; + entity_id: string; + kind: 'binary' | 'numeric'; + device_class: string | null; // HA's own class: "door", "temperature", ... + unit: string | null; // numeric sensors only + // What counts as needing attention. Binary sensors use alert_state, numeric + // ones the thresholds; all null means the sensor is display-only. + alert_state: 'on' | 'off' | null; + alert_above: number | null; + alert_below: number | null; + block_print: boolean; // hold the printer's queue while alerting + notify_on_alert: boolean; + show_on_printer_card: boolean; + sort_order: number; + last_state: string | null; + last_changed: string | null; + last_checked: string | null; + created_at: string; + updated_at: string; +} + +export interface PrinterHASensorReading { + id: number; + name: string; + entity_id: string; + kind: 'binary' | 'numeric'; + device_class: string | null; + unit: string | null; + state: string | null; // null when unreadable or not yet polled + value: number | null; // numeric sensors only + alerting: boolean; + block_print: boolean; + reachable: boolean; + last_changed: string | null; +} + +export interface PrinterHASensorCreate { + printer_id: number; + name: string; + entity_id: string; + kind: 'binary' | 'numeric'; + device_class?: string | null; + unit?: string | null; + alert_state?: 'on' | 'off' | null; + alert_above?: number | null; + alert_below?: number | null; + block_print?: boolean; + notify_on_alert?: boolean; + show_on_printer_card?: boolean; + sort_order?: number; +} + +export type PrinterHASensorUpdate = Partial>; + +// An entity offered by the binding picker. +export interface HADisplayEntity { + entity_id: string; + friendly_name: string; + state: string | null; + domain: string; // "binary_sensor" | "sensor" + device_class: string | null; + unit_of_measurement: string | null; +} + export interface SmartPlugEnergy { power: number | null; // Current watts voltage: number | null; // Volts @@ -2615,6 +2684,7 @@ export interface NotificationProvider { on_plate_clear_required: boolean; // Bed cooled on_bed_cooled: boolean; + on_ha_sensor_alert: boolean; // First layer complete on_first_layer_complete: boolean; // Inventory stock alerts @@ -2675,6 +2745,7 @@ export interface NotificationProviderCreate { on_plate_clear_required?: boolean; // Bed cooled on_bed_cooled?: boolean; + on_ha_sensor_alert?: boolean; // First layer complete on_first_layer_complete?: boolean; // Inventory stock alerts @@ -2728,6 +2799,7 @@ export interface NotificationProviderUpdate { on_plate_clear_required?: boolean; // Bed cooled on_bed_cooled?: boolean; + on_ha_sensor_alert?: boolean; // First layer complete on_first_layer_complete?: boolean; // Inventory stock alerts @@ -5277,6 +5349,22 @@ export const api = { getHASensorEntities: () => request('/smart-plugs/ha/sensors'), + // Home Assistant sensors bound to a printer (#1148, #448) + getHASensors: (printerId?: number) => + request(`/ha-sensors/${printerId ? `?printer_id=${printerId}` : ''}`), + getHASensorReadings: (printerId: number) => + request(`/ha-sensors/by-printer/${printerId}/readings`), + getBindableHAEntities: (search?: string) => { + const params = search ? `?search=${encodeURIComponent(search)}` : ''; + return request(`/ha-sensors/entities${params}`); + }, + createHASensor: (data: PrinterHASensorCreate) => + request('/ha-sensors/', { method: 'POST', body: JSON.stringify(data) }), + updateHASensor: (id: number, data: PrinterHASensorUpdate) => + request(`/ha-sensors/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), + deleteHASensor: (id: number) => + request<{ message: string }>(`/ha-sensors/${id}`, { method: 'DELETE' }), + // REST smart plug testRESTConnection: (url: string, method: string = 'GET', headers?: string | null) => request<{ success: boolean; error: string | null }>('/smart-plugs/rest/test-connection', { diff --git a/frontend/src/components/AddNotificationModal.tsx b/frontend/src/components/AddNotificationModal.tsx index 9ad040220..51e75eb0a 100644 --- a/frontend/src/components/AddNotificationModal.tsx +++ b/frontend/src/components/AddNotificationModal.tsx @@ -45,6 +45,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal const [onStockBreakAlert, setOnStockBreakAlert] = useState(provider?.on_stock_break_alert ?? false); const [onPlateClearRequired, setOnPlateClearRequired] = useState(provider?.on_plate_clear_required ?? false); const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false); + const [onHaSensorAlert, setOnHaSensorAlert] = useState(provider?.on_ha_sensor_alert ?? false); const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? false); // Provider-specific config (scalar fields only — event_priorities is split out @@ -200,6 +201,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal on_stock_break_alert: onStockBreakAlert, on_plate_clear_required: onPlateClearRequired, on_bed_cooled: onBedCooled, + on_ha_sensor_alert: onHaSensorAlert, on_first_layer_complete: onFirstLayerComplete, }; @@ -635,6 +637,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal {t('notifications.offline')} +
+
+ {t('notifications.haSensorAlert')} + {t('notifications.haSensorAlertDescription')} +
+ +
{t('notifications.error')} @@ -688,6 +697,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') }); if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') }); if (onPrinterError) enabledEvents.push({ key: 'on_printer_error', label: t('notifications.error') }); + if (onHaSensorAlert) enabledEvents.push({ key: 'on_ha_sensor_alert', label: t('notifications.haSensorAlert') }); if (onAiFailureDetection) enabledEvents.push({ key: 'on_ai_failure_detection', label: t('notifications.aiFailureDetection') }); if (onFilamentLow) enabledEvents.push({ key: 'on_filament_low', label: t('notifications.lowFilament') }); if (onMaintenanceDue) enabledEvents.push({ key: 'on_maintenance_due', label: t('notifications.maintenance') }); diff --git a/frontend/src/components/HASensorModal.tsx b/frontend/src/components/HASensorModal.tsx new file mode 100644 index 000000000..28f3ace89 --- /dev/null +++ b/frontend/src/components/HASensorModal.tsx @@ -0,0 +1,413 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Gauge, Loader2, Save, Search, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { api } from '../api/client'; +import type { HADisplayEntity, Printer, PrinterHASensor } from '../api/client'; +import { Button } from './Button'; +import { useToast } from '../contexts/ToastContext'; + +/** + * Bind a Home Assistant entity to a printer, or edit an existing binding + * (#1148, #448). + * + * The entity picker is the load-bearing part: kind, device_class and unit all + * come from the entity rather than from the user, because getting any of them + * wrong is a validation error from the backend that nobody could act on. + */ + +interface Props { + sensor?: PrinterHASensor | null; + printers: Printer[]; + onClose: () => void; +} + +// The alert wording follows the device class, so a door offers "Open" rather +// than "On". Shared with PrinterHASensorRow's rendering of the same classes. +const ALERT_LABEL_KEYS: Record = { + door: { on: 'open', off: 'closed' }, + garage_door: { on: 'open', off: 'closed' }, + window: { on: 'open', off: 'closed' }, + opening: { on: 'open', off: 'closed' }, + lock: { on: 'unlocked', off: 'locked' }, + motion: { on: 'detected', off: 'clear' }, + occupancy: { on: 'detected', off: 'clear' }, + presence: { on: 'detected', off: 'clear' }, + smoke: { on: 'detected', off: 'clear' }, + gas: { on: 'detected', off: 'clear' }, + moisture: { on: 'wet', off: 'dry' }, + problem: { on: 'problem', off: 'ok' }, + safety: { on: 'problem', off: 'ok' }, + running: { on: 'running', off: 'stopped' }, +}; + +export function HASensorModal({ sensor, printers, onClose }: Props) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const { showToast } = useToast(); + const isEditing = !!sensor; + + const [printerId, setPrinterId] = useState(sensor?.printer_id ?? printers[0]?.id ?? ''); + const [entityId, setEntityId] = useState(sensor?.entity_id ?? ''); + const [kind, setKind] = useState<'binary' | 'numeric'>(sensor?.kind ?? 'binary'); + const [deviceClass, setDeviceClass] = useState(sensor?.device_class ?? null); + const [unit, setUnit] = useState(sensor?.unit ?? null); + const [name, setName] = useState(sensor?.name ?? ''); + const [alertState, setAlertState] = useState<'on' | 'off' | ''>(sensor?.alert_state ?? ''); + const [alertAbove, setAlertAbove] = useState(sensor?.alert_above?.toString() ?? ''); + const [alertBelow, setAlertBelow] = useState(sensor?.alert_below?.toString() ?? ''); + const [showOnCard, setShowOnCard] = useState(sensor?.show_on_printer_card ?? true); + const [notifyOnAlert, setNotifyOnAlert] = useState(sensor?.notify_on_alert ?? false); + const [blockPrint, setBlockPrint] = useState(sensor?.block_print ?? false); + const [search, setSearch] = useState(''); + const [error, setError] = useState(null); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + // Same gate and the same wording as AddSmartPlugModal: without a configured + // Home Assistant the picker can only return an error, so say why up front + // instead of showing an empty list. + const { data: settings } = useQuery({ + queryKey: ['settings'], + queryFn: api.getSettings, + }); + const haConfigured = !!(settings?.ha_enabled && settings?.ha_url && settings?.ha_token); + + const { data: entities, isLoading: entitiesLoading, error: entitiesError } = useQuery({ + queryKey: ['bindableHAEntities'], + queryFn: () => api.getBindableHAEntities(), + enabled: haConfigured, + }); + + const filtered = useMemo(() => { + const needle = search.trim().toLowerCase(); + const all = entities ?? []; + if (!needle) return all; + return all.filter( + (e) => e.entity_id.toLowerCase().includes(needle) || e.friendly_name.toLowerCase().includes(needle) + ); + }, [entities, search]); + + const selectEntity = (entity: HADisplayEntity) => { + setEntityId(entity.entity_id); + setDeviceClass(entity.device_class); + setUnit(entity.unit_of_measurement); + const nextKind = entity.domain === 'binary_sensor' ? 'binary' : 'numeric'; + setKind(nextKind); + // Switching kind strands the other kind's alert fields, and the backend + // rejects a numeric sensor that still carries an alert_state. + if (nextKind === 'numeric') setAlertState(''); + else { + setAlertAbove(''); + setAlertBelow(''); + } + // Sliced to the column width: Home Assistant friendly names have no length + // limit, and a long one would come back as a Pydantic error on a field the + // user did not type into. + if (!name.trim()) setName(entity.friendly_name.slice(0, 100)); + }; + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: ['haSensors'] }); + queryClient.invalidateQueries({ queryKey: ['haSensorReadings'] }); + }; + + const saveMutation = useMutation({ + mutationFn: () => { + const payload = { + name: name.trim(), + entity_id: entityId, + kind, + device_class: deviceClass, + unit, + alert_state: kind === 'binary' && alertState ? alertState : null, + alert_above: kind === 'numeric' && alertAbove !== '' ? Number(alertAbove) : null, + alert_below: kind === 'numeric' && alertBelow !== '' ? Number(alertBelow) : null, + block_print: blockPrint, + notify_on_alert: notifyOnAlert, + show_on_printer_card: showOnCard, + }; + return isEditing + ? api.updateHASensor(sensor.id, payload) + : api.createHASensor({ ...payload, printer_id: Number(printerId) }); + }, + onSuccess: () => { + invalidate(); + showToast(isEditing ? t('haSensors.toast.updated') : t('haSensors.toast.created'), 'success'); + onClose(); + }, + onError: (err: Error) => setError(err.message), + }); + + const deleteMutation = useMutation({ + mutationFn: () => api.deleteHASensor(sensor!.id), + onSuccess: () => { + invalidate(); + showToast(t('haSensors.toast.deleted'), 'success'); + onClose(); + }, + onError: (err: Error) => setError(err.message), + }); + + const hasAlertCondition = + kind === 'binary' ? alertState !== '' : alertAbove !== '' || alertBelow !== ''; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + if (!entityId) return setError(t('haSensors.error.pickEntity')); + if (!name.trim()) return setError(t('haSensors.error.nameRequired')); + if (printerId === '') return setError(t('haSensors.error.printerRequired')); + // Mirrors the backend rule, so the user is told before the round trip + // rather than by a 422. + if ((blockPrint || notifyOnAlert) && !hasAlertCondition) { + return setError(t('haSensors.error.alertRequired')); + } + saveMutation.mutate(); + }; + + const alertLabels = ALERT_LABEL_KEYS[deviceClass ?? '']; + const stateLabel = (which: 'on' | 'off') => { + const key = alertLabels?.[which] ?? which; + return t(`haSensors.states.${key}`, { defaultValue: key }); + }; + + const isPending = saveMutation.isPending || deleteMutation.isPending; + + return ( +
+
e.stopPropagation()} + > +
+
+
+ +
+

+ {isEditing ? t('haSensors.editTitle') : t('haSensors.addTitle')} +

+
+ +
+ +
+ {error && ( +
+ {error} +
+ )} + + {!isEditing && ( +
+ + +
+ )} + + {!haConfigured && ( +
+ {t('smartPlugs.haNotConfigured')}{' '} + {t('smartPlugs.haSettingsPath')} +
+ )} + +
+ + {entitiesError && ( +
+ {(entitiesError as Error).message} +
+ )} +
+ + setSearch(e.target.value)} + placeholder={t('haSensors.searchPlaceholder')} + disabled={!haConfigured} + className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed" + /> +
+
+ {!haConfigured && ( +
{t('haSensors.noEntities')}
+ )} + {haConfigured && entitiesLoading && ( +
+ + {t('common.loading')} +
+ )} + {haConfigured && !entitiesLoading && filtered.length === 0 && ( +
{t('haSensors.noEntities')}
+ )} + {haConfigured && + !entitiesLoading && + filtered.map((entity) => ( + + ))} +
+
+ +
+ + setName(e.target.value)} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white" + /> +
+ +
+ + {kind === 'binary' ? ( + + ) : ( +
+
+ + {t('haSensors.alertAbove')} {unit ?? ''} + + setAlertAbove(e.target.value)} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white" + /> +
+
+ + {t('haSensors.alertBelow')} {unit ?? ''} + + setAlertBelow(e.target.value)} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white" + /> +
+
+ )} +

{t('haSensors.alertHint')}

+
+ + + + + + + +
+ {isEditing ? ( + + ) : ( + + )} +
+ + {/* An unconfigured Home Assistant leaves nothing to bind to. + Editing an existing sensor still saves — its alert rule and + card visibility are Bambuddy's own settings and do not need + Home Assistant to be reachable to change. */} + +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/NotificationProviderCard.tsx b/frontend/src/components/NotificationProviderCard.tsx index 40f5523e3..5194159b1 100644 --- a/frontend/src/components/NotificationProviderCard.tsx +++ b/frontend/src/components/NotificationProviderCard.tsx @@ -144,6 +144,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi {provider.on_ai_failure_detection && ( {t('notifications.aiFailureDetection')} )} + {provider.on_ha_sensor_alert && ( + {t('notifications.haSensorAlert')} + )} {provider.on_filament_low && ( {t('notifications.lowFilament')} )} @@ -394,6 +397,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi />
+
+
+

{t('notifications.haSensorAlert')}

+

{t('notifications.haSensorAlertDescription')}

+
+ updateMutation.mutate({ on_ha_sensor_alert: checked })} + /> +
+

{t('notifications.lowFilamentLabel')}

= { + door: { on: 'open', off: 'closed' }, + garage_door: { on: 'open', off: 'closed' }, + window: { on: 'open', off: 'closed' }, + opening: { on: 'open', off: 'closed' }, + lock: { on: 'unlocked', off: 'locked' }, + motion: { on: 'detected', off: 'clear' }, + occupancy: { on: 'detected', off: 'clear' }, + presence: { on: 'detected', off: 'clear' }, + smoke: { on: 'detected', off: 'clear' }, + gas: { on: 'detected', off: 'clear' }, + moisture: { on: 'wet', off: 'dry' }, + problem: { on: 'problem', off: 'ok' }, + safety: { on: 'problem', off: 'ok' }, + running: { on: 'running', off: 'stopped' }, +}; + +const ICONS: Record = { + door: DoorOpen, + garage_door: DoorOpen, + window: DoorOpen, + opening: DoorOpen, + lock: LockOpen, + temperature: Thermometer, + humidity: Droplets, + moisture: Droplets, + motion: Activity, + occupancy: Activity, + presence: Activity, + smoke: AlertTriangle, + gas: AlertTriangle, + problem: AlertTriangle, + safety: AlertTriangle, + running: Wind, +}; + +function iconFor(reading: PrinterHASensorReading): LucideIcon { + const deviceClass = reading.device_class ?? ''; + // A closed door wants the closed-door glyph — the map is keyed by class, so + // the two states that have a distinct "off" icon are special-cased here. + if (reading.state === 'off') { + if (ICONS[deviceClass] === DoorOpen) return DoorClosed; + if (ICONS[deviceClass] === LockOpen) return Lock; + } + return ICONS[deviceClass] ?? (reading.kind === 'numeric' ? Gauge : Activity); +} + +interface Props { + printerId: number; +} + +export function PrinterHASensorRow({ printerId }: Props) { + const { t } = useTranslation(); + + const { data: readings } = useQuery({ + queryKey: ['haSensorReadings', printerId], + queryFn: () => api.getHASensorReadings(printerId), + // Served from the backend poller's cache, so this costs a local request + // and never a Home Assistant round trip. Matched to the poller's own + // cadence — refetching faster would only re-read the same reading. + refetchInterval: 15000, + }); + + if (!readings?.length) return null; + + const describe = (reading: PrinterHASensorReading): string => { + if (!reading.reachable || reading.state === null) return t('haSensors.unavailable'); + if (reading.kind === 'numeric') { + if (reading.value === null) return reading.state; + return reading.unit ? `${reading.value} ${reading.unit}` : String(reading.value); + } + const labels = BINARY_LABELS[reading.device_class ?? '']; + const key = labels ? labels[reading.state === 'on' ? 'on' : 'off'] : reading.state; + return t(`haSensors.states.${key}`, { defaultValue: key }); + }; + + return ( +
+ + {t('haSensors.label')} +
+
+ {readings.map((reading) => { + const Icon = iconFor(reading); + const unreachable = !reading.reachable || reading.state === null; + return ( + + + {reading.name} + {describe(reading)} + + ); + })} +
+
+ ); +} diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 1bc9dfe7b..d2325f075 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -5479,6 +5479,61 @@ export default { }, // Smart Plugs + haSensors: { + label: 'Sensoren', + unavailable: 'Nicht verfügbar', + blocksPrints: '{{entity}} — hält Drucke zurück, solange der Alarm aktiv ist', + states: { + open: 'Offen', + closed: 'Geschlossen', + unlocked: 'Entriegelt', + locked: 'Verriegelt', + detected: 'Erkannt', + clear: 'Frei', + wet: 'Nass', + dry: 'Trocken', + problem: 'Problem', + ok: 'OK', + running: 'Läuft', + stopped: 'Gestoppt', + on: 'An', + off: 'Aus', + }, + sectionTitle: 'Home-Assistant-Sensoren', + add: 'Sensor hinzufügen', + addTitle: 'Home-Assistant-Sensor hinzufügen', + editTitle: 'Home-Assistant-Sensor bearbeiten', + empty: 'Noch keine Sensoren. Verknüpfe einen Türkontakt oder ein Thermometer aus Home Assistant, um es auf der Druckerkarte anzuzeigen.', + unknownPrinter: 'Unbekannter Drucker', + badgeBlocks: 'Hält Drucke zurück', + badgeNotifies: 'Benachrichtigt', + badgeHidden: 'Auf Karte ausgeblendet', + printer: 'Drucker', + entity: 'Entität', + searchPlaceholder: 'Entitäten suchen ...', + noEntities: 'Keine passenden Entitäten', + name: 'Anzeigename', + alertWhen: 'Alarm wenn', + alertNever: 'Nie — nur anzeigen', + alertAbove: 'Über', + alertBelow: 'Unter', + alertHint: 'Der Alarmzustand hebt den Sensor auf der Druckerkarte hervor und aktiviert die Optionen unten.', + showOnCard: 'Auf Druckerkarte anzeigen', + notifyOnAlert: 'Benachrichtigen, sobald der Alarm ausgelöst wird', + blockPrint: 'Wartende Drucke zurückhalten, solange der Alarm aktiv ist', + blockPrintHint: 'Aufträge bleiben in der Warteschlange und starten von selbst, sobald der Sensor wieder frei ist. Wird ignoriert, solange Home Assistant nicht erreichbar ist.', + toast: { + created: 'Sensor hinzugefügt', + updated: 'Sensor gespeichert', + deleted: 'Sensor entfernt', + }, + error: { + pickEntity: 'Wähle eine Home-Assistant-Entität', + nameRequired: 'Gib einen Anzeigenamen ein', + printerRequired: 'Wähle einen Drucker', + alertRequired: 'Lege zuerst eine Alarmbedingung fest', + }, + }, smartPlugs: { offline: 'Offline', admin: 'Admin', @@ -5791,6 +5846,8 @@ export default { notificationEvents: 'Benachrichtigungsereignisse', progressPercent: '(25 %, 50 %, 75 %)', bedCooledAfterPrint: '(nach Druckabschluss)', + haSensorAlert: 'Sensor-Alarm', + haSensorAlertDescription: '(ein verknüpfter Home-Assistant-Sensor braucht Aufmerksamkeit)', // Per-event ntfy priority (#990) eventPriority: { sectionTitle: 'ntfy-Priorität', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index a0802427d..72ae7f17d 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5528,6 +5528,61 @@ export default { }, // Smart Plugs + haSensors: { + label: 'Sensors', + unavailable: 'Unavailable', + blocksPrints: '{{entity}} — holds prints while alerting', + states: { + open: 'Open', + closed: 'Closed', + unlocked: 'Unlocked', + locked: 'Locked', + detected: 'Detected', + clear: 'Clear', + wet: 'Wet', + dry: 'Dry', + problem: 'Problem', + ok: 'OK', + running: 'Running', + stopped: 'Stopped', + on: 'On', + off: 'Off', + }, + sectionTitle: 'Home Assistant Sensors', + add: 'Add Sensor', + addTitle: 'Add Home Assistant Sensor', + editTitle: 'Edit Home Assistant Sensor', + empty: 'No sensors yet. Bind a door contact or a thermometer from Home Assistant to show it on a printer card.', + unknownPrinter: 'Unknown printer', + badgeBlocks: 'Holds prints', + badgeNotifies: 'Notifies', + badgeHidden: 'Hidden on card', + printer: 'Printer', + entity: 'Entity', + searchPlaceholder: 'Search entities...', + noEntities: 'No matching entities', + name: 'Display name', + alertWhen: 'Alert when', + alertNever: 'Never — display only', + alertAbove: 'Above', + alertBelow: 'Below', + alertHint: 'The alert state highlights the sensor on the printer card and enables the options below.', + showOnCard: 'Show on printer card', + notifyOnAlert: 'Send a notification when it starts alerting', + blockPrint: 'Hold queued prints while alerting', + blockPrintHint: 'Jobs stay queued and start on their own once the sensor clears. Ignored while Home Assistant is unreachable.', + toast: { + created: 'Sensor added', + updated: 'Sensor saved', + deleted: 'Sensor removed', + }, + error: { + pickEntity: 'Pick a Home Assistant entity', + nameRequired: 'Enter a display name', + printerRequired: 'Pick a printer', + alertRequired: 'Set an alert condition first', + }, + }, smartPlugs: { offline: 'Offline', admin: 'Admin', @@ -5840,6 +5895,8 @@ export default { notificationEvents: 'Notification Events', progressPercent: '(25%, 50%, 75%)', bedCooledAfterPrint: '(after print completes)', + haSensorAlert: 'Sensor Alert', + haSensorAlertDescription: '(a bound Home Assistant sensor needs attention)', // Per-event ntfy priority (#990) eventPriority: { sectionTitle: 'ntfy Priority', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 6906013a1..6d3c23913 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -5488,6 +5488,61 @@ export default { }, // Smart Plugs + haSensors: { + label: 'Sensores', + unavailable: 'No disponible', + blocksPrints: '{{entity}} — retiene las impresiones mientras haya alerta', + states: { + open: 'Abierto', + closed: 'Cerrado', + unlocked: 'Desbloqueado', + locked: 'Bloqueado', + detected: 'Detectado', + clear: 'Despejado', + wet: 'Húmedo', + dry: 'Seco', + problem: 'Problema', + ok: 'OK', + running: 'En marcha', + stopped: 'Detenido', + on: 'Encendido', + off: 'Apagado', + }, + sectionTitle: 'Sensores de Home Assistant', + add: 'Añadir sensor', + addTitle: 'Añadir sensor de Home Assistant', + editTitle: 'Editar sensor de Home Assistant', + empty: 'Aún no hay sensores. Vincula un contacto de puerta o un termómetro de Home Assistant para mostrarlo en la tarjeta de la impresora.', + unknownPrinter: 'Impresora desconocida', + badgeBlocks: 'Retiene impresiones', + badgeNotifies: 'Notifica', + badgeHidden: 'Oculto en la tarjeta', + printer: 'Impresora', + entity: 'Entidad', + searchPlaceholder: 'Buscar entidades...', + noEntities: 'No hay entidades coincidentes', + name: 'Nombre visible', + alertWhen: 'Alertar cuando', + alertNever: 'Nunca — solo mostrar', + alertAbove: 'Por encima de', + alertBelow: 'Por debajo de', + alertHint: 'El estado de alerta resalta el sensor en la tarjeta de la impresora y habilita las opciones de abajo.', + showOnCard: 'Mostrar en la tarjeta de la impresora', + notifyOnAlert: 'Enviar una notificación cuando empiece la alerta', + blockPrint: 'Retener las impresiones en cola mientras haya alerta', + blockPrintHint: 'Los trabajos siguen en cola y arrancan solos cuando el sensor se despeja. Se ignora mientras Home Assistant no esté accesible.', + toast: { + created: 'Sensor añadido', + updated: 'Sensor guardado', + deleted: 'Sensor eliminado', + }, + error: { + pickEntity: 'Elige una entidad de Home Assistant', + nameRequired: 'Introduce un nombre visible', + printerRequired: 'Elige una impresora', + alertRequired: 'Define primero una condición de alerta', + }, + }, smartPlugs: { offline: 'Desconectado', admin: 'Administración', @@ -5800,6 +5855,8 @@ export default { notificationEvents: 'Eventos de notificación', progressPercent: '(25%, 50%, 75%)', bedCooledAfterPrint: '(después de completar la impresión)', + haSensorAlert: 'Alerta de sensor', + haSensorAlertDescription: '(un sensor de Home Assistant vinculado requiere atención)', // Per-event ntfy priority (#990) eventPriority: { sectionTitle: 'Prioridad de ntfy', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index d822fd7a8..46356473c 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -5469,6 +5469,61 @@ export default { }, // Smart Plugs + haSensors: { + label: 'Capteurs', + unavailable: 'Indisponible', + blocksPrints: '{{entity}} — retient les impressions tant que l\'alerte est active', + states: { + open: 'Ouvert', + closed: 'Fermé', + unlocked: 'Déverrouillé', + locked: 'Verrouillé', + detected: 'Détecté', + clear: 'Dégagé', + wet: 'Humide', + dry: 'Sec', + problem: 'Problème', + ok: 'OK', + running: 'En marche', + stopped: 'Arrêté', + on: 'Activé', + off: 'Désactivé', + }, + sectionTitle: 'Capteurs Home Assistant', + add: 'Ajouter un capteur', + addTitle: 'Ajouter un capteur Home Assistant', + editTitle: 'Modifier le capteur Home Assistant', + empty: 'Aucun capteur pour l\'instant. Liez un contact de porte ou un thermomètre depuis Home Assistant pour l\'afficher sur la carte imprimante.', + unknownPrinter: 'Imprimante inconnue', + badgeBlocks: 'Retient les impressions', + badgeNotifies: 'Notifie', + badgeHidden: 'Masqué sur la carte', + printer: 'Imprimante', + entity: 'Entité', + searchPlaceholder: 'Rechercher des entités...', + noEntities: 'Aucune entité correspondante', + name: 'Nom affiché', + alertWhen: 'Alerter quand', + alertNever: 'Jamais — affichage seul', + alertAbove: 'Au-dessus de', + alertBelow: 'En dessous de', + alertHint: 'L\'état d\'alerte met le capteur en évidence sur la carte imprimante et active les options ci-dessous.', + showOnCard: 'Afficher sur la carte imprimante', + notifyOnAlert: 'Envoyer une notification au déclenchement de l\'alerte', + blockPrint: 'Retenir les impressions en file tant que l\'alerte est active', + blockPrintHint: 'Les travaux restent en file et démarrent d\'eux-mêmes une fois le capteur revenu à la normale. Ignoré tant que Home Assistant est injoignable.', + toast: { + created: 'Capteur ajouté', + updated: 'Capteur enregistré', + deleted: 'Capteur supprimé', + }, + error: { + pickEntity: 'Choisissez une entité Home Assistant', + nameRequired: 'Saisissez un nom affiché', + printerRequired: 'Choisissez une imprimante', + alertRequired: 'Définissez d\'abord une condition d\'alerte', + }, + }, smartPlugs: { offline: 'Hors ligne', admin: 'Administrateur', @@ -5781,6 +5836,8 @@ export default { notificationEvents: 'Événements de notification', progressPercent: '(25 %, 50 %, 75 %)', bedCooledAfterPrint: '(après la fin de l\'impression)', + haSensorAlert: 'Alerte de capteur', + haSensorAlertDescription: '(un capteur Home Assistant lié nécessite votre attention)', // Per-event ntfy priority (#990) eventPriority: { sectionTitle: 'Priorité ntfy', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 459d30d33..87889d35a 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -5468,6 +5468,61 @@ export default { }, // Smart Plugs + haSensors: { + label: 'Sensori', + unavailable: 'Non disponibile', + blocksPrints: '{{entity}} — trattiene le stampe finché l\'allarme è attivo', + states: { + open: 'Aperto', + closed: 'Chiuso', + unlocked: 'Sbloccato', + locked: 'Bloccato', + detected: 'Rilevato', + clear: 'Libero', + wet: 'Bagnato', + dry: 'Asciutto', + problem: 'Problema', + ok: 'OK', + running: 'In funzione', + stopped: 'Fermo', + on: 'Acceso', + off: 'Spento', + }, + sectionTitle: 'Sensori Home Assistant', + add: 'Aggiungi sensore', + addTitle: 'Aggiungi sensore Home Assistant', + editTitle: 'Modifica sensore Home Assistant', + empty: 'Nessun sensore. Collega un contatto porta o un termometro da Home Assistant per mostrarlo sulla scheda stampante.', + unknownPrinter: 'Stampante sconosciuta', + badgeBlocks: 'Trattiene le stampe', + badgeNotifies: 'Notifica', + badgeHidden: 'Nascosto sulla scheda', + printer: 'Stampante', + entity: 'Entità', + searchPlaceholder: 'Cerca entità...', + noEntities: 'Nessuna entità corrispondente', + name: 'Nome visualizzato', + alertWhen: 'Allarme quando', + alertNever: 'Mai — solo visualizzazione', + alertAbove: 'Sopra', + alertBelow: 'Sotto', + alertHint: 'Lo stato di allarme evidenzia il sensore sulla scheda stampante e abilita le opzioni sottostanti.', + showOnCard: 'Mostra sulla scheda stampante', + notifyOnAlert: 'Invia una notifica quando scatta l\'allarme', + blockPrint: 'Trattieni le stampe in coda finché l\'allarme è attivo', + blockPrintHint: 'I lavori restano in coda e partono da soli quando il sensore rientra. Ignorato finché Home Assistant non è raggiungibile.', + toast: { + created: 'Sensore aggiunto', + updated: 'Sensore salvato', + deleted: 'Sensore rimosso', + }, + error: { + pickEntity: 'Scegli un\'entità Home Assistant', + nameRequired: 'Inserisci un nome visualizzato', + printerRequired: 'Scegli una stampante', + alertRequired: 'Imposta prima una condizione di allarme', + }, + }, smartPlugs: { offline: 'Offline', admin: 'Amministrazione', @@ -5780,6 +5835,8 @@ export default { notificationEvents: 'Eventi di notifica', progressPercent: '(25%, 50%, 75%)', bedCooledAfterPrint: '(dopo il completamento della stampa)', + haSensorAlert: 'Avviso sensore', + haSensorAlertDescription: '(un sensore Home Assistant collegato richiede attenzione)', // Per-event ntfy priority (#990) eventPriority: { sectionTitle: 'Priorità ntfy', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 2c9e1e150..6ffffdbd2 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -5480,6 +5480,61 @@ export default { }, // Smart Plugs + haSensors: { + label: 'センサー', + unavailable: '利用不可', + blocksPrints: '{{entity}} — アラート中は印刷を保留します', + states: { + open: '開', + closed: '閉', + unlocked: '解錠', + locked: '施錠', + detected: '検知', + clear: 'なし', + wet: '濡れ', + dry: '乾燥', + problem: '異常', + ok: '正常', + running: '作動中', + stopped: '停止', + on: 'オン', + off: 'オフ', + }, + sectionTitle: 'Home Assistant センサー', + add: 'センサーを追加', + addTitle: 'Home Assistant センサーを追加', + editTitle: 'Home Assistant センサーを編集', + empty: 'センサーがまだありません。Home Assistant のドアセンサーや温度計を連携すると、プリンターカードに表示されます。', + unknownPrinter: '不明なプリンター', + badgeBlocks: '印刷を保留', + badgeNotifies: '通知する', + badgeHidden: 'カードに非表示', + printer: 'プリンター', + entity: 'エンティティ', + searchPlaceholder: 'エンティティを検索...', + noEntities: '一致するエンティティがありません', + name: '表示名', + alertWhen: 'アラート条件', + alertNever: 'なし — 表示のみ', + alertAbove: 'しきい値超過', + alertBelow: 'しきい値未満', + alertHint: 'アラート状態になるとプリンターカードで強調表示され、下のオプションが有効になります。', + showOnCard: 'プリンターカードに表示', + notifyOnAlert: 'アラート発生時に通知を送信', + blockPrint: 'アラート中はキューの印刷を保留', + blockPrintHint: 'ジョブはキューに残り、センサーが解除されると自動的に開始します。Home Assistant に接続できない間は無視されます。', + toast: { + created: 'センサーを追加しました', + updated: 'センサーを保存しました', + deleted: 'センサーを削除しました', + }, + error: { + pickEntity: 'Home Assistant のエンティティを選択してください', + nameRequired: '表示名を入力してください', + printerRequired: 'プリンターを選択してください', + alertRequired: '先にアラート条件を設定してください', + }, + }, smartPlugs: { offline: 'オフライン', admin: '管理', @@ -5792,6 +5847,8 @@ export default { notificationEvents: '通知イベント', progressPercent: '(25%、50%、75%)', bedCooledAfterPrint: '(印刷完了後)', + haSensorAlert: 'センサーアラート', + haSensorAlertDescription: '(連携した Home Assistant センサーに注意が必要です)', // Per-event ntfy priority (#990) eventPriority: { sectionTitle: 'ntfy 優先度', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 6290bdc95..f1ac47afd 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -5211,6 +5211,61 @@ export default { replaceCarbonFilter: '활성탄 필터 교체', lubricateLeftNozzleRail: '왼쪽 노즐 레일 윤활 (H2 시리즈)' }, + haSensors: { + label: '센서', + unavailable: '사용 불가', + blocksPrints: '{{entity}} — 경고 중에는 인쇄를 보류합니다', + states: { + open: '열림', + closed: '닫힘', + unlocked: '잠금 해제', + locked: '잠김', + detected: '감지됨', + clear: '없음', + wet: '젖음', + dry: '건조', + problem: '문제', + ok: '정상', + running: '작동 중', + stopped: '정지', + on: '켜짐', + off: '꺼짐', + }, + sectionTitle: 'Home Assistant 센서', + add: '센서 추가', + addTitle: 'Home Assistant 센서 추가', + editTitle: 'Home Assistant 센서 편집', + empty: '아직 센서가 없습니다. Home Assistant의 도어 센서나 온도계를 연결하면 프린터 카드에 표시됩니다.', + unknownPrinter: '알 수 없는 프린터', + badgeBlocks: '인쇄 보류', + badgeNotifies: '알림', + badgeHidden: '카드에서 숨김', + printer: '프린터', + entity: '엔터티', + searchPlaceholder: '엔터티 검색...', + noEntities: '일치하는 엔터티 없음', + name: '표시 이름', + alertWhen: '경고 조건', + alertNever: '없음 — 표시만', + alertAbove: '초과', + alertBelow: '미만', + alertHint: '경고 상태가 되면 프린터 카드에서 강조되고 아래 옵션이 활성화됩니다.', + showOnCard: '프린터 카드에 표시', + notifyOnAlert: '경고가 시작되면 알림 보내기', + blockPrint: '경고 중에는 대기 중인 인쇄를 보류', + blockPrintHint: '작업은 대기열에 남아 있다가 센서가 해제되면 자동으로 시작됩니다. Home Assistant에 연결할 수 없는 동안에는 무시됩니다.', + toast: { + created: '센서를 추가했습니다', + updated: '센서를 저장했습니다', + deleted: '센서를 삭제했습니다', + }, + error: { + pickEntity: 'Home Assistant 엔터티를 선택하세요', + nameRequired: '표시 이름을 입력하세요', + printerRequired: '프린터를 선택하세요', + alertRequired: '먼저 경고 조건을 설정하세요', + }, + }, smartPlugs: { offline: '오프라인', admin: '관리자', @@ -5506,6 +5561,8 @@ export default { notificationEvents: '알림 이벤트', progressPercent: '(25%, 50%, 75%)', bedCooledAfterPrint: '(인쇄 완료 후)', + haSensorAlert: '센서 경고', + haSensorAlertDescription: '(연결된 Home Assistant 센서에 주의가 필요합니다)', eventPriority: { sectionTitle: 'ntfy 우선순위', helpNtfy: '각 활성화된 이벤트에 대한 우선순위를 선택하세요. ntfy는 이를 사용하여 알림을 에스컬레이션합니다(소리, 가시성, 푸시 동작). 여기서 설정되지 않은 수준은 ntfy 서버 기본값을 사용합니다.', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 4f10eb0f1..27b2393d4 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -5468,6 +5468,61 @@ export default { }, // Smart Plugs + haSensors: { + label: 'Sensores', + unavailable: 'Indisponível', + blocksPrints: '{{entity}} — segura as impressões enquanto houver alerta', + states: { + open: 'Aberto', + closed: 'Fechado', + unlocked: 'Destrancado', + locked: 'Trancado', + detected: 'Detectado', + clear: 'Livre', + wet: 'Molhado', + dry: 'Seco', + problem: 'Problema', + ok: 'OK', + running: 'Em funcionamento', + stopped: 'Parado', + on: 'Ligado', + off: 'Desligado', + }, + sectionTitle: 'Sensores do Home Assistant', + add: 'Adicionar sensor', + addTitle: 'Adicionar sensor do Home Assistant', + editTitle: 'Editar sensor do Home Assistant', + empty: 'Nenhum sensor ainda. Vincule um contato de porta ou um termômetro do Home Assistant para exibi-lo no cartão da impressora.', + unknownPrinter: 'Impressora desconhecida', + badgeBlocks: 'Segura impressões', + badgeNotifies: 'Notifica', + badgeHidden: 'Oculto no cartão', + printer: 'Impressora', + entity: 'Entidade', + searchPlaceholder: 'Buscar entidades...', + noEntities: 'Nenhuma entidade correspondente', + name: 'Nome exibido', + alertWhen: 'Alertar quando', + alertNever: 'Nunca — apenas exibir', + alertAbove: 'Acima de', + alertBelow: 'Abaixo de', + alertHint: 'O estado de alerta destaca o sensor no cartão da impressora e habilita as opções abaixo.', + showOnCard: 'Mostrar no cartão da impressora', + notifyOnAlert: 'Enviar uma notificação quando o alerta começar', + blockPrint: 'Segurar as impressões na fila enquanto houver alerta', + blockPrintHint: 'Os trabalhos permanecem na fila e começam sozinhos quando o sensor normaliza. Ignorado enquanto o Home Assistant estiver inacessível.', + toast: { + created: 'Sensor adicionado', + updated: 'Sensor salvo', + deleted: 'Sensor removido', + }, + error: { + pickEntity: 'Escolha uma entidade do Home Assistant', + nameRequired: 'Informe um nome exibido', + printerRequired: 'Escolha uma impressora', + alertRequired: 'Defina primeiro uma condição de alerta', + }, + }, smartPlugs: { offline: 'Offline', admin: 'Administrador', @@ -5780,6 +5835,8 @@ export default { notificationEvents: 'Eventos de Notificação', progressPercent: '(25%, 50%, 75%)', bedCooledAfterPrint: '(após conclusão da impressão)', + haSensorAlert: 'Alerta de sensor', + haSensorAlertDescription: '(um sensor do Home Assistant vinculado precisa de atenção)', // Per-event ntfy priority (#990) eventPriority: { sectionTitle: 'Prioridade ntfy', diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index cf37c1639..92b57118c 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/frontend/src/i18n/locales/ru.ts @@ -5199,6 +5199,61 @@ export default { replaceCarbonFilter: "Заменить угольный фильтр", lubricateLeftNozzleRail: "Смазать направляющую левого сопла (серия H2)", }, + haSensors: { + label: "Датчики", + unavailable: "Недоступно", + blocksPrints: "{{entity}} — удерживает печать, пока активно оповещение", + states: { + open: "Открыто", + closed: "Закрыто", + unlocked: "Разблокировано", + locked: "Заблокировано", + detected: "Обнаружено", + clear: "Чисто", + wet: "Влажно", + dry: "Сухо", + problem: "Проблема", + ok: "Норма", + running: "Работает", + stopped: "Остановлено", + on: "Вкл", + off: "Выкл", + }, + sectionTitle: "Датчики Home Assistant", + add: "Добавить датчик", + addTitle: "Добавить датчик Home Assistant", + editTitle: "Изменить датчик Home Assistant", + empty: "Датчиков пока нет. Свяжите дверной контакт или термометр из Home Assistant, чтобы показать его на карточке принтера.", + unknownPrinter: "Неизвестный принтер", + badgeBlocks: "Удерживает печать", + badgeNotifies: "Уведомляет", + badgeHidden: "Скрыт на карточке", + printer: "Принтер", + entity: "Сущность", + searchPlaceholder: "Поиск сущностей...", + noEntities: "Подходящих сущностей нет", + name: "Отображаемое имя", + alertWhen: "Оповещать когда", + alertNever: "Никогда — только показывать", + alertAbove: "Выше", + alertBelow: "Ниже", + alertHint: "Состояние оповещения подсвечивает датчик на карточке принтера и включает параметры ниже.", + showOnCard: "Показывать в карточке принтера", + notifyOnAlert: "Отправлять уведомление при срабатывании", + blockPrint: "Удерживать печать в очереди, пока активно оповещение", + blockPrintHint: "Задания остаются в очереди и запускаются сами, когда датчик приходит в норму. Игнорируется, пока Home Assistant недоступен.", + toast: { + created: "Датчик добавлен", + updated: "Датчик сохранён", + deleted: "Датчик удалён", + }, + error: { + pickEntity: "Выберите сущность Home Assistant", + nameRequired: "Введите отображаемое имя", + printerRequired: "Выберите принтер", + alertRequired: "Сначала задайте условие оповещения", + }, + }, smartPlugs: { offline: "Не в сети", admin: "Управление", @@ -5493,6 +5548,8 @@ export default { notificationEvents: "События для уведомлений", progressPercent: "(25%, 50%, 75%)", bedCooledAfterPrint: "(после завершения печати)", + haSensorAlert: "Оповещение датчика", + haSensorAlertDescription: "(связанный датчик Home Assistant требует внимания)", eventPriority: { sectionTitle: "Приоритет ntfy", helpNtfy: "Выберите приоритет для каждого включённого события. ntfy использует его для усиления оповещений: звука, видимости и поведения push-уведомлений. Для неуказанных событий используется приоритет по умолчанию сервера ntfy.", diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index baf690274..9fa4f7410 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -5444,6 +5444,61 @@ export default { }, // Akıllı Prizler + haSensors: { + label: 'Sensörler', + unavailable: 'Kullanılamıyor', + blocksPrints: '{{entity}} — uyarı sürerken baskıları bekletir', + states: { + open: 'Açık', + closed: 'Kapalı', + unlocked: 'Kilit açık', + locked: 'Kilitli', + detected: 'Algılandı', + clear: 'Temiz', + wet: 'Islak', + dry: 'Kuru', + problem: 'Sorun', + ok: 'Normal', + running: 'Çalışıyor', + stopped: 'Durdu', + on: 'Açık', + off: 'Kapalı', + }, + sectionTitle: 'Home Assistant Sensörleri', + add: 'Sensör ekle', + addTitle: 'Home Assistant sensörü ekle', + editTitle: 'Home Assistant sensörünü düzenle', + empty: 'Henüz sensör yok. Yazıcı kartında göstermek için Home Assistant\'tan bir kapı kontağı veya termometre bağlayın.', + unknownPrinter: 'Bilinmeyen yazıcı', + badgeBlocks: 'Baskıları bekletir', + badgeNotifies: 'Bildirir', + badgeHidden: 'Kartta gizli', + printer: 'Yazıcı', + entity: 'Varlık', + searchPlaceholder: 'Varlık ara...', + noEntities: 'Eşleşen varlık yok', + name: 'Görünen ad', + alertWhen: 'Şu durumda uyar', + alertNever: 'Asla — yalnızca göster', + alertAbove: 'Şunun üstünde', + alertBelow: 'Şunun altında', + alertHint: 'Uyarı durumu sensörü yazıcı kartında vurgular ve aşağıdaki seçenekleri etkinleştirir.', + showOnCard: 'Yazıcı kartında göster', + notifyOnAlert: 'Uyarı başladığında bildirim gönder', + blockPrint: 'Uyarı sürerken kuyruktaki baskıları beklet', + blockPrintHint: 'İşler kuyrukta kalır ve sensör normale döndüğünde kendiliğinden başlar. Home Assistant\'a ulaşılamadığı sürece yok sayılır.', + toast: { + created: 'Sensör eklendi', + updated: 'Sensör kaydedildi', + deleted: 'Sensör kaldırıldı', + }, + error: { + pickEntity: 'Bir Home Assistant varlığı seçin', + nameRequired: 'Bir görünen ad girin', + printerRequired: 'Bir yazıcı seçin', + alertRequired: 'Önce bir uyarı koşulu belirleyin', + }, + }, smartPlugs: { offline: 'Çevrimdışı', admin: 'Yönetici', @@ -5742,6 +5797,8 @@ export default { notificationEvents: 'Bildirim Olayları', progressPercent: '(%25, %50, %75)', bedCooledAfterPrint: '(baskı tamamlandıktan sonra)', + haSensorAlert: 'Sensör Uyarısı', + haSensorAlertDescription: '(bağlı bir Home Assistant sensörü dikkat gerektiriyor)', eventPriority: { sectionTitle: 'ntfy Önceliği', helpNtfy: 'Her etkin olay için bir öncelik seçin. ntfy uyarıları kademelendirmek için bunları kullanır (ses, görünürlük, push davranışı). Burada ayarlanmamış seviyeler ntfy sunucu varsayılanını kullanır.', diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index 0d9460afb..a5c071da5 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/frontend/src/i18n/locales/uk.ts @@ -5523,6 +5523,61 @@ export default { }, // Smart Plugs + haSensors: { + label: "Датчики", + unavailable: "Недоступно", + blocksPrints: "{{entity}} — утримує друк, поки триває сповіщення", + states: { + open: "Відчинено", + closed: "Зачинено", + unlocked: "Розблоковано", + locked: "Заблоковано", + detected: "Виявлено", + clear: "Чисто", + wet: "Волого", + dry: "Сухо", + problem: "Проблема", + ok: "Норма", + running: "Працює", + stopped: "Зупинено", + on: "Увімк", + off: "Вимк", + }, + sectionTitle: "Датчики Home Assistant", + add: "Додати датчик", + addTitle: "Додати датчик Home Assistant", + editTitle: "Редагувати датчик Home Assistant", + empty: "Датчиків ще немає. Прив'яжіть дверний контакт або термометр із Home Assistant, щоб показати його на картці принтера.", + unknownPrinter: "Невідомий принтер", + badgeBlocks: "Утримує друк", + badgeNotifies: "Сповіщає", + badgeHidden: "Приховано на картці", + printer: "Принтер", + entity: "Сутність", + searchPlaceholder: "Пошук сутностей...", + noEntities: "Немає відповідних сутностей", + name: "Відображувана назва", + alertWhen: "Сповіщати коли", + alertNever: "Ніколи — лише показувати", + alertAbove: "Вище", + alertBelow: "Нижче", + alertHint: "Стан сповіщення підсвічує датчик на картці принтера та вмикає параметри нижче.", + showOnCard: "Показувати на картці принтера", + notifyOnAlert: "Надсилати сповіщення, коли спрацьовує", + blockPrint: "Утримувати друк у черзі, поки триває сповіщення", + blockPrintHint: "Завдання лишаються в черзі й запускаються самі, коли датчик повертається до норми. Ігнорується, поки Home Assistant недоступний.", + toast: { + created: "Датчик додано", + updated: "Датчик збережено", + deleted: "Датчик видалено", + }, + error: { + pickEntity: "Оберіть сутність Home Assistant", + nameRequired: "Введіть відображувану назву", + printerRequired: "Оберіть принтер", + alertRequired: "Спершу задайте умову сповіщення", + }, + }, smartPlugs: { offline: "Не в мережі", admin: "Адміністрування", @@ -5835,6 +5890,8 @@ export default { notificationEvents: "Події сповіщень", progressPercent: "(25%, 50%, 75%)", bedCooledAfterPrint: "(після завершення друку)", + haSensorAlert: "Сповіщення датчика", + haSensorAlertDescription: "(пов'язаний датчик Home Assistant потребує уваги)", // Per-event ntfy priority (#990) eventPriority: { sectionTitle: "Пріоритет ntfy", diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 38d40d371..ea81a44e0 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -5468,6 +5468,61 @@ export default { }, // Smart Plugs + haSensors: { + label: '传感器', + unavailable: '不可用', + blocksPrints: '{{entity}} — 警报期间暂停打印', + states: { + open: '打开', + closed: '关闭', + unlocked: '已解锁', + locked: '已锁定', + detected: '已检测', + clear: '无', + wet: '潮湿', + dry: '干燥', + problem: '异常', + ok: '正常', + running: '运行中', + stopped: '已停止', + on: '开', + off: '关', + }, + sectionTitle: 'Home Assistant 传感器', + add: '添加传感器', + addTitle: '添加 Home Assistant 传感器', + editTitle: '编辑 Home Assistant 传感器', + empty: '还没有传感器。绑定 Home Assistant 的门磁或温度计,即可显示在打印机卡片上。', + unknownPrinter: '未知打印机', + badgeBlocks: '暂停打印', + badgeNotifies: '发送通知', + badgeHidden: '卡片上隐藏', + printer: '打印机', + entity: '实体', + searchPlaceholder: '搜索实体...', + noEntities: '没有匹配的实体', + name: '显示名称', + alertWhen: '警报条件', + alertNever: '从不 — 仅显示', + alertAbove: '高于', + alertBelow: '低于', + alertHint: '进入警报状态后会在打印机卡片上高亮显示,并启用下面的选项。', + showOnCard: '在打印机卡片上显示', + notifyOnAlert: '触发警报时发送通知', + blockPrint: '警报期间暂停队列中的打印', + blockPrintHint: '任务会留在队列中,传感器恢复后自动开始。Home Assistant 无法连接时忽略此设置。', + toast: { + created: '已添加传感器', + updated: '已保存传感器', + deleted: '已删除传感器', + }, + error: { + pickEntity: '请选择一个 Home Assistant 实体', + nameRequired: '请输入显示名称', + printerRequired: '请选择一台打印机', + alertRequired: '请先设置警报条件', + }, + }, smartPlugs: { offline: '离线', admin: '管理', @@ -5780,6 +5835,8 @@ export default { notificationEvents: '通知事件', progressPercent: '(25%、50%、75%)', bedCooledAfterPrint: '(打印完成后)', + haSensorAlert: '传感器警报', + haSensorAlertDescription: '(已绑定的 Home Assistant 传感器需要关注)', // Per-event ntfy priority (#990) eventPriority: { sectionTitle: 'ntfy 优先级', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 388a817fc..66c6603ee 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -5468,6 +5468,61 @@ export default { }, // Smart Plugs + haSensors: { + label: '感測器', + unavailable: '無法使用', + blocksPrints: '{{entity}} — 警報期間暫停列印', + states: { + open: '開啟', + closed: '關閉', + unlocked: '已解鎖', + locked: '已鎖定', + detected: '已偵測', + clear: '無', + wet: '潮濕', + dry: '乾燥', + problem: '異常', + ok: '正常', + running: '運轉中', + stopped: '已停止', + on: '開', + off: '關', + }, + sectionTitle: 'Home Assistant 感測器', + add: '新增感測器', + addTitle: '新增 Home Assistant 感測器', + editTitle: '編輯 Home Assistant 感測器', + empty: '還沒有感測器。綁定 Home Assistant 的門磁或溫度計,即可顯示在印表機卡片上。', + unknownPrinter: '未知印表機', + badgeBlocks: '暫停列印', + badgeNotifies: '發送通知', + badgeHidden: '卡片上隱藏', + printer: '印表機', + entity: '實體', + searchPlaceholder: '搜尋實體...', + noEntities: '沒有相符的實體', + name: '顯示名稱', + alertWhen: '警報條件', + alertNever: '永不 — 僅顯示', + alertAbove: '高於', + alertBelow: '低於', + alertHint: '進入警報狀態後會在印表機卡片上醒目顯示,並啟用下方選項。', + showOnCard: '在印表機卡片上顯示', + notifyOnAlert: '觸發警報時發送通知', + blockPrint: '警報期間暫停佇列中的列印', + blockPrintHint: '工作會留在佇列中,感測器恢復後自動開始。Home Assistant 無法連線時會忽略此設定。', + toast: { + created: '已新增感測器', + updated: '已儲存感測器', + deleted: '已移除感測器', + }, + error: { + pickEntity: '請選擇一個 Home Assistant 實體', + nameRequired: '請輸入顯示名稱', + printerRequired: '請選擇一台印表機', + alertRequired: '請先設定警報條件', + }, + }, smartPlugs: { offline: '離線', admin: '管理', @@ -5780,6 +5835,8 @@ export default { notificationEvents: '通知事件', progressPercent: '(25%、50%、75%)', bedCooledAfterPrint: '(列印完成後)', + haSensorAlert: '感測器警報', + haSensorAlertDescription: '(已綁定的 Home Assistant 感測器需要注意)', // Per-event ntfy priority (#990) eventPriority: { sectionTitle: 'ntfy 優先級', diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 6a18f1f4c..2b87a325a 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -110,6 +110,7 @@ import { MQTTDebugModal } from '../components/MQTTDebugModal'; import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal'; import { AiDetectionModal } from '../components/AiDetectionModal'; import { PrinterQueueWidget } from '../components/PrinterQueueWidget'; +import { PrinterHASensorRow } from '../components/PrinterHASensorRow'; import { AMSHistoryModal } from '../components/AMSHistoryModal'; import { AmsBackupModal } from '../components/AmsBackupModal'; import { HeaterHistoryModal } from '../components/HeaterHistoryModal'; @@ -6018,6 +6019,11 @@ function PrinterCard({
)} + {/* Home Assistant sensors (#1148). Outside the smartPlug block above: + a printer can have an enclosure door contact without having a plug, + and nesting it there would hide the row on exactly those setups. */} + + {/* Connection Info & Actions */}
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index fffdf57ed..d85746f69 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -11,7 +11,7 @@ import { fleetAudience, sponsorHref } from '../utils/fleetAudience'; import { PRESET_CATEGORIES, parsePresetTriple } from '../utils/temperatureFanPresets'; import { CALIBRATION_MODES, CALIBRATION_MODE_ACTIVE, CALIBRATION_MODE_INACTIVE } from '../utils/calibrationMode'; import { PreheatFilamentTargetsEditor } from '../components/PreheatFilamentTargetsEditor'; -import type { APIKey, AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse, CalibrationMode } from '../api/client'; +import type { APIKey, AppSettings, AppSettingsUpdate, PrinterHASensor, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse, CalibrationMode } from '../api/client'; import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card'; import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel'; import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel'; @@ -22,6 +22,7 @@ import { CopyButton } from '../components/CopyButton'; import { Button } from '../components/Button'; import { SmartPlugCard } from '../components/SmartPlugCard'; import { AddSmartPlugModal } from '../components/AddSmartPlugModal'; +import { HASensorModal } from '../components/HASensorModal'; import { NotificationProviderCard } from '../components/NotificationProviderCard'; import { AddNotificationModal } from '../components/AddNotificationModal'; import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor'; @@ -50,7 +51,7 @@ import { availableLanguages } from '../i18n'; import { useToast } from '../contexts/ToastContext'; import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, type ThemeAccent } from '../contexts/ThemeContext'; import { useState, useEffect, useRef, useCallback } from 'react'; -import { Palette } from 'lucide-react'; +import { Gauge, Palette } from 'lucide-react'; import { registerSettingsSearch, getSettingsSearchEntries } from '../lib/settingsSearch'; import type { UsersSubTab } from '../lib/settingsSearch'; @@ -184,6 +185,8 @@ export function SettingsPage() { const [humidityDrafts, setHumidityDrafts] = useState>({}); const [showPlugModal, setShowPlugModal] = useState(false); const [editingPlug, setEditingPlug] = useState(null); + const [showHASensorModal, setShowHASensorModal] = useState(false); + const [editingHASensor, setEditingHASensor] = useState(null); const [showNotificationModal, setShowNotificationModal] = useState(false); const [editingProvider, setEditingProvider] = useState(null); const [editingTemplate, setEditingTemplate] = useState(null); @@ -434,6 +437,12 @@ export function SettingsPage() { queryFn: api.getPrinters, }); + const { data: haSensors } = useQuery({ + queryKey: ['haSensors'], + queryFn: () => api.getHASensors(), + enabled: activeTab === 'plugs', + }); + // A business-sized fleet gets the commercial ask instead of the donation ask. const sponsorAudience = fleetAudience(printers?.length ?? 0); @@ -3523,6 +3532,88 @@ export function SettingsPage() { )} + + {/* Home Assistant sensors (#1148, #448). Sits under the plugs on the + same tab: same integration, same credentials, but read-only — + these are contacts and thermometers, not switches. */} +
+
+

+ + {t('haSensors.sectionTitle')} +

+ +
+ + {haSensors && haSensors.length > 0 ? ( +
+ {haSensors.map((sensor) => { + const printer = printers?.find((p) => p.id === sensor.printer_id); + return ( + + +
+
+
{sensor.name}
+
{sensor.entity_id}
+
+ {printer?.name ?? t('haSensors.unknownPrinter')} +
+
+ +
+
+ {sensor.block_print && ( + + {t('haSensors.badgeBlocks')} + + )} + {sensor.notify_on_alert && ( + + {t('haSensors.badgeNotifies')} + + )} + {!sensor.show_on_printer_card && ( + + {t('haSensors.badgeHidden')} + + )} +
+
+
+ ); + })} +
+ ) : ( + + +
+ +

{t('haSensors.empty')}

+
+
+
+ )} +
)} @@ -5552,6 +5643,18 @@ export function SettingsPage() { /> )} + {/* Home Assistant Sensor Modal (#1148) */} + {showHASensorModal && ( + { + setShowHASensorModal(false); + setEditingHASensor(null); + }} + /> + )} + {/* Notification Modal */} {showNotificationModal && (