فهرست منبع

feat(smart-plugs): auto-off after AMS drying completes (#1349)

  Reporter Kyobinoyo asked for the equivalent of the existing
  print-finish auto-off but triggered when AMS drying ends.

  Two new SmartPlug columns: auto_off_after_drying (default false),
  off_delay_after_drying_minutes (default 10 — AMS chamber is hot
  post-cycle so longer cooldown than the print-finish default of 5).
  SQLite + Postgres migrations both idempotent.

  Trigger lives in BambuMQTTClient — per-AMS _previous_dry_times
  tracks the dry_time > 0 → 0 falling edge and fires a new
  on_drying_complete(ams_id) callback. Plumbed through
  PrinterManager.set_drying_complete_callback to
  SmartPlugManager.on_drying_complete(printer_id, db), which walks
  linked plugs and respects the per-plug toggle. Catches queue,
  ambient and manual drying identically because it observes firmware
  state, not scheduler intent.

  Frontend: single "Auto Off After Drying" toggle + delay input on
  the smart plug card, next to the existing print-finish auto-off
  section.

  Per-AMS plug routing (separate plug for AMS only, per-AMS targeting
  on dual-AMS printers) deferred — Bambuddy's plug model is
  plug→printer, so the trigger fires whenever any AMS on the linked
  printer finishes a cycle.
maziggy 1 هفته پیش
والد
کامیت
6f2cec5eb3

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


+ 19 - 0
backend/app/core/database.py

@@ -2591,6 +2591,25 @@ async def run_migrations(conn):
             """)
             """)
         )
         )
 
 
+    # Migration: smart_plugs gets per-plug auto-off-after-drying toggle and
+    # delay (#1349). Fires whenever any AMS attached to the linked printer
+    # finishes a dry cycle. Plain ANSI ALTER TABLE works on both SQLite and
+    # Postgres for INTEGER/BOOLEAN with simple defaults.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN auto_off_after_drying BOOLEAN DEFAULT 0")
+        await _safe_execute(
+            conn, "ALTER TABLE smart_plugs ADD COLUMN off_delay_after_drying_minutes INTEGER DEFAULT 10"
+        )
+    else:
+        await _safe_execute(
+            conn,
+            "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS auto_off_after_drying BOOLEAN DEFAULT false",
+        )
+        await _safe_execute(
+            conn,
+            "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS off_delay_after_drying_minutes INTEGER DEFAULT 10",
+        )
+
 
 
 async def seed_notification_templates():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """Seed default notification templates if they don't exist."""

+ 25 - 0
backend/app/main.py

@@ -4741,6 +4741,31 @@ async def lifespan(app: FastAPI):
 
 
     printer_manager.set_bed_temp_update_callback(on_bed_temp_update)
     printer_manager.set_bed_temp_update_callback(on_bed_temp_update)
 
 
+    async def on_drying_complete(printer_id: int, ams_id: int):
+        """Smart-plug auto-off-after-drying trigger (#1349).
+
+        Fires once per AMS unit when ``dry_time`` falls from >0 to 0. The
+        manager walks all plugs linked to this printer and turns off only
+        the ones with ``auto_off_after_drying`` enabled, after their
+        per-plug delay. Multiple AMS units finishing close together (e.g. a
+        dual-AMS dry that ends within the same MQTT push) call this once
+        per unit — the manager's ``_cancel_pending_off`` collapses
+        repeated scheduling on the same plug to one timer, so duplicate
+        fires are safe.
+        """
+        try:
+            async with async_session() as db:
+                await smart_plug_manager.on_drying_complete(printer_id, db)
+        except Exception as e:
+            logging.getLogger(__name__).warning(
+                "Failed to schedule auto-off-after-drying for printer %d (AMS %d): %s",
+                printer_id,
+                ams_id,
+                e,
+            )
+
+    printer_manager.set_drying_complete_callback(on_drying_complete)
+
     # Initialize MQTT relay from settings
     # Initialize MQTT relay from settings
     async with async_session() as db:
     async with async_session() as db:
         from backend.app.api.routes.settings import get_setting
         from backend.app.api.routes.settings import get_setting

+ 9 - 0
backend/app/models/smart_plug.py

@@ -86,6 +86,15 @@ class SmartPlug(Base):
     off_delay_minutes: Mapped[int] = mapped_column(Integer, default=5)  # For time mode
     off_delay_minutes: Mapped[int] = mapped_column(Integer, default=5)  # For time mode
     off_temp_threshold: Mapped[int] = mapped_column(Integer, default=70)  # For temp mode (°C)
     off_temp_threshold: Mapped[int] = mapped_column(Integer, default=70)  # For temp mode (°C)
 
 
+    # Auto-off after AMS drying completes (#1349). Independent of `auto_off`
+    # (which only fires after a print finishes). Uses its own delay because
+    # the AMS is hot after a drying cycle and users may want longer cooldown
+    # than the print-finish default. Fires whenever any AMS attached to the
+    # linked printer finishes a dry cycle — Bambuddy doesn't model per-AMS
+    # plug routing, the trigger is plug-vs-printer-level.
+    auto_off_after_drying: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+    off_delay_after_drying_minutes: Mapped[int] = mapped_column(Integer, default=10, server_default="10")
+
     # Optional auth (some Tasmota configs require it)
     # Optional auth (some Tasmota configs require it)
     username: Mapped[str | None] = mapped_column(String(50), nullable=True)
     username: Mapped[str | None] = mapped_column(String(50), nullable=True)
     password: Mapped[str | None] = mapped_column(String(100), nullable=True)
     password: Mapped[str | None] = mapped_column(String(100), nullable=True)

+ 8 - 0
backend/app/schemas/smart_plug.py

@@ -69,6 +69,11 @@ class SmartPlugBase(BaseModel):
     off_delay_mode: Literal["time", "temperature"] = "time"
     off_delay_mode: Literal["time", "temperature"] = "time"
     off_delay_minutes: int = Field(default=5, ge=0, le=60)
     off_delay_minutes: int = Field(default=5, ge=0, le=60)
     off_temp_threshold: int = Field(default=70, ge=30, le=150)
     off_temp_threshold: int = Field(default=70, ge=30, le=150)
+    # #1349: auto-off after AMS drying completes. Independent of `auto_off`
+    # (print-finish). Fires whenever any AMS on the linked printer finishes
+    # a dry cycle.
+    auto_off_after_drying: bool = False
+    off_delay_after_drying_minutes: int = Field(default=10, ge=0, le=120)
     # Power alerts
     # Power alerts
     power_alert_enabled: bool = False
     power_alert_enabled: bool = False
     power_alert_high: float | None = Field(default=None, ge=0, le=5000)  # Alert when power > this (watts)
     power_alert_high: float | None = Field(default=None, ge=0, le=5000)  # Alert when power > this (watts)
@@ -156,6 +161,9 @@ class SmartPlugUpdate(BaseModel):
     off_delay_mode: Literal["time", "temperature"] | None = None
     off_delay_mode: Literal["time", "temperature"] | None = None
     off_delay_minutes: int | None = Field(default=None, ge=0, le=60)
     off_delay_minutes: int | None = Field(default=None, ge=0, le=60)
     off_temp_threshold: int | None = Field(default=None, ge=30, le=150)
     off_temp_threshold: int | None = Field(default=None, ge=30, le=150)
+    # #1349: per-plug drying auto-off.
+    auto_off_after_drying: bool | None = None
+    off_delay_after_drying_minutes: int | None = Field(default=None, ge=0, le=120)
     username: str | None = None
     username: str | None = None
     password: str | None = None
     password: str | None = None
     # Power alerts
     # Power alerts

+ 36 - 0
backend/app/services/bambu_mqtt.py

@@ -332,6 +332,7 @@ class BambuMQTTClient:
         on_ams_change: Callable[[list], None] | None = None,
         on_ams_change: Callable[[list], None] | None = None,
         on_layer_change: Callable[[int], None] | None = None,
         on_layer_change: Callable[[int], None] | None = None,
         on_bed_temp_update: Callable[[float], None] | None = None,
         on_bed_temp_update: Callable[[float], None] | None = None,
+        on_drying_complete: Callable[[int], None] | None = None,
     ):
     ):
         self.ip_address = ip_address
         self.ip_address = ip_address
         self.serial_number = serial_number
         self.serial_number = serial_number
@@ -343,6 +344,13 @@ class BambuMQTTClient:
         self.on_ams_change = on_ams_change
         self.on_ams_change = on_ams_change
         self.on_layer_change = on_layer_change
         self.on_layer_change = on_layer_change
         self.on_bed_temp_update = on_bed_temp_update
         self.on_bed_temp_update = on_bed_temp_update
+        # #1349: fired when an AMS unit's dry_time falls from >0 to 0 — i.e.
+        # the drying cycle just finished (auto- or manually-triggered).
+        # Receives the AMS id of the unit that finished drying.
+        self.on_drying_complete = on_drying_complete
+        # Per-AMS previous dry_time, used to detect the falling edge above.
+        # Seeded lazily as we observe each AMS unit.
+        self._previous_dry_times: dict[int, int] = {}
 
 
         self.state = PrinterState()
         self.state = PrinterState()
         self._client: mqtt.Client | None = None
         self._client: mqtt.Client | None = None
@@ -1829,6 +1837,34 @@ class BambuMQTTClient:
         # Persist updated drying fields back to raw_data
         # Persist updated drying fields back to raw_data
         self.state.raw_data["ams"] = merged_ams
         self.state.raw_data["ams"] = merged_ams
 
 
+        # Detect AMS drying-complete falling edge per-unit (#1349). When an
+        # AMS's `dry_time` transitions from >0 to 0 the cycle just finished
+        # — fire the callback so smart-plug auto-off-after-drying can run.
+        # Works identically for queue-triggered, ambient, and manual drying
+        # because we observe the firmware-reported state, not our own intent.
+        if self.on_drying_complete:
+            for ams_unit in merged_ams:
+                try:
+                    ams_id = int(ams_unit.get("id", -1))
+                except (TypeError, ValueError):
+                    continue
+                if ams_id < 0:
+                    continue
+                try:
+                    current = int(ams_unit.get("dry_time") or 0)
+                except (TypeError, ValueError):
+                    current = 0
+                previous = self._previous_dry_times.get(ams_id, 0)
+                self._previous_dry_times[ams_id] = current
+                if previous > 0 and current == 0:
+                    logger.info(
+                        "[%s] AMS %d drying complete (dry_time %d → 0)",
+                        self.serial_number,
+                        ams_id,
+                        previous,
+                    )
+                    self.on_drying_complete(ams_id)
+
         # Create a hash of relevant AMS data to detect changes
         # Create a hash of relevant AMS data to detect changes
         ams_hash_data = []
         ams_hash_data = []
         for ams_unit in ams_list:
         for ams_unit in ams_list:

+ 14 - 0
backend/app/services/printer_manager.py

@@ -173,6 +173,7 @@ class PrinterManager:
         self._on_ams_change: Callable[[int, list], None] | None = None
         self._on_ams_change: Callable[[int, list], None] | None = None
         self._on_layer_change: Callable[[int, int], None] | None = None
         self._on_layer_change: Callable[[int, int], None] | None = None
         self._on_bed_temp_update: Callable[[int, float], None] | None = None
         self._on_bed_temp_update: Callable[[int, float], None] | None = None
+        self._on_drying_complete: Callable[[int, int], None] | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
         # Track who started the current print (Issue #206)
         # Track who started the current print (Issue #206)
         self._current_print_user: dict[int, dict] = {}  # {printer_id: {"user_id": int, "username": str}}
         self._current_print_user: dict[int, dict] = {}  # {printer_id: {"user_id": int, "username": str}}
@@ -324,6 +325,14 @@ class PrinterManager:
         """Set callback for bed temperature updates. Receives (printer_id, bed_temp)."""
         """Set callback for bed temperature updates. Receives (printer_id, bed_temp)."""
         self._on_bed_temp_update = callback
         self._on_bed_temp_update = callback
 
 
+    def set_drying_complete_callback(self, callback: Callable[[int, int], None]):
+        """Set callback for AMS drying completion events (#1349).
+
+        Receives ``(printer_id, ams_id)``. Fires once per falling edge of
+        ``dry_time`` (>0 → 0) for each AMS unit.
+        """
+        self._on_drying_complete = callback
+
     def _schedule_async(self, coro):
     def _schedule_async(self, coro):
         """Schedule an async coroutine from a sync context.
         """Schedule an async coroutine from a sync context.
 
 
@@ -375,6 +384,10 @@ class PrinterManager:
             if self._on_bed_temp_update:
             if self._on_bed_temp_update:
                 self._schedule_async(self._on_bed_temp_update(printer_id, bed_temp))
                 self._schedule_async(self._on_bed_temp_update(printer_id, bed_temp))
 
 
+        def on_drying_complete(ams_id: int):
+            if self._on_drying_complete:
+                self._schedule_async(self._on_drying_complete(printer_id, ams_id))
+
         client = BambuMQTTClient(
         client = BambuMQTTClient(
             ip_address=printer.ip_address,
             ip_address=printer.ip_address,
             serial_number=printer.serial_number,
             serial_number=printer.serial_number,
@@ -386,6 +399,7 @@ class PrinterManager:
             on_ams_change=on_ams_change,
             on_ams_change=on_ams_change,
             on_layer_change=on_layer_change,
             on_layer_change=on_layer_change,
             on_bed_temp_update=on_bed_temp_update,
             on_bed_temp_update=on_bed_temp_update,
+            on_drying_complete=on_drying_complete,
         )
         )
 
 
         client.connect()
         client.connect()

+ 39 - 0
backend/app/services/smart_plug_manager.py

@@ -293,6 +293,45 @@ class SmartPlugManager:
             elif plug.off_delay_mode == "temperature":
             elif plug.off_delay_mode == "temperature":
                 self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)
                 self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)
 
 
+    async def on_drying_complete(self, printer_id: int, db: AsyncSession):
+        """Schedule turn-off for plugs flagged ``auto_off_after_drying`` when
+        an AMS drying cycle finishes on this printer (#1349).
+
+        Mirrors :meth:`on_print_complete` but uses the drying-specific
+        toggle and delay. Iterates every plug linked to the printer and
+        fires only on the ones the user has opted-in via the per-plug
+        toggle. Always uses the time-delay branch — temperature-based
+        cooldown is about the printer's hotend, which isn't meaningful
+        after a drying cycle (AMS chamber is the thing that's hot, and
+        Bambuddy doesn't track its temperature).
+        """
+        plugs = await self._get_plugs_for_printer(printer_id, db)
+        if not plugs:
+            return
+
+        for plug in plugs:
+            if not plug.enabled:
+                logger.debug("Smart plug '%s' is disabled, skipping drying auto-off", plug.name)
+                continue
+
+            if not plug.auto_off_after_drying:
+                logger.debug("Smart plug '%s' auto_off_after_drying is disabled, skipping", plug.name)
+                continue
+
+            # HA script entities can only be triggered, not turned off — same
+            # guard the print-finish path uses.
+            if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
+                logger.debug("Smart plug '%s' is a HA script entity, skipping drying auto-off", plug.name)
+                continue
+
+            logger.info(
+                "Drying completed on printer %s, scheduling turn-off for plug '%s' in %d min",
+                printer_id,
+                plug.name,
+                plug.off_delay_after_drying_minutes,
+            )
+            self._schedule_delayed_off(plug, printer_id, plug.off_delay_after_drying_minutes * 60)
+
     def _schedule_delayed_off(self, plug: "SmartPlug", printer_id: int, delay_seconds: int):
     def _schedule_delayed_off(self, plug: "SmartPlug", printer_id: int, delay_seconds: int):
         """Schedule turn-off after delay."""
         """Schedule turn-off after delay."""
         # Cancel any existing task for this plug
         # Cancel any existing task for this plug

+ 74 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -4998,3 +4998,77 @@ class TestAmsFilamentSettingExternalSpoolEncoding:
         # a future capture-driven change shows up in the diff.
         # a future capture-driven change shows up in the diff.
         assert cmd["tray_id"] == 0
         assert cmd["tray_id"] == 0
         assert cmd["slot_id"] == 0
         assert cmd["slot_id"] == 0
+
+
+class TestDryingCompleteCallback:
+    """#1349 — fires ``on_drying_complete(ams_id)`` on a dry_time falling edge."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        events: list[int] = []
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST-DRYING",
+            access_code="12345678",
+            on_drying_complete=events.append,
+        )
+        client._drying_events = events  # Expose for assertions
+        return client
+
+    def test_falling_edge_fires_callback(self, mqtt_client):
+        """First push reports drying active, second reports drying done."""
+        # Push 1: AMS 0 drying with 60 minutes remaining.
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 60, "tray": []}]})
+        assert mqtt_client._drying_events == []
+
+        # Push 2: dry_time hits 0 → callback fires with the AMS id.
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+        assert mqtt_client._drying_events == [0]
+
+    def test_no_fire_when_dry_time_never_started(self, mqtt_client):
+        """dry_time = 0 across consecutive pushes does NOT fire — there was
+        no drying cycle to finish. Guards against the seed-from-zero false
+        positive on startup."""
+        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 == []
+
+    def test_falling_edge_fires_once(self, mqtt_client):
+        """Subsequent zero-pushes after the edge don't refire the callback."""
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 30, "tray": []}]})
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+        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]
+
+    def test_per_ams_tracking(self, mqtt_client):
+        """Two AMS units finishing drying at different times each fire once
+        — the falling-edge state is keyed per AMS id."""
+        # Both start drying.
+        mqtt_client._handle_ams_data(
+            {"ams": [{"id": "0", "dry_time": 30, "tray": []}, {"id": "1", "dry_time": 30, "tray": []}]}
+        )
+        # AMS 0 finishes, AMS 1 still drying.
+        mqtt_client._handle_ams_data(
+            {"ams": [{"id": "0", "dry_time": 0, "tray": []}, {"id": "1", "dry_time": 15, "tray": []}]}
+        )
+        assert mqtt_client._drying_events == [0]
+        # AMS 1 finishes.
+        mqtt_client._handle_ams_data(
+            {"ams": [{"id": "0", "dry_time": 0, "tray": []}, {"id": "1", "dry_time": 0, "tray": []}]}
+        )
+        assert mqtt_client._drying_events == [0, 1]
+
+    def test_restart_drying_after_completion_refires_callback(self, mqtt_client):
+        """A new drying cycle after the previous one finished fires the
+        callback again on its own falling edge — covers the user manually
+        starting a second dry from the UI."""
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 30, "tray": []}]})
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+        # New cycle starts.
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 45, "tray": []}]})
+        # And finishes.
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+        assert mqtt_client._drying_events == [0, 0]

+ 95 - 0
backend/tests/unit/services/test_smart_plug_manager.py

@@ -40,6 +40,13 @@ class TestSmartPlugManager:
         plug.auto_off_pending = False
         plug.auto_off_pending = False
         plug.last_state = "ON"
         plug.last_state = "ON"
         plug.last_checked = None
         plug.last_checked = None
+        # #1349: drying defaults match the new schema — both off until the
+        # user opts in, so existing tests don't accidentally activate the
+        # post-drying path.
+        plug.plug_type = "tasmota"
+        plug.ha_entity_id = None
+        plug.auto_off_after_drying = False
+        plug.off_delay_after_drying_minutes = 10
         return plug
         return plug
 
 
     @pytest.fixture
     @pytest.fixture
@@ -248,6 +255,94 @@ class TestSmartPlugManager:
 
 
             mock_schedule.assert_not_called()
             mock_schedule.assert_not_called()
 
 
+    # ========================================================================
+    # Tests for on_drying_complete (#1349)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_on_drying_complete_schedules_delayed_off_when_enabled(self, manager, mock_plug, mock_db):
+        """Plug with ``auto_off_after_drying=True`` gets a delayed-off scheduled
+        using its drying-specific delay (independent of print-finish delay)."""
+        mock_plug.auto_off_after_drying = True
+        mock_plug.off_delay_after_drying_minutes = 15
+
+        with (
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_schedule_delayed_off") as mock_schedule,
+        ):
+            mock_get_plug.return_value = [mock_plug]
+
+            await manager.on_drying_complete(printer_id=1, db=mock_db)
+
+            mock_schedule.assert_called_once_with(mock_plug, 1, 15 * 60)
+
+    @pytest.mark.asyncio
+    async def test_on_drying_complete_skipped_when_toggle_off(self, manager, mock_plug, mock_db):
+        """Default state — toggle off → nothing scheduled. This is the regression
+        guard for users who only enable the print-finish auto-off and don't
+        want the AMS-drying path silently running on the same plug."""
+        mock_plug.auto_off_after_drying = False
+        # auto_off itself is True (existing print-finish behaviour) — the
+        # drying path must still be a no-op without its own toggle.
+        mock_plug.auto_off = True
+
+        with (
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_schedule_delayed_off") as mock_schedule,
+        ):
+            mock_get_plug.return_value = [mock_plug]
+
+            await manager.on_drying_complete(printer_id=1, db=mock_db)
+
+            mock_schedule.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_drying_complete_skipped_when_plug_disabled(self, manager, mock_plug, mock_db):
+        """Drying auto-off honours the master ``enabled`` flag."""
+        mock_plug.auto_off_after_drying = True
+        mock_plug.enabled = False
+
+        with (
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_schedule_delayed_off") as mock_schedule,
+        ):
+            mock_get_plug.return_value = [mock_plug]
+
+            await manager.on_drying_complete(printer_id=1, db=mock_db)
+
+            mock_schedule.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_drying_complete_skipped_for_ha_script_entity(self, manager, mock_plug, mock_db):
+        """HA script entities can be triggered but not turned off — same
+        guard the print-finish path has."""
+        mock_plug.auto_off_after_drying = True
+        mock_plug.plug_type = "homeassistant"
+        mock_plug.ha_entity_id = "script.lights_off"
+
+        with (
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_schedule_delayed_off") as mock_schedule,
+        ):
+            mock_get_plug.return_value = [mock_plug]
+
+            await manager.on_drying_complete(printer_id=1, db=mock_db)
+
+            mock_schedule.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_drying_complete_no_op_when_no_plugs(self, manager, mock_db):
+        """Printer without any linked plugs is a silent no-op (not an error)."""
+        with (
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_schedule_delayed_off") as mock_schedule,
+        ):
+            mock_get_plug.return_value = []
+
+            await manager.on_drying_complete(printer_id=1, db=mock_db)
+
+            mock_schedule.assert_not_called()
+
     # ========================================================================
     # ========================================================================
     # Tests for _cancel_pending_off
     # Tests for _cancel_pending_off
     # ========================================================================
     # ========================================================================

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

@@ -1447,6 +1447,9 @@ export interface SmartPlug {
   off_delay_mode: 'time' | 'temperature';
   off_delay_mode: 'time' | 'temperature';
   off_delay_minutes: number;
   off_delay_minutes: number;
   off_temp_threshold: number;
   off_temp_threshold: number;
+  // #1349: auto-off after AMS drying completes.
+  auto_off_after_drying: boolean;
+  off_delay_after_drying_minutes: number;
   username: string | null;
   username: string | null;
   password: string | null;
   password: string | null;
   // Power alerts
   // Power alerts
@@ -1518,6 +1521,9 @@ export interface SmartPlugCreate {
   off_delay_mode?: 'time' | 'temperature';
   off_delay_mode?: 'time' | 'temperature';
   off_delay_minutes?: number;
   off_delay_minutes?: number;
   off_temp_threshold?: number;
   off_temp_threshold?: number;
+  // #1349
+  auto_off_after_drying?: boolean;
+  off_delay_after_drying_minutes?: number;
   username?: string | null;
   username?: string | null;
   password?: string | null;
   password?: string | null;
   // Power alerts
   // Power alerts
@@ -1581,6 +1587,9 @@ export interface SmartPlugUpdate {
   off_delay_mode?: 'time' | 'temperature';
   off_delay_mode?: 'time' | 'temperature';
   off_delay_minutes?: number;
   off_delay_minutes?: number;
   off_temp_threshold?: number;
   off_temp_threshold?: number;
+  // #1349
+  auto_off_after_drying?: boolean;
+  off_delay_after_drying_minutes?: number;
   username?: string | null;
   username?: string | null;
   password?: string | null;
   password?: string | null;
   // Power alerts
   // Power alerts

+ 37 - 0
frontend/src/components/SmartPlugCard.tsx

@@ -209,6 +209,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
             </div>
             </div>
           )}
           )}
 
 
+
           {/* Feature Badges */}
           {/* Feature Badges */}
           {(plug.power_alert_enabled || plug.schedule_enabled || plug.plug_type === 'mqtt') && (
           {(plug.power_alert_enabled || plug.schedule_enabled || plug.plug_type === 'mqtt') && (
             <div className="flex flex-wrap gap-1.5 mb-3">
             <div className="flex flex-wrap gap-1.5 mb-3">
@@ -448,6 +449,42 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                   )}
                   )}
                 </div>
                 </div>
               )}
               )}
+
+              {/* Auto Off After Drying (#1349) — independent of the
+                  print-finish auto-off above. Uses its own delay because
+                  the AMS chamber is hot post-cycle and users often want
+                  more cooldown than the print-finish default. Fires when
+                  any AMS attached to the linked printer finishes a dry
+                  cycle. */}
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-sm text-white">{t('smartPlugs.autoOffAfterDrying')}</p>
+                  <p className="text-xs text-bambu-gray">{t('smartPlugs.autoOffAfterDryingDescription')}</p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={plug.auto_off_after_drying}
+                    onChange={(e) => updateMutation.mutate({ auto_off_after_drying: e.target.checked })}
+                    className="sr-only peer"
+                  />
+                  <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+
+              {plug.auto_off_after_drying && (
+                <div className="pl-4 border-l-2 border-bambu-dark-tertiary">
+                  <label className="block text-xs text-bambu-gray mb-1">{t('smartPlugs.delayAfterDryingMinutes')}</label>
+                  <input
+                    type="number"
+                    min="0"
+                    max="120"
+                    value={plug.off_delay_after_drying_minutes}
+                    onChange={(e) => updateMutation.mutate({ off_delay_after_drying_minutes: parseInt(e.target.value) || 10 })}
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
+                  />
+                </div>
+              )}
                 </>
                 </>
               )}
               )}
 
 

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

@@ -4462,6 +4462,9 @@ export default {
     autoOffDescription: 'Ausschalten wenn Druck abgeschlossen (einmalig)',
     autoOffDescription: 'Ausschalten wenn Druck abgeschlossen (einmalig)',
     autoOffPersistent: 'Aktiviert lassen',
     autoOffPersistent: 'Aktiviert lassen',
     autoOffPersistentDescription: 'Zwischen Drucken aktiviert bleiben statt einmalig',
     autoOffPersistentDescription: 'Zwischen Drucken aktiviert bleiben statt einmalig',
+    autoOffAfterDrying: 'Automatisch aus nach Trocknung',
+    autoOffAfterDryingDescription: 'Ausschalten, wenn AMS-Trocknung abgeschlossen ist',
+    delayAfterDryingMinutes: 'Verzögerung nach Trocknung (Minuten)',
     turnOffDelayMode: 'Ausschaltverzögerungsmodus',
     turnOffDelayMode: 'Ausschaltverzögerungsmodus',
     time: 'Zeit',
     time: 'Zeit',
     temp: 'Temp.',
     temp: 'Temp.',

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

@@ -4471,6 +4471,9 @@ export default {
     autoOffDescription: 'Turn off when print completes (one-shot)',
     autoOffDescription: 'Turn off when print completes (one-shot)',
     autoOffPersistent: 'Keep Enabled',
     autoOffPersistent: 'Keep Enabled',
     autoOffPersistentDescription: 'Stay enabled between prints instead of one-shot',
     autoOffPersistentDescription: 'Stay enabled between prints instead of one-shot',
+    autoOffAfterDrying: 'Auto Off After Drying',
+    autoOffAfterDryingDescription: 'Turn off when AMS drying completes',
+    delayAfterDryingMinutes: 'Drying delay (minutes)',
     turnOffDelayMode: 'Turn Off Delay Mode',
     turnOffDelayMode: 'Turn Off Delay Mode',
     time: 'Time',
     time: 'Time',
     temp: 'Temp',
     temp: 'Temp',

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

@@ -4452,6 +4452,9 @@ export default {
     autoOffDescription: 'Éteindre à la fin de l\'impression (unique)',
     autoOffDescription: 'Éteindre à la fin de l\'impression (unique)',
     autoOffPersistent: 'Garder activé',
     autoOffPersistent: 'Garder activé',
     autoOffPersistentDescription: 'Rester activé entre les impressions au lieu d\'une seule fois',
     autoOffPersistentDescription: 'Rester activé entre les impressions au lieu d\'une seule fois',
+    autoOffAfterDrying: 'Arrêt auto après séchage',
+    autoOffAfterDryingDescription: 'Éteindre à la fin du séchage de l\'AMS',
+    delayAfterDryingMinutes: 'Délai après séchage (minutes)',
     turnOffDelayMode: 'Mode de délai d\'extinction',
     turnOffDelayMode: 'Mode de délai d\'extinction',
     time: 'Temps',
     time: 'Temps',
     temp: 'Temp.',
     temp: 'Temp.',

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

@@ -4451,6 +4451,9 @@ export default {
     autoOffDescription: 'Spegni quando la stampa è completata (una tantum)',
     autoOffDescription: 'Spegni quando la stampa è completata (una tantum)',
     autoOffPersistent: 'Mantieni attivo',
     autoOffPersistent: 'Mantieni attivo',
     autoOffPersistentDescription: 'Resta attivo tra le stampe invece di una tantum',
     autoOffPersistentDescription: 'Resta attivo tra le stampe invece di una tantum',
+    autoOffAfterDrying: 'Spegni dopo asciugatura',
+    autoOffAfterDryingDescription: 'Spegni al termine dell\'asciugatura AMS',
+    delayAfterDryingMinutes: 'Ritardo dopo asciugatura (minuti)',
     turnOffDelayMode: 'Modalità ritardo spegnimento',
     turnOffDelayMode: 'Modalità ritardo spegnimento',
     time: 'Tempo',
     time: 'Tempo',
     temp: 'Temp.',
     temp: 'Temp.',

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

@@ -4463,6 +4463,9 @@ export default {
     autoOffDescription: '印刷完了時にオフにする(ワンショット)',
     autoOffDescription: '印刷完了時にオフにする(ワンショット)',
     autoOffPersistent: '有効のまま維持',
     autoOffPersistent: '有効のまま維持',
     autoOffPersistentDescription: 'ワンショットではなく印刷間で有効のまま維持',
     autoOffPersistentDescription: 'ワンショットではなく印刷間で有効のまま維持',
+    autoOffAfterDrying: '乾燥完了後に自動オフ',
+    autoOffAfterDryingDescription: 'AMSの乾燥が完了したらオフにする',
+    delayAfterDryingMinutes: '乾燥後の遅延(分)',
     turnOffDelayMode: 'オフ遅延モード',
     turnOffDelayMode: 'オフ遅延モード',
     time: '時間',
     time: '時間',
     temp: '温度',
     temp: '温度',

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

@@ -4451,6 +4451,9 @@ export default {
     autoOffDescription: 'Desligar quando a impressão terminar (única vez)',
     autoOffDescription: 'Desligar quando a impressão terminar (única vez)',
     autoOffPersistent: 'Manter ativado',
     autoOffPersistent: 'Manter ativado',
     autoOffPersistentDescription: 'Permanecer ativado entre impressões em vez de única vez',
     autoOffPersistentDescription: 'Permanecer ativado entre impressões em vez de única vez',
+    autoOffAfterDrying: 'Desligar Após Secagem',
+    autoOffAfterDryingDescription: 'Desligar quando a secagem do AMS terminar',
+    delayAfterDryingMinutes: 'Atraso após secagem (minutos)',
     turnOffDelayMode: 'Modo de atraso para desligar',
     turnOffDelayMode: 'Modo de atraso para desligar',
     time: 'Tempo',
     time: 'Tempo',
     temp: 'Temp.',
     temp: 'Temp.',

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

@@ -4451,6 +4451,9 @@ export default {
     autoOffDescription: '打印完成时关闭(一次性)',
     autoOffDescription: '打印完成时关闭(一次性)',
     autoOffPersistent: '保持启用',
     autoOffPersistent: '保持启用',
     autoOffPersistentDescription: '在打印之间保持启用而非一次性',
     autoOffPersistentDescription: '在打印之间保持启用而非一次性',
+    autoOffAfterDrying: '干燥完成后自动关闭',
+    autoOffAfterDryingDescription: 'AMS 干燥完成后关闭',
+    delayAfterDryingMinutes: '干燥后延迟(分钟)',
     turnOffDelayMode: '关闭延迟模式',
     turnOffDelayMode: '关闭延迟模式',
     time: '时间',
     time: '时间',
     temp: '温度',
     temp: '温度',

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

@@ -4451,6 +4451,9 @@ export default {
     autoOffDescription: '列印完成時關閉(一次性)',
     autoOffDescription: '列印完成時關閉(一次性)',
     autoOffPersistent: '保持啟用',
     autoOffPersistent: '保持啟用',
     autoOffPersistentDescription: '在列印之間保持啟用而非一次性',
     autoOffPersistentDescription: '在列印之間保持啟用而非一次性',
+    autoOffAfterDrying: '乾燥完成後自動關閉',
+    autoOffAfterDryingDescription: 'AMS 乾燥完成後關閉',
+    delayAfterDryingMinutes: '乾燥後延遲(分鐘)',
     turnOffDelayMode: '關閉延遲模式',
     turnOffDelayMode: '關閉延遲模式',
     time: '時間',
     time: '時間',
     temp: '溫度',
     temp: '溫度',

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


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-ChT_Zli3.js"></script>
+    <script type="module" crossorigin src="/assets/index-Cy6PHBkY.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Baw5c3Hn.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Baw5c3Hn.css">
   </head>
   </head>
   <body>
   <body>

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