|
|
@@ -13,7 +13,11 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
-from backend.app.services.print_scheduler import PrintScheduler
|
|
|
+from backend.app.services.print_scheduler import (
|
|
|
+ AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES,
|
|
|
+ AUTO_DRY_REARM_COOLDOWN_SECONDS,
|
|
|
+ PrintScheduler,
|
|
|
+)
|
|
|
|
|
|
|
|
|
class TestConservativeDryingParams:
|
|
|
@@ -1192,3 +1196,385 @@ class TestMidPrintDrying(_DryingTestBase):
|
|
|
await scheduler._check_auto_drying(db, [], {1})
|
|
|
|
|
|
mock_pm.send_drying_command.assert_not_called()
|
|
|
+
|
|
|
+
|
|
|
+class TestAutoDryRearmGuards(_DryingTestBase):
|
|
|
+ """The re-arm loop from #2770.
|
|
|
+
|
|
|
+ An AMS reads higher humidity while it is warm than once it has cooled, so a
|
|
|
+ threshold set inside that band is never satisfied at the moment a cycle
|
|
|
+ ends. The firmware is separately free to end a cycle early when it decides
|
|
|
+ the filament is dry. Together those produced five 12-hour cycles armed in
|
|
|
+ four hours on the reporter's H2D, one of them six seconds after the previous
|
|
|
+ ended. These tests pin the two guards that break the loop and, just as
|
|
|
+ importantly, that neither guard ever stops a cycle that is running.
|
|
|
+ """
|
|
|
+
|
|
|
+ THRESHOLD = "14"
|
|
|
+ ABOVE = 16
|
|
|
+ BELOW = 10
|
|
|
+
|
|
|
+ @pytest.fixture
|
|
|
+ def scheduler(self):
|
|
|
+ return PrintScheduler()
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _ams_state(dry_time, humidity):
|
|
|
+ state = MagicMock()
|
|
|
+ state.raw_data = {
|
|
|
+ "ams": [
|
|
|
+ {
|
|
|
+ "id": 0,
|
|
|
+ "module_type": "n3f",
|
|
|
+ "dry_time": dry_time,
|
|
|
+ "humidity_raw": str(humidity),
|
|
|
+ "dry_sf_reason": [],
|
|
|
+ "tray": [{"tray_type": "PLA"}],
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ state.firmware_version = "01.09.00.00"
|
|
|
+ return state
|
|
|
+
|
|
|
+ def _db(self):
|
|
|
+ db = AsyncMock()
|
|
|
+ db.execute = AsyncMock(
|
|
|
+ side_effect=self._make_db_side_effect(
|
|
|
+ {
|
|
|
+ "queue_drying_enabled": self._make_setting("false"),
|
|
|
+ "ambient_drying_enabled": self._make_setting("true"),
|
|
|
+ "ams_humidity_fair": self._make_setting(self.THRESHOLD),
|
|
|
+ "queue_drying_block": self._make_setting("false"),
|
|
|
+ "drying_presets": None,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ )
|
|
|
+ return db
|
|
|
+
|
|
|
+ async def _pass(self, scheduler, mock_pm, db, dry_time, humidity):
|
|
|
+ """One 30-second scheduler pass with the AMS in the given state."""
|
|
|
+ mock_pm.get_status.return_value = self._ams_state(dry_time, humidity)
|
|
|
+ await scheduler._check_auto_drying(db, [], set())
|
|
|
+
|
|
|
+ async def _unproductive_cycle(self, scheduler, mock_pm, db):
|
|
|
+ """Arm a cycle, watch it run, then see it end with humidity still high."""
|
|
|
+ await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
|
|
|
+ await self._pass(scheduler, mock_pm, db, 720, self.ABOVE)
|
|
|
+ await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @patch("backend.app.services.print_scheduler.printer_manager")
|
|
|
+ @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
|
|
|
+ async def test_does_not_rearm_immediately_after_a_cycle_ends(self, mock_sd, mock_pm, scheduler):
|
|
|
+ """The six-second re-arm: one cycle ends, the next pass must not start another."""
|
|
|
+ mock_pm.is_connected.return_value = True
|
|
|
+ mock_pm.get_model.return_value = "H2D"
|
|
|
+ mock_pm.send_drying_command.return_value = True
|
|
|
+ scheduler._is_printer_idle = MagicMock(return_value=True)
|
|
|
+ db = self._db()
|
|
|
+
|
|
|
+ await self._unproductive_cycle(scheduler, mock_pm, db)
|
|
|
+ await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
|
|
|
+
|
|
|
+ assert mock_pm.send_drying_command.call_count == 1
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @patch("backend.app.services.print_scheduler.notification_service")
|
|
|
+ @patch("backend.app.services.print_scheduler.printer_manager")
|
|
|
+ @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
|
|
|
+ async def test_suspends_after_repeated_unproductive_cycles(self, mock_sd, mock_pm, mock_notify, scheduler):
|
|
|
+ """Past the cooldown the loop would resume, so the counter has to stop it."""
|
|
|
+ mock_pm.is_connected.return_value = True
|
|
|
+ mock_pm.get_model.return_value = "H2D"
|
|
|
+ mock_pm.send_drying_command.return_value = True
|
|
|
+ mock_notify.on_ams_drying_suspended = AsyncMock()
|
|
|
+ scheduler._is_printer_idle = MagicMock(return_value=True)
|
|
|
+ db = self._db()
|
|
|
+
|
|
|
+ for _ in range(AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES):
|
|
|
+ await self._unproductive_cycle(scheduler, mock_pm, db)
|
|
|
+ # Age the last cycle out of its cooldown so the next arm is allowed.
|
|
|
+ scheduler._auto_dry_units[(1, 0)]["ended_at"] -= AUTO_DRY_REARM_COOLDOWN_SECONDS + 1
|
|
|
+
|
|
|
+ assert mock_pm.send_drying_command.call_count == AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES
|
|
|
+
|
|
|
+ await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
|
|
|
+
|
|
|
+ assert mock_pm.send_drying_command.call_count == AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES
|
|
|
+ assert scheduler._auto_dry_units[(1, 0)]["suspended"] is True
|
|
|
+ mock_notify.on_ams_drying_suspended.assert_awaited_once()
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @patch("backend.app.services.print_scheduler.notification_service")
|
|
|
+ @patch("backend.app.services.print_scheduler.printer_manager")
|
|
|
+ @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
|
|
|
+ async def test_suspension_notifies_once_and_stays_put(self, mock_sd, mock_pm, mock_notify, scheduler):
|
|
|
+ """A suspension is a state, not a repeating alarm."""
|
|
|
+ mock_pm.is_connected.return_value = True
|
|
|
+ mock_pm.get_model.return_value = "H2D"
|
|
|
+ mock_pm.send_drying_command.return_value = True
|
|
|
+ mock_notify.on_ams_drying_suspended = AsyncMock()
|
|
|
+ scheduler._is_printer_idle = MagicMock(return_value=True)
|
|
|
+ db = self._db()
|
|
|
+ scheduler._auto_dry_units[(1, 0)] = {
|
|
|
+ "unproductive": AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES,
|
|
|
+ "suspended": False,
|
|
|
+ "ended_at": None,
|
|
|
+ }
|
|
|
+
|
|
|
+ for _ in range(4):
|
|
|
+ await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
|
|
|
+
|
|
|
+ mock_pm.send_drying_command.assert_not_called()
|
|
|
+ assert mock_notify.on_ams_drying_suspended.await_count == 1
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @patch("backend.app.services.print_scheduler.notification_service")
|
|
|
+ @patch("backend.app.services.print_scheduler.printer_manager")
|
|
|
+ @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
|
|
|
+ async def test_humidity_dropping_lifts_the_suspension(self, mock_sd, mock_pm, mock_notify, scheduler):
|
|
|
+ """The reading coming down is the evidence that drying works after all."""
|
|
|
+ mock_pm.is_connected.return_value = True
|
|
|
+ mock_pm.get_model.return_value = "H2D"
|
|
|
+ mock_pm.send_drying_command.return_value = True
|
|
|
+ mock_notify.on_ams_drying_suspended = AsyncMock()
|
|
|
+ scheduler._is_printer_idle = MagicMock(return_value=True)
|
|
|
+ db = self._db()
|
|
|
+ scheduler._auto_dry_units[(1, 0)] = {
|
|
|
+ "unproductive": AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES,
|
|
|
+ "suspended": True,
|
|
|
+ "ended_at": None,
|
|
|
+ }
|
|
|
+
|
|
|
+ await self._pass(scheduler, mock_pm, db, 0, self.BELOW)
|
|
|
+ assert (1, 0) not in scheduler._auto_dry_units
|
|
|
+
|
|
|
+ await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
|
|
|
+ mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @patch("backend.app.services.print_scheduler.printer_manager")
|
|
|
+ @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
|
|
|
+ async def test_manual_cycle_is_neither_counted_nor_interrupted(self, mock_sd, mock_pm, scheduler):
|
|
|
+ """A dry the user started by hand carries no history and is never stopped."""
|
|
|
+ mock_pm.is_connected.return_value = True
|
|
|
+ mock_pm.get_model.return_value = "H2D"
|
|
|
+ mock_pm.send_drying_command.return_value = True
|
|
|
+ scheduler._is_printer_idle = MagicMock(return_value=True)
|
|
|
+ db = self._db()
|
|
|
+
|
|
|
+ await self._pass(scheduler, mock_pm, db, 720, self.ABOVE)
|
|
|
+ await self._pass(scheduler, mock_pm, db, 600, self.ABOVE)
|
|
|
+
|
|
|
+ mock_pm.send_drying_command.assert_not_called()
|
|
|
+ assert (1, 0) not in scheduler._auto_dry_units
|
|
|
+
|
|
|
+ # It ends; Bambuddy is free to arm its own cycle, with a clean slate.
|
|
|
+ await self._pass(scheduler, mock_pm, db, 0, self.ABOVE)
|
|
|
+ mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
|
|
|
+ assert scheduler._auto_dry_units[(1, 0)]["unproductive"] == 0
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @patch("backend.app.services.print_scheduler.printer_manager")
|
|
|
+ @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
|
|
|
+ async def test_history_is_dropped_when_the_printer_goes_away(self, mock_sd, mock_pm, scheduler):
|
|
|
+ """A printer deleted and re-added must not inherit a suspension."""
|
|
|
+ mock_pm.get_status.return_value = None
|
|
|
+ scheduler._auto_dry_units[(1, 0)] = {"unproductive": 5, "suspended": True, "ended_at": None}
|
|
|
+
|
|
|
+ scheduler._sync_drying_state()
|
|
|
+
|
|
|
+ assert scheduler._auto_dry_units == {}
|
|
|
+
|
|
|
+
|
|
|
+class TestAutoDryStoppedByBambuddy(_DryingTestBase):
|
|
|
+ """A cycle Bambuddy itself cut short must not count against the unit."""
|
|
|
+
|
|
|
+ THRESHOLD = "14"
|
|
|
+ ABOVE = 16
|
|
|
+
|
|
|
+ @pytest.fixture
|
|
|
+ def scheduler(self):
|
|
|
+ return PrintScheduler()
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _ams_state(dry_time, humidity):
|
|
|
+ state = MagicMock()
|
|
|
+ state.raw_data = {
|
|
|
+ "ams": [
|
|
|
+ {
|
|
|
+ "id": 0,
|
|
|
+ "module_type": "n3f",
|
|
|
+ "dry_time": dry_time,
|
|
|
+ "humidity_raw": str(humidity),
|
|
|
+ "dry_sf_reason": [],
|
|
|
+ "tray": [{"tray_type": "PLA"}],
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ state.firmware_version = "01.09.00.00"
|
|
|
+ return state
|
|
|
+
|
|
|
+ def _db(self):
|
|
|
+ db = AsyncMock()
|
|
|
+ db.execute = AsyncMock(
|
|
|
+ side_effect=self._make_db_side_effect(
|
|
|
+ {
|
|
|
+ "queue_drying_enabled": self._make_setting("false"),
|
|
|
+ "ambient_drying_enabled": self._make_setting("true"),
|
|
|
+ "ams_humidity_fair": self._make_setting(self.THRESHOLD),
|
|
|
+ "queue_drying_block": self._make_setting("false"),
|
|
|
+ "drying_presets": None,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ )
|
|
|
+ return db
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @patch("backend.app.services.print_scheduler.printer_manager")
|
|
|
+ @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
|
|
|
+ async def test_print_takes_priority_stop_is_not_an_unproductive_cycle(self, mock_sd, mock_pm, scheduler):
|
|
|
+ """The queue stopping a dry so a print can start is Bambuddy's own doing.
|
|
|
+
|
|
|
+ Counting it would suspend auto-drying on any printer that dries between
|
|
|
+ jobs often enough -- exactly the install queue-drying exists for.
|
|
|
+ """
|
|
|
+ mock_pm.is_connected.return_value = True
|
|
|
+ mock_pm.get_model.return_value = "H2D"
|
|
|
+ mock_pm.send_drying_command.return_value = True
|
|
|
+ scheduler._is_printer_idle = MagicMock(return_value=True)
|
|
|
+ db = self._db()
|
|
|
+
|
|
|
+ mock_pm.get_status.return_value = self._ams_state(0, self.ABOVE)
|
|
|
+ await scheduler._check_auto_drying(db, [], set())
|
|
|
+ mock_pm.get_status.return_value = self._ams_state(720, self.ABOVE)
|
|
|
+ await scheduler._check_auto_drying(db, [], set())
|
|
|
+
|
|
|
+ # A print is ready: check_queue stops drying on this printer.
|
|
|
+ await scheduler._stop_drying(1)
|
|
|
+
|
|
|
+ mock_pm.get_status.return_value = self._ams_state(0, self.ABOVE)
|
|
|
+ await scheduler._check_auto_drying(db, [], set())
|
|
|
+
|
|
|
+ assert scheduler._auto_dry_units[(1, 0)]["unproductive"] == 0
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @patch("backend.app.services.print_scheduler.printer_manager")
|
|
|
+ @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
|
|
|
+ async def test_manual_stop_button_is_not_an_unproductive_cycle(self, mock_sd, mock_pm, scheduler):
|
|
|
+ """The Stop button goes straight to printer_manager, bypassing the
|
|
|
+ scheduler, so the route has to tell the scheduler to forget the cycle."""
|
|
|
+ mock_pm.is_connected.return_value = True
|
|
|
+ mock_pm.get_model.return_value = "H2D"
|
|
|
+ mock_pm.send_drying_command.return_value = True
|
|
|
+ scheduler._is_printer_idle = MagicMock(return_value=True)
|
|
|
+ db = self._db()
|
|
|
+
|
|
|
+ mock_pm.get_status.return_value = self._ams_state(0, self.ABOVE)
|
|
|
+ await scheduler._check_auto_drying(db, [], set())
|
|
|
+ mock_pm.get_status.return_value = self._ams_state(720, self.ABOVE)
|
|
|
+ await scheduler._check_auto_drying(db, [], set())
|
|
|
+
|
|
|
+ scheduler.forget_auto_dry_cycle(1, 0)
|
|
|
+
|
|
|
+ mock_pm.get_status.return_value = self._ams_state(0, self.ABOVE)
|
|
|
+ await scheduler._check_auto_drying(db, [], set())
|
|
|
+
|
|
|
+ assert scheduler._auto_dry_units[(1, 0)]["unproductive"] == 0
|
|
|
+
|
|
|
+
|
|
|
+class TestAutoDryProgressKeepsItGoing(_DryingTestBase):
|
|
|
+ """Suspension must not punish a spool that is genuinely drying, just slowly."""
|
|
|
+
|
|
|
+ THRESHOLD = "25"
|
|
|
+
|
|
|
+ @pytest.fixture
|
|
|
+ def scheduler(self):
|
|
|
+ return PrintScheduler()
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _ams_state(dry_time, humidity):
|
|
|
+ state = MagicMock()
|
|
|
+ state.raw_data = {
|
|
|
+ "ams": [
|
|
|
+ {
|
|
|
+ "id": 0,
|
|
|
+ "module_type": "n3f",
|
|
|
+ "dry_time": dry_time,
|
|
|
+ "humidity_raw": str(humidity),
|
|
|
+ "dry_sf_reason": [],
|
|
|
+ "tray": [{"tray_type": "PLA"}],
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ state.firmware_version = "01.09.00.00"
|
|
|
+ return state
|
|
|
+
|
|
|
+ def _db(self):
|
|
|
+ db = AsyncMock()
|
|
|
+ db.execute = AsyncMock(
|
|
|
+ side_effect=self._make_db_side_effect(
|
|
|
+ {
|
|
|
+ "queue_drying_enabled": self._make_setting("false"),
|
|
|
+ "ambient_drying_enabled": self._make_setting("true"),
|
|
|
+ "ams_humidity_fair": self._make_setting(self.THRESHOLD),
|
|
|
+ "queue_drying_block": self._make_setting("false"),
|
|
|
+ "drying_presets": None,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ )
|
|
|
+ return db
|
|
|
+
|
|
|
+ async def _cycle(self, scheduler, mock_pm, db, end_humidity):
|
|
|
+ """Arm a cycle, run it, and end it at the given reading."""
|
|
|
+ mock_pm.get_status.return_value = self._ams_state(0, end_humidity)
|
|
|
+ await scheduler._check_auto_drying(db, [], set())
|
|
|
+ mock_pm.get_status.return_value = self._ams_state(720, end_humidity)
|
|
|
+ await scheduler._check_auto_drying(db, [], set())
|
|
|
+ mock_pm.get_status.return_value = self._ams_state(0, end_humidity)
|
|
|
+ await scheduler._check_auto_drying(db, [], set())
|
|
|
+ entry = scheduler._auto_dry_units.get((1, 0))
|
|
|
+ if entry is not None and isinstance(entry.get("ended_at"), float):
|
|
|
+ entry["ended_at"] -= AUTO_DRY_REARM_COOLDOWN_SECONDS + 1
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @patch("backend.app.services.print_scheduler.notification_service")
|
|
|
+ @patch("backend.app.services.print_scheduler.printer_manager")
|
|
|
+ @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
|
|
|
+ async def test_a_reading_that_keeps_falling_is_never_suspended(self, mock_sd, mock_pm, mock_notify, scheduler):
|
|
|
+ """40 -> 37 -> 35 -> 33 with a 25% threshold: nowhere near it yet, but
|
|
|
+ every cycle is working. A humid workshop must not lose auto-drying."""
|
|
|
+ mock_pm.is_connected.return_value = True
|
|
|
+ mock_pm.get_model.return_value = "H2D"
|
|
|
+ mock_pm.send_drying_command.return_value = True
|
|
|
+ mock_notify.on_ams_drying_suspended = AsyncMock()
|
|
|
+ scheduler._is_printer_idle = MagicMock(return_value=True)
|
|
|
+ db = self._db()
|
|
|
+
|
|
|
+ for reading in (40, 37, 35, 33, 31, 29):
|
|
|
+ await self._cycle(scheduler, mock_pm, db, reading)
|
|
|
+
|
|
|
+ assert scheduler._auto_dry_units[(1, 0)]["suspended"] is False
|
|
|
+ mock_notify.on_ams_drying_suspended.assert_not_awaited()
|
|
|
+ assert mock_pm.send_drying_command.call_count == 6
|
|
|
+
|
|
|
+ @pytest.mark.asyncio
|
|
|
+ @patch("backend.app.services.print_scheduler.notification_service")
|
|
|
+ @patch("backend.app.services.print_scheduler.printer_manager")
|
|
|
+ @patch("backend.app.services.print_scheduler.supports_drying", return_value=True)
|
|
|
+ async def test_a_reading_oscillating_by_one_point_still_suspends(self, mock_sd, mock_pm, mock_notify, scheduler):
|
|
|
+ """A plateau with sensor noise — 31, 30, 31, 30 — is not progress.
|
|
|
+ Comparing against the previous cycle rather than the best-so-far would
|
|
|
+ read every other cycle as an improvement and loop indefinitely."""
|
|
|
+ mock_pm.is_connected.return_value = True
|
|
|
+ mock_pm.get_model.return_value = "H2D"
|
|
|
+ mock_pm.send_drying_command.return_value = True
|
|
|
+ mock_notify.on_ams_drying_suspended = AsyncMock()
|
|
|
+ scheduler._is_printer_idle = MagicMock(return_value=True)
|
|
|
+ db = self._db()
|
|
|
+
|
|
|
+ for reading in (31, 30, 31, 30, 31, 30):
|
|
|
+ await self._cycle(scheduler, mock_pm, db, reading)
|
|
|
+
|
|
|
+ assert scheduler._auto_dry_units[(1, 0)]["suspended"] is True
|
|
|
+ mock_notify.on_ams_drying_suspended.assert_awaited_once()
|