Kaynağa Gözat

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

jmoore-skild 1 ay önce
ebeveyn
işleme
bb25e36510
59 değiştirilmiş dosya ile 6042 ekleme ve 193 silme
  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

Dosya farkı çok büyük olduğundan ihmal edildi
+ 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_log,
         print_queue,
         print_queue,
         printer,
         printer,
+        printer_ha_sensor,
         printer_sensor_history,
         printer_sensor_history,
         project,
         project,
         project_bom,
         project_bom,
@@ -3996,6 +3997,18 @@ async def run_migrations(conn):
     )
     )
     await _migrate_backfill_variant_groups(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:
 async def _migrate_backfill_variant_groups(conn) -> None:
     """Build variant groups from the slice provenance already on disk (#671 / #2570).
     """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,
     firmware,
     github_backup,
     github_backup,
     groups,
     groups,
+    ha_sensors,
     inventory,
     inventory,
     kprofiles,
     kprofiles,
     labels,
     labels,
@@ -96,6 +97,7 @@ from backend.app.services.bambu_ftp import (
 )
 )
 from backend.app.services.bambu_mqtt import PrinterState
 from backend.app.services.bambu_mqtt import PrinterState
 from backend.app.services.github_backup import github_backup_service
 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.homeassistant import homeassistant_service
 from backend.app.services.library_trash import library_trash_service
 from backend.app.services.library_trash import library_trash_service
 from backend.app.services.local_backup import local_backup_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
     # 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 ()
     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)
     # 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 = (
     ams_tray_key = (
         tuple(
         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 a in (state.raw_data.get("ams") or [])
             for t in a.get("tray", [])
             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
     # Start the smart plug scheduler for time-based on/off
     smart_plug_manager.start_scheduler()
     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
     # Resume any pending auto-offs that were interrupted by restart
     await smart_plug_manager.resume_pending_auto_offs()
     await smart_plug_manager.resume_pending_auto_offs()
 
 
@@ -7422,6 +7447,7 @@ async def lifespan(app: FastAPI):
     # Shutdown
     # Shutdown
     print_scheduler.stop()
     print_scheduler.stop()
     smart_plug_manager.stop_scheduler()
     smart_plug_manager.stop_scheduler()
+    ha_sensor_manager.stop()
     notification_service.stop_digest_scheduler()
     notification_service.stop_digest_scheduler()
     github_backup_service.stop_scheduler()
     github_backup_service.stop_scheduler()
     local_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(orca_cloud.router, prefix=app_settings.api_prefix)
 app.include_router(local_presets.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(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_log.router, prefix=app_settings.api_prefix)
 app.include_router(print_queue.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)
 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.pipeline_run import PipelineJob, PipelineRun
 from backend.app.models.print_batch import PrintBatch, PrintBatchPlate
 from backend.app.models.print_batch import PrintBatch, PrintBatchPlate
 from backend.app.models.printer import Printer
 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.printer_sensor_history import PrinterSensorHistory
 from backend.app.models.project import Project
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
@@ -56,6 +57,7 @@ __all__ = [
     "APIKey",
     "APIKey",
     "AMSSensorHistory",
     "AMSSensorHistory",
     "PrinterSensorHistory",
     "PrinterSensorHistory",
+    "PrinterHASensor",
     "AmsLabel",
     "AmsLabel",
     "PendingUpload",
     "PendingUpload",
     "PrintBatch",
     "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_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
     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
     # Event triggers - Build plate detection
     on_plate_not_empty = Column(Boolean, default=True)  # Objects detected on plate before print
     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)
     # 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",
         "title_template": "Bed Cooled",
         "body_template": "{printer}: Bed cooled to {bed_temp}°C (threshold: {threshold}°C)",
         "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",
         "event_type": "first_layer_complete",
         "name": "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(
     sensor_history: Mapped[list["PrinterSensorHistory"]] = relationship(
         back_populates="printer", cascade="all, delete-orphan"
         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
 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.kprofile_note import KProfileNote  # noqa: E402
 from backend.app.models.maintenance import PrinterMaintenance  # noqa: E402
 from backend.app.models.maintenance import PrinterMaintenance  # noqa: E402
 from backend.app.models.notification import NotificationProvider  # 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.printer_sensor_history import PrinterSensorHistory  # noqa: E402
 from backend.app.models.smart_plug import SmartPlug  # 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"
         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
     # Event triggers - Build plate detection
     on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
     on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
     on_plate_clear_required: bool = Field(
     on_plate_clear_required: bool = Field(
@@ -148,6 +153,9 @@ class NotificationProviderUpdate(BaseModel):
     on_ams_ht_humidity_high: bool | None = None
     on_ams_ht_humidity_high: bool | None = None
     on_ams_ht_temperature_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
     # Event triggers - Build plate detection
     on_plate_not_empty: bool | None = None
     on_plate_not_empty: bool | None = None
     on_plate_clear_required: 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_HUMIDITY_HIGH = "ams_humidity_high"
     AMS_TEMPERATURE_HIGH = "ams_temperature_high"
     AMS_TEMPERATURE_HIGH = "ams_temperature_high"
     BED_COOLED = "bed_cooled"
     BED_COOLED = "bed_cooled"
+    HA_SENSOR_ALERT = "ha_sensor_alert"
     TEST = "test"
     TEST = "test"
 
 
 
 
@@ -77,6 +78,7 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     "ams_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"],
     "ams_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"],
     "ams_temperature_high": ["printer", "ams_label", "temperature", "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"],
     "bed_cooled": ["printer", "bed_temp", "threshold", "filename", "timestamp", "app_name"],
+    "ha_sensor_alert": ["printer", "sensor", "state", "timestamp", "app_name"],
     "test": ["app_name", "timestamp"],
     "test": ["app_name", "timestamp"],
     # Queue notifications
     # Queue notifications
     "queue_job_added": ["job_name", "target", "timestamp", "app_name"],
     "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",
         "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
         "app_name": "Bambuddy",
     },
     },
+    "ha_sensor_alert": {
+        "printer": "Bambu X1C",
+        "sensor": "Enclosure Door",
+        "state": "open",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
     "test": {
     "test": {
         "app_name": "Bambuddy",
         "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",
         "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.
 # are deliberately excluded — those SHOULD end it.
 _ACTIVE_DRY_STATUSES = frozenset({1, 2, 3})  # Checking, Drying, Cooling
 _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,
 # 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
 # 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:
 # 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
         # — 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.
         # the UI badge. Cleared on stop or on the dry_time falling edge to 0.
         self._drying_targets: dict[int, dict[str, object]] = {}
         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.state = PrinterState()
         self._client: mqtt.Client | None = None
         self._client: mqtt.Client | None = None
@@ -2819,13 +2834,7 @@ class BambuMQTTClient:
             previous = self._previous_dry_times.get(ams_id, 0)
             previous = self._previous_dry_times.get(ams_id, 0)
             self._previous_dry_times[ams_id] = current
             self._previous_dry_times[ams_id] = current
             if previous > 0 and current == 0:
             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:
                 if self.on_drying_complete:
                     self.on_drying_complete(ams_id)
                     self.on_drying_complete(ams_id)
 
 
@@ -2865,6 +2874,71 @@ class BambuMQTTClient:
         if self._pending_assignments:
         if self._pending_assignments:
             self._check_assignment_verifications()
             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(
     def register_assignment_verification(
         self,
         self,
         ams_id: int,
         ams_id: int,
@@ -5492,13 +5566,22 @@ class BambuMQTTClient:
         )
         )
         # Track the active-cycle target so the badge can show "PETG @ 65°C"
         # Track the active-cycle target so the badge can show "PETG @ 65°C"
         # while drying. Bambu only echoes dry_time on subsequent pushes.
         # 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:
         if mode == 1:
             self._drying_targets[ams_id] = {
             self._drying_targets[ams_id] = {
                 "filament": filament or "",
                 "filament": filament or "",
                 "temp": int(temp),
                 "temp": int(temp),
+                "duration_hours": int(duration),
             }
             }
+            self._drying_stops_sent.discard(ams_id)
         else:
         else:
             self._drying_targets.pop(ams_id, None)
             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
         return True
 
 
     @staticmethod
     @staticmethod

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

@@ -9,9 +9,11 @@ to ensure they are well-formed before use.
 
 
 import asyncio
 import asyncio
 import functools
 import functools
+import ipaddress
 import logging
 import logging
 import re
 import re
 import shutil
 import shutil
+import socket
 from collections.abc import AsyncGenerator, Callable
 from collections.abc import AsyncGenerator, Callable
 from pathlib import Path
 from pathlib import Path
 from urllib.parse import urlparse
 from urllib.parse import urlparse
@@ -22,13 +24,77 @@ from backend.app.core.logging_filters import redact_url_credentials
 
 
 logger = logging.getLogger(__name__)
 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:
 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.
     """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
     Note: This intentionally allows user-provided URLs as that is the
     purpose of external camera configuration. Local network IPs are
     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:
         if scheme not in allowed_schemes:
             return None
             return None
 
 
-        # Block cloud metadata service endpoints (SSRF mitigation)
-        # These are dangerous destinations that should never be accessed
         hostname = parsed.hostname or ""
         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
             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
             return None
 
 
         # Reconstruct URL from validated components to break taint chain
         # Reconstruct URL from validated components to break taint chain
         # This creates a new string from validated parts
         # 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 ""
         port_str = f":{parsed.port}" if parsed.port else ""
         path = parsed.path or ""
         path = parsed.path or ""
         query = f"?{parsed.query}" if parsed.query else ""
         query = f"?{parsed.query}" if parsed.query else ""
         fragment = f"#{parsed.fragment}" if parsed.fragment else ""
         fragment = f"#{parsed.fragment}" if parsed.fragment else ""
 
 
         # Build sanitized URL from validated components
         # 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
         return sanitized
     except ValueError:
     except ValueError:
         return None
         return None
@@ -380,18 +444,18 @@ async def _capture_frame_uncoalesced(
         return None
         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:
     if not device_match:
         logger.error("Invalid USB device path format: %s", device)
         logger.error("Invalid USB device path format: %s", device)
         return None
         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
     # Convert to integer to break taint chain - integers cannot contain path traversal
     # lgtm[py/path-injection] - device_num is validated integer 0-99
     # lgtm[py/path-injection] - device_num is validated integer 0-99
     device_num = int(device_match.group(1))  # Safe: regex guarantees 1-2 digits
     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)
     # Construct safe path from validated integer (completely untainted)
     safe_device_path = Path(f"/dev/video{device_num}")  # lgtm[py/path-injection]
     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)
         logger.error("USB device does not exist: %s", safe_device_path)
         return None
         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
     # 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
     # Use ffmpeg to grab a single frame from USB camera
     cmd = [
     cmd = [
@@ -542,22 +617,34 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
     """Capture frame from RTSP using ffmpeg.
     """Capture frame from RTSP using ffmpeg.
 
 
     For rtsps:// URLs, a local TLS proxy is used to avoid GnuTLS issues.
     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()
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
     if not ffmpeg:
         logger.error("ffmpeg not found - required for RTSP capture")
         logger.error("ffmpeg not found - required for RTSP capture")
         return None
         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
     # If rtsps://, use TLS proxy
     proxy_server = None
     proxy_server = None
-    effective_url = url
-    if url.lower().startswith("rtsps://"):
+    effective_url = safe_url
+    if safe_url.lower().startswith("rtsps://"):
         try:
         try:
             from urllib.parse import urlparse
             from urllib.parse import urlparse
 
 
             from backend.app.services.camera import create_tls_proxy
             from backend.app.services.camera import create_tls_proxy
 
 
-            parsed = urlparse(url)
+            parsed = urlparse(safe_url)
             target_port = parsed.port or 322
             target_port = parsed.port or 322
             proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
             proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
             userinfo = ""
             userinfo = ""
@@ -566,17 +653,24 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
                 if parsed.password:
                 if parsed.password:
                     userinfo += f":{parsed.password}"
                     userinfo += f":{parsed.password}"
                 userinfo += "@"
                 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}"
             effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
             if parsed.query:
             if parsed.query:
                 effective_url += f"?{parsed.query}"
                 effective_url += f"?{parsed.query}"
         except Exception as e:
         except Exception as e:
             logger.warning("Failed to create TLS proxy for RTSP capture, falling back: %s", e)
             logger.warning("Failed to create TLS proxy for RTSP capture, falling back: %s", e)
-            effective_url = url
+            effective_url = safe_url
 
 
     cmd = [
     cmd = [
         ffmpeg,
         ffmpeg,
         "-rtsp_transport",
         "-rtsp_transport",
         "tcp",
         "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",
         "-i",
         effective_url,
         effective_url,
         "-frames:v",
         "-frames:v",
@@ -956,6 +1050,11 @@ async def _stream_rtsp(
     For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
     For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
     of relying on ffmpeg's GnuTLS backend, which has compatibility issues
     of relying on ffmpeg's GnuTLS backend, which has compatibility issues
     with some printer firmwares.
     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()
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
     if not ffmpeg:
@@ -964,16 +1063,21 @@ async def _stream_rtsp(
 
 
     from backend.app.services.camera import rtsp_socket_timeout_flag
     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://
     # If the URL uses rtsps://, set up a TLS proxy so ffmpeg uses plain rtsp://
     proxy_server = None
     proxy_server = None
-    effective_url = url
-    if url.lower().startswith("rtsps://"):
+    effective_url = safe_url
+    if safe_url.lower().startswith("rtsps://"):
         try:
         try:
             from urllib.parse import urlparse
             from urllib.parse import urlparse
 
 
             from backend.app.services.camera import create_tls_proxy
             from backend.app.services.camera import create_tls_proxy
 
 
-            parsed = urlparse(url)
+            parsed = urlparse(safe_url)
             target_port = parsed.port or 322
             target_port = parsed.port or 322
             proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
             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
             # 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:
                 if parsed.password:
                     userinfo += f":{parsed.password}"
                     userinfo += f":{parsed.password}"
                 userinfo += "@"
                 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}"
             effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
             if parsed.query:
             if parsed.query:
                 effective_url += f"?{parsed.query}"
                 effective_url += f"?{parsed.query}"
         except Exception as e:
         except Exception as e:
             logger.warning("Failed to create TLS proxy for RTSP, falling back to direct: %s", e)
             logger.warning("Failed to create TLS proxy for RTSP, falling back to direct: %s", e)
-            effective_url = url
+            effective_url = safe_url
 
 
     cmd = [
     cmd = [
         ffmpeg,
         ffmpeg,
@@ -996,6 +1102,8 @@ async def _stream_rtsp(
         "tcp",
         "tcp",
         "-rtsp_flags",
         "-rtsp_flags",
         "prefer_tcp",
         "prefer_tcp",
+        "-protocol_whitelist",
+        _RTSP_PROTOCOL_WHITELIST,
         # Socket I/O timeout name varies by ffmpeg version (#1504); see
         # Socket I/O timeout name varies by ffmpeg version (#1504); see
         # `rtsp_socket_timeout_flag()` in services.camera.
         # `rtsp_socket_timeout_flag()` in services.camera.
         f"-{rtsp_socket_timeout_flag()}",
         f"-{rtsp_socket_timeout_flag()}",
@@ -1109,14 +1217,13 @@ async def _stream_usb(
         logger.error("ffmpeg not found - required for USB camera streaming")
         logger.error("ffmpeg not found - required for USB camera streaming")
         return
         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
         return
+    device = safe_device
 
 
     # ffmpeg command to stream from USB camera (v4l2)
     # ffmpeg command to stream from USB camera (v4l2)
     cmd = [
     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."""
 """Service for communicating with Home Assistant via REST API."""
 
 
+import asyncio
 import logging
 import logging
 from typing import TYPE_CHECKING
 from typing import TYPE_CHECKING
 from urllib.parse import urlparse
 from urllib.parse import urlparse
@@ -364,6 +365,107 @@ class HomeAssistantService:
             logger.warning("Failed to list HA sensor entities: %s", e)
             logger.warning("Failed to list HA sensor entities: %s", e)
             return []
             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
 # Singleton instance
 homeassistant_service = HomeAssistantService()
 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
             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(
     async def on_first_layer_complete(
         self,
         self,
         printer_id: int,
         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.bambu_mqtt import HMS_MQTT_VERIFY_FAILED
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 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.notification_service import notification_service
 from backend.app.services.printer_manager import (
 from backend.app.services.printer_manager import (
     printer_manager,
     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:
 class PrintScheduler:
     """Background scheduler that processes the print queue."""
     """Background scheduler that processes the print queue."""
 
 
@@ -663,6 +701,31 @@ class PrintScheduler:
                 if inflight_pid is not None:
                 if inflight_pid is not None:
                     busy_printers.add(inflight_pid)
                     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)
             # Log skip reasons once per queue check (not per item)
             skip_reasons: dict[str, int] = {}
             skip_reasons: dict[str, int] = {}
 
 
@@ -722,6 +785,28 @@ class PrintScheduler:
                     continue
                     continue
 
 
                 if item.printer_id:
                 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)
                     # Specific printer assignment (existing behavior)
                     if item.printer_id in busy_printers:
                     if item.printer_id in busy_printers:
                         continue
                         continue
@@ -808,7 +893,10 @@ class PrintScheduler:
                     # (all -1). A stored all-[-1] mapping is a bug artifact — a
                     # (all -1). A stored all-[-1] mapping is a bug artifact — a
                     # frontend status-load race can persist [-1] (#2589) — and
                     # frontend status-load race can persist [-1] (#2589) — and
                     # must be recomputed from live trays rather than trusted.
                     # 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
                     # Filament-deficit pre-dispatch check (#1496). If the
                     # assigned spool can't satisfy any required slot grams,
                     # 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(
                         match_id, match_reason = await self._find_idle_printer_for_model(
                             db,
                             db,
                             candidate.target_model,
                             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,
                             effective_types,
                             item.target_location,
                             item.target_location,
                             filament_overrides=filament_overrides,
                             filament_overrides=filament_overrides,
@@ -1010,7 +1101,10 @@ class PrintScheduler:
                         # missing OR unresolved (all -1). Critical for model-based
                         # missing OR unresolved (all -1). Critical for model-based
                         # jobs where mapping wasn't computed upfront, and it also
                         # jobs where mapping wasn't computed upfront, and it also
                         # self-heals a bogus stored [-1] (#2589).
                         # 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).
                         # Filament-deficit pre-dispatch check (#1496).
                         if await self._block_on_filament_deficit(db, item):
                         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.
             # actually going to run so history and the ETA agree with reality.
             item.print_time_seconds = variant.print_time_seconds
             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.
         """Ensure the queue item carries a usable AMS mapping before dispatch.
 
 
         Recomputes from live printer status when the stored mapping is missing OR
         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
         external selection; the print command then keeps use_ams=True and the
         firmware surfaces a clear AMS-mapping error instead of silently printing
         firmware surfaces a clear AMS-mapping error instead of silently printing
         to the empty external feed.
         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
         stored_mapping: list | None = None
         if item.ams_mapping:
         if item.ams_mapping:
@@ -1652,7 +1754,7 @@ class PrintScheduler:
         # Already resolved (present and not all-unresolved) — keep as-is so a
         # Already resolved (present and not all-unresolved) — keep as-is so a
         # user's manual mapping is never overwritten.
         # user's manual mapping is never overwritten.
         if item.ams_mapping and not _mapping_is_all_unresolved(stored_mapping):
         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)
         computed_mapping = await self._compute_ams_mapping_for_printer(db, printer_id, item)
         if computed_mapping and not _mapping_is_all_unresolved(computed_mapping):
         if computed_mapping and not _mapping_is_all_unresolved(computed_mapping):
@@ -1664,7 +1766,9 @@ class PrintScheduler:
                 computed_mapping,
                 computed_mapping,
             )
             )
             await db.commit()
             await db.commit()
-        elif _mapping_is_all_unresolved(stored_mapping):
+            return None
+
+        if _mapping_is_all_unresolved(stored_mapping):
             logger.warning(
             logger.warning(
                 "Queue item %s: stored ams_mapping %s is unresolved and could not be recomputed "
                 "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",
                 "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
             item.ams_mapping = None
             await db.commit()
             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(
     async def _compute_ams_mapping_for_printer(
         self, db: AsyncSession, printer_id: int, item: PrintQueueItem
         self, db: AsyncSession, printer_id: int, item: PrintQueueItem
     ) -> list[int] | None:
     ) -> list[int] | None:
@@ -1727,37 +1930,7 @@ class PrintScheduler:
             logger.debug("No filament requirements found for queue item %s", item.id)
             logger.debug("No filament requirements found for queue item %s", item.id)
             return None
             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
         # Build loaded filaments from printer status
         loaded_filaments = self._build_loaded_filaments(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
             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:
     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.
         """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)
         # Get ams_extruder_map for dual-nozzle printers (H2D, H2D Pro)
         ams_extruder_map = status.raw_data.get("ams_extruder_map", {})
         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
         # Parse AMS units from raw_data
         ams_data = status.raw_data.get("ams", [])
         ams_data = status.raw_data.get("ams", [])
         for ams_unit in ams_data:
         for ams_unit in ams_data:
@@ -1900,7 +2146,7 @@ class PrintScheduler:
                     )
                     )
 
 
         # Check external spool(s) (vt_tray is a list)
         # 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"):
             if vt.get("tray_type"):
                 color = self._normalize_color(vt.get("tray_color", ""))
                 color = self._normalize_color(vt.get("tray_color", ""))
                 tray_id = int(vt.get("id", 254))
                 tray_id = int(vt.get("id", 254))
@@ -1914,7 +2160,9 @@ class PrintScheduler:
                         "is_ht": False,
                         "is_ht": False,
                         "is_external": True,
                         "is_external": True,
                         "global_tray_id": tray_id,
                         "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),
                         "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):
     def test_start_caches_target_for_badge(self, mqtt_client):
         """mode=1 send populates _drying_targets so the badge can render it."""
         """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")
         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):
     def test_start_overwrites_prior_target_for_same_ams(self, mqtt_client):
         """A second start on the same AMS replaces the cached target."""
         """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=55, duration=4, mode=1, filament="PLA")
         mqtt_client.send_drying_command(ams_id=0, temp=70, duration=6, mode=1, filament="ABS")
         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):
     def test_stop_clears_target(self, mqtt_client):
         """mode=0 send drops the cache so the badge stops showing the target."""
         """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=128, temp=80, duration=6, mode=1, filament="PA-CF")
         mqtt_client.send_drying_command(ams_id=0, temp=0, duration=0, mode=0)
         mqtt_client.send_drying_command(ams_id=0, temp=0, duration=0, mode=0)
         assert 0 not in mqtt_client._drying_targets
         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:
 class TestStartPrintAmsMapping:
@@ -6120,6 +6120,89 @@ class TestDryingCompleteCallback:
         mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
         mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
         assert mqtt_client._drying_events == [0]
         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:
 class TestPrintRunningObservedCallback:
     """#1485 follow-up: on_print_running_observed fires the FIRST time we
     """#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.
             # field is JSON rather than key=value lines.
             assert payload["data"]["ttl"] == 0
             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
     @pytest.mark.asyncio
     async def test_send_homeassistant_without_data_omits_key(self, service):
     async def test_send_homeassistant_without_data_omits_key(self, service):
         """Without configured data the payload carries no "data" key — the
         """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()),
         patch.object(scheduler, "_check_auto_drying", AsyncMock()),
         # Selection is what's under test — keep AMS recomputation and the
         # Selection is what's under test — keep AMS recomputation and the
         # filament-deficit probe out of the way, and never actually dispatch.
         # 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, "_block_on_filament_deficit", AsyncMock(return_value=False)),
         patch.object(scheduler, "_launch_uploads", MagicMock()),
         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 { render } from '../utils';
 import { ModelViewerModal } from '../../components/ModelViewerModal';
 import { ModelViewerModal } from '../../components/ModelViewerModal';
 import { setStreamToken } from '../../api/client';
 import { setStreamToken } from '../../api/client';
+import { openInSlicer } from '../../utils/slicer';
 import { http, HttpResponse } from 'msw';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 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 = {
 const mockCapabilities = {
   has_model: true,
   has_model: true,
   has_gcode: 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(
       render(
         <ModelViewerModal
         <ModelViewerModal
           libraryFileId={1}
           libraryFileId={1}
-          title="Model.stl"
-          fileType="stl"
+          title="Model.gcode"
+          fileType="gcode"
           onClose={mockOnClose}
           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
    * #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)', () => {
   describe('hidden tab (#2754)', () => {
     let rafSpy: ReturnType<typeof vi.fn>;
     let rafSpy: ReturnType<typeof vi.fn>;
@@ -690,6 +697,49 @@ describe('useWebSocket hook', () => {
       expect(rafSpy).not.toHaveBeenCalled();
       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 () => {
     it('drains queued messages instead of wedging the queue', async () => {
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
       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', () => {
   describe('sendMessage', () => {
     it('sends JSON message when connected', async () => {
     it('sends JSON message when connected', async () => {
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       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 { 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 userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { render } from '../utils';
 import { FileManagerPage } from '../../pages/FileManagerPage';
 import { FileManagerPage } from '../../pages/FileManagerPage';
+import { openInSlicer } from '../../utils/slicer';
+import { setAuthToken } from '../../api/client';
 import { http, HttpResponse } from 'msw';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 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
 // Mock data
 const mockFolders = [
 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``.
 // Individual tests layer extra handlers on top via ``server.use``.
 function useAuthedHandlers(opts: {
 function useAuthedHandlers(opts: {
   slicer?: 'bambu_studio' | 'orcaslicer';
   slicer?: 'bambu_studio' | 'orcaslicer';
+  openInSlicer?: 'bambu_studio' | 'orcaslicer' | null;
+  useSlicerApi?: boolean;
   recent?: Array<Record<string, unknown>>;
   recent?: Array<Record<string, unknown>>;
 } = {}) {
 } = {}) {
   const slicer = opts.slicer ?? 'bambu_studio';
   const slicer = opts.slicer ?? 'bambu_studio';
@@ -74,6 +76,8 @@ function useAuthedHandlers(opts: {
         auto_archive: true,
         auto_archive: true,
         save_thumbnails: true,
         save_thumbnails: true,
         preferred_slicer: slicer,
         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);
     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 () => {
   it('clears the resolved preview when the URL input is edited after resolve', async () => {
     useAuthedHandlers();
     useAuthedHandlers();
     server.use(
     server.use(

+ 88 - 0
frontend/src/api/client.ts

@@ -2167,6 +2167,75 @@ export interface HATestConnectionResult {
   error: string | null;
   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 {
 export interface SmartPlugEnergy {
   power: number | null;  // Current watts
   power: number | null;  // Current watts
   voltage: number | null;  // Volts
   voltage: number | null;  // Volts
@@ -2615,6 +2684,7 @@ export interface NotificationProvider {
   on_plate_clear_required: boolean;
   on_plate_clear_required: boolean;
   // Bed cooled
   // Bed cooled
   on_bed_cooled: boolean;
   on_bed_cooled: boolean;
+  on_ha_sensor_alert: boolean;
   // First layer complete
   // First layer complete
   on_first_layer_complete: boolean;
   on_first_layer_complete: boolean;
   // Inventory stock alerts
   // Inventory stock alerts
@@ -2675,6 +2745,7 @@ export interface NotificationProviderCreate {
   on_plate_clear_required?: boolean;
   on_plate_clear_required?: boolean;
   // Bed cooled
   // Bed cooled
   on_bed_cooled?: boolean;
   on_bed_cooled?: boolean;
+  on_ha_sensor_alert?: boolean;
   // First layer complete
   // First layer complete
   on_first_layer_complete?: boolean;
   on_first_layer_complete?: boolean;
   // Inventory stock alerts
   // Inventory stock alerts
@@ -2728,6 +2799,7 @@ export interface NotificationProviderUpdate {
   on_plate_clear_required?: boolean;
   on_plate_clear_required?: boolean;
   // Bed cooled
   // Bed cooled
   on_bed_cooled?: boolean;
   on_bed_cooled?: boolean;
+  on_ha_sensor_alert?: boolean;
   // First layer complete
   // First layer complete
   on_first_layer_complete?: boolean;
   on_first_layer_complete?: boolean;
   // Inventory stock alerts
   // Inventory stock alerts
@@ -5277,6 +5349,22 @@ export const api = {
   getHASensorEntities: () =>
   getHASensorEntities: () =>
     request<HASensorEntity[]>('/smart-plugs/ha/sensors'),
     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
   // REST smart plug
   testRESTConnection: (url: string, method: string = 'GET', headers?: string | null) =>
   testRESTConnection: (url: string, method: string = 'GET', headers?: string | null) =>
     request<{ success: boolean; error: string | null }>('/smart-plugs/rest/test-connection', {
     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 [onStockBreakAlert, setOnStockBreakAlert] = useState(provider?.on_stock_break_alert ?? false);
   const [onPlateClearRequired, setOnPlateClearRequired] = useState(provider?.on_plate_clear_required ?? false);
   const [onPlateClearRequired, setOnPlateClearRequired] = useState(provider?.on_plate_clear_required ?? false);
   const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? 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);
   const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? false);
 
 
   // Provider-specific config (scalar fields only — event_priorities is split out
   // 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_stock_break_alert: onStockBreakAlert,
       on_plate_clear_required: onPlateClearRequired,
       on_plate_clear_required: onPlateClearRequired,
       on_bed_cooled: onBedCooled,
       on_bed_cooled: onBedCooled,
+      on_ha_sensor_alert: onHaSensorAlert,
       on_first_layer_complete: onFirstLayerComplete,
       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>
                   <span className="text-sm text-white">{t('notifications.offline')}</span>
                   <Toggle checked={onPrinterOffline} onChange={setOnPrinterOffline} />
                   <Toggle checked={onPrinterOffline} onChange={setOnPrinterOffline} />
                 </div>
                 </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">
                 <div className="flex items-center justify-between">
                   <span className="text-sm text-white">{t('notifications.error')}</span>
                   <span className="text-sm text-white">{t('notifications.error')}</span>
                   <Toggle checked={onPrinterError} onChange={setOnPrinterError} />
                   <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 (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 (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });
               if (onPrinterError) enabledEvents.push({ key: 'on_printer_error', label: t('notifications.error') });
               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 (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 (onFilamentLow) enabledEvents.push({ key: 'on_filament_low', label: t('notifications.lowFilament') });
               if (onMaintenanceDue) enabledEvents.push({ key: 'on_maintenance_due', label: t('notifications.maintenance') });
               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 { useTranslation } from 'react-i18next';
 import { useQuery } from '@tanstack/react-query';
 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 { ModelViewer } from './ModelViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { Button } from './Button';
 import { Button } from './Button';
 import { api, withStreamToken } from '../api/client';
 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';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 
 
 type ViewTab = '3d' | 'gcode';
 type ViewTab = '3d' | 'gcode';
@@ -32,14 +33,105 @@ interface Capabilities {
   filament_colors: string[];
   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) {
 export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose, onSliceWithBambuddy }: ModelViewerModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const { showToast } = useToast();
   const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
   const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
   // Desktop "Open in Slicer" target — falls back to preferred_slicer when the
   // Desktop "Open in Slicer" target — falls back to preferred_slicer when the
   // user hasn't explicitly chosen a different desktop slicer (#1329). This
   // user hasn't explicitly chosen a different desktop slicer (#1329). This
   // variable is only used for URI-handoff; sidecar slicing keeps using
   // variable is only used for URI-handoff; sidecar slicing keeps using
   // preferred_slicer directly.
   // 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 isLibrary = libraryFileId != null;
   const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
   const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
   const [capabilities, setCapabilities] = useState<Capabilities | 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]);
   }, [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 →
   // When the user has the in-app Slicer API enabled (Settings → Workflow →
   // Slicer → Use Slicer API), library-mode previews route the header's slicer
   // 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
   // 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),
   // 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.).
   // 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(
   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;
     if (!canOpenInSlicer) return;
     const filename = title || 'model';
     const filename = title || 'model';
     try {
     try {
       if (isLibrary) {
       if (isLibrary) {
         const { token } = await api.createLibrarySlicerToken(libraryFileId!);
         const { token } = await api.createLibrarySlicerToken(libraryFileId!);
         const path = api.getLibrarySlicerDownloadUrl(libraryFileId!, token, filename);
         const path = api.getLibrarySlicerDownloadUrl(libraryFileId!, token, filename);
-        openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
+        openInSlicer(`${window.location.origin}${path}`, slicer);
       } else {
       } else {
         const { token } = await api.createArchiveSlicerToken(archiveId!);
         const { token } = await api.createArchiveSlicerToken(archiveId!);
         const path = api.getArchiveSlicerDownloadUrl(archiveId!, token, filename);
         const path = api.getArchiveSlicerDownloadUrl(archiveId!, token, filename);
-        openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
+        openInSlicer(`${window.location.origin}${path}`, slicer);
       }
       }
     } catch {
     } 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) {
       if (isLibrary) {
         const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
         const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
-        openInSlicer(downloadUrl, preferredSlicer);
+        openInSlicer(downloadUrl, slicer);
       } else {
       } else {
         const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
         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 (
   return (
     <div
     <div
       className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-8'}`}
       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>
           <div className="flex items-center gap-2">
           <div className="flex items-center gap-2">
             {useBambuddySlicer ? (
             {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" />
                 <ExternalLink className="w-4 h-4" />
                 {t('modelViewer.openInSlicer')}
                 {t('modelViewer.openInSlicer')}
               </Button>
               </Button>

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

@@ -144,6 +144,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_ai_failure_detection && (
             {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>
               <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 && (
             {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>
               <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>
 
 
+                <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">
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">{t('notifications.lowFilamentLabel')}</p>
                   <p className="text-sm text-white">{t('notifications.lowFilamentLabel')}</p>
 <Toggle
 <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;
     wsRef.current = ws;
   }, [processMessageQueue]);
   }, [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>) => {
   const throttledPrinterStatusUpdate = useCallback((printerId: number, data: Record<string, unknown>) => {
     // Merge with any pending data for this printer
     // Merge with any pending data for this printer
     const existing = pendingPrinterStatus.current.get(printerId) || {};
     const existing = pendingPrinterStatus.current.get(printerId) || {};
     pendingPrinterStatus.current.set(printerId, { ...existing, ...data });
     pendingPrinterStatus.current.set(printerId, { ...existing, ...data });
 
 
+    if (document.hidden) {
+      flushPrinterStatus();
+      return;
+    }
+
     // Schedule update if not already scheduled
     // Schedule update if not already scheduled
     if (!printerStatusTimeoutRef.current) {
     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
   // Debounced invalidation helper - coalesces multiple rapid invalidations
   const debouncedInvalidate = useCallback((queryKey: string) => {
   const debouncedInvalidate = useCallback((queryKey: string) => {

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

@@ -3820,6 +3820,7 @@ export default {
     scanFolder: 'Scannen',
     scanFolder: 'Scannen',
     toast: {
     toast: {
       folderCreated: 'Ordner erstellt',
       folderCreated: 'Ordner erstellt',
+      openInSlicerFailed: 'Konnte nicht im Slicer öffnen',
       folderDeleted: 'Ordner gelöscht',
       folderDeleted: 'Ordner gelöscht',
       fileDeleted: 'Datei gelöscht',
       fileDeleted: 'Datei gelöscht',
       filesDeleted: '{{count}} Dateien gelöscht',
       filesDeleted: '{{count}} Dateien gelöscht',
@@ -5426,6 +5427,9 @@ export default {
   // Model Viewer
   // Model Viewer
   modelViewer: {
   modelViewer: {
     openInSlicer: 'Im Slicer öffnen',
     openInSlicer: 'Im Slicer öffnen',
+    openInSlicerWith: 'In {{slicer}} öffnen',
+    moreSlicerOptions: 'Weitere Slicer-Optionen',
+    openInSlicerFailed: 'Konnte nicht im Slicer öffnen',
     tabs: {
     tabs: {
       model: '3D-Modell',
       model: '3D-Modell',
       gcode: 'G-Code Vorschau',
       gcode: 'G-Code Vorschau',
@@ -5476,6 +5480,61 @@ export default {
   },
   },
 
 
   // Smart Plugs
   // 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: {
   smartPlugs: {
     offline: 'Offline',
     offline: 'Offline',
     admin: 'Admin',
     admin: 'Admin',
@@ -5788,6 +5847,8 @@ export default {
     notificationEvents: 'Benachrichtigungsereignisse',
     notificationEvents: 'Benachrichtigungsereignisse',
     progressPercent: '(25 %, 50 %, 75 %)',
     progressPercent: '(25 %, 50 %, 75 %)',
     bedCooledAfterPrint: '(nach Druckabschluss)',
     bedCooledAfterPrint: '(nach Druckabschluss)',
+    haSensorAlert: 'Sensor-Alarm',
+    haSensorAlertDescription: '(ein verknüpfter Home-Assistant-Sensor braucht Aufmerksamkeit)',
     // Per-event ntfy priority (#990)
     // Per-event ntfy priority (#990)
     eventPriority: {
     eventPriority: {
       sectionTitle: 'ntfy-Priorität',
       sectionTitle: 'ntfy-Priorität',

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

@@ -3849,6 +3849,7 @@ export default {
     scanFolder: 'Scan',
     scanFolder: 'Scan',
     toast: {
     toast: {
       folderCreated: 'Folder created',
       folderCreated: 'Folder created',
+      openInSlicerFailed: 'Could not open in slicer',
       folderDeleted: 'Folder deleted',
       folderDeleted: 'Folder deleted',
       fileDeleted: 'File deleted',
       fileDeleted: 'File deleted',
       filesDeleted: 'Deleted {{count}} files',
       filesDeleted: 'Deleted {{count}} files',
@@ -5475,6 +5476,9 @@ export default {
   // Model Viewer
   // Model Viewer
   modelViewer: {
   modelViewer: {
     openInSlicer: 'Open in Slicer',
     openInSlicer: 'Open in Slicer',
+    openInSlicerWith: 'Open in {{slicer}}',
+    moreSlicerOptions: 'More slicer options',
+    openInSlicerFailed: 'Could not open in slicer',
     tabs: {
     tabs: {
       model: '3D Model',
       model: '3D Model',
       gcode: 'G-code Preview',
       gcode: 'G-code Preview',
@@ -5525,6 +5529,61 @@ export default {
   },
   },
 
 
   // Smart Plugs
   // 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: {
   smartPlugs: {
     offline: 'Offline',
     offline: 'Offline',
     admin: 'Admin',
     admin: 'Admin',
@@ -5837,6 +5896,8 @@ export default {
     notificationEvents: 'Notification Events',
     notificationEvents: 'Notification Events',
     progressPercent: '(25%, 50%, 75%)',
     progressPercent: '(25%, 50%, 75%)',
     bedCooledAfterPrint: '(after print completes)',
     bedCooledAfterPrint: '(after print completes)',
+    haSensorAlert: 'Sensor Alert',
+    haSensorAlertDescription: '(a bound Home Assistant sensor needs attention)',
     // Per-event ntfy priority (#990)
     // Per-event ntfy priority (#990)
     eventPriority: {
     eventPriority: {
       sectionTitle: 'ntfy Priority',
       sectionTitle: 'ntfy Priority',

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

@@ -3823,6 +3823,7 @@ export default {
     scanFolder: 'Escanear',
     scanFolder: 'Escanear',
     toast: {
     toast: {
       folderCreated: 'Carpeta creada',
       folderCreated: 'Carpeta creada',
+      openInSlicerFailed: 'No se pudo abrir en el laminador',
       folderDeleted: 'Carpeta eliminada',
       folderDeleted: 'Carpeta eliminada',
       fileDeleted: 'Archivo eliminado',
       fileDeleted: 'Archivo eliminado',
       filesDeleted: 'Se eliminaron {{count}} archivos',
       filesDeleted: 'Se eliminaron {{count}} archivos',
@@ -5435,6 +5436,9 @@ export default {
   // Model Viewer
   // Model Viewer
   modelViewer: {
   modelViewer: {
     openInSlicer: 'Abrir en el laminador',
     openInSlicer: 'Abrir en el laminador',
+    openInSlicerWith: 'Abrir en {{slicer}}',
+    moreSlicerOptions: 'Más opciones de laminador',
+    openInSlicerFailed: 'No se pudo abrir en el laminador',
     tabs: {
     tabs: {
       model: 'Modelo 3D',
       model: 'Modelo 3D',
       gcode: 'Vista previa de G-code',
       gcode: 'Vista previa de G-code',
@@ -5485,6 +5489,61 @@ export default {
   },
   },
 
 
   // Smart Plugs
   // 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: {
   smartPlugs: {
     offline: 'Desconectado',
     offline: 'Desconectado',
     admin: 'Administración',
     admin: 'Administración',
@@ -5797,6 +5856,8 @@ export default {
     notificationEvents: 'Eventos de notificación',
     notificationEvents: 'Eventos de notificación',
     progressPercent: '(25%, 50%, 75%)',
     progressPercent: '(25%, 50%, 75%)',
     bedCooledAfterPrint: '(después de completar la impresión)',
     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)
     // Per-event ntfy priority (#990)
     eventPriority: {
     eventPriority: {
       sectionTitle: 'Prioridad de ntfy',
       sectionTitle: 'Prioridad de ntfy',

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

@@ -3809,6 +3809,7 @@ export default {
     scanFolder: 'Scanner',
     scanFolder: 'Scanner',
     toast: {
     toast: {
       folderCreated: 'Dossier créé',
       folderCreated: 'Dossier créé',
+      openInSlicerFailed: "Impossible d'ouvrir dans le slicer",
       folderDeleted: 'Dossier supprimé',
       folderDeleted: 'Dossier supprimé',
       fileDeleted: 'Fichier supprimé',
       fileDeleted: 'Fichier supprimé',
       filesDeleted: '{{count}} fichiers supprimés',
       filesDeleted: '{{count}} fichiers supprimés',
@@ -5416,6 +5417,9 @@ export default {
   // Model Viewer
   // Model Viewer
   modelViewer: {
   modelViewer: {
     openInSlicer: 'Ouvrir dans le Slicer',
     openInSlicer: 'Ouvrir dans le Slicer',
+    openInSlicerWith: 'Ouvrir dans {{slicer}}',
+    moreSlicerOptions: "Plus d'options de slicer",
+    openInSlicerFailed: "Impossible d'ouvrir dans le slicer",
     tabs: {
     tabs: {
       model: 'Modèle 3D',
       model: 'Modèle 3D',
       gcode: 'Aperçu G-code',
       gcode: 'Aperçu G-code',
@@ -5466,6 +5470,61 @@ export default {
   },
   },
 
 
   // Smart Plugs
   // 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: {
   smartPlugs: {
     offline: 'Hors ligne',
     offline: 'Hors ligne',
     admin: 'Administrateur',
     admin: 'Administrateur',
@@ -5778,6 +5837,8 @@ export default {
     notificationEvents: 'Événements de notification',
     notificationEvents: 'Événements de notification',
     progressPercent: '(25 %, 50 %, 75 %)',
     progressPercent: '(25 %, 50 %, 75 %)',
     bedCooledAfterPrint: '(après la fin de l\'impression)',
     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)
     // Per-event ntfy priority (#990)
     eventPriority: {
     eventPriority: {
       sectionTitle: 'Priorité ntfy',
       sectionTitle: 'Priorité ntfy',

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

@@ -3808,6 +3808,7 @@ export default {
     scanFolder: 'Scansiona',
     scanFolder: 'Scansiona',
     toast: {
     toast: {
       folderCreated: 'Cartella creata',
       folderCreated: 'Cartella creata',
+      openInSlicerFailed: 'Impossibile aprire nello slicer',
       folderDeleted: 'Cartella eliminata',
       folderDeleted: 'Cartella eliminata',
       fileDeleted: 'File eliminato',
       fileDeleted: 'File eliminato',
       filesDeleted: 'Eliminati {{count}} file',
       filesDeleted: 'Eliminati {{count}} file',
@@ -5415,6 +5416,9 @@ export default {
   // Model Viewer
   // Model Viewer
   modelViewer: {
   modelViewer: {
     openInSlicer: 'Apri nello slicer',
     openInSlicer: 'Apri nello slicer',
+    openInSlicerWith: 'Apri in {{slicer}}',
+    moreSlicerOptions: 'Altre opzioni dello slicer',
+    openInSlicerFailed: 'Impossibile aprire nello slicer',
     tabs: {
     tabs: {
       model: 'Modello 3D',
       model: 'Modello 3D',
       gcode: 'Anteprima G-code',
       gcode: 'Anteprima G-code',
@@ -5465,6 +5469,61 @@ export default {
   },
   },
 
 
   // Smart Plugs
   // 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: {
   smartPlugs: {
     offline: 'Offline',
     offline: 'Offline',
     admin: 'Amministrazione',
     admin: 'Amministrazione',
@@ -5777,6 +5836,8 @@ export default {
     notificationEvents: 'Eventi di notifica',
     notificationEvents: 'Eventi di notifica',
     progressPercent: '(25%, 50%, 75%)',
     progressPercent: '(25%, 50%, 75%)',
     bedCooledAfterPrint: '(dopo il completamento della stampa)',
     bedCooledAfterPrint: '(dopo il completamento della stampa)',
+    haSensorAlert: 'Avviso sensore',
+    haSensorAlertDescription: '(un sensore Home Assistant collegato richiede attenzione)',
     // Per-event ntfy priority (#990)
     // Per-event ntfy priority (#990)
     eventPriority: {
     eventPriority: {
       sectionTitle: 'Priorità ntfy',
       sectionTitle: 'Priorità ntfy',

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

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

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

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

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

@@ -3808,6 +3808,7 @@ export default {
     scanFolder: 'Escanear',
     scanFolder: 'Escanear',
     toast: {
     toast: {
       folderCreated: 'Pasta criada',
       folderCreated: 'Pasta criada',
+      openInSlicerFailed: 'Não foi possível abrir no fatiador',
       folderDeleted: 'Pasta excluída',
       folderDeleted: 'Pasta excluída',
       fileDeleted: 'Arquivo excluído',
       fileDeleted: 'Arquivo excluído',
       filesDeleted: 'Excluídos {{count}} arquivos',
       filesDeleted: 'Excluídos {{count}} arquivos',
@@ -5415,6 +5416,9 @@ export default {
   // Model Viewer
   // Model Viewer
   modelViewer: {
   modelViewer: {
     openInSlicer: 'Abrir no Slicer',
     openInSlicer: 'Abrir no Slicer',
+    openInSlicerWith: 'Abrir em {{slicer}}',
+    moreSlicerOptions: 'Mais opções de fatiador',
+    openInSlicerFailed: 'Não foi possível abrir no fatiador',
     tabs: {
     tabs: {
       model: 'Modelo 3D',
       model: 'Modelo 3D',
       gcode: 'Pré-visualização G-code',
       gcode: 'Pré-visualização G-code',
@@ -5465,6 +5469,61 @@ export default {
   },
   },
 
 
   // Smart Plugs
   // 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: {
   smartPlugs: {
     offline: 'Offline',
     offline: 'Offline',
     admin: 'Administrador',
     admin: 'Administrador',
@@ -5777,6 +5836,8 @@ export default {
     notificationEvents: 'Eventos de Notificação',
     notificationEvents: 'Eventos de Notificação',
     progressPercent: '(25%, 50%, 75%)',
     progressPercent: '(25%, 50%, 75%)',
     bedCooledAfterPrint: '(após conclusão da impressão)',
     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)
     // Per-event ntfy priority (#990)
     eventPriority: {
     eventPriority: {
       sectionTitle: 'Prioridade ntfy',
       sectionTitle: 'Prioridade ntfy',

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

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

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

@@ -3816,6 +3816,7 @@ export default {
     scanFolder: 'Tara',
     scanFolder: 'Tara',
     toast: {
     toast: {
       folderCreated: 'Klasör oluşturuldu',
       folderCreated: 'Klasör oluşturuldu',
+      openInSlicerFailed: 'Dilimleyicide açılamadı',
       folderDeleted: 'Klasör silindi',
       folderDeleted: 'Klasör silindi',
       fileDeleted: 'Dosya silindi',
       fileDeleted: 'Dosya silindi',
       filesDeleted: '{{count}} dosya silindi',
       filesDeleted: '{{count}} dosya silindi',
@@ -5391,6 +5392,9 @@ export default {
   // Model Görüntüleyici
   // Model Görüntüleyici
   modelViewer: {
   modelViewer: {
     openInSlicer: 'Dilimleyicide Aç',
     openInSlicer: 'Dilimleyicide Aç',
+    openInSlicerWith: '{{slicer}} ile aç',
+    moreSlicerOptions: 'Diğer dilimleyici seçenekleri',
+    openInSlicerFailed: 'Dilimleyicide açılamadı',
     tabs: {
     tabs: {
       model: '3B Model',
       model: '3B Model',
       gcode: 'G-kod Önizleme',
       gcode: 'G-kod Önizleme',
@@ -5441,6 +5445,61 @@ export default {
   },
   },
 
 
   // Akıllı Prizler
   // 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: {
   smartPlugs: {
     offline: 'Çevrimdışı',
     offline: 'Çevrimdışı',
     admin: 'Yönetici',
     admin: 'Yönetici',
@@ -5739,6 +5798,8 @@ export default {
     notificationEvents: 'Bildirim Olayları',
     notificationEvents: 'Bildirim Olayları',
     progressPercent: '(%25, %50, %75)',
     progressPercent: '(%25, %50, %75)',
     bedCooledAfterPrint: '(baskı tamamlandıktan sonra)',
     bedCooledAfterPrint: '(baskı tamamlandıktan sonra)',
+    haSensorAlert: 'Sensör Uyarısı',
+    haSensorAlertDescription: '(bağlı bir Home Assistant sensörü dikkat gerektiriyor)',
     eventPriority: {
     eventPriority: {
       sectionTitle: 'ntfy Önceliği',
       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.',
       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: "Сканувати",
     scanFolder: "Сканувати",
     toast: {
     toast: {
       folderCreated: "Папка створена",
       folderCreated: "Папка створена",
+      openInSlicerFailed: "Не вдалося відкрити у слайсері",
       folderDeleted: "Папку видалено",
       folderDeleted: "Папку видалено",
       fileDeleted: "Файл видалено",
       fileDeleted: "Файл видалено",
       filesDeleted: "Видалені файли {{count}}.",
       filesDeleted: "Видалені файли {{count}}.",
@@ -5470,6 +5471,9 @@ export default {
   // Model Viewer
   // Model Viewer
   modelViewer: {
   modelViewer: {
     openInSlicer: "Відкрити у слайсері",
     openInSlicer: "Відкрити у слайсері",
+    openInSlicerWith: "Відкрити у {{slicer}}",
+    moreSlicerOptions: "Більше варіантів слайсера",
+    openInSlicerFailed: "Не вдалося відкрити у слайсері",
     tabs: {
     tabs: {
       model: "3D-модель",
       model: "3D-модель",
       gcode: "Попередній перегляд G-коду",
       gcode: "Попередній перегляд G-коду",
@@ -5520,6 +5524,61 @@ export default {
   },
   },
 
 
   // Smart Plugs
   // 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: {
   smartPlugs: {
     offline: "Не в мережі",
     offline: "Не в мережі",
     admin: "Адміністрування",
     admin: "Адміністрування",
@@ -5832,6 +5891,8 @@ export default {
     notificationEvents: "Події сповіщень",
     notificationEvents: "Події сповіщень",
     progressPercent: "(25%, 50%, 75%)",
     progressPercent: "(25%, 50%, 75%)",
     bedCooledAfterPrint: "(після завершення друку)",
     bedCooledAfterPrint: "(після завершення друку)",
+    haSensorAlert: "Сповіщення датчика",
+    haSensorAlertDescription: "(пов'язаний датчик Home Assistant потребує уваги)",
     // Per-event ntfy priority (#990)
     // Per-event ntfy priority (#990)
     eventPriority: {
     eventPriority: {
       sectionTitle: "Пріоритет ntfy",
       sectionTitle: "Пріоритет ntfy",

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

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

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

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

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

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

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

@@ -9,6 +9,7 @@ import {
   Upload,
   Upload,
   Trash2,
   Trash2,
   Download,
   Download,
+  ExternalLink,
   MoreVertical,
   MoreVertical,
   ChevronRight,
   ChevronRight,
   FolderPlus,
   FolderPlus,
@@ -73,6 +74,7 @@ import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
 import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
 import { formatFileSize } from '../utils/file';
 import { formatFileSize } from '../utils/file';
+import { isSliceableFilename, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
 type SortDirection = 'asc' | 'desc';
@@ -741,14 +743,6 @@ function isSlicedFilename(filename: string): boolean {
   return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf');
   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
 // File Card
 interface FileCardProps {
 interface FileCardProps {
   file: LibraryFileListItem;
   file: LibraryFileListItem;
@@ -759,8 +753,10 @@ interface FileCardProps {
   onDownload: (id: number) => void;
   onDownload: (id: number) => void;
   onPrint?: (file: LibraryFileListItem) => void;
   onPrint?: (file: LibraryFileListItem) => void;
   onSlice?: (file: LibraryFileListItem) => void;
   onSlice?: (file: LibraryFileListItem) => void;
+  onOpenInSlicer?: (file: LibraryFileListItem) => void;
   onRunPipeline?: (file: LibraryFileListItem) => void;
   onRunPipeline?: (file: LibraryFileListItem) => void;
   useSlicerApi?: boolean;
   useSlicerApi?: boolean;
+  canSlice?: boolean;
   onPreview3d?: (file: LibraryFileListItem) => void;
   onPreview3d?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
@@ -773,7 +769,7 @@ interface FileCardProps {
   t: TFunction;
   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);
   const [showActions, setShowActions] = useState(false);
 
 
   return (
   return (
@@ -899,16 +895,21 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
                   {t('common.print')}
                   {t('common.print')}
                 </button>
                 </button>
               )}
               )}
-              {onSlice && useSlicerApi && isSliceableFilename(file.filename) && (
+              {isSliceableFilename(file.filename) && (useSlicerApi ? onSlice : onOpenInSlicer) && (
                 <button
                 <button
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
                   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')}
                   {t('slice.action')}
                 </button>
                 </button>
               )}
               )}
@@ -1145,6 +1146,45 @@ export function FileManagerPage() {
     queryKey: ['settings'],
     queryKey: ['settings'],
     queryFn: () => api.getSettings() as Promise<AppSettings>,
     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({
   const { data: folders, isLoading: foldersLoading } = useQuery({
     queryKey: ['library-folders'],
     queryKey: ['library-folders'],
     queryFn: () => api.getLibraryFolders(),
     queryFn: () => api.getLibraryFolders(),
@@ -2420,8 +2460,10 @@ export function FileManagerPage() {
                     onDownload={handleDownload}
                     onDownload={handleDownload}
                     onPrint={setPrintFile}
                     onPrint={setPrintFile}
                     onSlice={setSliceFile}
                     onSlice={setSliceFile}
+                    onOpenInSlicer={handleOpenInSlicer}
                     onRunPipeline={setRunPipelineFile}
                     onRunPipeline={setRunPipelineFile}
                     useSlicerApi={settings?.use_slicer_api ?? false}
                     useSlicerApi={settings?.use_slicer_api ?? false}
+                    canSlice={canSlice()}
                     onPreview3d={(f) => {
                     onPreview3d={(f) => {
                       // Sliced files (.gcode / .gcode.3mf) open the same
                       // Sliced files (.gcode / .gcode.3mf) open the same
                       // full-page gcode viewer the archive card uses, so
                       // full-page gcode viewer the archive card uses, so
@@ -2597,18 +2639,21 @@ export function FileManagerPage() {
                           </button>
                           </button>
                         </>
                         </>
                       )}
                       )}
-                      {(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (
+                      {isSliceableFilename(file.filename) && (
                         <button
                         <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 ${
                           className={`p-1.5 rounded transition-colors ${
-                            hasPermission('library:upload')
+                            canSlice()
                               ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
                               ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
                               : 'text-bambu-gray/50 cursor-not-allowed'
                               : '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>
                         </button>
                       )}
                       )}
                       {(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (
                       {(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (

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

@@ -11,7 +11,7 @@ import {
   type MakerworldRecentImport,
   type MakerworldRecentImport,
   type MakerworldResolvedModel,
   type MakerworldResolvedModel,
 } from '../api/client';
 } from '../api/client';
-import { openInSlicer, type SlicerType } from '../utils/slicer';
+import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
@@ -185,7 +185,7 @@ export function MakerworldPage() {
   // whichever slicer this button actually drives — depends on useSlicerApi.
   // whichever slicer this button actually drives — depends on useSlicerApi.
   const useSlicerApi = settingsQuery.data?.use_slicer_api ?? false;
   const useSlicerApi = settingsQuery.data?.use_slicer_api ?? false;
   const apiSlicer: SlicerType = settingsQuery.data?.preferred_slicer || 'bambu_studio';
   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 preferredSlicer: SlicerType = useSlicerApi ? apiSlicer : desktopSlicer;
   const preferredSlicerName =
   const preferredSlicerName =
     preferredSlicer === 'orcaslicer' ? 'OrcaSlicer' : 'Bambu Studio';
     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 { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal';
 import { AiDetectionModal } from '../components/AiDetectionModal';
 import { AiDetectionModal } from '../components/AiDetectionModal';
 import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
 import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
+import { PrinterHASensorRow } from '../components/PrinterHASensorRow';
 import { AMSHistoryModal } from '../components/AMSHistoryModal';
 import { AMSHistoryModal } from '../components/AMSHistoryModal';
 import { AmsBackupModal } from '../components/AmsBackupModal';
 import { AmsBackupModal } from '../components/AmsBackupModal';
 import { HeaterHistoryModal } from '../components/HeaterHistoryModal';
 import { HeaterHistoryModal } from '../components/HeaterHistoryModal';
@@ -6018,6 +6019,11 @@ function PrinterCard({
           </div>
           </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 */}
         {/* Connection Info & Actions */}
         <div className="pt-4">
         <div className="pt-4">
             <div className="mb-3 h-[2px] bg-bambu-dark-tertiary" />
             <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 { PRESET_CATEGORIES, parsePresetTriple } from '../utils/temperatureFanPresets';
 import { CALIBRATION_MODES, CALIBRATION_MODE_ACTIVE, CALIBRATION_MODE_INACTIVE } from '../utils/calibrationMode';
 import { CALIBRATION_MODES, CALIBRATION_MODE_ACTIVE, CALIBRATION_MODE_INACTIVE } from '../utils/calibrationMode';
 import { PreheatFilamentTargetsEditor } from '../components/PreheatFilamentTargetsEditor';
 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 { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
 import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
 import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
 import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel';
 import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel';
@@ -22,6 +22,7 @@ import { CopyButton } from '../components/CopyButton';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { SmartPlugCard } from '../components/SmartPlugCard';
 import { SmartPlugCard } from '../components/SmartPlugCard';
 import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
 import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
+import { HASensorModal } from '../components/HASensorModal';
 import { NotificationProviderCard } from '../components/NotificationProviderCard';
 import { NotificationProviderCard } from '../components/NotificationProviderCard';
 import { AddNotificationModal } from '../components/AddNotificationModal';
 import { AddNotificationModal } from '../components/AddNotificationModal';
 import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
 import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
@@ -50,7 +51,7 @@ import { availableLanguages } from '../i18n';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, type ThemeAccent } from '../contexts/ThemeContext';
 import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, type ThemeAccent } from '../contexts/ThemeContext';
 import { useState, useEffect, useRef, useCallback } from 'react';
 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 { registerSettingsSearch, getSettingsSearchEntries } from '../lib/settingsSearch';
 import type { UsersSubTab } from '../lib/settingsSearch';
 import type { UsersSubTab } from '../lib/settingsSearch';
 
 
@@ -184,6 +185,8 @@ export function SettingsPage() {
   const [humidityDrafts, setHumidityDrafts] = useState<Record<string, string>>({});
   const [humidityDrafts, setHumidityDrafts] = useState<Record<string, string>>({});
   const [showPlugModal, setShowPlugModal] = useState(false);
   const [showPlugModal, setShowPlugModal] = useState(false);
   const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
   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 [showNotificationModal, setShowNotificationModal] = useState(false);
   const [editingProvider, setEditingProvider] = useState<NotificationProvider | null>(null);
   const [editingProvider, setEditingProvider] = useState<NotificationProvider | null>(null);
   const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
   const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
@@ -434,6 +437,12 @@ export function SettingsPage() {
     queryFn: api.getPrinters,
     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.
   // A business-sized fleet gets the commercial ask instead of the donation ask.
   const sponsorAudience = fleetAudience(printers?.length ?? 0);
   const sponsorAudience = fleetAudience(printers?.length ?? 0);
 
 
@@ -3523,6 +3532,88 @@ export function SettingsPage() {
               </CardContent>
               </CardContent>
             </Card>
             </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>
         </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 */}
       {/* Notification Modal */}
       {showNotificationModal && (
       {showNotificationModal && (
         <AddNotificationModal
         <AddNotificationModal

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

@@ -28,6 +28,56 @@ export type SlicerType = 'bambu_studio' | 'orcaslicer';
 
 
 type Platform = 'windows' | 'macos' | 'linux' | 'unknown';
 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
  * Detect the user's operating system
  */
  */

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor