Преглед изворни кода

Merge branch 'dev' into feature/2656-restore-from-github

jmoore-skild пре 1 месец
родитељ
комит
bb25e36510
59 измењених фајлова са 6042 додато и 193 уклоњено
  1. 0 0
      CHANGELOG.md
  2. 239 0
      backend/app/api/routes/ha_sensors.py
  3. 13 0
      backend/app/core/database.py
  4. 28 1
      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. 90 7
      backend/app/services/bambu_mqtt.py
  14. 158 51
      backend/app/services/external_camera.py
  15. 271 0
      backend/app/services/ha_sensor_manager.py
  16. 102 0
      backend/app/services/homeassistant.py
  17. 37 0
      backend/app/services/notification_service.py
  18. 287 39
      backend/app/services/print_scheduler.py
  19. 317 0
      backend/tests/integration/test_ha_sensors_api_1148.py
  20. 86 3
      backend/tests/unit/services/test_bambu_mqtt.py
  21. 58 0
      backend/tests/unit/services/test_notification_service.py
  22. 290 0
      backend/tests/unit/test_external_camera_ssrf.py
  23. 281 0
      backend/tests/unit/test_ha_sensor_manager_1148.py
  24. 4 1
      backend/tests/unit/test_scheduler_cross_model_variants.py
  25. 259 0
      backend/tests/unit/test_scheduler_external_spool_nozzle_2771.py
  26. 306 0
      backend/tests/unit/test_scheduler_ha_interlock_1148.py
  27. 247 0
      backend/tests/unit/test_status_broadcast_ams_slot_config.py
  28. 91 0
      frontend/src/__tests__/components/HASensorModal.test.tsx
  29. 244 3
      frontend/src/__tests__/components/ModelViewerModal.test.tsx
  30. 161 0
      frontend/src/__tests__/components/PrinterHASensorRow.test.tsx
  31. 99 9
      frontend/src/__tests__/hooks/useWebSocket.test.ts
  32. 229 1
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  33. 44 0
      frontend/src/__tests__/pages/MakerworldPage.test.tsx
  34. 88 0
      frontend/src/api/client.ts
  35. 10 0
      frontend/src/components/AddNotificationModal.tsx
  36. 413 0
      frontend/src/components/HASensorModal.tsx
  37. 140 21
      frontend/src/components/ModelViewerModal.tsx
  38. 14 0
      frontend/src/components/NotificationProviderCard.tsx
  39. 142 0
      frontend/src/components/PrinterHASensorRow.tsx
  40. 54 30
      frontend/src/hooks/useWebSocket.ts
  41. 61 0
      frontend/src/i18n/locales/de.ts
  42. 61 0
      frontend/src/i18n/locales/en.ts
  43. 61 0
      frontend/src/i18n/locales/es.ts
  44. 61 0
      frontend/src/i18n/locales/fr.ts
  45. 61 0
      frontend/src/i18n/locales/it.ts
  46. 61 0
      frontend/src/i18n/locales/ja.ts
  47. 61 0
      frontend/src/i18n/locales/ko.ts
  48. 61 0
      frontend/src/i18n/locales/pt-BR.ts
  49. 61 0
      frontend/src/i18n/locales/ru.ts
  50. 61 0
      frontend/src/i18n/locales/tr.ts
  51. 61 0
      frontend/src/i18n/locales/uk.ts
  52. 61 0
      frontend/src/i18n/locales/zh-CN.ts
  53. 61 0
      frontend/src/i18n/locales/zh-TW.ts
  54. 2 2
      frontend/src/pages/ArchivesPage.tsx
  55. 66 21
      frontend/src/pages/FileManagerPage.tsx
  56. 2 2
      frontend/src/pages/MakerworldPage.tsx
  57. 6 0
      frontend/src/pages/PrintersPage.tsx
  58. 105 2
      frontend/src/pages/SettingsPage.tsx
  59. 50 0
      frontend/src/utils/slicer.ts

Разлика између датотеке није приказан због своје велике величине
+ 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).

+ 28 - 1
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
@@ -1277,9 +1279,29 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
     # Include AMS dry_time and tray state values so drying/slot changes trigger broadcasts
     ams_dry_key = tuple(a.get("dry_time", 0) for a in (state.raw_data.get("ams") or [])) if state.raw_data else ()
     # Include tray states so load/unload transitions (state 11→10) trigger broadcasts (#784)
+    #
+    # The filament identity fields are here because Configure Slot writes
+    # exactly those and nothing else. Re-configuring a slot from PLA to another
+    # brand or colour of PLA leaves id/tray_type/state identical, so the key
+    # matched, this function returned before broadcasting, and the card kept
+    # showing the old filament until the 30s fallback poll or a page reload —
+    # even though the configure route asks the printer for a fresh pushall and
+    # that push does carry the new values. Reset always worked, because it
+    # clears tray_type.
+    #
+    # These fields only change when someone configures a slot or swaps a spool,
+    # so unlike temperature or progress they add no broadcast traffic mid-print.
     ams_tray_key = (
         tuple(
-            (t.get("id"), t.get("tray_type", ""), t.get("state"))
+            (
+                t.get("id"),
+                t.get("tray_type", ""),
+                t.get("state"),
+                t.get("tray_color", ""),
+                t.get("tray_info_idx", ""),
+                t.get("tray_sub_brands", ""),
+                t.get("cali_idx"),
+            )
             for a in (state.raw_data.get("ams") or [])
             for t in a.get("tray", [])
         )
@@ -7344,6 +7366,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 +7447,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 +7948,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

+ 90 - 7
backend/app/services/bambu_mqtt.py

@@ -46,6 +46,16 @@ _ACTIVE_PRINT_STATES = frozenset({"PREPARE", "SLICING", "RUNNING", "PAUSE"})
 # are deliberately excluded — those SHOULD end it.
 _ACTIVE_DRY_STATUSES = frozenset({1, 2, 3})  # Checking, Drying, Cooling
 
+# A drying cycle that runs to term ends with its countdown all but exhausted, so
+# the last dry_time we saw before the drop to 0 tells us whether the firmware
+# ended the cycle on schedule or aborted it. More than this many minutes still on
+# the clock means it was cut short, and the firmware's own reason codes are worth
+# capturing at INFO — #2770 aborted a 12-hour cycle 20 minutes in (700 minutes
+# left), and the log said only "drying complete", so the report carried no
+# evidence of why. The margin absorbs a stale last observation between AMS
+# pushes; it is not a judgement about how short "short" is.
+_EARLY_DRY_END_MINUTES = 5
+
 # CONNACK reason codes that mean the printer actively refused our credentials,
 # as opposed to being unreachable or busy. Bambu speaks MQTT 3.1.1, whose
 # single-byte CONNACK return codes paho maps onto the v5 reason-code space:
@@ -750,6 +760,11 @@ class BambuMQTTClient:
         # — only the dry_time countdown — so we cache what we sent to drive
         # the UI badge. Cleared on stop or on the dry_time falling edge to 0.
         self._drying_targets: dict[int, dict[str, object]] = {}
+        # AMS ids we have sent a stop for and not yet seen end. A stop always
+        # ends a cycle far short of its duration, which on the telemetry alone
+        # is indistinguishable from the firmware abandoning it — so the cycle-end
+        # log would otherwise blame the printer for our own decision (#2770).
+        self._drying_stops_sent: set[int] = set()
 
         self.state = PrinterState()
         self._client: mqtt.Client | None = None
@@ -2819,13 +2834,7 @@ class BambuMQTTClient:
             previous = self._previous_dry_times.get(ams_id, 0)
             self._previous_dry_times[ams_id] = current
             if previous > 0 and current == 0:
-                logger.info(
-                    "[%s] AMS %d drying complete (dry_time %d → 0)",
-                    self.serial_number,
-                    ams_id,
-                    previous,
-                )
-                self._drying_targets.pop(ams_id, None)
+                self._log_drying_cycle_end(ams_id, previous, ams_unit, self._drying_targets.pop(ams_id, None))
                 if self.on_drying_complete:
                     self.on_drying_complete(ams_id)
 
@@ -2865,6 +2874,71 @@ class BambuMQTTClient:
         if self._pending_assignments:
             self._check_assignment_verifications()
 
+    def _log_drying_cycle_end(
+        self,
+        ams_id: int,
+        remaining: int,
+        ams_unit: dict,
+        target: dict[str, object] | None,
+    ) -> None:
+        """Report a finished drying cycle, with the firmware's reason when it was
+        cut short (#2770).
+
+        A cycle that reaches its configured duration needs no explanation and
+        keeps the one-line "drying complete" it has always had. One that ends
+        with most of its countdown left was ended by somebody, and there are
+        only two candidates: a stop Bambuddy sent — the print-takes-priority
+        stop, or the user's Stop button — which is named as such, or the
+        firmware.
+
+        For the firmware case the only account of why lives in fields we already
+        parse but have never written down: the ``dry_status`` /
+        ``dry_sub_status`` phase from the info hex, the per-unit
+        ``dry_sf_reason`` constraint codes, and whatever HMS errors are live at
+        that moment. Logging them at INFO puts them in every support bundle by
+        default, which is what a report like #2770 needs before its cause can be
+        argued about at all.
+        """
+        if ams_id in self._drying_stops_sent:
+            self._drying_stops_sent.discard(ams_id)
+            logger.info(
+                "[%s] AMS %d drying stopped by Bambuddy (dry_time %d → 0)",
+                self.serial_number,
+                ams_id,
+                remaining,
+            )
+            return
+
+        if remaining <= _EARLY_DRY_END_MINUTES:
+            logger.info(
+                "[%s] AMS %d drying complete (dry_time %d → 0)",
+                self.serial_number,
+                ams_id,
+                remaining,
+            )
+            return
+
+        requested_minutes: int | None = None
+        if target is not None:
+            try:
+                requested_minutes = int(target.get("duration_hours") or 0) * 60 or None
+            except (TypeError, ValueError):
+                requested_minutes = None
+
+        logger.info(
+            "[%s] AMS %d drying ended early — %d of %s minutes still on the clock. "
+            "Bambuddy sent no stop command, so the firmware ended this cycle: "
+            "dry_status=%s dry_sub_status=%s dry_sf_reason=%s hms=%s",
+            self.serial_number,
+            ams_id,
+            remaining,
+            requested_minutes if requested_minutes is not None else "?",
+            ams_unit.get("dry_status"),
+            ams_unit.get("dry_sub_status"),
+            ams_unit.get("dry_sf_reason") or [],
+            [e.full_code for e in self.state.hms_errors] or "none",
+        )
+
     def register_assignment_verification(
         self,
         ams_id: int,
@@ -5492,13 +5566,22 @@ class BambuMQTTClient:
         )
         # Track the active-cycle target so the badge can show "PETG @ 65°C"
         # while drying. Bambu only echoes dry_time on subsequent pushes.
+        # duration_hours is not shown anywhere; it is what lets the cycle-end log
+        # say how much of the requested time the firmware actually ran (#2770).
         if mode == 1:
             self._drying_targets[ams_id] = {
                 "filament": filament or "",
                 "temp": int(temp),
+                "duration_hours": int(duration),
             }
+            self._drying_stops_sent.discard(ams_id)
         else:
             self._drying_targets.pop(ams_id, None)
+            # Remember that this cycle's end is ours, so the cycle-end log
+            # attributes it to Bambuddy instead of to the firmware (#2770). A
+            # stop always ends the cycle far short of its duration, which is
+            # otherwise indistinguishable from the firmware abandoning it.
+            self._drying_stops_sent.add(ams_id)
         return True
 
     @staticmethod

+ 158 - 51
backend/app/services/external_camera.py

@@ -9,9 +9,11 @@ to ensure they are well-formed before use.
 
 import asyncio
 import functools
+import ipaddress
 import logging
 import re
 import shutil
+import socket
 from collections.abc import AsyncGenerator, Callable
 from pathlib import Path
 from urllib.parse import urlparse
@@ -22,13 +24,77 @@ from backend.app.core.logging_filters import redact_url_credentials
 
 logger = logging.getLogger(__name__)
 
+# Protocols ffmpeg may use for an RTSP input. RTSP negotiates its media
+# transport at runtime, so the transports have to be here alongside rtsp itself;
+# tls and crypto cover encrypted variants. Everything ffmpeg would otherwise
+# accept behind an -i — file, http, tcp to anywhere, concat — is left out, so a
+# stream that references something outside itself cannot pull it in.
+_RTSP_PROTOCOL_WHITELIST = "rtsp,rtp,udp,tcp,tls,crypto"
+
+
+def _blocked_host_reason(hostname: str) -> str | None:
+    """Describe why *hostname* is a destination we refuse to fetch, or None to allow it.
+
+    Camera URLs are user-supplied and reach the network — over aiohttp for the
+    HTTP types, and as an ``ffmpeg -i`` argument for RTSP — so this is where the
+    SSRF boundary sits. LAN addresses are deliberately allowed: cameras live on
+    the same network as Bambuddy, and blocking RFC-1918 would remove the feature
+    rather than protect it. What is left to refuse is the host talking to
+    itself, the unspecified address, link-local (which is where the cloud
+    metadata endpoint lives), and the metadata hostnames.
+
+    IP literals are classified with ``ipaddress`` rather than compared against a
+    list of spellings, because 127.0.0.1, 127.0.0.2, 2130706433, 0177.0.0.1,
+    127.1 and ::ffff:127.0.0.1 all arrive at loopback and a list of strings only
+    ever catches whichever one someone thought to write down. ``inet_aton``
+    comes first because it accepts the legacy octal, decimal and short forms
+    that ``ip_address`` rejects — the C resolvers behind aiohttp and ffmpeg
+    accept them, so refusing to understand them here would only mean not seeing
+    where the request is actually going.
+    """
+    host = hostname.lower()
+
+    ip: ipaddress.IPv4Address | ipaddress.IPv6Address | None = None
+    try:
+        ip = ipaddress.ip_address(socket.inet_aton(host))
+    except OSError:
+        try:
+            ip = ipaddress.ip_address(host)
+        except ValueError:
+            ip = None
+
+    if ip is None:
+        # A name, not an address. It is not resolved here on purpose: aiohttp
+        # and ffmpeg each resolve independently afterwards, so a check here
+        # decides nothing about where they end up (DNS rebinding), while a
+        # lookup on every capture would break LAN cameras behind slow or
+        # intermittent local DNS.
+        if host == "localhost" or host.endswith(".localhost"):
+            return "localhost"
+        if host in ("metadata.google.internal", "metadata.google"):
+            return "a cloud metadata service"
+        return None
+
+    # ::ffff:127.0.0.1 is loopback wearing an IPv6 spelling.
+    mapped = getattr(ip, "ipv4_mapped", None)
+    if mapped is not None:
+        ip = mapped
+
+    if ip.is_loopback:
+        return "loopback"
+    if ip.is_unspecified:
+        return "the unspecified address"
+    if ip.is_link_local:
+        return "a link-local address (the cloud metadata range)"
+    return None
+
 
 def _sanitize_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "https", "rtsp")) -> str | None:
     """Validate and sanitize camera URL, returning a safe reconstructed URL.
 
-    This validates that the URL is well-formed, uses an allowed scheme,
-    does not target cloud metadata services, and returns a reconstructed
-    URL from validated components.
+    This validates that the URL is well-formed, uses an allowed scheme, does not
+    target the host itself or a cloud metadata service, and returns a URL
+    reconstructed from the validated components.
 
     Note: This intentionally allows user-provided URLs as that is the
     purpose of external camera configuration. Local network IPs are
@@ -51,37 +117,35 @@ def _sanitize_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "
         if scheme not in allowed_schemes:
             return None
 
-        # Block cloud metadata service endpoints (SSRF mitigation)
-        # These are dangerous destinations that should never be accessed
         hostname = parsed.hostname or ""
-        hostname_lower = hostname.lower()
-        blocked_hosts = (
-            "169.254.169.254",  # AWS/GCP/Azure metadata
-            "metadata.google.internal",  # GCP metadata
-            "metadata.google",
-            "localhost",  # Block localhost to prevent internal service access
-            "127.0.0.1",
-            "::1",
-            "0.0.0.0",  # nosec B104
-        )
-        if hostname_lower in blocked_hosts:
-            logger.warning("Blocked camera URL targeting restricted host: %s", hostname)
+        if not hostname:
             return None
-
-        # Block link-local addresses (169.254.x.x)
-        if hostname.startswith("169.254."):
-            logger.warning("Blocked camera URL targeting link-local address: %s", hostname)
+        blocked = _blocked_host_reason(hostname)
+        if blocked:
+            logger.warning("Blocked camera URL targeting %s: %s", blocked, hostname)
             return None
 
         # Reconstruct URL from validated components to break taint chain
         # This creates a new string from validated parts
+        #
+        # The credentials are carried across verbatim from netloc rather than
+        # via parsed.username/.password, which urlparse has already percent-
+        # decoded: re-emitting those would corrupt any password containing an
+        # @ or a :. They have to survive at all because most RTSP cameras — and
+        # a fair number of MJPEG ones — carry their login in the URL, and
+        # dropping it turns every one of them into an authentication failure.
+        netloc = parsed.netloc
+        userinfo = f"{netloc.rsplit('@', 1)[0]}@" if "@" in netloc else ""
+        # parsed.hostname has already stripped the brackets off an IPv6 literal;
+        # without them back the result is not a URL any client can parse.
+        host_str = f"[{hostname}]" if ":" in hostname else hostname
         port_str = f":{parsed.port}" if parsed.port else ""
         path = parsed.path or ""
         query = f"?{parsed.query}" if parsed.query else ""
         fragment = f"#{parsed.fragment}" if parsed.fragment else ""
 
         # Build sanitized URL from validated components
-        sanitized = f"{scheme}://{hostname}{port_str}{path}{query}{fragment}"
+        sanitized = f"{scheme}://{userinfo}{host_str}{port_str}{path}{query}{fragment}"
         return sanitized
     except ValueError:
         return None
@@ -380,18 +444,18 @@ async def _capture_frame_uncoalesced(
         return None
 
 
-async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
-    """Capture frame from USB camera using ffmpeg."""
-    ffmpeg = get_ffmpeg_path()
-    if not ffmpeg:
-        logger.error("ffmpeg not found - required for USB camera capture")
-        return None
+def _safe_usb_device_path(device: str) -> str | None:
+    """Rebuild a /dev/videoN path from a validated device number, or None.
 
-    # Validate device path - must be /dev/videoN format where N is 0-99
-    # This prevents path traversal by using a strict allowlist approach
-    import re as regex_module
+    Validate device path - must be /dev/videoN format where N is 0-99. This
+    prevents path traversal by using a strict allowlist approach: the returned
+    path is built from an integer, which cannot carry a traversal, rather than
+    from any part of the caller's string.
 
-    device_match = regex_module.match(r"^/dev/video(\d{1,2})$", device)
+    Returns None if the device does not exist, so a caller cannot hand ffmpeg a
+    path to something that is not a device node.
+    """
+    device_match = re.match(r"^/dev/video(\d{1,2})$", device)
     if not device_match:
         logger.error("Invalid USB device path format: %s", device)
         return None
@@ -399,9 +463,6 @@ async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
     # Convert to integer to break taint chain - integers cannot contain path traversal
     # lgtm[py/path-injection] - device_num is validated integer 0-99
     device_num = int(device_match.group(1))  # Safe: regex guarantees 1-2 digits
-    if device_num > 99:
-        logger.error("USB device number out of range: %s", device_num)
-        return None
 
     # Construct safe path from validated integer (completely untainted)
     safe_device_path = Path(f"/dev/video{device_num}")  # lgtm[py/path-injection]
@@ -410,8 +471,22 @@ async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
         logger.error("USB device does not exist: %s", safe_device_path)
         return None
 
+    return str(safe_device_path)  # lgtm[py/path-injection]
+
+
+async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
+    """Capture frame from USB camera using ffmpeg."""
+    ffmpeg = get_ffmpeg_path()
+    if not ffmpeg:
+        logger.error("ffmpeg not found - required for USB camera capture")
+        return None
+
+    safe_device = _safe_usb_device_path(device)
+    if not safe_device:
+        return None
+
     # Use the safe path for ffmpeg - this is a hardcoded /dev/videoN path
-    device = str(safe_device_path)  # lgtm[py/path-injection]
+    device = safe_device  # lgtm[py/path-injection]
 
     # Use ffmpeg to grab a single frame from USB camera
     cmd = [
@@ -542,22 +617,34 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
     """Capture frame from RTSP using ffmpeg.
 
     For rtsps:// URLs, a local TLS proxy is used to avoid GnuTLS issues.
+
+    Note: this function intentionally connects to user-configured URLs, the same
+    as the MJPEG and snapshot paths. The URL is sanitized and dangerous
+    destinations are blocked before it reaches ffmpeg.
     """
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
         logger.error("ffmpeg not found - required for RTSP capture")
         return None
 
+    # ffmpeg's -i accepts every protocol it was built with, so an unchecked URL
+    # here is a request to any host and scheme the caller names, not merely to a
+    # camera. Restricting the scheme to RTSP is what keeps this a camera fetch.
+    safe_url = _sanitize_camera_url(url, ("rtsp", "rtsps"))
+    if not safe_url:
+        logger.error("Invalid RTSP URL: %s...", redact_url_credentials(url)[:50])
+        return None
+
     # If rtsps://, use TLS proxy
     proxy_server = None
-    effective_url = url
-    if url.lower().startswith("rtsps://"):
+    effective_url = safe_url
+    if safe_url.lower().startswith("rtsps://"):
         try:
             from urllib.parse import urlparse
 
             from backend.app.services.camera import create_tls_proxy
 
-            parsed = urlparse(url)
+            parsed = urlparse(safe_url)
             target_port = parsed.port or 322
             proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
             userinfo = ""
@@ -566,17 +653,24 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
                 if parsed.password:
                     userinfo += f":{parsed.password}"
                 userinfo += "@"
+            # Points at loopback deliberately, and is built after the check
+            # above rather than re-checked: the destination that mattered was
+            # the one the caller named, and it has already been vetted.
             effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
             if parsed.query:
                 effective_url += f"?{parsed.query}"
         except Exception as e:
             logger.warning("Failed to create TLS proxy for RTSP capture, falling back: %s", e)
-            effective_url = url
+            effective_url = safe_url
 
     cmd = [
         ffmpeg,
         "-rtsp_transport",
         "tcp",
+        # Belt and braces on the scheme check above: a demuxer that follows a
+        # reference out of the stream cannot leave these protocols either.
+        "-protocol_whitelist",
+        _RTSP_PROTOCOL_WHITELIST,
         "-i",
         effective_url,
         "-frames:v",
@@ -956,6 +1050,11 @@ async def _stream_rtsp(
     For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
     of relying on ffmpeg's GnuTLS backend, which has compatibility issues
     with some printer firmwares.
+
+    Note: this function intentionally connects to user-configured URLs. The URL
+    is sanitized and dangerous destinations are blocked before it reaches
+    ffmpeg — see ``_capture_rtsp_frame``, which guards the one-shot path the
+    same way.
     """
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
@@ -964,16 +1063,21 @@ async def _stream_rtsp(
 
     from backend.app.services.camera import rtsp_socket_timeout_flag
 
+    safe_url = _sanitize_camera_url(url, ("rtsp", "rtsps"))
+    if not safe_url:
+        logger.error("Invalid RTSP stream URL: %s...", redact_url_credentials(url)[:50])
+        return
+
     # If the URL uses rtsps://, set up a TLS proxy so ffmpeg uses plain rtsp://
     proxy_server = None
-    effective_url = url
-    if url.lower().startswith("rtsps://"):
+    effective_url = safe_url
+    if safe_url.lower().startswith("rtsps://"):
         try:
             from urllib.parse import urlparse
 
             from backend.app.services.camera import create_tls_proxy
 
-            parsed = urlparse(url)
+            parsed = urlparse(safe_url)
             target_port = parsed.port or 322
             proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
             # Rewrite URL: rtsps://user:pass@host:port/path → rtsp://user:pass@127.0.0.1:proxy/path
@@ -983,12 +1087,14 @@ async def _stream_rtsp(
                 if parsed.password:
                     userinfo += f":{parsed.password}"
                 userinfo += "@"
+            # Loopback by design, and built after the check above rather than
+            # re-checked — see the same rewrite in _capture_rtsp_frame.
             effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
             if parsed.query:
                 effective_url += f"?{parsed.query}"
         except Exception as e:
             logger.warning("Failed to create TLS proxy for RTSP, falling back to direct: %s", e)
-            effective_url = url
+            effective_url = safe_url
 
     cmd = [
         ffmpeg,
@@ -996,6 +1102,8 @@ async def _stream_rtsp(
         "tcp",
         "-rtsp_flags",
         "prefer_tcp",
+        "-protocol_whitelist",
+        _RTSP_PROTOCOL_WHITELIST,
         # Socket I/O timeout name varies by ffmpeg version (#1504); see
         # `rtsp_socket_timeout_flag()` in services.camera.
         f"-{rtsp_socket_timeout_flag()}",
@@ -1109,14 +1217,13 @@ async def _stream_usb(
         logger.error("ffmpeg not found - required for USB camera streaming")
         return
 
-    # Validate device path
-    if not device.startswith("/dev/video"):
-        logger.error("Invalid USB device path: %s", device)
-        return
-
-    if not Path(device).exists():
-        logger.error("USB device does not exist: %s", device)
+    # Same validation as the one-shot path: a prefix check accepted
+    # /dev/video/../../<anything that exists>, which -f v4l2 would then refuse
+    # rather than the check refusing it.
+    safe_device = _safe_usb_device_path(device)
+    if not safe_device:
         return
+    device = safe_device
 
     # ffmpeg command to stream from USB camera (v4l2)
     cmd = [

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

+ 287 - 39
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,
@@ -396,6 +397,43 @@ def _nozzle_mismatch_message(sliced_nozzle: float | None, installed: list[float]
     )
 
 
+def _describe_filament(entry: dict, nozzle_key: str) -> str:
+    """One-line "PETG #000000 (left nozzle)" for an error message (#2771).
+
+    Shared by the required and loaded sides, which name their extruder
+    differently: a 3MF requirement carries ``nozzle_id``, a loaded tray carries
+    ``extruder_id``. Both are MQTT extruder ids — 0 is the right/main nozzle,
+    1 the left/deputy — and both are absent on single-nozzle printers, where
+    naming a nozzle would be noise.
+    """
+    parts = [(entry.get("type") or "filament").upper()]
+    if entry.get("color"):
+        parts.append(str(entry["color"]))
+    nozzle = entry.get(nozzle_key)
+    if nozzle == 0:
+        parts.append("(right nozzle)")
+    elif nozzle == 1:
+        parts.append("(left nozzle)")
+    return " ".join(parts)
+
+
+def _unmatched_filament_message(required: list[dict], loaded: list[dict]) -> str:
+    """Explain that nothing loaded matches what the file needs (#2771).
+
+    Only ever built for a printer with no AMS, where the loaded list is short
+    enough to quote in full and there is no "load another spool and hit Resume"
+    recovery — the external spool holder is all there is, so the user needs to
+    be told which filament to put on it.
+    """
+    want = ", ".join(_describe_filament(r, "nozzle_id") for r in required)
+    have = ", ".join(_describe_filament(f, "extruder_id") for f in loaded)
+    return (
+        f"No filament loaded on this printer matches the file. It needs {want}; "
+        f"the printer has {have} and no AMS. Load the required filament on the "
+        f"external spool holder, or send this job to a printer that has it."
+    )
+
+
 class PrintScheduler:
     """Background scheduler that processes the print queue."""
 
@@ -663,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] = {}
 
@@ -722,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
@@ -808,7 +893,10 @@ class PrintScheduler:
                     # (all -1). A stored all-[-1] mapping is a bug artifact — a
                     # frontend status-load race can persist [-1] (#2589) — and
                     # must be recomputed from live trays rather than trusted.
-                    await self._ensure_ams_mapping(db, item.printer_id, item)
+                    unmappable = await self._ensure_ams_mapping(db, item.printer_id, item)
+                    if unmappable:
+                        await self._fail_unmappable_item(db, item, item.printer_id, unmappable)
+                        continue
 
                     # Filament-deficit pre-dispatch check (#1496). If the
                     # assigned spool can't satisfy any required slot grams,
@@ -917,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,
@@ -1010,7 +1101,10 @@ class PrintScheduler:
                         # missing OR unresolved (all -1). Critical for model-based
                         # jobs where mapping wasn't computed upfront, and it also
                         # self-heals a bogus stored [-1] (#2589).
-                        await self._ensure_ams_mapping(db, printer_id, item)
+                        unmappable = await self._ensure_ams_mapping(db, printer_id, item)
+                        if unmappable:
+                            await self._fail_unmappable_item(db, item, printer_id, unmappable)
+                            continue
 
                         # Filament-deficit pre-dispatch check (#1496).
                         if await self._block_on_filament_deficit(db, item):
@@ -1625,7 +1719,7 @@ class PrintScheduler:
             # actually going to run so history and the ETA agree with reality.
             item.print_time_seconds = variant.print_time_seconds
 
-    async def _ensure_ams_mapping(self, db: AsyncSession, printer_id: int, item: PrintQueueItem) -> None:
+    async def _ensure_ams_mapping(self, db: AsyncSession, printer_id: int, item: PrintQueueItem) -> str | None:
         """Ensure the queue item carries a usable AMS mapping before dispatch.
 
         Recomputes from live printer status when the stored mapping is missing OR
@@ -1641,6 +1735,14 @@ class PrintScheduler:
         external selection; the print command then keeps use_ams=True and the
         firmware surfaces a clear AMS-mapping error instead of silently printing
         to the empty external feed.
+
+        Returns an actionable message when that firmware error is the only
+        possible outcome — the matcher ran, matched nothing, and the printer has
+        no AMS to load a different spool into (#2771). The caller fails the item
+        on it instead of spending an upload on a print that cannot start.
+        Returns None everywhere else, including every case where we simply lack
+        the data to judge, so dispatch is only ever blocked on a positive
+        finding.
         """
         stored_mapping: list | None = None
         if item.ams_mapping:
@@ -1652,7 +1754,7 @@ class PrintScheduler:
         # Already resolved (present and not all-unresolved) — keep as-is so a
         # user's manual mapping is never overwritten.
         if item.ams_mapping and not _mapping_is_all_unresolved(stored_mapping):
-            return
+            return None
 
         computed_mapping = await self._compute_ams_mapping_for_printer(db, printer_id, item)
         if computed_mapping and not _mapping_is_all_unresolved(computed_mapping):
@@ -1664,7 +1766,9 @@ class PrintScheduler:
                 computed_mapping,
             )
             await db.commit()
-        elif _mapping_is_all_unresolved(stored_mapping):
+            return None
+
+        if _mapping_is_all_unresolved(stored_mapping):
             logger.warning(
                 "Queue item %s: stored ams_mapping %s is unresolved and could not be recomputed "
                 "from live status on printer %s; clearing it so dispatch does not treat it as external",
@@ -1675,6 +1779,105 @@ class PrintScheduler:
             item.ams_mapping = None
             await db.commit()
 
+        return await self._unmappable_without_ams_message(db, printer_id, item, computed_mapping)
+
+    async def _unmappable_without_ams_message(
+        self,
+        db: AsyncSession,
+        printer_id: int,
+        item: PrintQueueItem,
+        computed_mapping: list[int] | None,
+    ) -> str | None:
+        """Message for a mapping that resolved nothing on an AMS-less printer (#2771).
+
+        A print dispatched with no mapping goes out as ``use_ams: true`` with no
+        ``ams_mapping`` and no ``ams_mapping2``, which the firmware rejects with
+        0700_8012 "Failed to get AMS mapping table" — after Bambuddy has already
+        uploaded several megabytes and burned its dispatch retries. With an AMS
+        attached that error is worth reaching: the user can load the right spool
+        and press Resume, so this returns None and today's behaviour stands. With
+        no AMS there is nothing to resume into — the external spool holder is the
+        whole inventory — so the useful answer is to say which filament is
+        missing and stop.
+
+        Fail-safe by construction, mirroring the nozzle-diameter guard (#1899):
+        every branch that lacks the evidence to be sure returns None.
+        """
+        # None means the matcher never ran (no requirements parsed from the 3MF,
+        # or nothing loaded at all) rather than "ran and matched nothing". Those
+        # dispatch as they always have.
+        if not _mapping_is_all_unresolved(computed_mapping):
+            return None
+
+        status = printer_manager.get_status(printer_id)
+        if status is None:
+            return None
+
+        # "No AMS" has to be a fact the printer stated, not the absence of a
+        # statement. `raw_data["ams"]` is written only once an AMS push has been
+        # handled and is preserved across partial pushes thereafter, so a missing
+        # key means we have not heard yet — most likely a reconnect, where the
+        # trays of a fully loaded AMS would be invisible for a few seconds. An
+        # empty list is the positive report of a printer with no AMS.
+        ams_units = status.raw_data.get("ams")
+        if not isinstance(ams_units, list) or ams_units:
+            return None
+
+        required = await self._get_filament_requirements(db, item)
+        loaded = self._build_loaded_filaments(status)
+        if not required or not loaded:
+            # Both were non-empty moments ago or the matcher could not have run.
+            # If the picture changed under us, say nothing rather than fail an
+            # item on stale evidence.
+            return None
+        self._apply_filament_overrides(item, required)
+        return _unmatched_filament_message(required, loaded)
+
+    async def _fail_unmappable_item(
+        self, db: AsyncSession, item: PrintQueueItem, printer_id: int, message: str
+    ) -> None:
+        """Fail a queue item whose filament mapping cannot resolve (#2771).
+
+        This replaces a failure, not a success: without it the item is uploaded,
+        rejected by the firmware with 0700_8012, retried twice more and failed
+        anyway with "never started the print after N dispatch attempts". So this
+        applies on the model-based path too, even though it means an "Any <model>"
+        job stops at the first printer offered rather than trying its siblings —
+        deferring instead would need the check to move inside
+        ``_find_printer_for_model``'s candidate loop, since un-assigning here just
+        re-assigns the same printer on the next tick.
+        """
+        item.status = "failed"
+        item.error_message = message
+        item.completed_at = datetime.now(timezone.utc)
+        item.waiting_reason = None
+        await db.commit()
+        logger.warning(
+            "Queue item %s: no usable AMS mapping on printer %s — %s",
+            item.id,
+            printer_id,
+            message,
+        )
+
+        job_name = await self._get_job_name(db, item)
+        printer = await self._get_printer(db, printer_id)
+        await notification_service.on_queue_job_failed(
+            job_name=job_name,
+            printer_id=printer_id,
+            printer_name=printer.name if printer else "Unknown",
+            reason=message,
+            db=db,
+        )
+        try:
+            await ws_manager.send_queue_item_failed(
+                user_id=item.created_by_id,
+                queue_item_id=item.id,
+                printer_id=printer_id,
+                reason="filament_unmappable",
+            )
+        except Exception:
+            pass
+
     async def _compute_ams_mapping_for_printer(
         self, db: AsyncSession, printer_id: int, item: PrintQueueItem
     ) -> list[int] | None:
@@ -1727,37 +1930,7 @@ class PrintScheduler:
             logger.debug("No filament requirements found for queue item %s", item.id)
             return None
 
-        # Apply filament overrides if present
-        if item.filament_overrides:
-            try:
-                overrides = json.loads(item.filament_overrides)
-                override_map = {o["slot_id"]: o for o in overrides}
-                for req in filament_reqs:
-                    if req["slot_id"] in override_map:
-                        override = override_map[req["slot_id"]]
-                        req["type"] = override["type"]
-                        req["color"] = override["color"]
-                        # A manual/preference override SWAPS the slot's filament, so the
-                        # 3MF's original tray_info_idx now points at the old spool and must
-                        # be cleared — matching then falls back to type+colour. A
-                        # force_color_match override is not a swap: it carries the 3MF's
-                        # intended variant (Basic GFA00 / Matte GFA01 / Silk GFA06), so keep
-                        # it here too, letting the matcher pin the correct variant slot on a
-                        # printer holding two same-colour spools of different variants (#2650).
-                        # If that variant isn't loaded the matcher falls back to type+colour,
-                        # so an eligible printer never fails to map.
-                        req["tray_info_idx"] = (
-                            override.get("tray_info_idx", "") if override.get("force_color_match") else ""
-                        )
-                        logger.debug(
-                            "Queue item %s: Override slot %d -> %s %s",
-                            item.id,
-                            req["slot_id"],
-                            override["type"],
-                            override["color"],
-                        )
-            except (json.JSONDecodeError, KeyError, TypeError) as e:
-                logger.warning("Failed to apply filament overrides for queue item %s: %s", item.id, e)
+        self._apply_filament_overrides(item, filament_reqs)
 
         # Build loaded filaments from printer status
         loaded_filaments = self._build_loaded_filaments(status)
@@ -1791,6 +1964,47 @@ class PrintScheduler:
             filament_reqs, loaded_filaments, prefer_lowest, inventory_remain_overrides, fts_installed
         )
 
+    def _apply_filament_overrides(self, item: PrintQueueItem, filament_reqs: list[dict]) -> None:
+        """Rewrite ``filament_reqs`` in place with the item's per-slot overrides.
+
+        Extracted from ``_compute_ams_mapping_for_printer`` so the unmappable
+        diagnosis (#2771) describes the filament the matcher actually looked
+        for, not the one the 3MF was sliced with — naming the pre-override
+        filament in a user-facing error would send the user to load the wrong
+        spool.
+        """
+        if not item.filament_overrides:
+            return
+        try:
+            overrides = json.loads(item.filament_overrides)
+            override_map = {o["slot_id"]: o for o in overrides}
+            for req in filament_reqs:
+                if req["slot_id"] in override_map:
+                    override = override_map[req["slot_id"]]
+                    req["type"] = override["type"]
+                    req["color"] = override["color"]
+                    # A manual/preference override SWAPS the slot's filament, so the
+                    # 3MF's original tray_info_idx now points at the old spool and must
+                    # be cleared — matching then falls back to type+colour. A
+                    # force_color_match override is not a swap: it carries the 3MF's
+                    # intended variant (Basic GFA00 / Matte GFA01 / Silk GFA06), so keep
+                    # it here too, letting the matcher pin the correct variant slot on a
+                    # printer holding two same-colour spools of different variants (#2650).
+                    # If that variant isn't loaded the matcher falls back to type+colour,
+                    # so an eligible printer never fails to map.
+                    req["tray_info_idx"] = (
+                        override.get("tray_info_idx", "") if override.get("force_color_match") else ""
+                    )
+                    logger.debug(
+                        "Queue item %s: Override slot %d -> %s %s",
+                        item.id,
+                        req["slot_id"],
+                        override["type"],
+                        override["color"],
+                    )
+        except (json.JSONDecodeError, KeyError, TypeError) as e:
+            logger.warning("Failed to apply filament overrides for queue item %s: %s", item.id, e)
+
     def _build_override_direct_mapping(self, force_overrides: list[dict], status) -> list[int] | None:
         """Build an AMS mapping directly from force-color overrides without a 3MF.
 
@@ -1864,6 +2078,38 @@ class PrintScheduler:
         # Get ams_extruder_map for dual-nozzle printers (H2D, H2D Pro)
         ams_extruder_map = status.raw_data.get("ams_extruder_map", {})
 
+        # Dual-nozzle detection, used below to route external spools to an
+        # extruder (#2771). Mirrors `buildLoadedFilaments` in the frontend,
+        # which was corrected for #1257 while this copy kept the old signal.
+        #
+        # `ams_extruder_map` is derived from AMS info bits, so a dual-nozzle
+        # printer with zero AMS units reports an empty map — and every external
+        # spool then got `extruder_id=None`, which the nozzle-aware filter in
+        # `_match_filaments_to_slots` rejects outright because `None` equals
+        # neither 0 nor 1. On an X2D feeding from external spools only that left
+        # nothing to match, the mapping came back all -1, and the print went out
+        # with `use_ams: true` and no mapping table at all — firmware 0700_8012,
+        # "Failed to get AMS mapping table".
+        #
+        # `nozzles` is always a two-entry list (the state seeds it with two empty
+        # NozzleInfo stubs), so its length proves nothing; only a populated
+        # diameter on the second entry means real hardware. The other two signals
+        # are fallbacks for firmware revisions that surface one but not the
+        # other: a populated `ams_extruder_map` is dual-nozzle by construction,
+        # and so is more than one `vt_tray` entry, since single-nozzle printers
+        # expose exactly one external feed.
+        nozzles = getattr(status, "nozzles", None) or []
+        vt_trays = status.raw_data.get("vt_tray") or []
+        is_dual_nozzle = bool(
+            (len(nozzles) > 1 and getattr(nozzles[1], "nozzle_diameter", ""))
+            or ams_extruder_map
+            # isinstance, because a dict here would count its ~30 keys as trays.
+            # bambu_mqtt normalises vt_tray to a list before it reaches raw_data,
+            # so this is unreachable — but the loop below would raise on a dict
+            # and that is the pre-existing behaviour to keep, not to paper over.
+            or (isinstance(vt_trays, list) and len(vt_trays) > 1)
+        )
+
         # Parse AMS units from raw_data
         ams_data = status.raw_data.get("ams", [])
         for ams_unit in ams_data:
@@ -1900,7 +2146,7 @@ class PrintScheduler:
                     )
 
         # Check external spool(s) (vt_tray is a list)
-        for idx, vt in enumerate(status.raw_data.get("vt_tray") or []):
+        for idx, vt in enumerate(vt_trays):
             if vt.get("tray_type"):
                 color = self._normalize_color(vt.get("tray_color", ""))
                 tray_id = int(vt.get("id", 254))
@@ -1914,7 +2160,9 @@ class PrintScheduler:
                         "is_ht": False,
                         "is_external": True,
                         "global_tray_id": tray_id,
-                        "extruder_id": (255 - tray_id) if ams_extruder_map else None,
+                        # 254 = VIRTUAL_TRAY_DEPUTY_ID feeds extruder 1 (left),
+                        # 255 = VIRTUAL_TRAY_MAIN_ID feeds extruder 0 (right).
+                        "extruder_id": (255 - tray_id) if is_dual_nozzle else None,
                         "remain": vt.get("remain", -1),
                     }
                 )

+ 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

+ 86 - 3
backend/tests/unit/services/test_bambu_mqtt.py

@@ -4044,13 +4044,13 @@ class TestSendDryingCommand:
     def test_start_caches_target_for_badge(self, mqtt_client):
         """mode=1 send populates _drying_targets so the badge can render it."""
         mqtt_client.send_drying_command(ams_id=2, temp=65, duration=12, mode=1, filament="PETG")
-        assert mqtt_client._drying_targets[2] == {"filament": "PETG", "temp": 65}
+        assert mqtt_client._drying_targets[2] == {"filament": "PETG", "temp": 65, "duration_hours": 12}
 
     def test_start_overwrites_prior_target_for_same_ams(self, mqtt_client):
         """A second start on the same AMS replaces the cached target."""
         mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament="PLA")
         mqtt_client.send_drying_command(ams_id=0, temp=70, duration=6, mode=1, filament="ABS")
-        assert mqtt_client._drying_targets[0] == {"filament": "ABS", "temp": 70}
+        assert mqtt_client._drying_targets[0] == {"filament": "ABS", "temp": 70, "duration_hours": 6}
 
     def test_stop_clears_target(self, mqtt_client):
         """mode=0 send drops the cache so the badge stops showing the target."""
@@ -4065,7 +4065,7 @@ class TestSendDryingCommand:
         mqtt_client.send_drying_command(ams_id=128, temp=80, duration=6, mode=1, filament="PA-CF")
         mqtt_client.send_drying_command(ams_id=0, temp=0, duration=0, mode=0)
         assert 0 not in mqtt_client._drying_targets
-        assert mqtt_client._drying_targets[128] == {"filament": "PA-CF", "temp": 80}
+        assert mqtt_client._drying_targets[128] == {"filament": "PA-CF", "temp": 80, "duration_hours": 6}
 
 
 class TestStartPrintAmsMapping:
@@ -6120,6 +6120,89 @@ class TestDryingCompleteCallback:
         mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
         assert mqtt_client._drying_events == [0]
 
+    def test_early_end_logs_firmware_reason_codes(self, mqtt_client, caplog):
+        """#2770 — a 12-hour cycle the firmware abandoned 20 minutes in logged
+        only 'drying complete', so the report carried no evidence of why. An
+        early end now names the shortfall and the reason fields we already
+        parse: phase, sub-phase, cannot-dry codes and live HMS."""
+        from backend.app.services.bambu_mqtt import HMSError
+
+        mqtt_client.state.hms_errors = [
+            HMSError(code="0x2000003", attr=0x07008000, module=7, severity=2, full_code="0700800002000003")
+        ]
+        mqtt_client._client = MagicMock()
+        mqtt_client.send_drying_command(ams_id=0, temp=65, duration=12, mode=1, filament="PETG")
+        mqtt_client._handle_ams_data(
+            {"ams": [{"id": "0", "dry_time": 700, "info": "10002123", "dry_sf_reason": [1], "tray": []}]}
+        )
+        with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
+            mqtt_client._handle_ams_data(
+                {"ams": [{"id": "0", "dry_time": 0, "info": "10002103", "dry_sf_reason": [1], "tray": []}]}
+            )
+
+        assert mqtt_client._drying_events == [0]
+        message = "\n".join(r.getMessage() for r in caplog.records)
+        assert "drying ended early" in message
+        # The shortfall, against the duration we asked the firmware for.
+        assert "700 of 720 minutes" in message
+        # dry_status 0 (Off) and dry_sub_status 0 from info hex 10002103.
+        assert "dry_status=0" in message
+        assert "dry_sub_status=0" in message
+        # InsufficientPower, and the AMS heater-fan HMS that goes with it.
+        assert "dry_sf_reason=[1]" in message
+        assert "0700800002000003" in message
+
+    def test_early_end_without_a_cached_target_still_logs(self, mqtt_client, caplog):
+        """A cycle Bambuddy did not start — from the printer's screen, from
+        Studio, or from before a restart — has no cached duration to compare
+        against. The remaining time alone still proves it was cut short, so the
+        reason codes must be logged rather than withheld for lack of a target."""
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 480, "tray": []}]})
+        with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
+            mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+
+        message = "\n".join(r.getMessage() for r in caplog.records)
+        assert "drying ended early" in message
+        assert "480 of ? minutes" in message
+        assert "hms=none" in message
+
+    def test_stop_we_sent_is_not_blamed_on_the_firmware(self, mqtt_client, caplog):
+        """A stop Bambuddy sends — print takes priority, or the user's Stop
+        button — also ends the cycle far short of its duration, which on the
+        telemetry alone looks exactly like the firmware abandoning it. It must
+        be named as ours rather than reported as an unexplained early end."""
+        mqtt_client._client = MagicMock()
+        mqtt_client.send_drying_command(ams_id=0, temp=65, duration=12, mode=1, filament="PETG")
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 700, "tray": []}]})
+        mqtt_client.send_drying_command(ams_id=0, temp=0, duration=0, mode=0)
+        with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
+            mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+
+        message = "\n".join(r.getMessage() for r in caplog.records)
+        assert "drying stopped by Bambuddy" in message
+        assert "ended early" not in message
+        # And the attribution is consumed, so a later firmware-ended cycle on
+        # the same unit is not credited to a stop we sent hours earlier.
+        mqtt_client.send_drying_command(ams_id=0, temp=65, duration=12, mode=1, filament="PETG")
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 700, "tray": []}]})
+        caplog.clear()
+        with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
+            mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+        assert "drying ended early" in "\n".join(r.getMessage() for r in caplog.records)
+
+    def test_cycle_that_runs_to_term_keeps_the_plain_completion_log(self, mqtt_client, caplog):
+        """The countdown of a cycle that finishes normally is all but exhausted
+        when it drops to 0. Nothing needs explaining, so it keeps the one-line
+        message it has always had — the early-end diagnostics must not become
+        noise on every completed dry."""
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 1, "tray": []}]})
+        with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
+            mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+
+        message = "\n".join(r.getMessage() for r in caplog.records)
+        assert "drying complete (dry_time 1 → 0)" in message
+        assert "ended early" not in message
+
 
 class TestPrintRunningObservedCallback:
     """#1485 follow-up: on_print_running_observed fires the FIRST time we

+ 58 - 0
backend/tests/unit/services/test_notification_service.py

@@ -919,6 +919,64 @@ class TestHomeAssistantProvider:
             # field is JSON rather than key=value lines.
             assert payload["data"]["ttl"] == 0
 
+    @pytest.mark.asyncio
+    async def test_send_homeassistant_custom_data_keeps_nested_structures(self, service):
+        """Nested objects and lists reach the notify service unaltered (#1441).
+
+        The three tests around this one all use flat scalars, which is also all
+        the placeholder and the wiki showed — so a user asking whether action
+        buttons work had nothing telling them the field is a verbatim
+        pass-through rather than a key/value list. ``actions`` is the case they
+        asked about: a list of objects, the shape an HA automation writes under
+        ``data.actions``. Nothing between the textarea and the POST inspects the
+        parsed value beyond "is it an object", so this asserts the whole
+        structure rather than a key at a time.
+        """
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        mock_db = AsyncMock()
+
+        with (
+            patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client,
+            patch(
+                "backend.app.api.routes.settings.get_homeassistant_settings",
+                new_callable=AsyncMock,
+            ) as mock_ha_settings,
+        ):
+            mock_get_client.return_value = mock_client
+            mock_ha_settings.return_value = {
+                "ha_url": "http://ha.local:8123",
+                "ha_token": "test-token-123",
+                "ha_enabled": True,
+            }
+
+            actions = [
+                {"action": "SNOOZE_PRINT_FINISHED", "title": "Snooze 20 min"},
+                {"action": "BED_COOL_NOTIFY_ON", "title": "Notify on Bed Cool"},
+            ]
+            config = {
+                "service": "notify.mobile_app_myphone",
+                "data": json.dumps({"ttl": 0, "priority": "high", "group": "3D Printer", "actions": actions}),
+            }
+            success, _ = await service._send_homeassistant(config, "Print Finished", "Print is finished", db=mock_db)
+
+            assert success is True
+            payload = mock_client.post.call_args.kwargs.get("json") or mock_client.post.call_args[1].get("json")
+            assert payload["data"] == {
+                "ttl": 0,
+                "priority": "high",
+                "group": "3D Printer",
+                "actions": actions,
+            }
+            # Spelled out separately: a flattening or scalar-only filter would
+            # still leave the three sibling keys correct, so the equality above
+            # is not on its own evidence that the list survived.
+            assert payload["data"]["actions"] == actions
+
     @pytest.mark.asyncio
     async def test_send_homeassistant_without_data_omits_key(self, service):
         """Without configured data the payload carries no "data" key — the

+ 290 - 0
backend/tests/unit/test_external_camera_ssrf.py

@@ -0,0 +1,290 @@
+"""The RTSP camera paths must not become a request generator for arbitrary hosts.
+
+`_sanitize_camera_url` is the SSRF boundary for user-configured camera URLs. It
+was applied to the MJPEG and snapshot paths but not to the two RTSP ones, which
+handed the URL to `ffmpeg -i` unchecked — and ffmpeg's `-i` speaks http, tcp,
+file and everything else it was built with, so `camera_type=rtsp` was a way to
+name any destination and any protocol.
+
+Wiring the guard in is only half of it. The guard rebuilt URLs from
+`parsed.hostname`, which drops credentials and unbrackets IPv6 literals, and it
+recognised loopback by comparing against four spellings of it. So these tests
+pin three things at once: the RTSP paths refuse what they should, the guard
+recognises a destination however it is written, and a real camera — which
+usually means an authenticated one — still works.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.external_camera import (
+    _blocked_host_reason,
+    _capture_rtsp_frame,
+    _safe_usb_device_path,
+    _sanitize_camera_url,
+    _stream_rtsp,
+)
+
+RTSP_SCHEMES = ("rtsp", "rtsps")
+HTTP_SCHEMES = ("http", "https")
+
+
+class TestTheHostsWeRefuse:
+    """Loopback, the unspecified address and link-local, however they are spelled."""
+
+    @pytest.mark.parametrize(
+        "host",
+        [
+            "127.0.0.1",
+            "127.0.0.2",  # the whole 127/8 range, not just .1
+            "127.1",  # short form
+            "2130706433",  # decimal
+            "0177.0.0.1",  # octal
+            "0x7f.0.0.1",  # hex
+            "[::1]",
+            "[::ffff:127.0.0.1]",  # loopback wearing an IPv6 spelling
+            "localhost",
+            "sub.localhost",
+        ],
+    )
+    def test_loopback_is_refused(self, host):
+        assert _sanitize_camera_url(f"rtsp://{host}:554/live", RTSP_SCHEMES) is None
+
+    @pytest.mark.parametrize("host", ["0.0.0.0", "[::]"])  # nosec B104
+    def test_the_unspecified_address_is_refused(self, host):
+        assert _sanitize_camera_url(f"rtsp://{host}:554/live", RTSP_SCHEMES) is None
+
+    @pytest.mark.parametrize(
+        "host",
+        [
+            "169.254.169.254",  # AWS/GCP/Azure metadata
+            "169.254.1.1",  # the rest of the range, not just the metadata IP
+            "[fe80::1]",
+            "metadata.google.internal",
+            "metadata.google",
+        ],
+    )
+    def test_link_local_and_metadata_are_refused(self, host):
+        assert _sanitize_camera_url(f"rtsp://{host}/live", RTSP_SCHEMES) is None
+
+    def test_the_reason_is_reported_for_logging(self):
+        assert _blocked_host_reason("2130706433") == "loopback"
+        assert _blocked_host_reason("169.254.169.254") is not None
+        assert _blocked_host_reason("192.168.1.50") is None
+
+
+class TestTheCamerasWeAllow:
+    """LAN is allowed on purpose — that is where cameras are."""
+
+    @pytest.mark.parametrize(
+        "url",
+        [
+            "rtsp://192.168.1.50:554/live",
+            "rtsp://10.0.0.5/stream1",
+            "rtsp://172.16.4.9:8554/cam",
+            "rtsp://[fd00::1]:554/live",  # unique-local IPv6
+            "rtsp://cam.lan/live",
+            "rtsps://camera.example.com:322/stream",
+        ],
+    )
+    def test_a_camera_url_survives(self, url):
+        assert _sanitize_camera_url(url, RTSP_SCHEMES) is not None
+
+    def test_a_hostname_is_not_resolved(self):
+        """A name that would resolve to loopback still passes.
+
+        Not an oversight: aiohttp and ffmpeg resolve independently afterwards,
+        so a lookup here decides nothing (DNS rebinding) while costing a DNS
+        round trip on every capture. Pinned so the omission stays deliberate.
+        """
+        assert _sanitize_camera_url("rtsp://localtest.me/live", RTSP_SCHEMES) is not None
+
+
+class TestWhatTheGuardMustNotDestroy:
+    """Most RTSP cameras carry their login in the URL. Stripping it would turn
+    every one of them into an authentication failure — a worse outage than the
+    hole being closed."""
+
+    def test_credentials_survive(self):
+        url = "rtsp://admin:hunter2@192.168.1.50:554/live"
+        assert _sanitize_camera_url(url, RTSP_SCHEMES) == url
+
+    def test_percent_encoded_credentials_survive_byte_for_byte(self):
+        """urlparse's .username/.password are already decoded, so rebuilding
+        from them would corrupt any password containing an @ or a :."""
+        url = "rtsp://ad%40min:p%3Ass%40word@192.168.1.50:554/live"
+        assert _sanitize_camera_url(url, RTSP_SCHEMES) == url
+
+    def test_an_ipv6_literal_keeps_its_brackets(self):
+        """Without them the result is not a URL any client can parse."""
+        assert _sanitize_camera_url("rtsp://[fd00::1]:554/live", RTSP_SCHEMES) == "rtsp://[fd00::1]:554/live"
+
+    def test_http_cameras_keep_their_basic_auth_too(self):
+        url = "http://admin:hunter2@192.168.1.50/stream.mjpg"
+        assert _sanitize_camera_url(url, HTTP_SCHEMES) == url
+
+    def test_port_query_and_fragment_survive(self):
+        url = "rtsp://192.168.1.50:8554/live?channel=2&subtype=1#frag"
+        assert _sanitize_camera_url(url, RTSP_SCHEMES) == url
+
+
+class TestSchemeAllowlist:
+    """What keeps an ffmpeg input a camera fetch rather than a fetch."""
+
+    @pytest.mark.parametrize(
+        "url",
+        [
+            "http://192.168.1.50:8080/internal",
+            "https://192.168.1.50/internal",
+            "tcp://192.168.1.50:22",
+            "file:///etc/passwd",
+            "concat:/etc/passwd",
+            "udp://192.168.1.50:1234",
+            "ftp://192.168.1.50/x",
+        ],
+    )
+    def test_only_rtsp_reaches_the_rtsp_paths(self, url):
+        assert _sanitize_camera_url(url, RTSP_SCHEMES) is None
+
+    def test_rtsp_does_not_reach_the_http_paths(self):
+        assert _sanitize_camera_url("rtsp://192.168.1.50/live", HTTP_SCHEMES) is None
+
+    @pytest.mark.parametrize("url", ["", "not a url", "rtsp://", "://192.168.1.50/x"])
+    def test_malformed_input_is_refused(self, url):
+        assert _sanitize_camera_url(url, RTSP_SCHEMES) is None
+
+
+def _fake_ffmpeg():
+    return patch("backend.app.services.external_camera.get_ffmpeg_path", return_value="/usr/bin/ffmpeg")
+
+
+def _spawn_spy(returncode: int | None = 0, stdout: bytes = b"\xff\xd8" + b"\x00" * 200):
+    """Stand in for the ffmpeg subprocess, recording the argv it was handed.
+
+    The streaming path reads until EOF, so stdout.read returns b"" and the
+    generator finishes immediately — these tests are about whether ffmpeg was
+    launched and with what, not about frame extraction.
+    """
+    process = MagicMock()
+    process.returncode = returncode
+    process.communicate = AsyncMock(return_value=(stdout, b""))
+    process.stdout.read = AsyncMock(return_value=b"")
+    process.stderr.read = AsyncMock(return_value=b"")
+    process.wait = AsyncMock(return_value=returncode)
+    process.kill = MagicMock()
+    process.terminate = MagicMock()
+    return patch(
+        "backend.app.services.external_camera.asyncio.create_subprocess_exec",
+        new=AsyncMock(return_value=process),
+    )
+
+
+class TestRtspCaptureRefusesUnsafeUrls:
+    """`_capture_rtsp_frame` — the one-shot path behind the test-connection
+    endpoint, which takes url and camera_type straight off the query string."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "url",
+        [
+            "http://127.0.0.1:8080/internal-service",  # the reported PoC
+            "http://192.168.1.100:8080/any-image.jpg",
+            "file:///etc/passwd",
+            "rtsp://127.0.0.1:554/live",
+            "rtsp://2130706433:554/live",
+            "rtsp://169.254.169.254/live",
+        ],
+    )
+    async def test_no_process_is_spawned(self, url):
+        with _fake_ffmpeg(), _spawn_spy() as spawn:
+            assert await _capture_rtsp_frame(url, timeout=5) is None
+        spawn.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_a_real_camera_still_captures(self):
+        with _fake_ffmpeg(), _spawn_spy() as spawn:
+            frame = await _capture_rtsp_frame("rtsp://admin:hunter2@192.168.1.50:554/live", timeout=5)
+
+        assert frame is not None
+        cmd = spawn.await_args.args
+        assert "rtsp://admin:hunter2@192.168.1.50:554/live" in cmd, (
+            "the camera's credentials must reach ffmpeg or every authenticated camera breaks"
+        )
+
+    @pytest.mark.asyncio
+    async def test_ffmpeg_is_confined_to_rtsp_protocols(self):
+        """Belt and braces behind the scheme check: a stream that references
+        something outside itself must not be able to pull it in."""
+        with _fake_ffmpeg(), _spawn_spy() as spawn:
+            await _capture_rtsp_frame("rtsp://192.168.1.50:554/live", timeout=5)
+
+        cmd = spawn.await_args.args
+        whitelist = cmd[cmd.index("-protocol_whitelist") + 1].split(",")
+        assert "rtsp" in whitelist
+        assert "file" not in whitelist
+        assert "http" not in whitelist
+
+
+class TestRtspStreamRefusesUnsafeUrls:
+    """`_stream_rtsp` — the live-view path, and the one the report missed."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "url",
+        [
+            "http://127.0.0.1:8080/internal-service",
+            "rtsp://127.0.0.1:554/live",
+            "rtsp://[::ffff:127.0.0.1]:554/live",
+            "file:///etc/passwd",
+        ],
+    )
+    async def test_no_process_is_spawned(self, url):
+        with _fake_ffmpeg(), _spawn_spy() as spawn:
+            frames = [frame async for frame in _stream_rtsp(url, fps=5)]
+
+        assert frames == []
+        spawn.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_a_real_camera_still_reaches_ffmpeg(self):
+        with _fake_ffmpeg(), _spawn_spy(returncode=None) as spawn:
+            [frame async for frame in _stream_rtsp("rtsp://admin:hunter2@192.168.1.50:554/live", fps=5)]
+
+        spawn.assert_awaited_once()
+        cmd = spawn.await_args.args
+        assert "rtsp://admin:hunter2@192.168.1.50:554/live" in cmd
+        assert "-protocol_whitelist" in cmd
+
+
+class TestUsbDevicePaths:
+    """The USB paths take a device path from the same request field, and the
+    streaming one used to check only that it started with /dev/video."""
+
+    @pytest.mark.parametrize(
+        "device",
+        [
+            "/dev/video/../../etc/passwd",
+            "/dev/videos/../../etc/shadow",
+            "/dev/video0; rm -rf /",
+            "/etc/passwd",
+            "/dev/video100",  # three digits is not a device number
+            "",
+        ],
+    )
+    def test_a_path_that_is_not_a_device_node_is_refused(self, device):
+        assert _safe_usb_device_path(device) is None
+
+    def test_a_missing_device_is_refused(self):
+        """Existence is part of the check — ffmpeg must never be pointed at a
+        path just because it is shaped like one."""
+        with patch("backend.app.services.external_camera.Path") as path_cls:
+            path_cls.return_value.exists.return_value = False
+            assert _safe_usb_device_path("/dev/video0") is None
+
+    def test_the_path_is_rebuilt_from_the_device_number(self):
+        with patch("backend.app.services.external_camera.Path") as path_cls:
+            path_cls.return_value.exists.return_value = True
+            path_cls.return_value.__str__.return_value = "/dev/video7"
+            assert _safe_usb_device_path("/dev/video7") == "/dev/video7"
+        path_cls.assert_called_once_with("/dev/video7")

+ 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

+ 4 - 1
backend/tests/unit/test_scheduler_cross_model_variants.py

@@ -299,7 +299,10 @@ async def _run_check_queue(ctx, scheduler, finder, waiting_notification=None):
         patch.object(scheduler, "_check_auto_drying", AsyncMock()),
         # Selection is what's under test — keep AMS recomputation and the
         # filament-deficit probe out of the way, and never actually dispatch.
-        patch.object(scheduler, "_ensure_ams_mapping", AsyncMock()),
+        # None is the mapping-resolved answer; a bare AsyncMock returns a truthy
+        # sentinel, which the unmappable guard (#2771) reads as "this job can
+        # never print" and fails the item on.
+        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", MagicMock()),
     ]

+ 259 - 0
backend/tests/unit/test_scheduler_external_spool_nozzle_2771.py

@@ -0,0 +1,259 @@
+"""Regression tests for external-spool nozzle routing on AMS-less printers (#2771).
+
+A fleet of X2Ds with no AMS, printing from external spools, could not be sent a
+job with "Any X2D": the print uploaded, then the firmware rejected it with
+0700_8012 "Failed to get AMS mapping table" and the item failed after three
+dispatch attempts. Sending the same file to a named printer worked, because that
+path carries a mapping the *frontend* resolved and the scheduler's matcher never
+runs.
+
+Cause: ``_build_loaded_filaments`` derived dual-nozzle status from
+``ams_extruder_map``, which is built from AMS info bits — a dual-nozzle printer
+with zero AMS units reports an empty map. Every external spool then got
+``extruder_id=None``, and the nozzle-aware hard filter in
+``_match_filaments_to_slots`` rejected it because ``None`` equals neither 0 nor
+1. Nothing matched, the mapping came back all -1 and was cleared to None, and the
+print command went out as ``use_ams: true`` with no mapping table at all.
+
+This is the backend half of #1257, which fixed the identical logic in
+``useFilamentMapping.ts`` and left this copy behind; the first two tests below
+mirror its frontend regression tests.
+
+The second half covers the guard that keeps a genuinely unmappable job from
+being uploaded at all, since without an AMS there is no "load another spool and
+press Resume" recovery for the firmware error to lead to.
+"""
+
+import json
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.print_scheduler import (
+    PrintScheduler,
+    _unmatched_filament_message,
+)
+
+# Two external feeds, as an X2D/H2D reports them: 254 is Ext-L (deputy/left,
+# extruder 1) and 255 is Ext-R (main/right, extruder 0).
+DUAL_EXTERNAL = [
+    {"id": "254", "tray_type": "PETG", "tray_color": "000000FF", "tray_info_idx": "GFG00"},
+    {"id": "255", "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA00"},
+]
+
+REAL_NOZZLES = [
+    SimpleNamespace(nozzle_diameter="0.4"),
+    SimpleNamespace(nozzle_diameter="0.4"),
+]
+# The state seeds `nozzles` with two empty NozzleInfo stubs even on single-nozzle
+# printers, so the second entry's presence proves nothing — only a diameter does.
+STUB_NOZZLES = [
+    SimpleNamespace(nozzle_diameter="0.4"),
+    SimpleNamespace(nozzle_diameter=""),
+]
+
+
+def _status(raw_data, nozzles=None):
+    return SimpleNamespace(raw_data=raw_data, nozzles=nozzles)
+
+
+@pytest.fixture
+def scheduler():
+    return PrintScheduler()
+
+
+class TestExternalSpoolExtruderRouting:
+    """``_build_loaded_filaments`` must route external spools without an AMS."""
+
+    def test_dual_nozzle_without_ams_routes_both_external_feeds(self, scheduler):
+        """The X2D case from the report: no AMS, so ams_extruder_map is empty."""
+        loaded = scheduler._build_loaded_filaments(
+            _status({"ams": [], "ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL}, REAL_NOZZLES)
+        )
+
+        by_tray = {f["global_tray_id"]: f for f in loaded}
+        assert by_tray[254]["extruder_id"] == 1  # Ext-L -> left
+        assert by_tray[255]["extruder_id"] == 0  # Ext-R -> right
+
+    def test_single_nozzle_stub_does_not_fabricate_an_extruder(self, scheduler):
+        """A P1S/A1/X1C must keep extruder_id=None, matching pre-fix behaviour.
+
+        Sibling regression to the fix: `nozzles` always has two entries, so
+        inferring dual-nozzle from its length would hand every single-nozzle
+        printer's external spool a nozzle it does not have.
+        """
+        loaded = scheduler._build_loaded_filaments(
+            _status(
+                {"ams": [], "ams_extruder_map": {}, "vt_tray": [DUAL_EXTERNAL[0]]},
+                STUB_NOZZLES,
+            )
+        )
+
+        assert len(loaded) == 1
+        assert loaded[0]["extruder_id"] is None
+
+    def test_two_external_feeds_alone_imply_dual_nozzle(self, scheduler):
+        """Fallback signal: only dual-nozzle hardware exposes two external feeds.
+
+        Kept for firmware revisions that report the feeds but not the nozzle
+        diameters — here `nozzles` is absent entirely.
+        """
+        loaded = scheduler._build_loaded_filaments(
+            _status({"ams": [], "ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL})
+        )
+
+        assert {f["extruder_id"] for f in loaded} == {0, 1}
+
+    def test_populated_ams_extruder_map_still_implies_dual_nozzle(self, scheduler):
+        """The original signal keeps working when there IS an AMS."""
+        loaded = scheduler._build_loaded_filaments(
+            _status(
+                {"ams": [], "ams_extruder_map": {"0": 1}, "vt_tray": [DUAL_EXTERNAL[0]]},
+                STUB_NOZZLES,
+            )
+        )
+
+        assert loaded[0]["extruder_id"] == 1
+
+    def test_mapping_resolves_for_the_nozzle_the_spool_feeds(self, scheduler):
+        """End to end: the matcher now finds the external spool, as it did for
+        the working named-printer dispatch (which sent ams_mapping [254])."""
+        loaded = scheduler._build_loaded_filaments(
+            _status({"ams": [], "ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL}, REAL_NOZZLES)
+        )
+        req = {"slot_id": 1, "type": "PETG", "color": "#000000", "tray_info_idx": "GFG00"}
+
+        assert scheduler._match_filaments_to_slots([{**req, "nozzle_id": 1}], loaded) == [254]
+        # Nothing PETG on the right nozzle — still correctly unmatched.
+        assert scheduler._match_filaments_to_slots([{**req, "nozzle_id": 0}], loaded) == [-1]
+
+
+class TestUnmatchedFilamentMessage:
+    """The message has to name the filament and, on dual-nozzle, the nozzle."""
+
+    def test_names_type_colour_and_nozzle(self):
+        message = _unmatched_filament_message(
+            [{"slot_id": 1, "type": "PETG", "color": "#000000", "nozzle_id": 0}],
+            [{"type": "PETG", "color": "#000000", "extruder_id": 1}],
+        )
+
+        assert "PETG #000000 (right nozzle)" in message
+        assert "PETG #000000 (left nozzle)" in message
+
+    def test_omits_nozzle_on_single_nozzle_printers(self):
+        message = _unmatched_filament_message(
+            [{"slot_id": 1, "type": "ABS", "color": "#FF0000"}],
+            [{"type": "PLA", "color": "#000000"}],
+        )
+
+        assert "ABS #FF0000" in message
+        assert "nozzle" not in message
+
+
+class TestUnmappableWithoutAmsGuard:
+    """``_ensure_ams_mapping`` reports only a positive, unrecoverable finding."""
+
+    def _item(self, ams_mapping=None):
+        item = MagicMock()
+        item.id = 22
+        item.printer_id = 4
+        item.ams_mapping = ams_mapping
+        item.filament_overrides = None
+        return item
+
+    async def _ensure(self, scheduler, computed, status):
+        scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=computed)
+        scheduler._get_filament_requirements = AsyncMock(
+            return_value=[{"slot_id": 1, "type": "PETG", "color": "#000000", "nozzle_id": 0}]
+        )
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = status
+            return await scheduler._ensure_ams_mapping(AsyncMock(), 4, self._item())
+
+    @pytest.mark.asyncio
+    async def test_reports_when_nothing_matches_and_there_is_no_ams(self, scheduler):
+        status = _status({"ams": [], "ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL}, REAL_NOZZLES)
+
+        message = await self._ensure(scheduler, [-1], status)
+
+        assert message is not None
+        assert "no AMS" in message
+
+    @pytest.mark.asyncio
+    async def test_silent_when_an_ams_is_attached(self, scheduler):
+        """With an AMS the user can load a spool and press Resume, so the
+        firmware's own error is worth reaching — behaviour is unchanged."""
+        status = _status(
+            {
+                "ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000"}]}],
+                "ams_extruder_map": {},
+                "vt_tray": DUAL_EXTERNAL,
+            },
+            REAL_NOZZLES,
+        )
+
+        assert await self._ensure(scheduler, [-1], status) is None
+
+    @pytest.mark.asyncio
+    async def test_silent_when_the_ams_field_has_not_arrived_yet(self, scheduler):
+        """Absence of an AMS report is not a report of no AMS.
+
+        `raw_data["ams"]` appears only once an AMS push has been handled, so a
+        missing key means a reconnect or a cold start — where a fully loaded
+        AMS is briefly invisible and everything would look unmappable.
+        """
+        status = _status({"ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL}, REAL_NOZZLES)
+
+        assert await self._ensure(scheduler, [-1], status) is None
+
+    @pytest.mark.asyncio
+    async def test_silent_when_the_matcher_never_ran(self, scheduler):
+        """A None mapping means no requirements parsed or nothing loaded — not
+        evidence of a mismatch. Fail-safe: dispatch as before."""
+        status = _status({"ams": [], "ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL}, REAL_NOZZLES)
+
+        assert await self._ensure(scheduler, None, status) is None
+
+    @pytest.mark.asyncio
+    async def test_silent_when_the_mapping_resolves(self, scheduler):
+        status = _status({"ams": [], "ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL}, REAL_NOZZLES)
+        item = self._item()
+        scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[254])
+
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = status
+            assert await scheduler._ensure_ams_mapping(AsyncMock(), 4, item) is None
+
+        assert json.loads(item.ams_mapping) == [254]
+
+    @pytest.mark.asyncio
+    async def test_silent_when_the_printer_status_is_gone(self, scheduler):
+        assert await self._ensure(scheduler, [-1], None) is None
+
+
+class TestFailUnmappableItem:
+    """The guard fails the item instead of spending an upload on it."""
+
+    @pytest.mark.asyncio
+    async def test_marks_failed_with_the_message(self, scheduler):
+        db = AsyncMock()
+        item = MagicMock()
+        item.id = 22
+        item.created_by_id = 1
+
+        with (
+            patch("backend.app.services.print_scheduler.notification_service") as notify,
+            patch("backend.app.services.print_scheduler.ws_manager"),
+        ):
+            notify.on_queue_job_failed = AsyncMock()
+            scheduler._get_job_name = AsyncMock(return_value="Fidget")
+            scheduler._get_printer = AsyncMock(return_value=SimpleNamespace(name="X2D-1"))
+
+            await scheduler._fail_unmappable_item(db, item, 4, "needs PETG")
+
+        assert item.status == "failed"
+        assert item.error_message == "needs PETG"
+        assert item.completed_at is not None
+        db.commit.assert_awaited()
+        notify.on_queue_job_failed.assert_awaited_once()

+ 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

+ 247 - 0
backend/tests/unit/test_status_broadcast_ams_slot_config.py

@@ -0,0 +1,247 @@
+"""Configuring an AMS slot must reach the printer card without a page reload.
+
+`on_printer_status_change` deduplicates WebSocket broadcasts against a
+`status_key`. Its AMS component used to carry only id / tray_type / state, so
+re-configuring a slot to a different brand or colour of the SAME material
+produced an identical key: the printer's pushall arrived with the new values,
+the handler compared, found no change, and returned without broadcasting. The
+card then showed the old filament until the 30s fallback poll or an F5.
+
+Reset never had the bug — it clears tray_type, which was always in the key.
+That asymmetry is what these tests pin: every field Configure Slot writes has
+to move the key, and the fields that churn every second still must not.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app import main as main_module
+
+
+def _spawn_patch():
+    """Close the reconcile coroutine the handler builds as a call argument.
+
+    Same reason as test_printer_offline_notification.py: a bare MagicMock keeps
+    it alive in call_args and it finalises unawaited during a later test's GC.
+    """
+    return patch(
+        "backend.app.main.spawn_background_task",
+        side_effect=lambda coro, **kwargs: coro.close(),
+    )
+
+
+def _tray(**overrides) -> dict:
+    """One AMS tray as the firmware reports it, mid-way through a print job.
+
+    Defaults describe a configured slot: Bambu PLA Basic in black, bound to
+    calibration slot 3.
+    """
+    tray = {
+        "id": "0",
+        "tray_type": "PLA",
+        "state": 10,
+        "tray_color": "000000FF",
+        "tray_info_idx": "GFA00",
+        "tray_sub_brands": "PLA Basic",
+        "cali_idx": 3,
+        "remain": 42,
+    }
+    tray.update(overrides)
+    return tray
+
+
+def _state(trays: list[dict]) -> SimpleNamespace:
+    """Minimal PrinterState stub carrying one AMS unit.
+
+    Idle and unheated, so the handler runs straight from the dedup check to the
+    broadcast without touching progress milestones, HMS notifications or the DB.
+    """
+    return SimpleNamespace(
+        connected=True,
+        state="IDLE",
+        progress=0,
+        layer_num=0,
+        temperatures={},
+        raw_data={"ams": [{"id": "0", "dry_time": 0, "tray": trays}]},
+        stg_cur=0,
+        cooling_fan_speed=0,
+        big_fan1_speed=0,
+        big_fan2_speed=0,
+        chamber_light="",
+        active_extruder=0,
+        tray_now=0,
+        door_open=False,
+        subtask_name="",
+        gcode_file="",
+        remaining_time=None,
+        hms_errors=[],
+        ams_filament_backup=None,
+    )
+
+
+@pytest.fixture(autouse=True)
+def _reset_edge_state():
+    main_module._last_status_broadcast.clear()
+    main_module._printer_last_connected.clear()
+    main_module._printer_reconciled_since_connect.clear()
+    yield
+    main_module._last_status_broadcast.clear()
+    main_module._printer_last_connected.clear()
+    main_module._printer_reconciled_since_connect.clear()
+
+
+async def _push(ws_mgr, trays: list[dict]) -> None:
+    """Deliver one status push to the handler."""
+    relay = MagicMock()
+    relay.on_printer_status = AsyncMock()
+    pm = MagicMock()
+    pm.get_printer.return_value = None  # Skip the relay payload branch.
+    pm.get_model.return_value = ""
+
+    with (
+        patch("backend.app.main.ws_manager", ws_mgr),
+        patch("backend.app.main.mqtt_relay", relay),
+        patch("backend.app.main.printer_manager", pm),
+        _spawn_patch(),
+        patch("backend.app.main.printer_state_to_dict", return_value={}),
+    ):
+        await main_module.on_printer_status_change(1, _state(trays))
+
+
+@pytest.fixture
+def ws_mgr():
+    mgr = MagicMock()
+    mgr.send_printer_status = AsyncMock()
+    return mgr
+
+
+class TestConfigureSlotBroadcasts:
+    """Each field Configure Slot writes must break the dedup on its own —
+    the user may change only the colour, or only the K-profile."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "field,new_value",
+        [
+            ("tray_color", "FF0000FF"),
+            ("tray_info_idx", "GFA01"),
+            ("tray_sub_brands", "PLA Matte"),
+            ("cali_idx", 7),
+        ],
+    )
+    async def test_a_changed_filament_field_broadcasts(self, ws_mgr, field, new_value):
+        await _push(ws_mgr, [_tray()])
+        assert ws_mgr.send_printer_status.await_count == 1
+
+        await _push(ws_mgr, [_tray(**{field: new_value})])
+
+        assert ws_mgr.send_printer_status.await_count == 2, (
+            f"changing {field} did not reach the frontend — the card would keep "
+            "showing the old filament until the fallback poll"
+        )
+
+    @pytest.mark.asyncio
+    async def test_the_realistic_reconfigure_broadcasts(self, ws_mgr):
+        """Black Bambu PLA Basic → red eSUN PLA+ with its own K-profile.
+
+        The whole point of the report: same material, so every field the old key
+        looked at is unchanged.
+        """
+        await _push(ws_mgr, [_tray()])
+
+        await _push(
+            ws_mgr,
+            [
+                _tray(
+                    tray_color="C1121FFF",
+                    tray_info_idx="GFL99",
+                    tray_sub_brands="eSUN PLA+",
+                    cali_idx=5,
+                )
+            ],
+        )
+
+        assert ws_mgr.send_printer_status.await_count == 2
+
+    @pytest.mark.asyncio
+    async def test_a_second_slot_is_watched_too(self, ws_mgr):
+        """The key spans every tray, so configuring slot 2 must broadcast even
+        though slot 1 is untouched."""
+        trays = [_tray(id="0"), _tray(id="1", tray_type="PETG", tray_info_idx="GFG00")]
+        await _push(ws_mgr, trays)
+
+        changed = [_tray(id="0"), _tray(id="1", tray_type="PETG", tray_info_idx="GFG01")]
+        await _push(ws_mgr, changed)
+
+        assert ws_mgr.send_printer_status.await_count == 2
+
+
+class TestDedupStillHolds:
+    """The dedup exists to keep a printing machine from flooding the socket.
+    Widening the key must not have cost that."""
+
+    @pytest.mark.asyncio
+    async def test_an_identical_push_is_still_suppressed(self, ws_mgr):
+        await _push(ws_mgr, [_tray()])
+        await _push(ws_mgr, [_tray()])
+
+        assert ws_mgr.send_printer_status.await_count == 1
+
+    @pytest.mark.asyncio
+    async def test_remaining_filament_does_not_broadcast(self, ws_mgr):
+        """`remain` ticks down throughout a print and is deliberately absent
+        from the key. It sits in the same tray dict as the fields we added, so
+        this pins that we widened the key rather than hashing the whole tray."""
+        await _push(ws_mgr, [_tray(remain=42)])
+        await _push(ws_mgr, [_tray(remain=41)])
+
+        assert ws_mgr.send_printer_status.await_count == 1
+
+
+class TestExistingBehaviourUnchanged:
+    """The cases that already worked, kept working."""
+
+    @pytest.mark.asyncio
+    async def test_a_load_unload_transition_still_broadcasts(self, ws_mgr):
+        """#784 — tray state 11→10."""
+        await _push(ws_mgr, [_tray(state=11)])
+        await _push(ws_mgr, [_tray(state=10)])
+
+        assert ws_mgr.send_printer_status.await_count == 2
+
+    @pytest.mark.asyncio
+    async def test_resetting_a_slot_still_broadcasts(self, ws_mgr):
+        """Reset clears the filament identity outright."""
+        await _push(ws_mgr, [_tray()])
+        await _push(
+            ws_mgr,
+            [_tray(tray_type="", tray_color="", tray_info_idx="", tray_sub_brands="", cali_idx=-1)],
+        )
+
+        assert ws_mgr.send_printer_status.await_count == 2
+
+    @pytest.mark.asyncio
+    async def test_a_printer_with_no_ams_still_broadcasts_once(self, ws_mgr):
+        """The `else ()` branch — an AMS-less printer must not crash or
+        double-broadcast."""
+        relay = MagicMock()
+        relay.on_printer_status = AsyncMock()
+        pm = MagicMock()
+        pm.get_printer.return_value = None
+        pm.get_model.return_value = ""
+        state = _state([])
+        state.raw_data = {}
+
+        for _ in range(2):
+            with (
+                patch("backend.app.main.ws_manager", ws_mgr),
+                patch("backend.app.main.mqtt_relay", relay),
+                patch("backend.app.main.printer_manager", pm),
+                _spawn_patch(),
+                patch("backend.app.main.printer_state_to_dict", return_value={}),
+            ):
+                await main_module.on_printer_status_change(1, state)
+
+        assert ws_mgr.send_printer_status.await_count == 1

+ 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();
+  });
+});

+ 244 - 3
frontend/src/__tests__/components/ModelViewerModal.test.tsx

@@ -8,6 +8,7 @@ import { screen, fireEvent, waitFor } from '@testing-library/react';
 import { render } from '../utils';
 import { ModelViewerModal } from '../../components/ModelViewerModal';
 import { setStreamToken } from '../../api/client';
+import { openInSlicer } from '../../utils/slicer';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 
@@ -28,6 +29,15 @@ vi.mock('../../components/GcodeViewer', () => ({
   ),
 }));
 
+// Only the protocol-handler launch is stubbed — it would navigate the jsdom
+// window. Everything else in the module is a pure predicate, so keep the real
+// implementations: re-declaring them here would let the file-type rule these
+// tests assert on drift away from the one the component actually runs.
+vi.mock('../../utils/slicer', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../../utils/slicer')>()),
+  openInSlicer: vi.fn(),
+}));
+
 const mockCapabilities = {
   has_model: true,
   has_gcode: true,
@@ -512,12 +522,12 @@ describe('ModelViewerModal', () => {
       });
     });
 
-    it('disables Open in Slicer for non-3mf library files', async () => {
+    it('disables Open in Slicer for library files that cannot be handed to a slicer', async () => {
       render(
         <ModelViewerModal
           libraryFileId={1}
-          title="Model.stl"
-          fileType="stl"
+          title="Model.gcode"
+          fileType="gcode"
           onClose={mockOnClose}
         />
       );
@@ -528,4 +538,235 @@ describe('ModelViewerModal', () => {
       });
     });
   });
+
+  describe('slicer split button (#2725)', () => {
+    it('shows both slicers in the dropdown when Bambuddy is the default slicer', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({ use_slicer_api: true });
+        }),
+        http.get('/api/v1/library/files/:id/plates', () => {
+          return HttpResponse.json(mockSinglePlateResponse);
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Model.3mf"
+          fileType="3mf"
+          onClose={mockOnClose}
+          onSliceWithBambuddy={vi.fn()}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+
+      await waitFor(() => {
+        expect(screen.getByText('Open in Bambu Studio')).toBeInTheDocument();
+        expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
+      });
+    });
+
+    it('opens the selected local slicer from the Bambuddy dropdown', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({ use_slicer_api: true });
+        }),
+        http.get('/api/v1/library/files/:id/plates', () => {
+          return HttpResponse.json(mockSinglePlateResponse);
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Model.3mf"
+          fileType="3mf"
+          onClose={mockOnClose}
+          onSliceWithBambuddy={vi.fn()}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+
+      const orcaItem = await screen.findByText('Open in OrcaSlicer');
+      fireEvent.click(orcaItem);
+
+      await waitFor(() => {
+        expect(openInSlicer).toHaveBeenCalledWith(expect.any(String), 'orcaslicer');
+      });
+    });
+
+    it('shows only the non-preferred slicer in the dropdown for a desktop handoff', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+
+      await waitFor(() => {
+        expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('Open in Bambu Studio')).not.toBeInTheDocument();
+    });
+
+    it('offers Bambu Studio when the preferred desktop slicer is OrcaSlicer', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({ preferred_slicer: 'orcaslicer' });
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+
+      await waitFor(() => {
+        expect(screen.getByText('Open in Bambu Studio')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('Open in OrcaSlicer')).not.toBeInTheDocument();
+    });
+
+    it('closes the split dropdown on Escape without closing the modal', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+      await waitFor(() => {
+        expect(screen.getByRole('menu')).toBeInTheDocument();
+      });
+
+      fireEvent.keyDown(document, { key: 'Escape' });
+
+      expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+      expect(mockOnClose).not.toHaveBeenCalled();
+    });
+
+    it('closes the split dropdown on an outside click without closing the modal', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+      await waitFor(() => {
+        expect(screen.getByRole('menu')).toBeInTheDocument();
+      });
+
+      fireEvent.mouseDown(document.body);
+
+      expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+      expect(mockOnClose).not.toHaveBeenCalled();
+    });
+
+    it('does not render a split chevron when the file cannot open in a slicer', async () => {
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Model.gcode"
+          fileType="gcode"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeDisabled();
+      });
+
+      expect(screen.queryByRole('button', { name: 'More slicer options' })).not.toBeInTheDocument();
+    });
+
+    it('offers the desktop handoff for an STL library file', async () => {
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Model.stl"
+          fileType="stl"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeEnabled();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+
+      await waitFor(() => {
+        expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
+      });
+    });
+
+    it('offers the split Slice button for an STL when the slicer API is enabled', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({ use_slicer_api: true });
+        })
+      );
+
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Model.stl"
+          fileType="stl"
+          onClose={mockOnClose}
+          onSliceWithBambuddy={vi.fn()}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+
+      await waitFor(() => {
+        expect(screen.getByText('Open in Bambu Studio')).toBeInTheDocument();
+        expect(screen.getByText('Open in OrcaSlicer')).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();
+  });
+});

+ 99 - 9
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -623,16 +623,23 @@ describe('useWebSocket hook', () => {
 
   /**
    * #2754 (reporter @mic4rd): live updates froze whenever the tab wasn't in
-   * front, and caught up all at once on switching back. The cache writes ran
-   * inside requestAnimationFrame, and a hidden tab gets no rendering
-   * opportunities — so the browser holds queued frame callbacks indefinitely
-   * rather than merely throttling them.
+   * front, and caught up all at once on switching back.
    *
-   * The stub below is what makes these tests meaningful: it hands back a
-   * handle and never invokes the callback, which is what a real hidden tab
-   * does. `document.hidden` is set alongside it to name the scenario, but the
-   * production code doesn't branch on visibility — it simply no longer defers
-   * to a frame. Reintroduce a rAF wrapper on either path and these fail.
+   * Two causes, fixed in two rounds. First the cache writes ran inside a
+   * requestAnimationFrame, and a hidden tab gets no rendering opportunities —
+   * the browser holds queued frame callbacks indefinitely rather than merely
+   * throttling them. The rAF stub below is what makes those tests meaningful:
+   * it hands back a handle and never invokes the callback, which is what a
+   * real hidden tab does.
+   *
+   * Removing the frame callback did not close the report, because the 100ms
+   * coalescing timer was still in the path and a hidden page's timers are
+   * clamped to at best once a second — once a minute past five minutes hidden.
+   * So the writes must not depend on a timer either while hidden, which is
+   * what `writes without waiting on a timer` pins down. Note it deliberately
+   * never advances the clock: a test that advances fake timers cannot tell a
+   * throttled timer from a prompt one, which is exactly why the original tests
+   * kept passing while the reporter's tab stayed frozen.
    */
   describe('hidden tab (#2754)', () => {
     let rafSpy: ReturnType<typeof vi.fn>;
@@ -690,6 +697,49 @@ describe('useWebSocket hook', () => {
       expect(rafSpy).not.toHaveBeenCalled();
     });
 
+    it('writes without waiting on a timer', async () => {
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+
+      renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
+      const ws = await waitForWs();
+      act(() => ws.open());
+
+      act(() => {
+        ws.simulateMessage({
+          type: 'printer_status',
+          printer_id: 1,
+          data: { state: 'RUNNING', progress: 42 },
+        });
+      });
+
+      // No advanceTimersByTime: a hidden tab's timers are throttled to once a
+      // second at best, so anything the title depends on has to have landed
+      // already. Reintroduce the coalescing timer on this path and the cache
+      // is still empty here.
+      expect(queryClient.getQueryData(['printerStatus', 1])).toMatchObject({
+        state: 'RUNNING',
+        progress: 42,
+      });
+    });
+
+    it('applies the newest value when several arrive before a frame would have run', async () => {
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+
+      renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
+      const ws = await waitForWs();
+      act(() => ws.open());
+
+      act(() => {
+        ws.simulateMessage({ type: 'printer_status', printer_id: 1, data: { progress: 40 } });
+        ws.simulateMessage({ type: 'printer_status', printer_id: 1, data: { progress: 41 } });
+      });
+
+      // Writing through per message must not resurrect an earlier one: the
+      // pending map is drained on each flush, so a stale entry cannot be
+      // re-applied over the newer value.
+      expect(queryClient.getQueryData(['printerStatus', 1])).toMatchObject({ progress: 41 });
+    });
+
     it('drains queued messages instead of wedging the queue', async () => {
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -715,6 +765,46 @@ describe('useWebSocket hook', () => {
     });
   });
 
+  describe('visible tab still coalesces (#2754)', () => {
+    /**
+     * The counterpart to the hidden-tab block: the write-through is scoped to
+     * a hidden tab on purpose. A visible one is painting, and the 100ms window
+     * is what stops a burst of status messages turning into a render cascade —
+     * so "just always write through" is not the simplification it looks like.
+     */
+    it('defers the write while the tab is visible', async () => {
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+
+      const client = new QueryClient({
+        defaultOptions: { queries: { retry: false, gcTime: Infinity } },
+      });
+      vi.useFakeTimers();
+      try {
+        renderHook(() => useWebSocket(), { wrapper: createWrapper(client) });
+        const ws = await waitForWs();
+        act(() => ws.open());
+
+        act(() => {
+          ws.simulateMessage({
+            type: 'printer_status',
+            printer_id: 1,
+            data: { state: 'RUNNING', progress: 42 },
+          });
+        });
+
+        expect(client.getQueryData(['printerStatus', 1])).toBeUndefined();
+
+        await act(async () => {
+          vi.advanceTimersByTime(200);
+        });
+
+        expect(client.getQueryData(['printerStatus', 1])).toMatchObject({ progress: 42 });
+      } finally {
+        vi.useRealTimers();
+      }
+    });
+  });
+
   describe('sendMessage', () => {
     it('sends JSON message when connected', async () => {
       const { useWebSocket } = await import('../../hooks/useWebSocket');

+ 229 - 1
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -3,13 +3,30 @@
  */
 
 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
+import { screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { FileManagerPage } from '../../pages/FileManagerPage';
+import { openInSlicer } from '../../utils/slicer';
+import { setAuthToken } from '../../api/client';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 
+// Only the protocol-handler launch is stubbed — it would navigate the jsdom
+// window. Everything else in the module is a pure predicate, so keep the real
+// implementations: isSliceableFilename decides which rows even offer the
+// action these tests click.
+vi.mock('../../utils/slicer', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../../utils/slicer')>()),
+  openInSlicer: vi.fn(),
+}));
+
+vi.mock('../../components/SliceModal', () => ({
+  SliceModal: ({ source }: { source: { filename: string } }) => (
+    <div data-testid="slice-modal">{source.filename}</div>
+  ),
+}));
+
 // Mock data
 const mockFolders = [
   {
@@ -1155,4 +1172,215 @@ describe('FileManagerPage', () => {
       });
     });
   });
+
+  describe('slice action', () => {
+    beforeEach(() => {
+      vi.mocked(openInSlicer).mockClear();
+      server.use(
+        http.post('/api/v1/library/files/:id/slicer-token', () => HttpResponse.json({ token: 'test-token' })),
+      );
+    });
+
+    afterEach(() => {
+      // Permission tests set a token; clear it so it can't leak into the
+      // list-view tests that follow (mirrors FileManagerFolderDelete.test.tsx).
+      setAuthToken(null);
+    });
+
+    const openMenu = async (user: ReturnType<typeof userEvent.setup>, filename: string) => {
+      const card = screen.getByText(filename).closest('.group') as HTMLElement;
+      // Target the kebab (ellipsis) toggle specifically rather than the card's
+      // first button — a button added ahead of the kebab would otherwise
+      // break the menu-opening assumption.
+      const kebab = card.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
+      await user.click(kebab);
+      return card;
+    };
+
+    it('opens the desktop slicer when the slicer API is disabled', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      await user.click(within(card).getByText('Slice'));
+
+      await waitFor(() => {
+        expect(openInSlicer).toHaveBeenCalledWith(
+          expect.stringContaining('/library/files/2/dl/test-token/'),
+          'bambu_studio',
+        );
+      });
+    });
+
+    it('opens the in-app SliceModal when the slicer API is enabled', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      await user.click(within(card).getByText('Slice'));
+
+      expect(await screen.findByTestId('slice-modal')).toBeInTheDocument();
+      expect(openInSlicer).not.toHaveBeenCalled();
+    });
+
+    it('hides the slice item for already-sliced files', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'Benchy');
+      expect(within(card).queryByText('Slice')).not.toBeInTheDocument();
+    });
+
+    // Permission gating is the security-relevant half of the slice action: the
+    // in-app API path needs library:upload, and the desktop handoff mirrors the
+    // ownership check the slicer-token endpoint runs — library:read_all or
+    // library:read_own. The legacy library:read is deliberately not accepted;
+    // it satisfies neither the token endpoint nor the folder listing that gets
+    // a user to this page at all.
+    const mockAuthUser = (permissions: string[]) => {
+      setAuthToken('test-token', 'session');
+      server.use(
+        http.get('*/api/v1/auth/status', () =>
+          HttpResponse.json({ auth_enabled: true, requires_setup: false }),
+        ),
+        http.get('*/api/v1/auth/me', () =>
+          HttpResponse.json({
+            id: 7,
+            username: 'operator1',
+            is_admin: false,
+            permissions,
+          }),
+        ),
+        http.get('/api/v1/users/', () => HttpResponse.json([])),
+      );
+    };
+
+    it('disables the Slice menu item without library:upload when the slicer API is enabled', async () => {
+      mockAuthUser([]);
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      const sliceItem = within(card).getByText('Slice').closest('button');
+      expect(sliceItem).toBeDisabled();
+
+      await user.click(sliceItem!);
+      expect(openInSlicer).not.toHaveBeenCalled();
+    });
+
+    it('enables the Slice menu item with library:upload when the slicer API is enabled', async () => {
+      mockAuthUser(['library:upload']);
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      const sliceItem = within(card).getByText('Slice').closest('button');
+      expect(sliceItem).not.toBeDisabled();
+    });
+
+    it('disables the Slice menu item without any library read permission for the desktop handoff', async () => {
+      mockAuthUser(['library:upload']);
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      const sliceItem = within(card).getByText('Slice').closest('button');
+      expect(sliceItem).toBeDisabled();
+
+      await user.click(sliceItem!);
+      expect(openInSlicer).not.toHaveBeenCalled();
+    });
+
+    it('enables the Slice menu item with library:read_own for the desktop handoff', async () => {
+      mockAuthUser(['library:read_own']);
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      const sliceItem = within(card).getByText('Slice').closest('button');
+      expect(sliceItem).not.toBeDisabled();
+    });
+
+    it('does not accept the legacy library:read for the desktop handoff', async () => {
+      // require_ownership_permission(LIBRARY_READ_ALL, LIBRARY_READ_OWN) does no
+      // legacy expansion, so this group 403s on the slicer-token endpoint.
+      // Enabling the item would offer an action the server refuses, and the
+      // failure would look like "no slicer installed" once the fallback URL is
+      // handed over.
+      mockAuthUser(['library:read']);
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      const sliceItem = within(card).getByText('Slice').closest('button');
+      expect(sliceItem).toBeDisabled();
+
+      await user.click(sliceItem!);
+      expect(openInSlicer).not.toHaveBeenCalled();
+    });
+
+    it('slices from the list-view button when the slicer API is disabled', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      await user.click(screen.getByTitle('List view'));
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const row = screen.getByText('bracket.stl').closest('div[class*="cursor-pointer"]') as HTMLElement;
+      await user.click(within(row).getByTitle('Slice'));
+
+      await waitFor(() => {
+        expect(openInSlicer).toHaveBeenCalledWith(
+          expect.stringContaining('/library/files/2/dl/test-token/'),
+          'bambu_studio',
+        );
+      });
+    });
+
+    it('slices from the list-view button into the in-app modal when the slicer API is enabled', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      await user.click(screen.getByTitle('List view'));
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const row = screen.getByText('bracket.stl').closest('div[class*="cursor-pointer"]') as HTMLElement;
+      await user.click(within(row).getByTitle('Slice'));
+
+      expect(await screen.findByTestId('slice-modal')).toBeInTheDocument();
+      expect(openInSlicer).not.toHaveBeenCalled();
+    });
+  });
 });

+ 44 - 0
frontend/src/__tests__/pages/MakerworldPage.test.tsx

@@ -61,6 +61,8 @@ function resolveResponse(overrides: Partial<Record<string, unknown>> = {}) {
 // Individual tests layer extra handlers on top via ``server.use``.
 function useAuthedHandlers(opts: {
   slicer?: 'bambu_studio' | 'orcaslicer';
+  openInSlicer?: 'bambu_studio' | 'orcaslicer' | null;
+  useSlicerApi?: boolean;
   recent?: Array<Record<string, unknown>>;
 } = {}) {
   const slicer = opts.slicer ?? 'bambu_studio';
@@ -74,6 +76,8 @@ function useAuthedHandlers(opts: {
         auto_archive: true,
         save_thumbnails: true,
         preferred_slicer: slicer,
+        ...(opts.openInSlicer !== undefined ? { open_in_slicer: opts.openInSlicer } : {}),
+        ...(opts.useSlicerApi !== undefined ? { use_slicer_api: opts.useSlicerApi } : {}),
       }),
     ),
   );
@@ -190,6 +194,46 @@ describe('MakerworldPage', () => {
     expect(sliceButtons.length).toBe(2);
   });
 
+  it('prefers the open_in_slicer override over preferred_slicer for the desktop handoff', async () => {
+    // The URI-handoff label resolves via resolveDesktopSlicer: open_in_slicer
+    // beats preferred_slicer (#1329) when the slicer API is off.
+    useAuthedHandlers({ slicer: 'bambu_studio', openInSlicer: 'orcaslicer' });
+    server.use(
+      http.post('*/makerworld/resolve', () => HttpResponse.json(resolveResponse())),
+    );
+    render(<MakerworldPage />);
+    await userEvent.type(
+      await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
+      'https://makerworld.com/en/models/1400373',
+    );
+    await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
+
+    const sliceButtons = await screen.findAllByRole('button', {
+      name: /Save & Slice in OrcaSlicer/,
+    });
+    expect(sliceButtons.length).toBe(2);
+  });
+
+  it('keeps preferred_slicer for the in-app API slice label, ignoring open_in_slicer', async () => {
+    // With the slicer API on, the sidecar drives the button, so the
+    // open_in_slicer desktop override must not leak into the label.
+    useAuthedHandlers({ slicer: 'bambu_studio', openInSlicer: 'orcaslicer', useSlicerApi: true });
+    server.use(
+      http.post('*/makerworld/resolve', () => HttpResponse.json(resolveResponse())),
+    );
+    render(<MakerworldPage />);
+    await userEvent.type(
+      await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
+      'https://makerworld.com/en/models/1400373',
+    );
+    await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
+
+    const sliceButtons = await screen.findAllByRole('button', {
+      name: /Save & Slice in Bambu Studio/,
+    });
+    expect(sliceButtons.length).toBe(2);
+  });
+
   it('clears the resolved preview when the URL input is edited after resolve', async () => {
     useAuthedHandlers();
     server.use(

+ 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
@@ -5277,6 +5349,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>
+  );
+}

+ 140 - 21
frontend/src/components/ModelViewerModal.tsx

@@ -1,12 +1,13 @@
-import { useState, useEffect, useRef, useMemo } from 'react';
+import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQuery } from '@tanstack/react-query';
-import { X, ExternalLink, Box, Code2, Cog, Loader2, Layers, Check, Maximize2, Minimize2 } from 'lucide-react';
+import { X, ExternalLink, Box, Code2, Cog, Loader2, Layers, Check, Maximize2, Minimize2, ChevronDown } from 'lucide-react';
 import { ModelViewer } from './ModelViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { Button } from './Button';
 import { api, withStreamToken } from '../api/client';
-import { openInSlicer, type SlicerType } from '../utils/slicer';
+import { useToast } from '../contexts/ToastContext';
+import { isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 
 type ViewTab = '3d' | 'gcode';
@@ -32,14 +33,105 @@ interface Capabilities {
   filament_colors: string[];
 }
 
+interface SlicerSplitButtonProps {
+  icon: ReactNode;
+  label: string;
+  dropdownLabel: string;
+  onPrimary: () => void;
+  items: Array<{ key: string; label: string; onClick: () => void }>;
+}
+
+// Split button: the primary part runs the default slicer action, the chevron
+// opens a dropdown with the other slicer options. Outside click or Escape
+// (non-propagating) closes the dropdown. The split only renders when the
+// action is already possible, so there is no disabled state to express.
+function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, items }: SlicerSplitButtonProps) {
+  const [open, setOpen] = useState(false);
+  const containerRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (!open) return;
+    const handlePointerDown = (e: MouseEvent) => {
+      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
+        setOpen(false);
+      }
+    };
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        e.stopPropagation();
+        setOpen(false);
+      }
+    };
+    document.addEventListener('mousedown', handlePointerDown);
+    document.addEventListener('keydown', handleKeyDown);
+    return () => {
+      document.removeEventListener('mousedown', handlePointerDown);
+      document.removeEventListener('keydown', handleKeyDown);
+    };
+  }, [open]);
+
+  return (
+    <div className="relative inline-flex" ref={containerRef}>
+      <div className="flex relative z-50">
+        <Button
+          variant="secondary"
+          size="sm"
+          onClick={() => {
+            setOpen(false);
+            onPrimary();
+          }}
+          className="rounded-r-none"
+        >
+          {icon}
+          {label}
+        </Button>
+        <Button
+          variant="secondary"
+          size="sm"
+          onClick={() => setOpen((prev) => !prev)}
+          aria-label={dropdownLabel}
+          aria-haspopup="menu"
+          aria-expanded={open}
+          className="rounded-l-none border-l border-bambu-dark px-2"
+        >
+          <ChevronDown className={`w-4 h-4 transition-transform ${open ? 'rotate-180' : ''}`} />
+        </Button>
+      </div>
+      {open && (
+        <div
+          role="menu"
+          className="absolute right-0 top-full mt-1 w-56 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-50 py-1"
+        >
+          {items.map((item) => (
+            <button
+              key={item.key}
+              type="button"
+              role="menuitem"
+              onClick={() => {
+                setOpen(false);
+                item.onClick();
+              }}
+              className="w-full text-left px-3 py-2 text-sm text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white transition-colors flex items-center gap-2"
+            >
+              <ExternalLink className="w-4 h-4 flex-shrink-0" />
+              {item.label}
+            </button>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}
+
 export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose, onSliceWithBambuddy }: ModelViewerModalProps) {
   const { t } = useTranslation();
+  const { showToast } = useToast();
   const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
   // Desktop "Open in Slicer" target — falls back to preferred_slicer when the
   // user hasn't explicitly chosen a different desktop slicer (#1329). This
   // variable is only used for URI-handoff; sidecar slicing keeps using
   // preferred_slicer directly.
-  const preferredSlicer: SlicerType = settings?.open_in_slicer || settings?.preferred_slicer || 'bambu_studio';
+  const preferredSlicer: SlicerType = resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer);
   const isLibrary = libraryFileId != null;
   const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
   const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
@@ -280,7 +372,13 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
     };
   }, [isDraggingDivider, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);
 
-  const canOpenInSlicer = isLibrary ? (fileType || '').toLowerCase() === '3mf' : true;
+  // Which file types can be handed to a desktop slicer via the URL protocol
+  // handler — and sliced in-app via the sidecar. Shares its list with
+  // `isSliceableFilename()`, which the File Manager's card menu and list row
+  // use, so a file's "Slice" action and its 3D-preview slicer button can no
+  // longer disagree about the same file.
+  const slicerReadyType = isSliceableFileType(fileType);
+  const canOpenInSlicer = isLibrary ? slicerReadyType : true;
 
   // When the user has the in-app Slicer API enabled (Settings → Workflow →
   // Slicer → Use Slicer API), library-mode previews route the header's slicer
@@ -288,39 +386,49 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
   // in the file-row actions. Falls back to the external-slicer launcher when
   // the API is off, when no in-app handler is wired (e.g. archive preview),
   // or when the file type can't be sliced (.gcode / .gcode.3mf, etc.).
-  const sliceableType = (() => {
-    const t = (fileType || '').toLowerCase();
-    return t === '3mf' || t === 'stl' || t === 'step' || t === 'stp';
-  })();
   const useBambuddySlicer = Boolean(
-    isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && sliceableType,
+    isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && slicerReadyType,
   );
 
-  const handleOpenInSlicer = async () => {
+  const handleOpenInSlicer = async (slicer: SlicerType) => {
     if (!canOpenInSlicer) return;
     const filename = title || 'model';
     try {
       if (isLibrary) {
         const { token } = await api.createLibrarySlicerToken(libraryFileId!);
         const path = api.getLibrarySlicerDownloadUrl(libraryFileId!, token, filename);
-        openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
+        openInSlicer(`${window.location.origin}${path}`, slicer);
       } else {
         const { token } = await api.createArchiveSlicerToken(archiveId!);
         const path = api.getArchiveSlicerDownloadUrl(archiveId!, token, filename);
-        openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
+        openInSlicer(`${window.location.origin}${path}`, slicer);
       }
     } catch {
-      // Fallback to direct URL (works when auth is disabled)
+      // Fallback to direct URL (works when auth is disabled). With auth on the
+      // slicer may then hit a 401, so surface the failure instead of making a
+      // permission denial look identical to "no slicer installed".
+      showToast(t('modelViewer.openInSlicerFailed'), 'error');
       if (isLibrary) {
         const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
-        openInSlicer(downloadUrl, preferredSlicer);
+        openInSlicer(downloadUrl, slicer);
       } else {
         const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
-        openInSlicer(downloadUrl, preferredSlicer);
+        openInSlicer(downloadUrl, slicer);
       }
     }
   };
 
+  const slicerDropdownTypes: SlicerType[] = useBambuddySlicer
+    ? ['bambu_studio', 'orcaslicer']
+    : [preferredSlicer === 'orcaslicer' ? 'bambu_studio' : 'orcaslicer'];
+  const slicerName = (slicer: SlicerType) =>
+    slicer === 'orcaslicer' ? t('settings.slicerOrcaSlicer') : t('settings.slicerBambuStudio');
+  const slicerDropdownItems = slicerDropdownTypes.map((slicer) => ({
+    key: slicer,
+    label: t('modelViewer.openInSlicerWith', { slicer: slicerName(slicer) }),
+    onClick: () => handleOpenInSlicer(slicer),
+  }));
+
   return (
     <div
       className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-8'}`}
@@ -344,12 +452,23 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
           </div>
           <div className="flex items-center gap-2">
             {useBambuddySlicer ? (
-              <Button variant="secondary" size="sm" onClick={onSliceWithBambuddy}>
-                <Cog className="w-4 h-4" />
-                {t('slice.action')}
-              </Button>
+              <SlicerSplitButton
+                icon={<Cog className="w-4 h-4" />}
+                label={t('slice.action')}
+                dropdownLabel={t('modelViewer.moreSlicerOptions')}
+                onPrimary={() => onSliceWithBambuddy?.()}
+                items={slicerDropdownItems}
+              />
+            ) : canOpenInSlicer ? (
+              <SlicerSplitButton
+                icon={<ExternalLink className="w-4 h-4" />}
+                label={t('modelViewer.openInSlicer')}
+                dropdownLabel={t('modelViewer.moreSlicerOptions')}
+                onPrimary={() => handleOpenInSlicer(preferredSlicer)}
+                items={slicerDropdownItems}
+              />
             ) : (
-              <Button variant="secondary" size="sm" onClick={handleOpenInSlicer} disabled={!canOpenInSlicer}>
+              <Button variant="secondary" size="sm" disabled>
                 <ExternalLink className="w-4 h-4" />
                 {t('modelViewer.openInSlicer')}
               </Button>

+ 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>
+  );
+}

+ 54 - 30
frontend/src/hooks/useWebSocket.ts

@@ -194,45 +194,69 @@ export function useWebSocket() {
     wsRef.current = ws;
   }, [processMessageQueue]);
 
-  // Throttled printer status update - coalesces rapid updates per printer.
+  // Write every pending printer status into the query cache.
   //
-  // #2754: these cache writes used to happen inside a requestAnimationFrame.
-  // A hidden tab gets no rendering opportunities, so the browser *holds*
-  // queued frame callbacks rather than throttling them — every status update
-  // parked in a pending frame and nothing reached the query cache until the
-  // tab was shown again, at which point they all ran at once. That froze the
-  // tab-title progress (usePrintProgressTitle reads this key and nothing
-  // else) and stalled every other live view. The 100ms coalescing below is
-  // what prevented the original render cascade; the frame callback only ever
-  // deferred the write by a frame, so it is gone.
+  // Extracted so the hidden-tab path below can run it inline: both paths share
+  // this one body, so the merge semantics cannot drift apart. Cancels any
+  // scheduled coalescing timer, since everything it was going to write has
+  // just been written and re-running it would re-apply stale data over newer.
+  const flushPrinterStatus = useCallback(() => {
+    if (printerStatusTimeoutRef.current) {
+      clearTimeout(printerStatusTimeoutRef.current);
+      printerStatusTimeoutRef.current = null;
+    }
+
+    const updates = new Map(pendingPrinterStatus.current);
+    pendingPrinterStatus.current.clear();
+
+    updates.forEach((statusData, id) => {
+      queryClient.setQueryData(['printerStatus', id], (old: Record<string, unknown> | undefined) => {
+        const merged = { ...old, ...statusData };
+        if (merged.wifi_signal == null && old?.wifi_signal != null) {
+          merged.wifi_signal = old.wifi_signal;
+        }
+        return merged;
+      });
+    });
+  }, [queryClient]);
+
+  // Printer status update — coalesced while the tab is visible, written
+  // straight through while it is not.
+  //
+  // #2754 (reporter @mic4rd), in two stages. First, these writes ran inside a
+  // requestAnimationFrame: a hidden tab gets no rendering opportunities, so
+  // the browser *holds* queued frame callbacks rather than throttling them,
+  // and nothing reached the cache until the tab was shown again. Removing the
+  // frame callback fixed that total stall but not the report, because a second
+  // timer-shaped dependency was left behind — this 100ms coalescing window.
+  //
+  // Browsers clamp timers in a hidden page to at best once a second, and drop
+  // pages hidden for more than five minutes to roughly one wake-up a minute.
+  // The reporter saw a tab title stuck at 2% beside a page at 40%.
+  //
+  // The coalescing exists to stop rapid messages triggering a render cascade.
+  // A hidden tab is not painting, so there is no cascade to prevent there —
+  // the timer is pure cost, and it is exactly the thing being throttled. So
+  // when hidden, skip it and write immediately.
+  //
+  // Note "hidden", not "unfocused": on Windows a fully-occluded window reports
+  // visibilityState 'hidden' too, which is why the reporter saw this from
+  // merely clicking away rather than only from switching tabs.
   const throttledPrinterStatusUpdate = useCallback((printerId: number, data: Record<string, unknown>) => {
     // Merge with any pending data for this printer
     const existing = pendingPrinterStatus.current.get(printerId) || {};
     pendingPrinterStatus.current.set(printerId, { ...existing, ...data });
 
+    if (document.hidden) {
+      flushPrinterStatus();
+      return;
+    }
+
     // Schedule update if not already scheduled
     if (!printerStatusTimeoutRef.current) {
-      printerStatusTimeoutRef.current = window.setTimeout(() => {
-        const updates = new Map(pendingPrinterStatus.current);
-        pendingPrinterStatus.current.clear();
-        printerStatusTimeoutRef.current = null;
-
-        // Apply all pending updates
-        updates.forEach((statusData, id) => {
-          queryClient.setQueryData(
-            ['printerStatus', id],
-            (old: Record<string, unknown> | undefined) => {
-              const merged = { ...old, ...statusData };
-              if (merged.wifi_signal == null && old?.wifi_signal != null) {
-                merged.wifi_signal = old.wifi_signal;
-              }
-              return merged;
-            }
-          );
-        });
-      }, 100); // Update at most every 100ms
+      printerStatusTimeoutRef.current = window.setTimeout(flushPrinterStatus, 100);
     }
-  }, [queryClient]);
+  }, [flushPrinterStatus]);
 
   // Debounced invalidation helper - coalesces multiple rapid invalidations
   const debouncedInvalidate = useCallback((queryKey: string) => {

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

@@ -3820,6 +3820,7 @@ export default {
     scanFolder: 'Scannen',
     toast: {
       folderCreated: 'Ordner erstellt',
+      openInSlicerFailed: 'Konnte nicht im Slicer öffnen',
       folderDeleted: 'Ordner gelöscht',
       fileDeleted: 'Datei gelöscht',
       filesDeleted: '{{count}} Dateien gelöscht',
@@ -5426,6 +5427,9 @@ export default {
   // Model Viewer
   modelViewer: {
     openInSlicer: 'Im Slicer öffnen',
+    openInSlicerWith: 'In {{slicer}} öffnen',
+    moreSlicerOptions: 'Weitere Slicer-Optionen',
+    openInSlicerFailed: 'Konnte nicht im Slicer öffnen',
     tabs: {
       model: '3D-Modell',
       gcode: 'G-Code Vorschau',
@@ -5476,6 +5480,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',
@@ -5788,6 +5847,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',

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

@@ -3849,6 +3849,7 @@ export default {
     scanFolder: 'Scan',
     toast: {
       folderCreated: 'Folder created',
+      openInSlicerFailed: 'Could not open in slicer',
       folderDeleted: 'Folder deleted',
       fileDeleted: 'File deleted',
       filesDeleted: 'Deleted {{count}} files',
@@ -5475,6 +5476,9 @@ export default {
   // Model Viewer
   modelViewer: {
     openInSlicer: 'Open in Slicer',
+    openInSlicerWith: 'Open in {{slicer}}',
+    moreSlicerOptions: 'More slicer options',
+    openInSlicerFailed: 'Could not open in slicer',
     tabs: {
       model: '3D Model',
       gcode: 'G-code Preview',
@@ -5525,6 +5529,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',
@@ -5837,6 +5896,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',

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

@@ -3823,6 +3823,7 @@ export default {
     scanFolder: 'Escanear',
     toast: {
       folderCreated: 'Carpeta creada',
+      openInSlicerFailed: 'No se pudo abrir en el laminador',
       folderDeleted: 'Carpeta eliminada',
       fileDeleted: 'Archivo eliminado',
       filesDeleted: 'Se eliminaron {{count}} archivos',
@@ -5435,6 +5436,9 @@ export default {
   // Model Viewer
   modelViewer: {
     openInSlicer: 'Abrir en el laminador',
+    openInSlicerWith: 'Abrir en {{slicer}}',
+    moreSlicerOptions: 'Más opciones de laminador',
+    openInSlicerFailed: 'No se pudo abrir en el laminador',
     tabs: {
       model: 'Modelo 3D',
       gcode: 'Vista previa de G-code',
@@ -5485,6 +5489,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',
@@ -5797,6 +5856,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',

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

@@ -3809,6 +3809,7 @@ export default {
     scanFolder: 'Scanner',
     toast: {
       folderCreated: 'Dossier créé',
+      openInSlicerFailed: "Impossible d'ouvrir dans le slicer",
       folderDeleted: 'Dossier supprimé',
       fileDeleted: 'Fichier supprimé',
       filesDeleted: '{{count}} fichiers supprimés',
@@ -5416,6 +5417,9 @@ export default {
   // Model Viewer
   modelViewer: {
     openInSlicer: 'Ouvrir dans le Slicer',
+    openInSlicerWith: 'Ouvrir dans {{slicer}}',
+    moreSlicerOptions: "Plus d'options de slicer",
+    openInSlicerFailed: "Impossible d'ouvrir dans le slicer",
     tabs: {
       model: 'Modèle 3D',
       gcode: 'Aperçu G-code',
@@ -5466,6 +5470,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',
@@ -5778,6 +5837,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',

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

@@ -3808,6 +3808,7 @@ export default {
     scanFolder: 'Scansiona',
     toast: {
       folderCreated: 'Cartella creata',
+      openInSlicerFailed: 'Impossibile aprire nello slicer',
       folderDeleted: 'Cartella eliminata',
       fileDeleted: 'File eliminato',
       filesDeleted: 'Eliminati {{count}} file',
@@ -5415,6 +5416,9 @@ export default {
   // Model Viewer
   modelViewer: {
     openInSlicer: 'Apri nello slicer',
+    openInSlicerWith: 'Apri in {{slicer}}',
+    moreSlicerOptions: 'Altre opzioni dello slicer',
+    openInSlicerFailed: 'Impossibile aprire nello slicer',
     tabs: {
       model: 'Modello 3D',
       gcode: 'Anteprima G-code',
@@ -5465,6 +5469,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',
@@ -5777,6 +5836,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',

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

@@ -3820,6 +3820,7 @@ export default {
     scanFolder: 'スキャン',
     toast: {
       folderCreated: 'フォルダを作成しました',
+      openInSlicerFailed: 'スライサーで開けませんでした',
       folderDeleted: 'フォルダを削除しました',
       fileDeleted: 'ファイルを削除しました',
       filesDeleted: '{{count}}件のファイルを削除しました',
@@ -5427,6 +5428,9 @@ export default {
   // Model Viewer
   modelViewer: {
     openInSlicer: 'スライサーで開く',
+    openInSlicerWith: '{{slicer}}で開く',
+    moreSlicerOptions: 'その他のスライサーオプション',
+    openInSlicerFailed: 'スライサーで開けませんでした',
     tabs: {
       model: '3Dモデル',
       gcode: 'G-codeプレビュー',
@@ -5477,6 +5481,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: '管理',
@@ -5789,6 +5848,8 @@ export default {
     notificationEvents: '通知イベント',
     progressPercent: '(25%、50%、75%)',
     bedCooledAfterPrint: '(印刷完了後)',
+    haSensorAlert: 'センサーアラート',
+    haSensorAlertDescription: '(連携した Home Assistant センサーに注意が必要です)',
     // Per-event ntfy priority (#990)
     eventPriority: {
       sectionTitle: 'ntfy 優先度',

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

@@ -3631,6 +3631,7 @@ export default {
     scanFolder: '스캔',
     toast: {
       folderCreated: '폴더 생성됨',
+      openInSlicerFailed: '슬라이서에서 열 수 없습니다',
       folderDeleted: '폴더 삭제됨',
       fileDeleted: '파일 삭제됨',
       filesDeleted: '{{count}}개 파일 삭제됨',
@@ -5162,6 +5163,9 @@ export default {
   },
   modelViewer: {
     openInSlicer: '슬라이서에서 열기',
+    openInSlicerWith: '{{slicer}}에서 열기',
+    moreSlicerOptions: '슬라이서 옵션 더 보기',
+    openInSlicerFailed: '슬라이서에서 열 수 없습니다',
     tabs: {
       model: '3D 모델',
       gcode: 'G-code 미리보기'
@@ -5208,6 +5212,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: '관리자',
@@ -5503,6 +5562,8 @@ export default {
     notificationEvents: '알림 이벤트',
     progressPercent: '(25%, 50%, 75%)',
     bedCooledAfterPrint: '(인쇄 완료 후)',
+    haSensorAlert: '센서 경고',
+    haSensorAlertDescription: '(연결된 Home Assistant 센서에 주의가 필요합니다)',
     eventPriority: {
       sectionTitle: 'ntfy 우선순위',
       helpNtfy: '각 활성화된 이벤트에 대한 우선순위를 선택하세요. ntfy는 이를 사용하여 알림을 에스컬레이션합니다(소리, 가시성, 푸시 동작). 여기서 설정되지 않은 수준은 ntfy 서버 기본값을 사용합니다.',

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

@@ -3808,6 +3808,7 @@ export default {
     scanFolder: 'Escanear',
     toast: {
       folderCreated: 'Pasta criada',
+      openInSlicerFailed: 'Não foi possível abrir no fatiador',
       folderDeleted: 'Pasta excluída',
       fileDeleted: 'Arquivo excluído',
       filesDeleted: 'Excluídos {{count}} arquivos',
@@ -5415,6 +5416,9 @@ export default {
   // Model Viewer
   modelViewer: {
     openInSlicer: 'Abrir no Slicer',
+    openInSlicerWith: 'Abrir em {{slicer}}',
+    moreSlicerOptions: 'Mais opções de fatiador',
+    openInSlicerFailed: 'Não foi possível abrir no fatiador',
     tabs: {
       model: 'Modelo 3D',
       gcode: 'Pré-visualização G-code',
@@ -5465,6 +5469,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',
@@ -5777,6 +5836,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',

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

@@ -3623,6 +3623,7 @@ export default {
     scanFolder: "Сканировать",
     toast: {
       folderCreated: "Папка создана",
+      openInSlicerFailed: "Не удалось открыть в слайсере",
       folderDeleted: "Папка удалена",
       fileDeleted: "Файл удалён",
       filesDeleted: "Удалено файлов: {{count}}",
@@ -5150,6 +5151,9 @@ export default {
   },
   modelViewer: {
     openInSlicer: "Открыть в слайсере",
+    openInSlicerWith: "Открыть в {{slicer}}",
+    moreSlicerOptions: "Другие варианты слайсера",
+    openInSlicerFailed: "Не удалось открыть в слайсере",
     tabs: {
       model: "3D-модель",
       gcode: "Предпросмотр G-code",
@@ -5196,6 +5200,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: "Управление",
@@ -5490,6 +5549,8 @@ export default {
     notificationEvents: "События для уведомлений",
     progressPercent: "(25%, 50%, 75%)",
     bedCooledAfterPrint: "(после завершения печати)",
+    haSensorAlert: "Оповещение датчика",
+    haSensorAlertDescription: "(связанный датчик Home Assistant требует внимания)",
     eventPriority: {
       sectionTitle: "Приоритет ntfy",
       helpNtfy: "Выберите приоритет для каждого включённого события. ntfy использует его для усиления оповещений: звука, видимости и поведения push-уведомлений. Для неуказанных событий используется приоритет по умолчанию сервера ntfy.",

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

@@ -3816,6 +3816,7 @@ export default {
     scanFolder: 'Tara',
     toast: {
       folderCreated: 'Klasör oluşturuldu',
+      openInSlicerFailed: 'Dilimleyicide açılamadı',
       folderDeleted: 'Klasör silindi',
       fileDeleted: 'Dosya silindi',
       filesDeleted: '{{count}} dosya silindi',
@@ -5391,6 +5392,9 @@ export default {
   // Model Görüntüleyici
   modelViewer: {
     openInSlicer: 'Dilimleyicide Aç',
+    openInSlicerWith: '{{slicer}} ile aç',
+    moreSlicerOptions: 'Diğer dilimleyici seçenekleri',
+    openInSlicerFailed: 'Dilimleyicide açılamadı',
     tabs: {
       model: '3B Model',
       gcode: 'G-kod Önizleme',
@@ -5441,6 +5445,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',
@@ -5739,6 +5798,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.',

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

@@ -3849,6 +3849,7 @@ export default {
     scanFolder: "Сканувати",
     toast: {
       folderCreated: "Папка створена",
+      openInSlicerFailed: "Не вдалося відкрити у слайсері",
       folderDeleted: "Папку видалено",
       fileDeleted: "Файл видалено",
       filesDeleted: "Видалені файли {{count}}.",
@@ -5470,6 +5471,9 @@ export default {
   // Model Viewer
   modelViewer: {
     openInSlicer: "Відкрити у слайсері",
+    openInSlicerWith: "Відкрити у {{slicer}}",
+    moreSlicerOptions: "Більше варіантів слайсера",
+    openInSlicerFailed: "Не вдалося відкрити у слайсері",
     tabs: {
       model: "3D-модель",
       gcode: "Попередній перегляд G-коду",
@@ -5520,6 +5524,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: "Адміністрування",
@@ -5832,6 +5891,8 @@ export default {
     notificationEvents: "Події сповіщень",
     progressPercent: "(25%, 50%, 75%)",
     bedCooledAfterPrint: "(після завершення друку)",
+    haSensorAlert: "Сповіщення датчика",
+    haSensorAlertDescription: "(пов'язаний датчик Home Assistant потребує уваги)",
     // Per-event ntfy priority (#990)
     eventPriority: {
       sectionTitle: "Пріоритет ntfy",

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

@@ -3808,6 +3808,7 @@ export default {
     scanFolder: '扫描',
     toast: {
       folderCreated: '文件夹已创建',
+      openInSlicerFailed: '无法在切片软件中打开',
       folderDeleted: '文件夹已删除',
       fileDeleted: '文件已删除',
       filesDeleted: '已删除 {{count}} 个文件',
@@ -5415,6 +5416,9 @@ export default {
   // Model Viewer
   modelViewer: {
     openInSlicer: '在切片软件中打开',
+    openInSlicerWith: '用{{slicer}}打开',
+    moreSlicerOptions: '更多切片软件选项',
+    openInSlicerFailed: '无法在切片软件中打开',
     tabs: {
       model: '3D 模型',
       gcode: 'G-code 预览',
@@ -5465,6 +5469,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: '管理',
@@ -5777,6 +5836,8 @@ export default {
     notificationEvents: '通知事件',
     progressPercent: '(25%、50%、75%)',
     bedCooledAfterPrint: '(打印完成后)',
+    haSensorAlert: '传感器警报',
+    haSensorAlertDescription: '(已绑定的 Home Assistant 传感器需要关注)',
     // Per-event ntfy priority (#990)
     eventPriority: {
       sectionTitle: 'ntfy 优先级',

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

@@ -3808,6 +3808,7 @@ export default {
     scanFolder: '掃描',
     toast: {
       folderCreated: '資料夾已建立',
+      openInSlicerFailed: '無法在切片軟體中開啟',
       folderDeleted: '資料夾已刪除',
       fileDeleted: '檔案已刪除',
       filesDeleted: '已刪除 {{count}} 個檔案',
@@ -5415,6 +5416,9 @@ export default {
   // Model Viewer
   modelViewer: {
     openInSlicer: '在切片軟體中開啟',
+    openInSlicerWith: '用{{slicer}}開啟',
+    moreSlicerOptions: '更多切片軟體選項',
+    openInSlicerFailed: '無法在切片軟體中開啟',
     tabs: {
       model: '3D 模型',
       gcode: 'G-code 預覽',
@@ -5465,6 +5469,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: '管理',
@@ -5777,6 +5836,8 @@ export default {
     notificationEvents: '通知事件',
     progressPercent: '(25%、50%、75%)',
     bedCooledAfterPrint: '(列印完成後)',
+    haSensorAlert: '感測器警報',
+    haSensorAlertDescription: '(已綁定的 Home Assistant 感測器需要注意)',
     // Per-event ntfy priority (#990)
     eventPriority: {
       sectionTitle: 'ntfy 優先級',

+ 2 - 2
frontend/src/pages/ArchivesPage.tsx

@@ -64,7 +64,7 @@ import {
 import { api } from '../api/client';
 import { SliceModal } from '../components/SliceModal';
 import { RunWithPipelineModal } from '../components/RunWithPipelineModal';
-import { openInSlicer, type SlicerType } from '../utils/slicer';
+import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date';
 import { getCurrencySymbol } from '../utils/currency';
 import { getBedTypeInfo } from '../utils/bedType';
@@ -2956,7 +2956,7 @@ export function ArchivesPage() {
   // user hasn't explicitly chosen a different desktop slicer (#1329). This is
   // ONLY the URI-handoff target; the in-app SliceModal still uses
   // preferred_slicer for the sidecar.
-  const preferredSlicer: SlicerType = settings?.open_in_slicer || settings?.preferred_slicer || 'bambu_studio';
+  const preferredSlicer: SlicerType = resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer);
   const useSlicerApi = settings?.use_slicer_api ?? false;
   const currency = getCurrencySymbol(settings?.currency || 'USD');
 

+ 66 - 21
frontend/src/pages/FileManagerPage.tsx

@@ -9,6 +9,7 @@ import {
   Upload,
   Trash2,
   Download,
+  ExternalLink,
   MoreVertical,
   ChevronRight,
   FolderPlus,
@@ -73,6 +74,7 @@ import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import { useAuth } from '../contexts/AuthContext';
 import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
 import { formatFileSize } from '../utils/file';
+import { isSliceableFilename, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
@@ -741,14 +743,6 @@ function isSlicedFilename(filename: string): boolean {
   return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf');
 }
 
-// Files that can be fed to the slicer sidecar (model geometry inputs).
-// Excludes .gcode.* (already sliced) and any other non-model formats.
-function isSliceableFilename(filename: string): boolean {
-  const lower = filename.toLowerCase();
-  if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false;
-  return lower.endsWith('.stl') || lower.endsWith('.3mf') || lower.endsWith('.step') || lower.endsWith('.stp');
-}
-
 // File Card
 interface FileCardProps {
   file: LibraryFileListItem;
@@ -759,8 +753,10 @@ interface FileCardProps {
   onDownload: (id: number) => void;
   onPrint?: (file: LibraryFileListItem) => void;
   onSlice?: (file: LibraryFileListItem) => void;
+  onOpenInSlicer?: (file: LibraryFileListItem) => void;
   onRunPipeline?: (file: LibraryFileListItem) => void;
   useSlicerApi?: boolean;
+  canSlice?: boolean;
   onPreview3d?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
@@ -773,7 +769,7 @@ interface FileCardProps {
   t: TFunction;
 }
 
-function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onRunPipeline, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
+function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, canSlice, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
 
   return (
@@ -899,16 +895,21 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
                   {t('common.print')}
                 </button>
               )}
-              {onSlice && useSlicerApi && isSliceableFilename(file.filename) && (
+              {isSliceableFilename(file.filename) && (useSlicerApi ? onSlice : onOpenInSlicer) && (
                 <button
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                    hasPermission('library:upload') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                    canSlice ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
                   }`}
-                  onClick={() => { if (hasPermission('library:upload')) { onSlice(file); setShowActions(false); } }}
-                  disabled={!hasPermission('library:upload')}
-                  title={!hasPermission('library:upload') ? t('fileManager.noPermissionSlice') : undefined}
+                  onClick={() => {
+                    if (!canSlice) return;
+                    if (useSlicerApi) onSlice?.(file);
+                    else onOpenInSlicer?.(file);
+                    setShowActions(false);
+                  }}
+                  disabled={!canSlice}
+                  title={!canSlice ? (useSlicerApi ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload')) : undefined}
                 >
-                  <Cog className="w-3.5 h-3.5" />
+                  {useSlicerApi ? <Cog className="w-3.5 h-3.5" /> : <ExternalLink className="w-3.5 h-3.5" />}
                   {t('slice.action')}
                 </button>
               )}
@@ -1145,6 +1146,45 @@ export function FileManagerPage() {
     queryKey: ['settings'],
     queryFn: () => api.getSettings() as Promise<AppSettings>,
   });
+
+  const preferredSlicer: SlicerType = resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer);
+
+  const handleOpenInSlicer = useCallback(async (file: LibraryFileListItem) => {
+    try {
+      const { token } = await api.createLibrarySlicerToken(file.id);
+      const path = api.getLibrarySlicerDownloadUrl(file.id, token, file.filename);
+      openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
+    } catch {
+      // Fallback to direct URL (works when auth is disabled). With auth on the
+      // slicer may then hit a 401, so surface the failure instead of making a
+      // permission denial look identical to "no slicer installed".
+      showToast(t('fileManager.toast.openInSlicerFailed'), 'error');
+      const path = api.getLibraryFileDownloadUrl(file.id);
+      openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
+    }
+  }, [preferredSlicer, showToast, t]);
+
+  // Slice permission: API mode needs upload rights, the desktop handoff is a
+  // download. Each mirrors what the backend enforces on the endpoint that
+  // branch actually calls, so the UI never offers an action the server refuses.
+  //
+  // Deliberately NOT accepting the legacy `library:read` on the handoff branch.
+  // It looks like the safe back-compat term to include, but the slicer-token
+  // endpoint gates on require_ownership_permission(LIBRARY_READ_ALL,
+  // LIBRARY_READ_OWN), and neither that dependency nor User.has_permission
+  // expands the legacy name — so a group holding only `library:read` gets a 403
+  // there. It cannot reach this page to find out either: GET /library/folders
+  // gates on the same pair. Accepting it here would only enable a menu item
+  // that fails, and the `library:read` -> `library:read_own` migration in
+  // core/database.py runs only over the groups named in DEFAULT_GROUPS, so a
+  // custom role that still carries it is genuinely stuck rather than silently
+  // upgraded.
+  const canSlice = useCallback(() => {
+    if (settings?.use_slicer_api) {
+      return hasPermission('library:upload');
+    }
+    return hasAnyPermission('library:read_all', 'library:read_own');
+  }, [settings?.use_slicer_api, hasPermission, hasAnyPermission]);
   const { data: folders, isLoading: foldersLoading } = useQuery({
     queryKey: ['library-folders'],
     queryFn: () => api.getLibraryFolders(),
@@ -2420,8 +2460,10 @@ export function FileManagerPage() {
                     onDownload={handleDownload}
                     onPrint={setPrintFile}
                     onSlice={setSliceFile}
+                    onOpenInSlicer={handleOpenInSlicer}
                     onRunPipeline={setRunPipelineFile}
                     useSlicerApi={settings?.use_slicer_api ?? false}
+                    canSlice={canSlice()}
                     onPreview3d={(f) => {
                       // Sliced files (.gcode / .gcode.3mf) open the same
                       // full-page gcode viewer the archive card uses, so
@@ -2597,18 +2639,21 @@ export function FileManagerPage() {
                           </button>
                         </>
                       )}
-                      {(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (
+                      {isSliceableFilename(file.filename) && (
                         <button
-                          onClick={() => hasPermission('library:upload') && setSliceFile(file)}
+                          onClick={() => {
+                            if (!canSlice()) return;
+                            (settings?.use_slicer_api ? setSliceFile : handleOpenInSlicer)(file);
+                          }}
                           className={`p-1.5 rounded transition-colors ${
-                            hasPermission('library:upload')
+                            canSlice()
                               ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
                               : 'text-bambu-gray/50 cursor-not-allowed'
                           }`}
-                          title={hasPermission('library:upload') ? t('slice.action') : t('fileManager.noPermissionSlice')}
-                          disabled={!hasPermission('library:upload')}
+                          title={canSlice() ? t('slice.action') : (settings?.use_slicer_api ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload'))}
+                          disabled={!canSlice()}
                         >
-                          <Cog className="w-4 h-4" />
+                          {settings?.use_slicer_api ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />}
                         </button>
                       )}
                       {(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (

+ 2 - 2
frontend/src/pages/MakerworldPage.tsx

@@ -11,7 +11,7 @@ import {
   type MakerworldRecentImport,
   type MakerworldResolvedModel,
 } from '../api/client';
-import { openInSlicer, type SlicerType } from '../utils/slicer';
+import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import { Button } from '../components/Button';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { ConfirmModal } from '../components/ConfirmModal';
@@ -185,7 +185,7 @@ export function MakerworldPage() {
   // whichever slicer this button actually drives — depends on useSlicerApi.
   const useSlicerApi = settingsQuery.data?.use_slicer_api ?? false;
   const apiSlicer: SlicerType = settingsQuery.data?.preferred_slicer || 'bambu_studio';
-  const desktopSlicer: SlicerType = settingsQuery.data?.open_in_slicer || apiSlicer;
+  const desktopSlicer: SlicerType = resolveDesktopSlicer(settingsQuery.data?.open_in_slicer, settingsQuery.data?.preferred_slicer);
   const preferredSlicer: SlicerType = useSlicerApi ? apiSlicer : desktopSlicer;
   const preferredSlicerName =
     preferredSlicer === 'orcaslicer' ? 'OrcaSlicer' : 'Bambu Studio';

+ 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

+ 50 - 0
frontend/src/utils/slicer.ts

@@ -28,6 +28,56 @@ export type SlicerType = 'bambu_studio' | 'orcaslicer';
 
 type Platform = 'windows' | 'macos' | 'linux' | 'unknown';
 
+/**
+ * Resolve the desktop "Open in Slicer" target. Prefers an explicit
+ * `open_in_slicer` override (#1329), then falls back to the API slicer's
+ * `preferred_slicer`, then Bambu Studio. This is ONLY the URI-handoff target;
+ * the in-app SliceModal keeps using `preferred_slicer` for the sidecar.
+ */
+export function resolveDesktopSlicer(
+  openInSlicer?: SlicerType | null,
+  preferredSlicer?: SlicerType,
+): SlicerType {
+  return openInSlicer ?? preferredSlicer ?? 'bambu_studio';
+}
+
+/**
+ * File types a slicer can be handed — both by the desktop URI handler and by
+ * the in-app sidecar. Source geometry only: a sliced file is an output, and
+ * neither slicer has anything to do with one.
+ *
+ * Lives here rather than beside either caller because both the File Manager
+ * (which has a filename) and the 3D preview (which has a `LibraryFile.file_type`)
+ * decide the same thing about the same file. They used to hold separate lists,
+ * and the two disagreed — a card menu offered a desktop handoff for an STL
+ * whose own 3D preview showed "Open in Slicer" greyed out.
+ */
+export const SLICEABLE_FILE_TYPES = ['3mf', 'stl', 'step', 'stp'] as const;
+
+/**
+ * Does a `LibraryFile.file_type` name a sliceable source file?
+ *
+ * The backend stores compound extensions whole — a sliced 3MF classifies as
+ * `gcode.3mf`, not `3mf` (`classify_file_type` in `api/routes/library.py`) — so
+ * membership alone is enough to exclude sliced output here.
+ */
+export function isSliceableFileType(fileType?: string | null): boolean {
+  const normalized = (fileType || '').toLowerCase();
+  return (SLICEABLE_FILE_TYPES as readonly string[]).includes(normalized);
+}
+
+/**
+ * Does a filename name a sliceable source file?
+ *
+ * Checked against the name rather than a stored type, so the compound
+ * extensions have to be ruled out explicitly: `.gcode.3mf` ends with `.3mf`.
+ */
+export function isSliceableFilename(filename: string): boolean {
+  const lower = filename.toLowerCase();
+  if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false;
+  return SLICEABLE_FILE_TYPES.some((ext) => lower.endsWith(`.${ext}`));
+}
+
 /**
  * Detect the user's operating system
  */

Неке датотеке нису приказане због велике количине промена