瀏覽代碼

Auto-drying no longer waits on a stalled drying timer (#3478)

- A drying timer the stall check flags (dry_time unchanged, dry_status
  Off) is no longer taken for a running cycle by auto-drying or by
  _sync_drying_state, so the unit is dried again
- A stalled cycle Bambuddy started is closed with its own never_heated
  counter instead of the unproductive-humidity one; two in a row
  suspend the unit
- New notification template "Auto-Drying Not Heating" on the existing
  Auto-Drying Suspended provider switch; toggle description updated in
  all locales
- Typecheck baseline down by 2
- Tests: test_auto_drying_parked_3478.py
maziggy 3 天之前
父節點
當前提交
5be58d7f14
共有 56 個文件被更改,包括 365 次插入 和 42 次删除
  1. 0 16
      .basedpyright/baseline.json
  2. 1 0
      CHANGELOG.md
  3. 1 0
      backend/app/api/routes/notification_templates.py
  4. 11 0
      backend/app/models/notification_template.py
  5. 19 0
      backend/app/schemas/notification_template.py
  6. 8 1
      backend/app/services/notification_service.py
  7. 73 6
      backend/app/services/print_scheduler.py
  8. 233 0
      backend/tests/unit/test_auto_drying_parked_3478.py
  9. 1 1
      frontend/src/i18n/locales/de.ts
  10. 1 1
      frontend/src/i18n/locales/en.ts
  11. 1 1
      frontend/src/i18n/locales/es.ts
  12. 1 1
      frontend/src/i18n/locales/fr.ts
  13. 1 1
      frontend/src/i18n/locales/it.ts
  14. 1 1
      frontend/src/i18n/locales/ja.ts
  15. 1 1
      frontend/src/i18n/locales/ko.ts
  16. 1 1
      frontend/src/i18n/locales/nl.ts
  17. 1 1
      frontend/src/i18n/locales/pl.ts
  18. 1 1
      frontend/src/i18n/locales/pt-BR.ts
  19. 1 1
      frontend/src/i18n/locales/ru.ts
  20. 1 1
      frontend/src/i18n/locales/sv.ts
  21. 1 1
      frontend/src/i18n/locales/tr.ts
  22. 1 1
      frontend/src/i18n/locales/uk.ts
  23. 1 1
      frontend/src/i18n/locales/zh-CN.ts
  24. 1 1
      frontend/src/i18n/locales/zh-TW.ts
  25. 0 0
      static/assets/ArchivesPage-DxgEVTxj.js
  26. 0 0
      static/assets/CamWallPage-CMQTGlu4.js
  27. 0 0
      static/assets/CameraPage-DhtJEHNr.js
  28. 0 0
      static/assets/FileManagerPage-CBkRrbz6.js
  29. 0 0
      static/assets/InventoryPage-BarjIqiD.js
  30. 0 0
      static/assets/Layout-CB5M1Sq1.js
  31. 0 0
      static/assets/ModelSourcesPage-Caki-dOg.js
  32. 0 0
      static/assets/PrintersPage-cMWUTwVu.js
  33. 0 0
      static/assets/SettingsPage-LTmeqGTR.js
  34. 0 0
      static/assets/SliceModal-CuC8TvVV.js
  35. 0 0
      static/assets/SpoolBuddyInventoryPage-D_2Jc7PN.js
  36. 1 1
      static/assets/SpoolBuddySettingsPage-CgwIgNh_.js
  37. 0 0
      static/assets/de-BWt6eQQV.js
  38. 0 0
      static/assets/es-CkKpEBG4.js
  39. 0 0
      static/assets/fr-CNgEGm30.js
  40. 0 0
      static/assets/index-Bfi6_HSn.js
  41. 0 0
      static/assets/it-AkV28joJ.js
  42. 0 0
      static/assets/ja-DAOyMh7T.js
  43. 0 0
      static/assets/ko-CGDOBimQ.js
  44. 0 0
      static/assets/locationSensorDefaults-BpQWlsm1.js
  45. 0 0
      static/assets/nl-9hmiNd0i.js
  46. 0 0
      static/assets/pl-Dmknr1y_.js
  47. 0 0
      static/assets/pt-BR-Cu6j1VSY.js
  48. 0 0
      static/assets/ru-BzARiZ3F.js
  49. 0 0
      static/assets/spoolCost-B5GUnpea.js
  50. 0 0
      static/assets/sv-28uqPOCX.js
  51. 0 0
      static/assets/tr-eO_qWAJv.js
  52. 0 0
      static/assets/uk-B2J2MTvN.js
  53. 0 0
      static/assets/usePageFileDrop-tMAnaGv5.js
  54. 0 0
      static/assets/zh-CN-CjdQbDCx.js
  55. 0 0
      static/assets/zh-TW-CruxAX6u.js
  56. 2 2
      static/index.html

+ 0 - 16
.basedpyright/baseline.json

@@ -3859,22 +3859,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 32,
-                    "endColumn": 65,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 32,
-                    "endColumn": 65,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportArgumentType",
                 "range": {

+ 1 - 0
CHANGELOG.md

@@ -11,6 +11,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **"Any model" jobs go to the printer with the fewest print hours (#3480, sponsor vote Q4 2026)** — When several printers of a model can take a job, the queue used to pick the first one in its list, so on a farm the same printer got most of the work. It now picks the one with the fewest print hours (the Maintenance counter, including baseline hours entered there), so jobs and wear spread across the farm. Preferred colours still come first, and the queue powers on the least used printer too when it has to switch one on with an Auto Power On plug.
 
 ### Fixed
+- **Auto-drying no longer waits on a drying timer the printer never started (#3478, reported by @superflyer11)** — An AMS HT can accept a drying command while it feeds a print and never heat: its timer stays at the full time (12h 0m) and its status reads Off, for days. Since 1.3.1b1 the printer card shows such a timer as not running, but auto-drying still took it for a running cycle and never dried that unit again, while the humidity alarms kept firing. A timer that has not moved for a few minutes with the status Off now counts as not drying, so auto-drying tries again after its usual 30-minute pause. If that happens twice in a row, auto-drying stops on that unit and sends the **Auto-Drying Suspended** notification, saying the timer stood still (new template **Auto-Drying Not Heating**). It resumes once the humidity is back at or below the threshold. Wiki updated.
 - **The log says which auto-archive switch skipped a print (#3477, reported by @Fuzzylog2x)** — Since 1.3.0 the global **Auto-archive prints** switch (Settings > General > Archive Settings) takes effect for prints started outside Bambuddy. Before, it was saved and never read, so an install upgraded with it off stopped archiving, and the statistics stopped updating. The log only said "auto_archive: False", the same as for a printer's own switch. It now names the global switch or the printer, whichever turned archiving off.
 - **A camera window no longer opens a dozen streams to the printer at once (#3431, reported by @buffdriver)** — When the floating camera window was open while the page loaded (after a refresh, for example), Bambuddy opened about twenty camera streams for it. Only one was shown. The browser kept the rest open until the tab was closed, and they filled its six connections to the server. The page started each stream while still preparing to draw the window. It prepared the window several times before showing it, and every attempt left a stream behind. Streams now start only once the window is on the page. The camera page, the camera wall and the stream overlay start their streams the same way.
 - **A printer whose file service hangs during a print is no longer asked again and again until it refuses every upload (#3474, reported by @F9mm)** — When the card could not be read at print start, the timelapse baseline was read again every two minutes for the whole print, and each round walked the card twice: up to eight new FTPS connections, each hanging for 30 s against a hung printer. An H2S kept a session for every one of them, then answered every connection with "421 There are too many connections", and no queued job could reach it until the printer was restarted. The card is now read once per round. It is read again only while the print records a timelapse, the wait doubles after each failed round (up to 30 minutes), and it stops after five. A connection or listing that times out now pauses the background timelapse scans of that printer for five minutes. Opening files and sending prints are not held up by this. A printer that answers "421 too many connections" now gets its own message in the queue, saying it may need a restart, instead of "Bambuddy will retry automatically". Wiki updated.

+ 1 - 0
backend/app/api/routes/notification_templates.py

@@ -43,6 +43,7 @@ EVENT_NAMES = {
     "ams_humidity_high": "AMS Humidity High",
     "ams_temperature_high": "AMS Temperature High",
     "ams_drying_suspended": "Auto-Drying Suspended",
+    "ams_drying_not_heating": "Auto-Drying Not Heating",
     "ha_sensor_alert": "Printer Sensor Alert",
     "location_ha_sensor_alert": "Storage Location Sensor Alert",
     "filament_low": "Filament Low",

+ 11 - 0
backend/app/models/notification_template.py

@@ -145,6 +145,17 @@ DEFAULT_TEMPLATES = [
             "so raise the threshold or dry the spools off the printer."
         ),
     },
+    {
+        "event_type": "ams_drying_not_heating",
+        "name": "Auto-Drying Not Heating",
+        "title_template": "Auto-Drying Suspended",
+        "body_template": (
+            "{printer} {ams_label}: stopped automatic drying. The printer accepted {cycles} drying commands "
+            "in a row, but each time the drying timer stood still and the unit did not heat. Clear the timer "
+            "with Stop on the printer card and check the AMS power; automatic drying resumes once humidity "
+            "is back at or below {threshold}%."
+        ),
+    },
     {
         "event_type": "bed_cooled",
         "name": "Bed Cooled",

+ 19 - 0
backend/app/schemas/notification_template.py

@@ -26,6 +26,7 @@ class EventType(StrEnum):
     AMS_HUMIDITY_HIGH = "ams_humidity_high"
     AMS_TEMPERATURE_HIGH = "ams_temperature_high"
     AMS_DRYING_SUSPENDED = "ams_drying_suspended"
+    AMS_DRYING_NOT_HEATING = "ams_drying_not_heating"
     BED_COOLED = "bed_cooled"
     HA_SENSOR_ALERT = "ha_sensor_alert"
     TEST = "test"
@@ -122,6 +123,15 @@ EVENT_VARIABLES: dict[str, list[str]] = {
         "timestamp",
         "app_name",
     ],
+    "ams_drying_not_heating": [
+        "printer",
+        "ams_label",
+        "humidity",
+        "threshold",
+        "cycles",
+        "timestamp",
+        "app_name",
+    ],
     "bed_cooled": ["printer", "bed_temp", "threshold", "filename", "timestamp", "app_name"],
     "ha_sensor_alert": ["printer", "sensor", "state", "timestamp", "app_name"],
     "location_ha_sensor_alert": ["location", "sensor", "state", "timestamp", "app_name"],
@@ -353,6 +363,15 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
     },
+    "ams_drying_not_heating": {
+        "printer": "Bambu P2S",
+        "ams_label": "HT-A",
+        "humidity": "75",
+        "threshold": "60",
+        "cycles": "2",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
     "bed_cooled": {
         "printer": "Bambu X1C",
         "bed_temp": "34",

+ 8 - 1
backend/app/services/notification_service.py

@@ -2428,9 +2428,15 @@ class NotificationService:
         threshold: float,
         cycles: int,
         db: AsyncSession,
+        *,
+        never_heated: bool = False,
     ):
         """Handle automatic drying giving up on one AMS unit (#2770).
 
+        ``never_heated`` sends the message for a unit whose printer accepted
+        the drying commands but never heated (#3478). Same provider switch:
+        either way Bambuddy has stopped drying that unit.
+
         Worth sending immediately (as every notification is; a daily digest only
         repeats it): it reports that
         Bambuddy has STOPPED doing something, and a report of inaction that
@@ -2448,7 +2454,8 @@ class NotificationService:
             "cycles": str(cycles),
         }
 
-        title, message = await self._build_message_from_template(db, "ams_drying_suspended", variables)
+        template = "ams_drying_not_heating" if never_heated else "ams_drying_suspended"
+        title, message = await self._build_message_from_template(db, template, variables)
         await self._send_to_providers(
             providers,
             title,

+ 73 - 6
backend/app/services/print_scheduler.py

@@ -272,6 +272,20 @@ class _KeepWarmEntry:
 # manual or firmware-run dry is untouched.
 AUTO_DRY_REARM_COOLDOWN_SECONDS = 30 * 60
 AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES = 2
+# A cycle whose timer stood still (#3478): the firmware took the command and
+# left the heater off, so dry_time stays put with dry_status Off. It tells
+# nothing about humidity, so it has its own counter; this many in a row
+# suspends the unit with a message that says the unit did not heat, not that
+# the threshold is too low.
+AUTO_DRY_MAX_NEVER_HEATED_CYCLES = 2
+
+
+def _never_heated(unit_state: dict[str, object]) -> int:
+    """Cycles in a row whose timer stood still on this unit (#3478)."""
+    count = unit_state.get("never_heated")
+    return count if isinstance(count, int) else 0
+
+
 # Sustained-humidity wait (#2518): an above-threshold streak is only
 # "continuous" if some pass observed it within the gap ceiling. A unit that
 # goes unobserved longer (print running, printer disconnected, sensor silent)
@@ -6333,7 +6347,15 @@ class PrintScheduler:
                 # The firmware stops when the configured duration elapses; scheduling
                 # stops (print takes priority, queue no longer needs drying) are
                 # handled separately via _stop_drying().
-                if dry_time > 0:
+                #
+                # A parked countdown is not a running cycle (#3478): the firmware
+                # took the command and left the heater off, so dry_time sits at
+                # its full duration and never reaches 0 on its own. Treating it
+                # as running blocked auto-drying on that unit for days. A sample
+                # the stall detector has not classified yet (its first 150 s)
+                # still counts as running.
+                parked = is_countdown_parked(ams_data)
+                if dry_time > 0 and not parked:
                     if pid not in self._drying_in_progress:
                         # Drying we didn't start (manual or from before restart) —
                         # track it so scheduling stops still apply; never auto-stop it.
@@ -6370,7 +6392,25 @@ class PrintScheduler:
                     # below, so the wait overlaps the cooldown, never stacks on
                     # top of it).
                     self._auto_dry_above.pop(unit_key, None)
-                    if humidity is not None and humidity > humidity_threshold:
+                    if parked:
+                        # The timer stood still (never started, or paused by the
+                        # power limit or the print), so the cycle says nothing
+                        # about humidity: count it apart from the unproductive
+                        # cycles below (#3478).
+                        never_heated = _never_heated(unit_state) + 1
+                        unit_state["never_heated"] = never_heated
+                        logger.warning(
+                            "Auto-drying: printer %d AMS %d — drying cycle not running: the timer stands "
+                            "still at %dm with the status Off, %d in a row",
+                            pid,
+                            ams_id,
+                            dry_time,
+                            never_heated,
+                        )
+                    else:
+                        # A cycle that counted down ran; the parked streak is over.
+                        unit_state.pop("never_heated", None)
+                    if not parked and humidity is not None and humidity > humidity_threshold:
                         best = unit_state.get("best_end_humidity")
                         if isinstance(best, int) and humidity < best:
                             # Still coming down. Keep going, however far the
@@ -6420,6 +6460,7 @@ class PrintScheduler:
                         unit_state.pop("suspended", None)
                         unit_state.pop("unproductive", None)
                         unit_state.pop("best_end_humidity", None)
+                        unit_state.pop("never_heated", None)
                     # A real below-threshold reading ends any sustained-wait
                     # streak (#2518) -- "continuously above" means exactly that.
                     # An absent reading (humidity is None) is no-information and
@@ -6486,15 +6527,33 @@ class PrintScheduler:
                 if unit_state is not None:
                     if unit_state.get("suspended"):
                         logger.debug(
-                            "Auto-drying: printer %d AMS %d skipped — suspended, %d cycles left humidity "
-                            "above the %d%% threshold",
+                            "Auto-drying: printer %d AMS %d skipped — suspended until humidity is back at "
+                            "or below the %d%% threshold",
                             pid,
                             ams_id,
-                            int(unit_state.get("unproductive", 0)),
                             humidity_threshold,
                         )
                         continue
 
+                    never_heated = _never_heated(unit_state)
+                    if never_heated >= AUTO_DRY_MAX_NEVER_HEATED_CYCLES:
+                        unit_state["suspended"] = True
+                        logger.warning(
+                            "Auto-drying: printer %d AMS %d — suspending automatic drying. The printer "
+                            "accepted %d drying commands in a row, but each time the timer stood still and "
+                            "the unit did not heat. Clear the timer with Stop on the printer card and check "
+                            "the AMS power; automatic drying resumes once humidity is back at or below the "
+                            "%d%% threshold.",
+                            pid,
+                            ams_id,
+                            never_heated,
+                            humidity_threshold,
+                        )
+                        await self._notify_auto_drying_suspended(
+                            db, printer, ams_id, humidity, humidity_threshold, never_heated, never_heated=True
+                        )
+                        continue
+
                     if int(unit_state.get("unproductive", 0)) >= AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES:
                         unit_state["suspended"] = True
                         logger.warning(
@@ -6611,9 +6670,14 @@ class PrintScheduler:
         humidity: int,
         threshold: int,
         cycles: int,
+        *,
+        never_heated: bool = False,
     ) -> None:
         """Tell the user auto-drying has given up on one AMS unit (#2770).
 
+        ``never_heated`` picks the message for cycles the printer accepted but
+        never ran (#3478) instead of cycles that left humidity high.
+
         Fires once per suspension — the caller sets ``suspended`` before calling
         and every later pass short-circuits on it — because the whole point is
         that Bambuddy has stopped acting. Somebody whose printer sits in another
@@ -6633,6 +6697,7 @@ class PrintScheduler:
                 float(threshold),
                 cycles,
                 db,
+                never_heated=never_heated,
             )
         except Exception as e:
             logger.warning("Failed to send auto-drying suspended notification: %s", e)
@@ -6685,7 +6750,9 @@ class PrintScheduler:
                 continue
             # Check if any AMS unit is still drying
             ams_list = state.raw_data.get("ams", [])
-            any_drying = any(int(a.get("dry_time") or 0) > 0 for a in ams_list)
+            # A parked timer is not drying (#3478): it never ends on its own,
+            # so it would keep the printer in here for good.
+            any_drying = any(int(a.get("dry_time") or 0) > 0 and not is_countdown_parked(a) for a in ams_list)
             if not any_drying:
                 to_remove.append(pid)
         for pid in to_remove:

+ 233 - 0
backend/tests/unit/test_auto_drying_parked_3478.py

@@ -0,0 +1,233 @@
+"""Auto-drying and a countdown the firmware never runs (#3478).
+
+An AMS HT took a drying command mid-print and left the heater off: dry_time
+sat at 720 with dry_status Off for a week. The MQTT layer flags that as
+dry_countdown_stalled, but auto-drying treated any dry_time > 0 as a running
+cycle, so the unit was never dried again. Now a parked timer is not drying: a
+cycle Bambuddy armed that parks is closed out and counted on its own (it says
+nothing about humidity), and two in a row suspend the unit with a message that
+says the printer did not heat.
+"""
+
+import time
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.print_scheduler import (
+    AUTO_DRY_MAX_NEVER_HEATED_CYCLES,
+    AUTO_DRY_REARM_COOLDOWN_SECONDS,
+    PrintScheduler,
+)
+
+UNIT = (1, 128)
+WET = 75
+DRY = 50
+
+
+def _state(dry_time: int, stalled: bool, humidity: int = WET):
+    state = MagicMock()
+    state.raw_data = {
+        "ams": [
+            {
+                "id": 128,
+                "module_type": "n3s",
+                "dry_time": dry_time,
+                "dry_status": 0,
+                "dry_countdown_stalled": stalled,
+                "humidity_raw": str(humidity),
+                "dry_sf_reason": [],
+                "tray": [{"tray_type": "PLA"}],
+            }
+        ]
+    }
+    state.firmware_version = "01.09.00.00"
+    return state
+
+
+def _setting(value):
+    s = MagicMock()
+    s.value = value
+    return s
+
+
+def _db():
+    settings = {
+        "queue_drying_enabled": _setting("false"),
+        "ambient_drying_enabled": _setting("true"),
+        "ams_humidity_fair": _setting("60"),
+        "queue_drying_block": _setting("false"),
+    }
+
+    async def execute(stmt):
+        result = MagicMock()
+        try:
+            params = list(stmt.compile().params.values())
+        except Exception:
+            params = []
+        for key, val in settings.items():
+            if key in params:
+                result.scalar_one_or_none.return_value = val
+                return result
+        if "printer" in str(stmt).lower():
+            printer = MagicMock()
+            printer.id = 1
+            printer.is_active = True
+            scalars = MagicMock()
+            scalars.__iter__ = MagicMock(return_value=iter([printer]))
+            result.scalars.return_value = scalars
+        else:
+            result.scalar_one_or_none.return_value = None
+        return result
+
+    db = AsyncMock()
+    db.execute = AsyncMock(side_effect=execute)
+    return db
+
+
+@pytest.fixture
+def env():
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.notification_service") as notify,
+        patch("backend.app.services.print_scheduler.supports_drying", return_value=True),
+    ):
+        pm.is_connected.return_value = True
+        pm.get_model.return_value = "P2S"
+        pm.send_drying_command.return_value = True
+        notify.on_ams_drying_suspended = AsyncMock()
+        scheduler = PrintScheduler()
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = _db()
+
+        async def run(dry_time, stalled, humidity=WET):
+            pm.get_status.return_value = _state(dry_time, stalled, humidity)
+            await scheduler._check_auto_drying(db, [], set())
+
+        yield scheduler, pm, notify, run
+
+
+def _age_out_cooldown(scheduler):
+    scheduler._auto_dry_units[UNIT]["ended_at"] -= AUTO_DRY_REARM_COOLDOWN_SECONDS + 1
+
+
+async def _parked_cycle(run):
+    """Arm, see the command taken, then see the stall detector flag it."""
+    await run(0, False)
+    await run(720, False)
+    await run(720, True)
+
+
+class TestParkedTimerIsNotDrying:
+    @pytest.mark.asyncio
+    async def test_a_parked_timer_bambuddy_did_not_start_gets_a_start(self, env):
+        """The reporter's unit: parked at 720, humidity above the threshold."""
+        scheduler, pm, _notify, run = env
+        await run(720, True)
+        pm.send_drying_command.assert_called_once_with(1, 128, 45, 12, mode=1, filament="PLA")
+        assert scheduler._auto_dry_units[UNIT]["running"] is True
+
+    @pytest.mark.asyncio
+    async def test_a_timer_not_yet_called_parked_still_runs(self, env):
+        _scheduler, pm, _notify, run = env
+        await run(720, False)
+        pm.send_drying_command.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_a_parked_cycle_is_closed_counted_apart_and_cools_off(self, env):
+        scheduler, pm, _notify, run = env
+        await _parked_cycle(run)
+
+        unit = scheduler._auto_dry_units[UNIT]
+        assert "running" not in unit
+        assert unit["never_heated"] == 1
+        assert unit.get("unproductive", 0) == 0
+        assert "best_end_humidity" not in unit
+        assert isinstance(unit["ended_at"], float)
+
+        await run(720, True)
+        assert pm.send_drying_command.call_count == 1
+
+    @pytest.mark.asyncio
+    async def test_parked_cycles_suspend_with_the_not_heating_message(self, env):
+        scheduler, pm, notify, run = env
+        for _ in range(AUTO_DRY_MAX_NEVER_HEATED_CYCLES - 1):
+            await _parked_cycle(run)
+            _age_out_cooldown(scheduler)
+        # The next start goes out on top of the parked timer and parks again.
+        await run(720, True)
+        await run(720, True)
+
+        assert pm.send_drying_command.call_count == AUTO_DRY_MAX_NEVER_HEATED_CYCLES
+        unit = scheduler._auto_dry_units[UNIT]
+        assert unit["suspended"] is True
+        notify.on_ams_drying_suspended.assert_awaited_once()
+        assert notify.on_ams_drying_suspended.await_args.kwargs["never_heated"] is True
+
+        _age_out_cooldown(scheduler)
+        await run(720, True)
+        assert pm.send_drying_command.call_count == AUTO_DRY_MAX_NEVER_HEATED_CYCLES
+
+    @pytest.mark.asyncio
+    async def test_a_cycle_that_runs_resets_the_parked_count(self, env):
+        scheduler, pm, notify, run = env
+        await _parked_cycle(run)
+        _age_out_cooldown(scheduler)
+        # This one counts down and finishes.
+        await run(720, True)
+        await run(719, False)
+        await run(0, False)
+
+        assert "never_heated" not in scheduler._auto_dry_units[UNIT]
+        notify.on_ams_drying_suspended.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_dry_humidity_lifts_a_not_heating_suspension(self, env):
+        scheduler, _pm, _notify, run = env
+        scheduler._auto_dry_units[UNIT] = {"suspended": True, "never_heated": 2, "ended_at": None}
+        await run(0, False, humidity=DRY)
+        unit = scheduler._auto_dry_units[UNIT]
+        assert not unit.get("suspended")
+        assert "never_heated" not in unit
+
+
+class TestSyncDryingState:
+    def test_a_printer_with_only_a_parked_timer_is_not_drying(self):
+        scheduler = PrintScheduler()
+        scheduler._drying_in_progress[1] = time.monotonic()
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = _state(720, True)
+            scheduler._sync_drying_state()
+        assert 1 not in scheduler._drying_in_progress
+
+    def test_a_running_timer_keeps_the_printer_drying(self):
+        scheduler = PrintScheduler()
+        scheduler._drying_in_progress[1] = time.monotonic()
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = _state(700, False)
+            scheduler._sync_drying_state()
+        assert 1 in scheduler._drying_in_progress
+
+
+class TestNotification:
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        ("never_heated", "template"),
+        [(False, "ams_drying_suspended"), (True, "ams_drying_not_heating")],
+    )
+    async def test_picks_the_template_on_the_same_switch(self, never_heated, template):
+        from backend.app.services.notification_service import NotificationService
+
+        service = NotificationService()
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as build,
+        ):
+            get.return_value = [MagicMock()]
+            build.return_value = ("t", "m")
+            await service.on_ams_drying_suspended(
+                1, "P2S", "HT-A", 75.0, 60.0, 2, AsyncMock(), never_heated=never_heated
+            )
+        assert get.await_args.args[1] == "on_ams_drying_suspended"
+        assert build.await_args.args[1] == template

+ 1 - 1
frontend/src/i18n/locales/de.ts

@@ -6925,7 +6925,7 @@ export default {
     amsTemperatureHigh: 'AMS-Temperatur hoch',
     amsTemperatureHighDescription: 'Normale AMS-Temperatur überschreitet Schwellenwert',
     amsDryingSuspendedTitle: 'Automatische Trocknung ausgesetzt',
-    amsDryingSuspendedDescription: 'Automatische Trocknung wurde für eine AMS-Einheit aufgegeben, weil die Luftfeuchtigkeit nie unter den Schwellenwert fiel',
+    amsDryingSuspendedDescription: 'Automatische Trocknung wurde für eine AMS-Einheit aufgegeben: Die Luftfeuchtigkeit fiel nie unter den Schwellenwert, oder die Einheit hat nie zu heizen begonnen',
     amsHtHumidityHigh: 'AMS-HT-Feuchtigkeit hoch',
     amsHtHumidityHighDescription: 'AMS-HT-Feuchtigkeit überschreitet Schwellenwert',
     amsHtTemperatureHigh: 'AMS-HT-Temperatur hoch',

+ 1 - 1
frontend/src/i18n/locales/en.ts

@@ -6978,7 +6978,7 @@ export default {
     amsTemperatureHigh: 'AMS Temperature High',
     amsTemperatureHighDescription: 'Regular AMS temperature exceeds threshold',
     amsDryingSuspendedTitle: 'Auto-Drying Suspended',
-    amsDryingSuspendedDescription: 'Automatic drying gave up on an AMS unit because humidity never fell below the threshold',
+    amsDryingSuspendedDescription: 'Automatic drying gave up on an AMS unit: humidity never fell below the threshold, or the unit never started heating',
     amsHtHumidityHigh: 'AMS-HT Humidity High',
     amsHtHumidityHighDescription: 'AMS-HT humidity exceeds threshold',
     amsHtTemperatureHigh: 'AMS-HT Temperature High',

+ 1 - 1
frontend/src/i18n/locales/es.ts

@@ -6933,7 +6933,7 @@ export default {
     amsTemperatureHigh: 'Temperatura alta del AMS',
     amsTemperatureHighDescription: 'La temperatura del AMS normal supera el umbral',
     amsDryingSuspendedTitle: 'Secado automático suspendido',
-    amsDryingSuspendedDescription: 'El secado automático se rindió con una unidad AMS porque la humedad nunca bajó del umbral',
+    amsDryingSuspendedDescription: 'El secado automático se rindió con una unidad AMS: la humedad nunca bajó del umbral o la unidad nunca empezó a calentar',
     amsHtHumidityHigh: 'Humedad alta del AMS-HT',
     amsHtHumidityHighDescription: 'La humedad del AMS-HT supera el umbral',
     amsHtTemperatureHigh: 'Temperatura alta del AMS-HT',

+ 1 - 1
frontend/src/i18n/locales/fr.ts

@@ -6913,7 +6913,7 @@ export default {
     amsTemperatureHigh: 'Température AMS élevée',
     amsTemperatureHighDescription: 'La température de l\'AMS standard dépasse le seuil',
     amsDryingSuspendedTitle: 'Séchage automatique suspendu',
-    amsDryingSuspendedDescription: 'Le séchage automatique a abandonné une unité AMS car l\'humidité n\'est jamais descendue sous le seuil',
+    amsDryingSuspendedDescription: "Le séchage automatique a abandonné une unité AMS : l'humidité n'est jamais descendue sous le seuil, ou l'unité n'a jamais commencé à chauffer",
     amsHtHumidityHigh: 'Humidité AMS-HT élevée',
     amsHtHumidityHighDescription: 'L\'humidité de l\'AMS-HT dépasse le seuil',
     amsHtTemperatureHigh: 'Température AMS-HT élevée',

+ 1 - 1
frontend/src/i18n/locales/it.ts

@@ -6912,7 +6912,7 @@ export default {
     amsTemperatureHigh: 'Temperatura AMS elevata',
     amsTemperatureHighDescription: 'La temperatura dell\'AMS standard supera la soglia',
     amsDryingSuspendedTitle: 'Asciugatura automatica sospesa',
-    amsDryingSuspendedDescription: 'L\'asciugatura automatica ha rinunciato a un\'unità AMS perché l\'umidità non è mai scesa sotto la soglia',
+    amsDryingSuspendedDescription: "L'asciugatura automatica ha rinunciato a un'unità AMS: l'umidità non è mai scesa sotto la soglia, oppure l'unità non ha mai iniziato a riscaldare",
     amsHtHumidityHigh: 'Umidità AMS-HT elevata',
     amsHtHumidityHighDescription: 'L\'umidità dell\'AMS-HT supera la soglia',
     amsHtTemperatureHigh: 'Temperatura AMS-HT elevata',

+ 1 - 1
frontend/src/i18n/locales/ja.ts

@@ -6926,7 +6926,7 @@ export default {
     amsTemperatureHigh: 'AMS温度高',
     amsTemperatureHighDescription: '通常AMSの温度がしきい値を超過',
     amsDryingSuspendedTitle: '自動乾燥の停止',
-    amsDryingSuspendedDescription: '湿度がしきい値を下回らなかったため、AMS ユニットの自動乾燥を中止しました',
+    amsDryingSuspendedDescription: 'AMS ユニットの自動乾燥を中止しました:湿度がしきい値を下回らなかったか、ユニットが加熱を開始しませんでした',
     amsHtHumidityHigh: 'AMS-HT湿度高',
     amsHtHumidityHighDescription: 'AMS-HTの湿度がしきい値を超過',
     amsHtTemperatureHigh: 'AMS-HT温度高',

+ 1 - 1
frontend/src/i18n/locales/ko.ts

@@ -6645,7 +6645,7 @@ export default {
     amsTemperatureHigh: 'AMS 온도 높음',
     amsTemperatureHighDescription: '일반 AMS 온도가 임계값 초과',
     amsDryingSuspendedTitle: '자동 건조 중단',
-    amsDryingSuspendedDescription: '습도가 임계값 아래로 내려가지 않아 AMS 유닛의 자동 건조를 중단했습니다',
+    amsDryingSuspendedDescription: 'AMS 유닛의 자동 건조를 중단했습니다: 습도가 임계값 아래로 내려가지 않았거나 유닛이 가열을 시작하지 않았습니다',
     amsHtHumidityHigh: 'AMS-HT 습도 높음',
     amsHtHumidityHighDescription: 'AMS-HT 습도가 임계값 초과',
     amsHtTemperatureHigh: 'AMS-HT 온도 높음',

+ 1 - 1
frontend/src/i18n/locales/nl.ts

@@ -6976,7 +6976,7 @@ export default {
     amsTemperatureHigh: 'AMS-temperatuur hoog',
     amsTemperatureHighDescription: 'Temperatuur van normale AMS overschrijdt drempel',
     amsDryingSuspendedTitle: 'Automatisch drogen gestopt',
-    amsDryingSuspendedDescription: 'Automatisch drogen heeft een AMS-eenheid opgegeven omdat de luchtvochtigheid niet onder de drempel kwam',
+    amsDryingSuspendedDescription: 'Automatisch drogen heeft een AMS-eenheid opgegeven: de luchtvochtigheid kwam niet onder de drempel, of de eenheid is nooit begonnen met verwarmen',
     amsHtHumidityHigh: 'AMS-HT-luchtvochtigheid hoog',
     amsHtHumidityHighDescription: 'AMS-HT-luchtvochtigheid overschrijdt drempel',
     amsHtTemperatureHigh: 'AMS-HT-temperatuur hoog',

+ 1 - 1
frontend/src/i18n/locales/pl.ts

@@ -6978,7 +6978,7 @@ export default {
     amsTemperatureHigh: 'Wysoka temperatura AMS',
     amsTemperatureHighDescription: 'Temperatura zwykłego AMS przekracza próg',
     amsDryingSuspendedTitle: 'Automatyczne suszenie wstrzymane',
-    amsDryingSuspendedDescription: 'Automatyczne suszenie zrezygnowało z jednostki AMS, ponieważ wilgotność nigdy nie spadła poniżej progu',
+    amsDryingSuspendedDescription: 'Automatyczne suszenie zrezygnowało z jednostki AMS: wilgotność nigdy nie spadła poniżej progu albo jednostka nigdy nie zaczęła grzać',
     amsHtHumidityHigh: 'Wysoka wilgotność AMS-HT',
     amsHtHumidityHighDescription: 'Wilgotność AMS-HT przekracza próg',
     amsHtTemperatureHigh: 'Wysoka temperatura AMS-HT',

+ 1 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -6912,7 +6912,7 @@ export default {
     amsTemperatureHigh: 'Temperatura Alta do AMS',
     amsTemperatureHighDescription: 'Temperatura do AMS regular excede o limite',
     amsDryingSuspendedTitle: 'Secagem automática suspensa',
-    amsDryingSuspendedDescription: 'A secagem automática desistiu de uma unidade AMS porque a umidade nunca ficou abaixo do limite',
+    amsDryingSuspendedDescription: 'A secagem automática desistiu de uma unidade AMS: a umidade nunca ficou abaixo do limite ou a unidade nunca começou a aquecer',
     amsHtHumidityHigh: 'Umidade Alta do AMS-HT',
     amsHtHumidityHighDescription: 'Umidade do AMS-HT excede o limite',
     amsHtTemperatureHigh: 'Temperatura Alta do AMS-HT',

+ 1 - 1
frontend/src/i18n/locales/ru.ts

@@ -6632,7 +6632,7 @@ export default {
     amsTemperatureHigh: "Высокая температура в AMS",
     amsTemperatureHighDescription: "Температура в обычной AMS превысила заданный порог",
     amsDryingSuspendedTitle: "Автоматическая сушка приостановлена",
-    amsDryingSuspendedDescription: "Автоматическая сушка блока AMS прекращена: влажность так и не опустилась ниже порога",
+    amsDryingSuspendedDescription: 'Автоматическая сушка блока AMS прекращена: влажность так и не опустилась ниже порога или блок так и не начал нагрев',
     amsHtHumidityHigh: "Высокая влажность в AMS-HT",
     amsHtHumidityHighDescription: "Влажность в AMS-HT превысила заданный порог",
     amsHtTemperatureHigh: "Высокая температура в AMS-HT",

+ 1 - 1
frontend/src/i18n/locales/sv.ts

@@ -6974,7 +6974,7 @@ errors: {
     amsTemperatureHigh: 'Hög AMS-temperatur',
     amsTemperatureHighDescription: 'AMS-temperatur överskrider tröskelvärdet',
     amsDryingSuspendedTitle: 'Auto-torkning pausad',
-    amsDryingSuspendedDescription: 'Automatisk torkning avbröts för en AMS-enhet eftersom luftfuktigheten aldrig sjönk under tröskelvärdet',
+    amsDryingSuspendedDescription: 'Automatisk torkning avbröts för en AMS-enhet: luftfuktigheten sjönk aldrig under tröskelvärdet, eller enheten började aldrig värma',
     amsHtHumidityHigh: 'Hög AMS-HT-luftfuktighet',
     amsHtHumidityHighDescription: 'AMS-HT-luftfuktighet överskrider tröskelvärdet',
     amsHtTemperatureHigh: 'Hög AMS-HT-temperatur',

+ 1 - 1
frontend/src/i18n/locales/tr.ts

@@ -6881,7 +6881,7 @@ export default {
     amsTemperatureHigh: 'AMS Sıcaklığı Yüksek',
     amsTemperatureHighDescription: 'Normal AMS sıcaklığı eşiği aşıyor',
     amsDryingSuspendedTitle: 'Otomatik kurutma askıya alındı',
-    amsDryingSuspendedDescription: 'Nem hiçbir zaman eşiğin altına inmediği için bir AMS ünitesinde otomatik kurutmadan vazgeçildi',
+    amsDryingSuspendedDescription: 'Bir AMS ünitesinde otomatik kurutmadan vazgeçildi: nem hiçbir zaman eşiğin altına inmedi veya ünite hiç ısıtmaya başlamadı',
     amsHtHumidityHigh: 'AMS-HT Nemi Yüksek',
     amsHtHumidityHighDescription: 'AMS-HT nemi eşiği aşıyor',
     amsHtTemperatureHigh: 'AMS-HT Sıcaklığı Yüksek',

+ 1 - 1
frontend/src/i18n/locales/uk.ts

@@ -6966,7 +6966,7 @@ export default {
     amsTemperatureHigh: "AMS Висока температура",
     amsTemperatureHighDescription: "Звичайна AMS температура перевищує порогове значення",
     amsDryingSuspendedTitle: "Автоматичне сушіння призупинено",
-    amsDryingSuspendedDescription: "Автоматичне сушіння блока AMS припинено: вологість так і не опустилася нижче порога",
+    amsDryingSuspendedDescription: 'Автоматичне сушіння блока AMS припинено: вологість так і не опустилася нижче порога або блок так і не почав нагрівання',
     amsHtHumidityHigh: "AMS-HT Висока вологість",
     amsHtHumidityHighDescription: "AMS-HT вологість перевищує порогове значення",
     amsHtTemperatureHigh: "Висока температура AMS-HT",

+ 1 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -6911,7 +6911,7 @@ export default {
     amsTemperatureHigh: 'AMS 温度过高',
     amsTemperatureHighDescription: '普通 AMS 温度超过阈值',
     amsDryingSuspendedTitle: '自动干燥已暂停',
-    amsDryingSuspendedDescription: '湿度始终未降到阈值以下,已放弃对某个 AMS 单元的自动干燥',
+    amsDryingSuspendedDescription: '已放弃对某个 AMS 单元的自动干燥:湿度始终未降到阈值以下,或该单元从未开始加热',
     amsHtHumidityHigh: 'AMS-HT 湿度过高',
     amsHtHumidityHighDescription: 'AMS-HT 湿度超过阈值',
     amsHtTemperatureHigh: 'AMS-HT 温度过高',

+ 1 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -6911,7 +6911,7 @@ export default {
     amsTemperatureHigh: 'AMS 溫度過高',
     amsTemperatureHighDescription: '普通 AMS 溫度超過閾值',
     amsDryingSuspendedTitle: '自動乾燥已暫停',
-    amsDryingSuspendedDescription: '濕度始終未降到閾值以下,已放棄對某個 AMS 單元的自動乾燥',
+    amsDryingSuspendedDescription: '已放棄對某個 AMS 單元的自動乾燥:濕度始終未降到閾值以下,或該單元從未開始加熱',
     amsHtHumidityHigh: 'AMS-HT 濕度過高',
     amsHtHumidityHighDescription: 'AMS-HT 濕度超過閾值',
     amsHtTemperatureHigh: 'AMS-HT 溫度過高',

文件差異過大導致無法顯示
+ 0 - 0
static/assets/ArchivesPage-DxgEVTxj.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/CamWallPage-CMQTGlu4.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/CameraPage-DhtJEHNr.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/FileManagerPage-CBkRrbz6.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/InventoryPage-BarjIqiD.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/Layout-CB5M1Sq1.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ModelSourcesPage-Caki-dOg.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/PrintersPage-cMWUTwVu.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/SettingsPage-LTmeqGTR.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/SliceModal-CuC8TvVV.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-D_2Jc7PN.js


文件差異過大導致無法顯示
+ 1 - 1
static/assets/SpoolBuddySettingsPage-CgwIgNh_.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/de-BWt6eQQV.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/es-CkKpEBG4.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/fr-CNgEGm30.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/index-Bfi6_HSn.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/it-AkV28joJ.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ja-DAOyMh7T.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ko-CGDOBimQ.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/locationSensorDefaults-BpQWlsm1.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/nl-9hmiNd0i.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/pl-Dmknr1y_.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/pt-BR-Cu6j1VSY.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ru-BzARiZ3F.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/spoolCost-B5GUnpea.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/sv-28uqPOCX.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/tr-eO_qWAJv.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/uk-B2J2MTvN.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/usePageFileDrop-tMAnaGv5.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/zh-CN-CjdQbDCx.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/zh-TW-CruxAX6u.js


+ 2 - 2
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CPjehcO9.js"></script>
+    <script type="module" crossorigin src="/assets/index-Bfi6_HSn.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-A76-KVrT.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-82e9FLNr.js">
@@ -122,7 +122,7 @@
     <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-D6NM9pNL.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-6gmNNa1B.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-CHLiTxan.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-DQdP8ELS.js">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-CB5M1Sq1.js">
     <link rel="stylesheet" crossorigin href="/assets/index-vGjPx3LJ.css">
   </head>
   <body>

部分文件因文件數量過多而無法顯示