فهرست منبع

Show Home Assistant sensors on the printer card (#1148, #448)

Binds binary_sensor and reading-carrying sensor entities to a printer and
renders their state on its card, worded by Home Assistant's device_class.
Optional per-sensor alert condition drives a notification on the transition
into the alert state and an opt-in interlock that holds queued prints while
alerting -- a hold with a readable waiting_reason, never a failure, and only
ever on a sensor that was read successfully.

Sensors get their own table rather than a wider entity pattern on SmartPlug:
get_smart_plug_by_printer would otherwise hand the card's power button a door
contact to switch.

The hold is passed to the model matcher directly rather than merged into
busy_printers: _check_auto_drying reads that set as "is currently printing"
and would put an idle-but-held printer down the mid-print drying path.

The notification_providers migration spells its default FALSE, not 0 --
Postgres rejects an integer default for a boolean and _safe_execute swallows
the error.
maziggy 1 ماه پیش
والد
کامیت
cd004df817
45فایلهای تغییر یافته به همراه3615 افزوده شده و 6 حذف شده
  1. 0 0
      CHANGELOG.md
  2. 239 0
      backend/app/api/routes/ha_sensors.py
  3. 13 0
      backend/app/core/database.py
  4. 7 0
      backend/app/main.py
  5. 2 0
      backend/app/models/__init__.py
  6. 3 0
      backend/app/models/notification.py
  7. 6 0
      backend/app/models/notification_template.py
  8. 2 0
      backend/app/models/printer.py
  9. 72 0
      backend/app/models/printer_ha_sensor.py
  10. 8 0
      backend/app/schemas/notification.py
  11. 9 0
      backend/app/schemas/notification_template.py
  12. 114 0
      backend/app/schemas/printer_ha_sensor.py
  13. 271 0
      backend/app/services/ha_sensor_manager.py
  14. 102 0
      backend/app/services/homeassistant.py
  15. 37 0
      backend/app/services/notification_service.py
  16. 52 1
      backend/app/services/print_scheduler.py
  17. 317 0
      backend/tests/integration/test_ha_sensors_api_1148.py
  18. 281 0
      backend/tests/unit/test_ha_sensor_manager_1148.py
  19. 306 0
      backend/tests/unit/test_scheduler_ha_interlock_1148.py
  20. 91 0
      frontend/src/__tests__/components/HASensorModal.test.tsx
  21. 161 0
      frontend/src/__tests__/components/PrinterHASensorRow.test.tsx
  22. 88 0
      frontend/src/api/client.ts
  23. 10 0
      frontend/src/components/AddNotificationModal.tsx
  24. 413 0
      frontend/src/components/HASensorModal.tsx
  25. 14 0
      frontend/src/components/NotificationProviderCard.tsx
  26. 142 0
      frontend/src/components/PrinterHASensorRow.tsx
  27. 57 0
      frontend/src/i18n/locales/de.ts
  28. 57 0
      frontend/src/i18n/locales/en.ts
  29. 57 0
      frontend/src/i18n/locales/es.ts
  30. 57 0
      frontend/src/i18n/locales/fr.ts
  31. 57 0
      frontend/src/i18n/locales/it.ts
  32. 57 0
      frontend/src/i18n/locales/ja.ts
  33. 57 0
      frontend/src/i18n/locales/ko.ts
  34. 57 0
      frontend/src/i18n/locales/pt-BR.ts
  35. 57 0
      frontend/src/i18n/locales/ru.ts
  36. 57 0
      frontend/src/i18n/locales/tr.ts
  37. 57 0
      frontend/src/i18n/locales/uk.ts
  38. 57 0
      frontend/src/i18n/locales/zh-CN.ts
  39. 57 0
      frontend/src/i18n/locales/zh-TW.ts
  40. 6 0
      frontend/src/pages/PrintersPage.tsx
  41. 105 2
      frontend/src/pages/SettingsPage.tsx
  42. 0 0
      static/assets/index-BAVjF7qG.js
  43. 0 1
      static/assets/index-DDSj68H5.css
  44. 1 0
      static/assets/index-Db2rfQf-.css
  45. 2 2
      static/index.html

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
CHANGELOG.md


+ 239 - 0
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"}

+ 13 - 0
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).

+ 7 - 0
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)

+ 2 - 0
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",

+ 3 - 0
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)

+ 6 - 0
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",

+ 2 - 0
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

+ 72 - 0
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

+ 8 - 0
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

+ 9 - 0
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",

+ 114 - 0
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

+ 271 - 0
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()

+ 102 - 0
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()

+ 37 - 0
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,

+ 52 - 1
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 <model>"
+            # 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,

+ 317 - 0
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

+ 281 - 0
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

+ 306 - 0
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

+ 91 - 0
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<typeof import('../../api/client')>('../../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(<HASensorModal printers={PRINTERS} onClose={() => {}} />);
+
+    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(<HASensorModal printers={PRINTERS} onClose={() => {}} />);
+
+    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(<HASensorModal printers={PRINTERS} onClose={() => {}} />);
+
+    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(<HASensorModal printers={PRINTERS} onClose={() => {}} />);
+
+    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(<HASensorModal printers={PRINTERS} onClose={() => {}} />);
+
+    await waitFor(() => expect(getEntities).toHaveBeenCalled());
+    expect(screen.queryByText(/Home Assistant is not configured/)).not.toBeInTheDocument();
+  });
+});

+ 161 - 0
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<typeof import('../../api/client')>('../../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(<PrinterHASensorRow printerId={4} />);
+
+    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(<PrinterHASensorRow printerId={4} />);
+
+    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(<PrinterHASensorRow printerId={4} />);
+
+    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(<PrinterHASensorRow printerId={4} />);
+
+    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(<PrinterHASensorRow printerId={4} />);
+
+    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(<PrinterHASensorRow printerId={4} />);
+
+    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(<PrinterHASensorRow printerId={4} />);
+
+    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(<PrinterHASensorRow printerId={4} />);
+
+    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(<PrinterHASensorRow printerId={4} />);
+
+    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(<PrinterHASensorRow printerId={4} />);
+
+    expect(await screen.findByText('Enclosure Door')).toBeInTheDocument();
+    expect(screen.getByText('Enclosure Temp')).toBeInTheDocument();
+  });
+});

+ 88 - 0
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<Omit<PrinterHASensorCreate, 'printer_id'>>;
+
+// 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
@@ -5195,6 +5267,22 @@ export const api = {
   getHASensorEntities: () =>
     request<HASensorEntity[]>('/smart-plugs/ha/sensors'),
 
+  // Home Assistant sensors bound to a printer (#1148, #448)
+  getHASensors: (printerId?: number) =>
+    request<PrinterHASensor[]>(`/ha-sensors/${printerId ? `?printer_id=${printerId}` : ''}`),
+  getHASensorReadings: (printerId: number) =>
+    request<PrinterHASensorReading[]>(`/ha-sensors/by-printer/${printerId}/readings`),
+  getBindableHAEntities: (search?: string) => {
+    const params = search ? `?search=${encodeURIComponent(search)}` : '';
+    return request<HADisplayEntity[]>(`/ha-sensors/entities${params}`);
+  },
+  createHASensor: (data: PrinterHASensorCreate) =>
+    request<PrinterHASensor>('/ha-sensors/', { method: 'POST', body: JSON.stringify(data) }),
+  updateHASensor: (id: number, data: PrinterHASensorUpdate) =>
+    request<PrinterHASensor>(`/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', {

+ 10 - 0
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
                   <span className="text-sm text-white">{t('notifications.offline')}</span>
                   <Toggle checked={onPrinterOffline} onChange={setOnPrinterOffline} />
                 </div>
+                <div className="flex items-center justify-between col-span-2">
+                  <div>
+                    <span className="text-sm text-white">{t('notifications.haSensorAlert')}</span>
+                    <span className="text-xs text-bambu-gray ml-1">{t('notifications.haSensorAlertDescription')}</span>
+                  </div>
+                  <Toggle checked={onHaSensorAlert} onChange={setOnHaSensorAlert} />
+                </div>
                 <div className="flex items-center justify-between">
                   <span className="text-sm text-white">{t('notifications.error')}</span>
                   <Toggle checked={onPrinterError} onChange={setOnPrinterError} />
@@ -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') });

+ 413 - 0
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<string, { on: string; off: string }> = {
+  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<number | ''>(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<string | null>(sensor?.device_class ?? null);
+  const [unit, setUnit] = useState<string | null>(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<string | null>(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 (
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4" onClick={onClose}>
+      <div
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-3">
+            <div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
+              <Gauge className="w-5 h-5" />
+            </div>
+            <h2 className="text-lg font-semibold text-white">
+              {isEditing ? t('haSensors.editTitle') : t('haSensors.addTitle')}
+            </h2>
+          </div>
+          <button onClick={onClose} className="text-bambu-gray hover:text-white transition-colors">
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        <form onSubmit={handleSubmit} className="p-6 space-y-4">
+          {error && (
+            <div className="p-3 bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/50 rounded-lg text-sm text-red-700 dark:text-red-400">
+              {error}
+            </div>
+          )}
+
+          {!isEditing && (
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">{t('haSensors.printer')}</label>
+              <select
+                value={printerId}
+                onChange={(e) => setPrinterId(e.target.value === '' ? '' : Number(e.target.value))}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
+              >
+                {printers.map((p) => (
+                  <option key={p.id} value={p.id}>
+                    {p.name}
+                  </option>
+                ))}
+              </select>
+            </div>
+          )}
+
+          {!haConfigured && (
+            <div className="p-3 bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-400 dark:border-yellow-500/50 rounded-lg text-sm text-yellow-700 dark:text-yellow-400">
+              {t('smartPlugs.haNotConfigured')}{' '}
+              <span className="font-medium">{t('smartPlugs.haSettingsPath')}</span>
+            </div>
+          )}
+
+          <div>
+            <label className={`block text-sm text-bambu-gray mb-1 ${haConfigured ? '' : 'opacity-50'}`}>
+              {t('haSensors.entity')}
+            </label>
+            {entitiesError && (
+              <div className="p-3 mb-2 bg-red-100 dark:bg-red-500/20 rounded-lg text-sm text-red-700 dark:text-red-400">
+                {(entitiesError as Error).message}
+              </div>
+            )}
+            <div className="relative mb-2">
+              <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+              <input
+                type="text"
+                value={search}
+                onChange={(e) => 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"
+              />
+            </div>
+            <div
+              className={`max-h-44 overflow-y-auto rounded-lg border border-bambu-dark-tertiary ${
+                haConfigured ? '' : 'opacity-50'
+              }`}
+            >
+              {!haConfigured && (
+                <div className="p-3 text-sm text-bambu-gray">{t('haSensors.noEntities')}</div>
+              )}
+              {haConfigured && entitiesLoading && (
+                <div className="flex items-center gap-2 p-3 text-sm text-bambu-gray">
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                  {t('common.loading')}
+                </div>
+              )}
+              {haConfigured && !entitiesLoading && filtered.length === 0 && (
+                <div className="p-3 text-sm text-bambu-gray">{t('haSensors.noEntities')}</div>
+              )}
+              {haConfigured &&
+                !entitiesLoading &&
+                filtered.map((entity) => (
+                  <button
+                    key={entity.entity_id}
+                    type="button"
+                    onClick={() => selectEntity(entity)}
+                    className={`w-full text-left px-3 py-2 text-sm transition-colors ${
+                      entity.entity_id === entityId
+                        ? 'bg-bambu-green/20 text-bambu-green'
+                        : 'text-white hover:bg-bambu-dark'
+                    }`}
+                  >
+                    <div className="font-medium">{entity.friendly_name}</div>
+                    <div className="text-xs text-bambu-gray">
+                      {entity.entity_id}
+                      {entity.state !== null && ` — ${entity.state}`}
+                      {entity.unit_of_measurement ? ` ${entity.unit_of_measurement}` : ''}
+                    </div>
+                  </button>
+                ))}
+            </div>
+          </div>
+
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">{t('haSensors.name')}</label>
+            <input
+              type="text"
+              value={name}
+              onChange={(e) => setName(e.target.value)}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
+            />
+          </div>
+
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">{t('haSensors.alertWhen')}</label>
+            {kind === 'binary' ? (
+              <select
+                value={alertState}
+                onChange={(e) => setAlertState(e.target.value as 'on' | 'off' | '')}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
+              >
+                <option value="">{t('haSensors.alertNever')}</option>
+                <option value="on">{stateLabel('on')}</option>
+                <option value="off">{stateLabel('off')}</option>
+              </select>
+            ) : (
+              <div className="grid grid-cols-2 gap-3">
+                <div>
+                  <span className="block text-xs text-bambu-gray mb-1">
+                    {t('haSensors.alertAbove')} {unit ?? ''}
+                  </span>
+                  <input
+                    type="number"
+                    step="any"
+                    value={alertAbove}
+                    onChange={(e) => setAlertAbove(e.target.value)}
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
+                  />
+                </div>
+                <div>
+                  <span className="block text-xs text-bambu-gray mb-1">
+                    {t('haSensors.alertBelow')} {unit ?? ''}
+                  </span>
+                  <input
+                    type="number"
+                    step="any"
+                    value={alertBelow}
+                    onChange={(e) => setAlertBelow(e.target.value)}
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
+                  />
+                </div>
+              </div>
+            )}
+            <p className="mt-1 text-xs text-bambu-gray">{t('haSensors.alertHint')}</p>
+          </div>
+
+          <label className="flex items-center gap-3 cursor-pointer">
+            <input
+              type="checkbox"
+              checked={showOnCard}
+              onChange={(e) => setShowOnCard(e.target.checked)}
+              className="w-4 h-4"
+            />
+            <span className="text-sm text-white">{t('haSensors.showOnCard')}</span>
+          </label>
+
+          <label className="flex items-center gap-3 cursor-pointer">
+            <input
+              type="checkbox"
+              checked={notifyOnAlert}
+              onChange={(e) => setNotifyOnAlert(e.target.checked)}
+              disabled={!hasAlertCondition}
+              className="w-4 h-4"
+            />
+            <span className={`text-sm ${hasAlertCondition ? 'text-white' : 'text-bambu-gray'}`}>
+              {t('haSensors.notifyOnAlert')}
+            </span>
+          </label>
+
+          <label className="flex items-start gap-3 cursor-pointer">
+            <input
+              type="checkbox"
+              checked={blockPrint}
+              onChange={(e) => setBlockPrint(e.target.checked)}
+              disabled={!hasAlertCondition}
+              className="w-4 h-4 mt-0.5"
+            />
+            <span>
+              <span className={`block text-sm ${hasAlertCondition ? 'text-white' : 'text-bambu-gray'}`}>
+                {t('haSensors.blockPrint')}
+              </span>
+              <span className="block text-xs text-bambu-gray">{t('haSensors.blockPrintHint')}</span>
+            </span>
+          </label>
+
+          <div className="flex items-center justify-between pt-2">
+            {isEditing ? (
+              <Button type="button" variant="danger" onClick={() => deleteMutation.mutate()} disabled={isPending}>
+                {t('common.delete')}
+              </Button>
+            ) : (
+              <span />
+            )}
+            <div className="flex items-center gap-2">
+              <Button type="button" variant="secondary" onClick={onClose} disabled={isPending}>
+                {t('common.cancel')}
+              </Button>
+              {/* 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. */}
+              <Button type="submit" disabled={isPending || (!haConfigured && !isEditing)}>
+                {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
+                {t('common.save')}
+              </Button>
+            </div>
+          </div>
+        </form>
+      </div>
+    </div>
+  );
+}

+ 14 - 0
frontend/src/components/NotificationProviderCard.tsx

@@ -144,6 +144,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_ai_failure_detection && (
               <span className="px-2 py-0.5 bg-fuchsia-100 dark:bg-fuchsia-500/20 text-fuchsia-700 dark:text-fuchsia-300 text-xs rounded">{t('notifications.aiFailureDetection')}</span>
             )}
+            {provider.on_ha_sensor_alert && (
+              <span className="px-2 py-0.5 bg-indigo-100 dark:bg-indigo-500/20 text-indigo-700 dark:text-indigo-300 text-xs rounded">{t('notifications.haSensorAlert')}</span>
+            )}
             {provider.on_filament_low && (
               <span className="px-2 py-0.5 bg-cyan-100 dark:bg-cyan-500/20 text-cyan-700 dark:text-cyan-400 text-xs rounded">{t('notifications.lowFilament')}</span>
             )}
@@ -394,6 +397,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                   />
                 </div>
 
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">{t('notifications.haSensorAlert')}</p>
+                    <p className="text-xs text-bambu-gray">{t('notifications.haSensorAlertDescription')}</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_ha_sensor_alert ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_ha_sensor_alert: checked })}
+                  />
+                </div>
+
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">{t('notifications.lowFilamentLabel')}</p>
 <Toggle

+ 142 - 0
frontend/src/components/PrinterHASensorRow.tsx

@@ -0,0 +1,142 @@
+import { useQuery } from '@tanstack/react-query';
+import {
+  Activity,
+  AlertTriangle,
+  DoorClosed,
+  DoorOpen,
+  Droplets,
+  Gauge,
+  Lock,
+  LockOpen,
+  Thermometer,
+  Wind,
+} from 'lucide-react';
+import type { LucideIcon } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+import { api } from '../api/client';
+import type { PrinterHASensorReading } from '../api/client';
+
+/**
+ * The Home Assistant sensors bound to a printer, on its card (#1148, #448).
+ *
+ * Read-only by design — these are contacts and thermometers, not switches, so
+ * nothing here is clickable. The sibling "HA:" row above handles the entities
+ * you can actually operate.
+ */
+
+// Home Assistant's own device_class decides the wording, so a door reads
+// "Open"/"Closed" rather than the "on"/"off" the API actually carries. Classes
+// absent from this map fall through to on/off, which is what HA itself shows
+// for a binary_sensor with no class.
+const BINARY_LABELS: Record<string, { on: string; off: string }> = {
+  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<string, LucideIcon> = {
+  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 (
+    <div className="flex items-center gap-2 mt-2">
+      <Gauge className="w-[var(--pc-i35,0.875rem)] h-[var(--pc-i35,0.875rem)] text-blue-600 dark:text-blue-400 flex-shrink-0" />
+      <span className="text-xs text-bambu-gray">{t('haSensors.label')}</span>
+      <div className="h-[2px] w-5 bg-bambu-dark-tertiary/50" />
+      <div className="flex flex-wrap gap-1">
+        {readings.map((reading) => {
+          const Icon = iconFor(reading);
+          const unreachable = !reading.reachable || reading.state === null;
+          return (
+            <span
+              key={reading.id}
+              title={
+                reading.block_print
+                  ? t('haSensors.blocksPrints', { entity: reading.entity_id })
+                  : reading.entity_id
+              }
+              className={`px-2 py-0.5 text-xs rounded flex items-center gap-1 ${
+                unreachable
+                  ? 'bg-bambu-dark-tertiary/50 text-bambu-gray'
+                  : reading.alerting
+                    ? 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400'
+                    : 'bg-bambu-dark-tertiary text-bambu-gray'
+              }`}
+            >
+              <Icon className="w-[var(--pc-i25,0.625rem)] h-[var(--pc-i25,0.625rem)]" />
+              <span>{reading.name}</span>
+              <span className="font-medium">{describe(reading)}</span>
+            </span>
+          );
+        })}
+      </div>
+    </div>
+  );
+}

+ 57 - 0
frontend/src/i18n/locales/de.ts

@@ -5412,6 +5412,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',
@@ -5724,6 +5779,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',

+ 57 - 0
frontend/src/i18n/locales/en.ts

@@ -5456,6 +5456,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',
@@ -5768,6 +5823,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',

+ 57 - 0
frontend/src/i18n/locales/es.ts

@@ -5421,6 +5421,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',
@@ -5733,6 +5788,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',

+ 57 - 0
frontend/src/i18n/locales/fr.ts

@@ -5402,6 +5402,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',
@@ -5714,6 +5769,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',

+ 57 - 0
frontend/src/i18n/locales/it.ts

@@ -5401,6 +5401,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',
@@ -5713,6 +5768,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',

+ 57 - 0
frontend/src/i18n/locales/ja.ts

@@ -5413,6 +5413,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: '管理',
@@ -5725,6 +5780,8 @@ export default {
     notificationEvents: '通知イベント',
     progressPercent: '(25%、50%、75%)',
     bedCooledAfterPrint: '(印刷完了後)',
+    haSensorAlert: 'センサーアラート',
+    haSensorAlertDescription: '(連携した Home Assistant センサーに注意が必要です)',
     // Per-event ntfy priority (#990)
     eventPriority: {
       sectionTitle: 'ntfy 優先度',

+ 57 - 0
frontend/src/i18n/locales/ko.ts

@@ -5144,6 +5144,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: '관리자',
@@ -5439,6 +5494,8 @@ export default {
     notificationEvents: '알림 이벤트',
     progressPercent: '(25%, 50%, 75%)',
     bedCooledAfterPrint: '(인쇄 완료 후)',
+    haSensorAlert: '센서 경고',
+    haSensorAlertDescription: '(연결된 Home Assistant 센서에 주의가 필요합니다)',
     eventPriority: {
       sectionTitle: 'ntfy 우선순위',
       helpNtfy: '각 활성화된 이벤트에 대한 우선순위를 선택하세요. ntfy는 이를 사용하여 알림을 에스컬레이션합니다(소리, 가시성, 푸시 동작). 여기서 설정되지 않은 수준은 ntfy 서버 기본값을 사용합니다.',

+ 57 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -5401,6 +5401,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',
@@ -5713,6 +5768,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',

+ 57 - 0
frontend/src/i18n/locales/ru.ts

@@ -5132,6 +5132,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: "Управление",
@@ -5426,6 +5481,8 @@ export default {
     notificationEvents: "События для уведомлений",
     progressPercent: "(25%, 50%, 75%)",
     bedCooledAfterPrint: "(после завершения печати)",
+    haSensorAlert: "Оповещение датчика",
+    haSensorAlertDescription: "(связанный датчик Home Assistant требует внимания)",
     eventPriority: {
       sectionTitle: "Приоритет ntfy",
       helpNtfy: "Выберите приоритет для каждого включённого события. ntfy использует его для усиления оповещений: звука, видимости и поведения push-уведомлений. Для неуказанных событий используется приоритет по умолчанию сервера ntfy.",

+ 57 - 0
frontend/src/i18n/locales/tr.ts

@@ -5377,6 +5377,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',
@@ -5675,6 +5730,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.',

+ 57 - 0
frontend/src/i18n/locales/uk.ts

@@ -5456,6 +5456,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: "Адміністрування",
@@ -5768,6 +5823,8 @@ export default {
     notificationEvents: "Події сповіщень",
     progressPercent: "(25%, 50%, 75%)",
     bedCooledAfterPrint: "(після завершення друку)",
+    haSensorAlert: "Сповіщення датчика",
+    haSensorAlertDescription: "(пов'язаний датчик Home Assistant потребує уваги)",
     // Per-event ntfy priority (#990)
     eventPriority: {
       sectionTitle: "Пріоритет ntfy",

+ 57 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -5401,6 +5401,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: '管理',
@@ -5713,6 +5768,8 @@ export default {
     notificationEvents: '通知事件',
     progressPercent: '(25%、50%、75%)',
     bedCooledAfterPrint: '(打印完成后)',
+    haSensorAlert: '传感器警报',
+    haSensorAlertDescription: '(已绑定的 Home Assistant 传感器需要关注)',
     // Per-event ntfy priority (#990)
     eventPriority: {
       sectionTitle: 'ntfy 优先级',

+ 57 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -5401,6 +5401,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: '管理',
@@ -5713,6 +5768,8 @@ export default {
     notificationEvents: '通知事件',
     progressPercent: '(25%、50%、75%)',
     bedCooledAfterPrint: '(列印完成後)',
+    haSensorAlert: '感測器警報',
+    haSensorAlertDescription: '(已綁定的 Home Assistant 感測器需要注意)',
     // Per-event ntfy priority (#990)
     eventPriority: {
       sectionTitle: 'ntfy 優先級',

+ 6 - 0
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({
           </div>
         )}
 
+        {/* 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. */}
+        <PrinterHASensorRow printerId={printer.id} />
+
         {/* Connection Info & Actions */}
         <div className="pt-4">
             <div className="mb-3 h-[2px] bg-bambu-dark-tertiary" />

+ 105 - 2
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<Record<string, string>>({});
   const [showPlugModal, setShowPlugModal] = useState(false);
   const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
+  const [showHASensorModal, setShowHASensorModal] = useState(false);
+  const [editingHASensor, setEditingHASensor] = useState<PrinterHASensor | null>(null);
   const [showNotificationModal, setShowNotificationModal] = useState(false);
   const [editingProvider, setEditingProvider] = useState<NotificationProvider | null>(null);
   const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(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() {
               </CardContent>
             </Card>
           )}
+
+          {/* 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. */}
+          <div className="mt-8">
+            <div className="flex items-center justify-between mb-4">
+              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                <Gauge className="w-5 h-5 text-bambu-green" />
+                {t('haSensors.sectionTitle')}
+              </h2>
+              <Button
+                className="whitespace-nowrap"
+                disabled={!printers?.length}
+                onClick={() => {
+                  setEditingHASensor(null);
+                  setShowHASensorModal(true);
+                }}
+              >
+                <Plus className="w-4 h-4" />
+                {t('haSensors.add')}
+              </Button>
+            </div>
+
+            {haSensors && haSensors.length > 0 ? (
+              <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+                {haSensors.map((sensor) => {
+                  const printer = printers?.find((p) => p.id === sensor.printer_id);
+                  return (
+                    <Card key={sensor.id}>
+                      <CardContent className="py-4">
+                        <div className="flex items-start justify-between gap-2">
+                          <div className="min-w-0">
+                            <div className="text-white font-medium truncate">{sensor.name}</div>
+                            <div className="text-xs text-bambu-gray truncate">{sensor.entity_id}</div>
+                            <div className="text-xs text-bambu-gray mt-1">
+                              {printer?.name ?? t('haSensors.unknownPrinter')}
+                            </div>
+                          </div>
+                          <Button
+                            size="sm"
+                            variant="secondary"
+                            onClick={() => {
+                              setEditingHASensor(sensor);
+                              setShowHASensorModal(true);
+                            }}
+                          >
+                            {t('common.edit')}
+                          </Button>
+                        </div>
+                        <div className="flex flex-wrap gap-1 mt-3">
+                          {sensor.block_print && (
+                            <span className="px-2 py-0.5 text-xs rounded bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400">
+                              {t('haSensors.badgeBlocks')}
+                            </span>
+                          )}
+                          {sensor.notify_on_alert && (
+                            <span className="px-2 py-0.5 text-xs rounded bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400">
+                              {t('haSensors.badgeNotifies')}
+                            </span>
+                          )}
+                          {!sensor.show_on_printer_card && (
+                            <span className="px-2 py-0.5 text-xs rounded bg-bambu-dark-tertiary text-bambu-gray">
+                              {t('haSensors.badgeHidden')}
+                            </span>
+                          )}
+                        </div>
+                      </CardContent>
+                    </Card>
+                  );
+                })}
+              </div>
+            ) : (
+              <Card>
+                <CardContent className="py-8">
+                  <div className="text-center text-bambu-gray">
+                    <Gauge className="w-12 h-12 mx-auto mb-3 opacity-30" />
+                    <p className="text-sm">{t('haSensors.empty')}</p>
+                  </div>
+                </CardContent>
+              </Card>
+            )}
+          </div>
         </div>
       )}
 
@@ -5552,6 +5643,18 @@ export function SettingsPage() {
         />
       )}
 
+      {/* Home Assistant Sensor Modal (#1148) */}
+      {showHASensorModal && (
+        <HASensorModal
+          sensor={editingHASensor}
+          printers={printers ?? []}
+          onClose={() => {
+            setShowHASensorModal(false);
+            setEditingHASensor(null);
+          }}
+        />
+      )}
+
       {/* Notification Modal */}
       {showNotificationModal && (
         <AddNotificationModal

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-BAVjF7qG.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 1
static/assets/index-DDSj68H5.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 0
static/assets/index-Db2rfQf-.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-fpDLI8Il.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DDSj68H5.css">
+    <script type="module" crossorigin src="/assets/index-BAVjF7qG.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Db2rfQf-.css">
   </head>
   <body>
     <div id="root"></div>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است