Explorar el Código

Add ambient drying mode and fix block mode humidity auto-stop (#292)

  Ambient drying: automatically dry filament on idle printers when
  humidity exceeds threshold, regardless of queue state. Separate toggle
  from queue auto-drying — both can run simultaneously. Uses the same
  presets, humidity threshold, and power constraint detection.

  Fix: block mode (wait for drying) previously skipped the humidity
  auto-stop check for already-drying printers, causing drying to
  continue indefinitely. Now only prevents starting new drying.
maziggy hace 2 meses
padre
commit
14855ba8f4

+ 2 - 0
CHANGELOG.md

@@ -10,8 +10,10 @@ All notable changes to Bambuddy will be documented in this file.
 - **Queue Auto-Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Automatically dry filament between scheduled queue prints. When enabled in Settings → Print Queue, the scheduler starts drying on idle printers that have upcoming scheduled prints and whose AMS humidity exceeds the configured threshold. Uses conservative parameters (lowest temperature, longest duration) when mixed filament types are loaded. Drying stops automatically when humidity drops below threshold (with a 30-minute minimum to prevent oscillation), when scheduled items are removed, or when the feature is disabled. Optional "block queue" mode delays the next print until drying completes.
 - **Queue Auto-Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Automatically dry filament between scheduled queue prints. When enabled in Settings → Print Queue, the scheduler starts drying on idle printers that have upcoming scheduled prints and whose AMS humidity exceeds the configured threshold. Uses conservative parameters (lowest temperature, longest duration) when mixed filament types are loaded. Drying stops automatically when humidity drops below threshold (with a 30-minute minimum to prevent oscillation), when scheduled items are removed, or when the feature is disabled. Optional "block queue" mode delays the next print until drying completes.
 - **Configurable Drying Presets** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Customize temperature and duration for each filament type in Settings → Print Queue. Defaults match BambuStudio presets (PLA 55°C/8h, PETG 65°C/8h, etc.) and are used by both the manual drying popover and queue auto-drying. AMS 2 Pro and AMS-HT use separate presets reflecting their different heating capabilities.
 - **Configurable Drying Presets** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Customize temperature and duration for each filament type in Settings → Print Queue. Defaults match BambuStudio presets (PLA 55°C/8h, PETG 65°C/8h, etc.) and are used by both the manual drying popover and queue auto-drying. AMS 2 Pro and AMS-HT use separate presets reflecting their different heating capabilities.
 - **AMS PSU Detection** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — The drying button is disabled with a tooltip when the AMS lacks sufficient power for drying (e.g. not connected to the external PSU). Reads `dry_sf_reason` from printer firmware and surfaces HMS error codes for AMS 2 Pro and AMS-HT power issues.
 - **AMS PSU Detection** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — The drying button is disabled with a tooltip when the AMS lacks sufficient power for drying (e.g. not connected to the external PSU). Reads `dry_sf_reason` from printer firmware and surfaces HMS error codes for AMS 2 Pro and AMS-HT power issues.
+- **Ambient Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Automatically keep filament dry on idle printers based on humidity, even without queued prints. Enable "Ambient drying" in Settings → Print Queue to have the scheduler start drying on any idle printer whose AMS humidity exceeds the configured threshold — no scheduled prints required. Uses the same humidity threshold, drying presets, and power constraint detection as queue auto-drying. Both modes can be enabled simultaneously. Requested by community.
 
 
 ### Fixed
 ### Fixed
+- **Block Mode Skips Humidity Auto-Stop** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — When "Wait for drying to complete" was enabled and a printer had pending queue items, the scheduler skipped the humidity auto-stop check entirely. A drying session that reached its humidity target would continue indefinitely instead of stopping after the 30-minute minimum. Now, block mode only prevents starting new drying — already-drying printers still have their humidity checked and stopped when the threshold is met.
 - **AMS Fill Level Shows 0% for Non-Viewer Users** ([#676](https://github.com/maziggy/bambuddy/issues/676)) — When authentication was enabled with advanced permissions, users with `inventory:view_assignments` permission saw 0% fill level on AMS slots where inventory spool data had stale `weight_used` values. The fill level fallback chain (Spoolman → Inventory → AMS remain) used nullish coalescing (`??`), which doesn't fall through on `0` — so a stale inventory fill of 0% permanently shadowed the correct real-time AMS remain value from the printer. Now, when inventory says 0% but the AMS hardware reports a positive remain, the inventory value is bypassed in favor of the live AMS data. Viewer users were unaffected because their group lacked `inventory:view_assignments`, so the inventory query never fired and the AMS remain was used directly. Reported by @cadtoolbox.
 - **AMS Fill Level Shows 0% for Non-Viewer Users** ([#676](https://github.com/maziggy/bambuddy/issues/676)) — When authentication was enabled with advanced permissions, users with `inventory:view_assignments` permission saw 0% fill level on AMS slots where inventory spool data had stale `weight_used` values. The fill level fallback chain (Spoolman → Inventory → AMS remain) used nullish coalescing (`??`), which doesn't fall through on `0` — so a stale inventory fill of 0% permanently shadowed the correct real-time AMS remain value from the printer. Now, when inventory says 0% but the AMS hardware reports a positive remain, the inventory value is bypassed in favor of the live AMS data. Viewer users were unaffected because their group lacked `inventory:view_assignments`, so the inventory query never fired and the AMS remain was used directly. Reported by @cadtoolbox.
 - **Virtual Printer Proxy Mode Always Shows X1C Model** — Creating a virtual printer in Proxy mode always set the model to X1C regardless of the destination printer, because the frontend hides the model dropdown in proxy mode and the backend defaulted to X1C. Now auto-inherits the model from the target printer when creating or updating a proxy virtual printer (e.g. a proxy pointing at a P1S correctly presents itself as P1S to the slicer). The model also auto-updates when changing the target printer or switching to proxy mode.
 - **Virtual Printer Proxy Mode Always Shows X1C Model** — Creating a virtual printer in Proxy mode always set the model to X1C regardless of the destination printer, because the frontend hides the model dropdown in proxy mode and the backend defaulted to X1C. Now auto-inherits the model from the target printer when creating or updating a proxy virtual printer (e.g. a proxy pointing at a P1S correctly presents itself as P1S to the slicer). The model also auto-updates when changing the target printer or switching to proxy mode.
 - **Cloud Profiles Shared Across All Users** ([#665](https://github.com/maziggy/bambuddy/issues/665)) — When authentication was enabled, Bambu Cloud credentials were stored globally — one account per Bambuddy instance. If User A logged into Cloud, every other user saw User A's account and profiles. User B logging in would overwrite User A's credentials. Cloud credentials are now stored per-user: each user logs into their own Bambu Cloud account independently. When auth is disabled (single-user mode), behavior is unchanged. Also fixed cloud data endpoints (`/cloud/settings`, `/cloud/fields`, preset CRUD) requiring `settings:read` / `settings:update` permissions instead of `cloud:auth` — users who had "Cloud Auth" enabled but "Settings" disabled couldn't load profiles after logging in. Reported by @cadtoolbox.
 - **Cloud Profiles Shared Across All Users** ([#665](https://github.com/maziggy/bambuddy/issues/665)) — When authentication was enabled, Bambu Cloud credentials were stored globally — one account per Bambuddy instance. If User A logged into Cloud, every other user saw User A's account and profiles. User B logging in would overwrite User A's credentials. Cloud credentials are now stored per-user: each user logs into their own Bambu Cloud account independently. When auth is disabled (single-user mode), behavior is unchanged. Also fixed cloud data endpoints (`/cloud/settings`, `/cloud/fields`, preset CRUD) requiring `settings:read` / `settings:update` permissions instead of `cloud:auth` — users who had "Cloud Auth" enabled but "Settings" disabled couldn't load profiles after logging in. Reported by @cadtoolbox.

+ 1 - 0
README.md

@@ -93,6 +93,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - AMS info card (hover for serial number, firmware version) with custom friendly names that persist across printers
 - AMS info card (hover for serial number, firmware version) with custom friendly names that persist across printers
 - **AMS remote drying** — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page with filament-based temperature/duration presets; automatic PSU detection and HMS power error reporting
 - **AMS remote drying** — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page with filament-based temperature/duration presets; automatic PSU detection and HMS power error reporting
 - **Queue auto-drying** — Automatically dry filament between scheduled prints when humidity exceeds threshold; configurable presets per filament type, optional blocking mode
 - **Queue auto-drying** — Automatically dry filament between scheduled prints when humidity exceeds threshold; configurable presets per filament type, optional blocking mode
+- **Ambient drying** — Automatically keep filament dry on idle printers based on humidity, regardless of whether prints are queued
 - Configurable drying presets per filament type (temperature & duration for AMS 2 Pro and AMS-HT)
 - Configurable drying presets per filament type (temperature & duration for AMS 2 Pro and AMS-HT)
 - Dual external spool support for H2D (Ext-L / Ext-R)
 - Dual external spool support for H2D (Ext-L / Ext-R)
 - HMS error monitoring with history and clear errors
 - HMS error monitoring with history and clear errors

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

@@ -99,6 +99,7 @@ async def get_settings(
                 "prometheus_enabled",
                 "prometheus_enabled",
                 "queue_drying_enabled",
                 "queue_drying_enabled",
                 "queue_drying_block",
                 "queue_drying_block",
+                "ambient_drying_enabled",
             ]:
             ]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in [
             elif setting.key in [

+ 5 - 0
backend/app/schemas/settings.py

@@ -65,6 +65,10 @@ class AppSettings(BaseModel):
         default=False,
         default=False,
         description="Block queue until drying completes (when disabled, prints take priority over drying)",
         description="Block queue until drying completes (when disabled, prints take priority over drying)",
     )
     )
+    ambient_drying_enabled: bool = Field(
+        default=False,
+        description="Automatically dry AMS filament on idle printers when humidity exceeds threshold, regardless of queue",
+    )
     drying_presets: str = Field(
     drying_presets: str = Field(
         default="",
         default="",
         description="JSON blob of drying presets per filament type (empty = use built-in defaults)",
         description="JSON blob of drying presets per filament type (empty = use built-in defaults)",
@@ -199,6 +203,7 @@ class AppSettingsUpdate(BaseModel):
     ams_history_retention_days: int | None = None
     ams_history_retention_days: int | None = None
     queue_drying_enabled: bool | None = None
     queue_drying_enabled: bool | None = None
     queue_drying_block: bool | None = None
     queue_drying_block: bool | None = None
+    ambient_drying_enabled: bool | None = None
     drying_presets: str | None = None
     drying_presets: str | None = None
     per_printer_mapping_expanded: bool | None = None
     per_printer_mapping_expanded: bool | None = None
     date_format: str | None = None
     date_format: str | None = None

+ 15 - 12
backend/app/services/print_scheduler.py

@@ -1095,13 +1095,16 @@ class PrintScheduler:
         return (min_temp, max_hours or 12, filament_type)
         return (min_temp, max_hours or 12, filament_type)
 
 
     async def _check_auto_drying(self, db: AsyncSession, queue_items: list[PrintQueueItem], busy_printers: set[int]):
     async def _check_auto_drying(self, db: AsyncSession, queue_items: list[PrintQueueItem], busy_printers: set[int]):
-        """Start drying on idle printers that have no pending queue items.
+        """Start drying on idle printers based on humidity.
 
 
-        Only runs when queue_drying_enabled is True.
+        Two modes (can both be enabled):
+        - queue_drying_enabled: Dry between scheduled queue prints
+        - ambient_drying_enabled: Dry any idle printer when humidity is high, regardless of queue
         """
         """
         queue_drying_enabled = await self._get_bool_setting(db, "queue_drying_enabled")
         queue_drying_enabled = await self._get_bool_setting(db, "queue_drying_enabled")
-        if not queue_drying_enabled:
-            # Stop active drying on all printers if feature was just disabled
+        ambient_drying_enabled = await self._get_bool_setting(db, "ambient_drying_enabled")
+        if not queue_drying_enabled and not ambient_drying_enabled:
+            # Stop active drying on all printers if both features disabled
             if self._drying_in_progress:
             if self._drying_in_progress:
                 for pid in list(self._drying_in_progress):
                 for pid in list(self._drying_in_progress):
                     logger.info("Auto-drying: printer %d — stopping, auto-drying disabled", pid)
                     logger.info("Auto-drying: printer %d — stopping, auto-drying disabled", pid)
@@ -1111,8 +1114,7 @@ class PrintScheduler:
         # Update drying state from printer status (handles backend restart)
         # Update drying state from printer status (handles backend restart)
         self._sync_drying_state()
         self._sync_drying_state()
 
 
-        # Find printers with scheduled items (auto-drying only makes sense
-        # when there are upcoming scheduled prints to fill idle time between)
+        # Find printers with scheduled items (for queue drying mode)
         printers_with_scheduled: set[int] = set()
         printers_with_scheduled: set[int] = set()
         printers_with_items: set[int] = set()
         printers_with_items: set[int] = set()
         for item in queue_items:
         for item in queue_items:
@@ -1121,8 +1123,8 @@ class PrintScheduler:
                 if item.scheduled_time and not item.manual_start:
                 if item.scheduled_time and not item.manual_start:
                     printers_with_scheduled.add(item.printer_id)
                     printers_with_scheduled.add(item.printer_id)
 
 
-        # If no printers have scheduled items, stop any auto-started drying
-        if not printers_with_scheduled:
+        # If only queue mode is on and no printers have scheduled items, stop drying
+        if not ambient_drying_enabled and not printers_with_scheduled:
             for pid in list(self._drying_in_progress):
             for pid in list(self._drying_in_progress):
                 logger.info("Auto-drying: printer %d — stopping, no scheduled prints in queue", pid)
                 logger.info("Auto-drying: printer %d — stopping, no scheduled prints in queue", pid)
                 await self._stop_drying(pid)
                 await self._stop_drying(pid)
@@ -1146,15 +1148,16 @@ class PrintScheduler:
             if pid in busy_printers:
             if pid in busy_printers:
                 logger.debug("Auto-drying: printer %d skipped — busy", pid)
                 logger.debug("Auto-drying: printer %d skipped — busy", pid)
                 continue
                 continue
-            # Only dry printers that have scheduled prints (filling idle time between prints)
-            if pid not in printers_with_scheduled:
+            # In queue-only mode, only dry printers that have scheduled prints
+            if not ambient_drying_enabled and pid not in printers_with_scheduled:
                 if self._drying_in_progress.get(pid):
                 if self._drying_in_progress.get(pid):
                     logger.info("Auto-drying: printer %d — stopping, no scheduled prints for this printer", pid)
                     logger.info("Auto-drying: printer %d — stopping, no scheduled prints for this printer", pid)
                     await self._stop_drying(pid)
                     await self._stop_drying(pid)
                 logger.debug("Auto-drying: printer %d skipped — no scheduled prints", pid)
                 logger.debug("Auto-drying: printer %d skipped — no scheduled prints", pid)
                 continue
                 continue
-            # When block mode is on, skip printers whose scheduled time hasn't arrived
-            if block_for_drying and pid in printers_with_items:
+            # When block mode is on, don't START new drying on printers with pending items.
+            # But allow already-drying printers through so humidity auto-stop logic still runs.
+            if block_for_drying and pid in printers_with_items and not self._drying_in_progress.get(pid):
                 logger.debug("Auto-drying: printer %d skipped — has pending items (block mode)", pid)
                 logger.debug("Auto-drying: printer %d skipped — has pending items (block mode)", pid)
                 continue
                 continue
             if not printer_manager.is_connected(pid):
             if not printer_manager.is_connected(pid):

+ 386 - 20
backend/tests/unit/test_scheduler_auto_drying.py

@@ -436,10 +436,38 @@ class TestAutoStopOnNoScheduledItems:
     def scheduler(self):
     def scheduler(self):
         return PrintScheduler()
         return PrintScheduler()
 
 
+    @staticmethod
+    def _make_setting(value):
+        s = MagicMock()
+        s.value = value
+        return s
+
+    @staticmethod
+    def _make_db_side_effect(settings_map):
+        """Create a side_effect for db.execute that returns settings by key."""
+
+        async def side_effect(stmt):
+            result = MagicMock()
+            try:
+                compiled = stmt.compile(compile_kwargs={"literal_binds": False})
+                param_values = list(compiled.params.values())
+            except Exception:
+                param_values = []
+
+            for key, val in settings_map.items():
+                if key in param_values:
+                    result.scalar_one_or_none.return_value = val
+                    return result
+
+            result.scalar_one_or_none.return_value = None
+            return result
+
+        return side_effect
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @patch("backend.app.services.print_scheduler.printer_manager")
     @patch("backend.app.services.print_scheduler.printer_manager")
     async def test_stops_when_no_scheduled_items(self, mock_pm, scheduler):
     async def test_stops_when_no_scheduled_items(self, mock_pm, scheduler):
-        """Auto-drying stops when queue has no scheduled items."""
+        """Auto-drying stops when queue has no scheduled items (queue mode only)."""
         scheduler._drying_in_progress = {1: time.monotonic()}
         scheduler._drying_in_progress = {1: time.monotonic()}
 
 
         state = MagicMock()
         state = MagicMock()
@@ -447,19 +475,11 @@ class TestAutoStopOnNoScheduledItems:
         mock_pm.get_status.return_value = state
         mock_pm.get_status.return_value = state
 
 
         db = AsyncMock()
         db = AsyncMock()
-        # queue_drying_enabled = true
-        enabled_setting = MagicMock()
-        enabled_setting.value = "true"
-
-        call_count = [0]
-
-        async def db_execute(stmt):
-            call_count[0] += 1
-            result = MagicMock()
-            result.scalar_one_or_none.return_value = enabled_setting
-            return result
-
-        db.execute = AsyncMock(side_effect=db_execute)
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("true"),
+            "ambient_drying_enabled": self._make_setting("false"),
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
 
 
         # Manual-start items only (no scheduled_time)
         # Manual-start items only (no scheduled_time)
         item = MagicMock()
         item = MagicMock()
@@ -476,7 +496,7 @@ class TestAutoStopOnNoScheduledItems:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @patch("backend.app.services.print_scheduler.printer_manager")
     @patch("backend.app.services.print_scheduler.printer_manager")
     async def test_stops_when_empty_queue(self, mock_pm, scheduler):
     async def test_stops_when_empty_queue(self, mock_pm, scheduler):
-        """Auto-drying stops when queue is completely empty."""
+        """Auto-drying stops when queue is completely empty (queue mode only)."""
         scheduler._drying_in_progress = {1: time.monotonic()}
         scheduler._drying_in_progress = {1: time.monotonic()}
 
 
         state = MagicMock()
         state = MagicMock()
@@ -484,11 +504,11 @@ class TestAutoStopOnNoScheduledItems:
         mock_pm.get_status.return_value = state
         mock_pm.get_status.return_value = state
 
 
         db = AsyncMock()
         db = AsyncMock()
-        enabled_setting = MagicMock()
-        enabled_setting.value = "true"
-        result_mock = MagicMock()
-        result_mock.scalar_one_or_none.return_value = enabled_setting
-        db.execute = AsyncMock(return_value=result_mock)
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("true"),
+            "ambient_drying_enabled": self._make_setting("false"),
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
 
 
         await scheduler._check_auto_drying(db, [], set())
         await scheduler._check_auto_drying(db, [], set())
 
 
@@ -518,3 +538,349 @@ class TestDryingTrackingTimestamps:
         scheduler._drying_in_progress[1] = time.monotonic()
         scheduler._drying_in_progress[1] = time.monotonic()
         assert scheduler._drying_in_progress.get(1)
         assert scheduler._drying_in_progress.get(1)
         assert not scheduler._drying_in_progress.get(999)
         assert not scheduler._drying_in_progress.get(999)
+
+
+class _DryingTestBase:
+    """Shared helpers for auto-drying integration tests."""
+
+    @staticmethod
+    def _make_setting(value):
+        s = MagicMock()
+        s.value = value
+        return s
+
+    @staticmethod
+    def _make_db_side_effect(settings_map, printer_ids=None):
+        """Create a side_effect for db.execute that returns settings by key and printers."""
+        if printer_ids is None:
+            printer_ids = [1]
+
+        async def side_effect(stmt):
+            result = MagicMock()
+            stmt_str = str(stmt)
+
+            try:
+                compiled = stmt.compile(compile_kwargs={"literal_binds": False})
+                param_values = list(compiled.params.values())
+            except Exception:
+                param_values = []
+
+            for key, val in settings_map.items():
+                if key in param_values:
+                    result.scalar_one_or_none.return_value = val
+                    return result
+
+            if "printer" in stmt_str.lower() or "is_active" in stmt_str:
+                printers = []
+                for pid in printer_ids:
+                    p = MagicMock()
+                    p.id = pid
+                    p.is_active = True
+                    printers.append(p)
+                scalars_mock = MagicMock()
+                scalars_mock.__iter__ = MagicMock(return_value=iter(printers))
+                result.scalars.return_value = scalars_mock
+            else:
+                result.scalar_one_or_none.return_value = None
+            return result
+
+        return side_effect
+
+
+class TestAmbientDrying(_DryingTestBase):
+    """Tests for ambient drying mode — drying based on humidity regardless of queue state."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @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_ambient_dries_idle_printer_without_queue(self, mock_sd, mock_pm, scheduler):
+        """Ambient mode starts drying on idle printers even with no queue items."""
+        state = MagicMock()
+        state.raw_data = {
+            "ams": [
+                {
+                    "id": 0,
+                    "module_type": "n3f",
+                    "dry_time": 0,
+                    "humidity_raw": "75",
+                    "dry_sf_reason": [],
+                    "tray": [{"tray_type": "PLA"}],
+                }
+            ]
+        }
+        state.firmware_version = "01.09.00.00"
+        mock_pm.get_status.return_value = state
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+        mock_pm.send_drying_command.return_value = True
+
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = AsyncMock()
+
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("false"),
+            "ambient_drying_enabled": self._make_setting("true"),
+            "ams_humidity_fair": self._make_setting("60"),
+            "queue_drying_block": self._make_setting("false"),
+            "drying_presets": None,
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
+
+        # Empty queue — ambient mode should still dry
+        await scheduler._check_auto_drying(db, [], set())
+
+        mock_pm.send_drying_command.assert_called_once_with(1, 0, 45, 12, mode=1, filament="PLA")
+        assert 1 in scheduler._drying_in_progress
+
+    @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_ambient_does_not_dry_below_threshold(self, mock_sd, mock_pm, scheduler):
+        """Ambient mode does NOT dry when humidity is below threshold."""
+        state = MagicMock()
+        state.raw_data = {
+            "ams": [
+                {
+                    "id": 0,
+                    "module_type": "n3f",
+                    "dry_time": 0,
+                    "humidity_raw": "40",
+                    "dry_sf_reason": [],
+                    "tray": [{"tray_type": "PLA"}],
+                }
+            ]
+        }
+        state.firmware_version = "01.09.00.00"
+        mock_pm.get_status.return_value = state
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = AsyncMock()
+
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("false"),
+            "ambient_drying_enabled": self._make_setting("true"),
+            "ams_humidity_fair": self._make_setting("60"),
+            "queue_drying_block": self._make_setting("false"),
+            "drying_presets": None,
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
+
+        await scheduler._check_auto_drying(db, [], set())
+
+        mock_pm.send_drying_command.assert_not_called()
+
+    @pytest.mark.asyncio
+    @patch("backend.app.services.print_scheduler.printer_manager")
+    async def test_ambient_off_stops_drying_without_queue(self, mock_pm, scheduler):
+        """Disabling ambient drying stops drying on printers without queue items."""
+        scheduler._drying_in_progress = {1: time.monotonic()}
+
+        state = MagicMock()
+        state.raw_data = {"ams": [{"id": 0, "dry_time": 120}]}
+        mock_pm.get_status.return_value = state
+
+        db = AsyncMock()
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("false"),
+            "ambient_drying_enabled": self._make_setting("false"),
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
+
+        await scheduler._check_auto_drying(db, [], set())
+
+        assert mock_pm.send_drying_command.called
+        assert not scheduler._drying_in_progress
+
+    @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_ambient_continues_when_queue_empty(self, mock_sd, mock_pm, scheduler):
+        """Ambient drying continues even when queue has no scheduled items (unlike queue mode)."""
+        scheduler._drying_in_progress = {1: time.monotonic() - 100}
+
+        state = MagicMock()
+        state.raw_data = {
+            "ams": [
+                {
+                    "id": 0,
+                    "module_type": "n3f",
+                    "dry_time": 600,
+                    "humidity_raw": "75",
+                    "dry_sf_reason": [],
+                    "tray": [{"tray_type": "PLA"}],
+                }
+            ]
+        }
+        state.firmware_version = "01.09.00.00"
+        mock_pm.get_status.return_value = state
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = AsyncMock()
+
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("false"),
+            "ambient_drying_enabled": self._make_setting("true"),
+            "ams_humidity_fair": self._make_setting("60"),
+            "queue_drying_block": self._make_setting("false"),
+            "drying_presets": None,
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
+
+        await scheduler._check_auto_drying(db, [], set())
+
+        # Should NOT have sent stop — humidity still high, drying continues
+        for call in mock_pm.send_drying_command.call_args_list:
+            assert call.kwargs.get("mode") != 0, "Should not stop drying in ambient mode with high humidity"
+        assert 1 in scheduler._drying_in_progress
+
+    @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_queue_only_does_not_dry_without_scheduled_items(self, mock_sd, mock_pm, scheduler):
+        """Queue mode alone does NOT dry printers that have no scheduled queue items."""
+        state = MagicMock()
+        state.raw_data = {
+            "ams": [
+                {
+                    "id": 0,
+                    "module_type": "n3f",
+                    "dry_time": 0,
+                    "humidity_raw": "75",
+                    "dry_sf_reason": [],
+                    "tray": [{"tray_type": "PLA"}],
+                }
+            ]
+        }
+        state.firmware_version = "01.09.00.00"
+        mock_pm.get_status.return_value = state
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = AsyncMock()
+
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("true"),
+            "ambient_drying_enabled": self._make_setting("false"),
+            "ams_humidity_fair": self._make_setting("60"),
+            "queue_drying_block": self._make_setting("false"),
+            "drying_presets": None,
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
+
+        # No queue items at all
+        await scheduler._check_auto_drying(db, [], set())
+
+        mock_pm.send_drying_command.assert_not_called()
+
+
+class TestBlockForDryingBugFix(_DryingTestBase):
+    """Regression: block mode should not skip humidity auto-stop for already-drying printers."""
+
+    @pytest.fixture
+    def scheduler(self):
+        s = PrintScheduler()
+        s._min_drying_seconds = 1800
+        return s
+
+    @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_block_mode_allows_humidity_stop_for_active_drying(self, mock_sd, mock_pm, scheduler):
+        """Bug fix: printer already drying in block mode should still check humidity to auto-stop."""
+        # Drying started 35 minutes ago
+        scheduler._drying_in_progress = {1: time.monotonic() - 2100}
+
+        state = MagicMock()
+        state.raw_data = {
+            "ams": [
+                {
+                    "id": 0,
+                    "module_type": "n3f",
+                    "dry_time": 600,
+                    "humidity_raw": "30",  # Below threshold
+                    "dry_sf_reason": [],
+                    "tray": [{"tray_type": "PLA"}],
+                }
+            ]
+        }
+        state.firmware_version = "01.09.00.00"
+        mock_pm.get_status.return_value = state
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = AsyncMock()
+
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("true"),
+            "ambient_drying_enabled": self._make_setting("false"),
+            "ams_humidity_fair": self._make_setting("60"),
+            "queue_drying_block": self._make_setting("true"),
+            "drying_presets": None,
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
+
+        # Queue item exists for this printer (triggers block mode gate)
+        item = MagicMock()
+        item.printer_id = 1
+        item.scheduled_time = MagicMock()
+        item.manual_start = False
+
+        await scheduler._check_auto_drying(db, [item], set())
+
+        # Should have sent stop command — humidity dropped below threshold after 30+ min
+        mock_pm.send_drying_command.assert_any_call(1, 0, temp=0, duration=0, mode=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_block_mode_prevents_new_drying_start(self, mock_sd, mock_pm, scheduler):
+        """Block mode should still prevent starting NEW drying on printers with pending items."""
+        state = MagicMock()
+        state.raw_data = {
+            "ams": [
+                {
+                    "id": 0,
+                    "module_type": "n3f",
+                    "dry_time": 0,
+                    "humidity_raw": "75",
+                    "dry_sf_reason": [],
+                    "tray": [{"tray_type": "PLA"}],
+                }
+            ]
+        }
+        state.firmware_version = "01.09.00.00"
+        mock_pm.get_status.return_value = state
+        mock_pm.is_connected.return_value = True
+        mock_pm.get_model.return_value = "X1C"
+
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = AsyncMock()
+
+        settings_returns = {
+            "queue_drying_enabled": self._make_setting("true"),
+            "ambient_drying_enabled": self._make_setting("false"),
+            "ams_humidity_fair": self._make_setting("60"),
+            "queue_drying_block": self._make_setting("true"),
+            "drying_presets": None,
+        }
+        db.execute = AsyncMock(side_effect=self._make_db_side_effect(settings_returns))
+
+        item = MagicMock()
+        item.printer_id = 1
+        item.scheduled_time = MagicMock()
+        item.manual_start = False
+
+        await scheduler._check_auto_drying(db, [item], set())
+
+        # Should NOT start drying — block mode with pending items
+        mock_pm.send_drying_command.assert_not_called()

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

@@ -803,6 +803,7 @@ export interface AppSettings {
   // Queue auto-drying settings
   // Queue auto-drying settings
   queue_drying_enabled: boolean;  // Auto-dry AMS between queued prints
   queue_drying_enabled: boolean;  // Auto-dry AMS between queued prints
   queue_drying_block: boolean;  // Block queue until drying completes
   queue_drying_block: boolean;  // Block queue until drying completes
+  ambient_drying_enabled: boolean;  // Auto-dry idle printers based on humidity regardless of queue
   drying_presets: string;  // JSON blob of drying presets per filament type
   drying_presets: string;  // JSON blob of drying presets per filament type
   // Print modal settings
   // Print modal settings
   per_printer_mapping_expanded: boolean;  // Whether custom mapping is expanded by default in print modal
   per_printer_mapping_expanded: boolean;  // Whether custom mapping is expanded by default in print modal

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

@@ -1495,6 +1495,8 @@ export default {
     queueDryingEnabledDescription: 'AMS-Trocknung automatisch starten, wenn der Drucker im Leerlauf ist und die Feuchtigkeit über dem Schwellenwert liegt',
     queueDryingEnabledDescription: 'AMS-Trocknung automatisch starten, wenn der Drucker im Leerlauf ist und die Feuchtigkeit über dem Schwellenwert liegt',
     queueDryingBlock: 'Auf Trocknung warten',
     queueDryingBlock: 'Auf Trocknung warten',
     queueDryingBlockDescription: 'Druckwarteschlange blockieren, bis die Trocknung abgeschlossen ist. Wenn aus, haben Drucke Vorrang.',
     queueDryingBlockDescription: 'Druckwarteschlange blockieren, bis die Trocknung abgeschlossen ist. Wenn aus, haben Drucke Vorrang.',
+    ambientDryingEnabled: 'Umgebungstrocknung',
+    ambientDryingEnabledDescription: 'Filament auf inaktiven Druckern automatisch trocknen, wenn die Luftfeuchtigkeit den Schwellenwert überschreitet — auch ohne Warteschlange.',
     dryingPresets: 'Trocknungsvoreinstellungen',
     dryingPresets: 'Trocknungsvoreinstellungen',
     dryingPresetsDescription: 'Temperatur und Dauer pro Filamenttyp. AMS 2 Pro verwendet niedrigere Temperaturen, AMS-HT unterstützt höhere.',
     dryingPresetsDescription: 'Temperatur und Dauer pro Filamenttyp. AMS 2 Pro verwendet niedrigere Temperaturen, AMS-HT unterstützt höhere.',
     dryingFilament: 'Filament',
     dryingFilament: 'Filament',

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

@@ -1495,6 +1495,8 @@ export default {
     queueDryingEnabledDescription: 'Start AMS drying automatically when printer is idle and humidity is above threshold',
     queueDryingEnabledDescription: 'Start AMS drying automatically when printer is idle and humidity is above threshold',
     queueDryingBlock: 'Wait for drying to complete',
     queueDryingBlock: 'Wait for drying to complete',
     queueDryingBlockDescription: 'Block the print queue until drying finishes. When off, prints take priority over drying.',
     queueDryingBlockDescription: 'Block the print queue until drying finishes. When off, prints take priority over drying.',
+    ambientDryingEnabled: 'Ambient drying',
+    ambientDryingEnabledDescription: 'Automatically dry filament on idle printers when humidity exceeds threshold, even without queued prints.',
     dryingPresets: 'Drying Presets',
     dryingPresets: 'Drying Presets',
     dryingPresetsDescription: 'Temperature and duration per filament type. AMS 2 Pro uses lower temps, AMS-HT supports higher temps.',
     dryingPresetsDescription: 'Temperature and duration per filament type. AMS 2 Pro uses lower temps, AMS-HT supports higher temps.',
     dryingFilament: 'Filament',
     dryingFilament: 'Filament',

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

@@ -1495,6 +1495,8 @@ export default {
     queueDryingEnabledDescription: 'Démarrer le séchage AMS automatiquement lorsque l\'imprimante est inactive et l\'humidité dépasse le seuil',
     queueDryingEnabledDescription: 'Démarrer le séchage AMS automatiquement lorsque l\'imprimante est inactive et l\'humidité dépasse le seuil',
     queueDryingBlock: 'Attendre la fin du séchage',
     queueDryingBlock: 'Attendre la fin du séchage',
     queueDryingBlockDescription: 'Bloquer la file d\'attente jusqu\'à la fin du séchage. Désactivé, les impressions sont prioritaires.',
     queueDryingBlockDescription: 'Bloquer la file d\'attente jusqu\'à la fin du séchage. Désactivé, les impressions sont prioritaires.',
+    ambientDryingEnabled: 'Séchage ambiant',
+    ambientDryingEnabledDescription: 'Sécher automatiquement le filament sur les imprimantes inactives lorsque l\'humidité dépasse le seuil, même sans impressions en file.',
     dryingPresets: 'Préréglages de séchage',
     dryingPresets: 'Préréglages de séchage',
     dryingPresetsDescription: 'Température et durée par type de filament. AMS 2 Pro utilise des températures plus basses, AMS-HT supporte des températures plus élevées.',
     dryingPresetsDescription: 'Température et durée par type de filament. AMS 2 Pro utilise des températures plus basses, AMS-HT supporte des températures plus élevées.',
     dryingFilament: 'Filament',
     dryingFilament: 'Filament',

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

@@ -1495,6 +1495,8 @@ export default {
     queueDryingEnabledDescription: 'Avvia l\'asciugatura AMS automaticamente quando la stampante è inattiva e l\'umidità supera la soglia',
     queueDryingEnabledDescription: 'Avvia l\'asciugatura AMS automaticamente quando la stampante è inattiva e l\'umidità supera la soglia',
     queueDryingBlock: 'Attendi completamento asciugatura',
     queueDryingBlock: 'Attendi completamento asciugatura',
     queueDryingBlockDescription: 'Blocca la coda di stampa fino al completamento dell\'asciugatura. Se disattivato, le stampe hanno priorità.',
     queueDryingBlockDescription: 'Blocca la coda di stampa fino al completamento dell\'asciugatura. Se disattivato, le stampe hanno priorità.',
+    ambientDryingEnabled: 'Asciugatura ambientale',
+    ambientDryingEnabledDescription: 'Asciuga automaticamente il filamento sulle stampanti inattive quando l\'umidità supera la soglia, anche senza stampe in coda.',
     dryingPresets: 'Preset di asciugatura',
     dryingPresets: 'Preset di asciugatura',
     dryingPresetsDescription: 'Temperatura e durata per tipo di filamento. AMS 2 Pro usa temperature più basse, AMS-HT supporta temperature più alte.',
     dryingPresetsDescription: 'Temperatura e durata per tipo di filamento. AMS 2 Pro usa temperature più basse, AMS-HT supporta temperature più alte.',
     dryingFilament: 'Filamento',
     dryingFilament: 'Filamento',

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

@@ -1495,6 +1495,8 @@ export default {
     queueDryingEnabledDescription: 'プリンターがアイドル状態で湿度がしきい値を超えた場合、AMS乾燥を自動的に開始',
     queueDryingEnabledDescription: 'プリンターがアイドル状態で湿度がしきい値を超えた場合、AMS乾燥を自動的に開始',
     queueDryingBlock: '乾燥完了まで待機',
     queueDryingBlock: '乾燥完了まで待機',
     queueDryingBlockDescription: '乾燥が完了するまで印刷キューをブロックします。オフの場合、印刷が優先されます。',
     queueDryingBlockDescription: '乾燥が完了するまで印刷キューをブロックします。オフの場合、印刷が優先されます。',
+    ambientDryingEnabled: '常時乾燥',
+    ambientDryingEnabledDescription: 'キューに関係なく、アイドル状態のプリンターで湿度がしきい値を超えた場合に自動的にフィラメントを乾燥。',
     dryingPresets: '乾燥プリセット',
     dryingPresets: '乾燥プリセット',
     dryingPresetsDescription: 'フィラメントタイプごとの温度と時間。AMS 2 Proは低温、AMS-HTは高温に対応。',
     dryingPresetsDescription: 'フィラメントタイプごとの温度と時間。AMS 2 Proは低温、AMS-HTは高温に対応。',
     dryingFilament: 'フィラメント',
     dryingFilament: 'フィラメント',

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

@@ -1495,6 +1495,8 @@ export default {
     queueDryingEnabledDescription: 'Iniciar secagem AMS automaticamente quando a impressora estiver ociosa e a umidade estiver acima do limite',
     queueDryingEnabledDescription: 'Iniciar secagem AMS automaticamente quando a impressora estiver ociosa e a umidade estiver acima do limite',
     queueDryingBlock: 'Aguardar conclusão da secagem',
     queueDryingBlock: 'Aguardar conclusão da secagem',
     queueDryingBlockDescription: 'Bloquear a fila de impressão até a secagem terminar. Quando desativado, impressões têm prioridade.',
     queueDryingBlockDescription: 'Bloquear a fila de impressão até a secagem terminar. Quando desativado, impressões têm prioridade.',
+    ambientDryingEnabled: 'Secagem ambiente',
+    ambientDryingEnabledDescription: 'Secar automaticamente o filamento em impressoras ociosas quando a umidade exceder o limite, mesmo sem impressões na fila.',
     dryingPresets: 'Predefinições de secagem',
     dryingPresets: 'Predefinições de secagem',
     dryingPresetsDescription: 'Temperatura e duração por tipo de filamento. AMS 2 Pro usa temperaturas mais baixas, AMS-HT suporta temperaturas mais altas.',
     dryingPresetsDescription: 'Temperatura e duração por tipo de filamento. AMS 2 Pro usa temperaturas mais baixas, AMS-HT suporta temperaturas mais altas.',
     dryingFilament: 'Filamento',
     dryingFilament: 'Filamento',

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

@@ -1495,6 +1495,8 @@ export default {
     queueDryingEnabledDescription: '当打印机空闲且湿度超过阈值时,自动启动AMS干燥',
     queueDryingEnabledDescription: '当打印机空闲且湿度超过阈值时,自动启动AMS干燥',
     queueDryingBlock: '等待干燥完成',
     queueDryingBlock: '等待干燥完成',
     queueDryingBlockDescription: '阻止打印队列直到干燥完成。关闭时,打印优先于干燥。',
     queueDryingBlockDescription: '阻止打印队列直到干燥完成。关闭时,打印优先于干燥。',
+    ambientDryingEnabled: '环境干燥',
+    ambientDryingEnabledDescription: '当空闲打印机的湿度超过阈值时自动干燥耗材,无需排队打印。',
     dryingPresets: '干燥预设',
     dryingPresets: '干燥预设',
     dryingPresetsDescription: '每种耗材类型的温度和时长。AMS 2 Pro使用较低温度,AMS-HT支持较高温度。',
     dryingPresetsDescription: '每种耗材类型的温度和时长。AMS 2 Pro使用较低温度,AMS-HT支持较高温度。',
     dryingFilament: '耗材',
     dryingFilament: '耗材',

+ 21 - 0
frontend/src/pages/SettingsPage.tsx

@@ -711,6 +711,7 @@ export function SettingsPage() {
       settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
       settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
       (settings.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) ||
       (settings.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) ||
       (settings.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) ||
       (settings.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) ||
+      (settings.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) ||
       (settings.drying_presets ?? '') !== (localSettings.drying_presets ?? '') ||
       (settings.drying_presets ?? '') !== (localSettings.drying_presets ?? '') ||
       settings.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded ||
       settings.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded ||
       settings.date_format !== localSettings.date_format ||
       settings.date_format !== localSettings.date_format ||
@@ -780,6 +781,7 @@ export function SettingsPage() {
         ams_history_retention_days: localSettings.ams_history_retention_days,
         ams_history_retention_days: localSettings.ams_history_retention_days,
         queue_drying_enabled: localSettings.queue_drying_enabled,
         queue_drying_enabled: localSettings.queue_drying_enabled,
         queue_drying_block: localSettings.queue_drying_block,
         queue_drying_block: localSettings.queue_drying_block,
+        ambient_drying_enabled: localSettings.ambient_drying_enabled,
         drying_presets: localSettings.drying_presets,
         drying_presets: localSettings.drying_presets,
         per_printer_mapping_expanded: localSettings.per_printer_mapping_expanded,
         per_printer_mapping_expanded: localSettings.per_printer_mapping_expanded,
         date_format: localSettings.date_format,
         date_format: localSettings.date_format,
@@ -3420,6 +3422,25 @@ export function SettingsPage() {
                       </div>
                       </div>
                     </>
                     </>
                   )}
                   )}
+                  <div className="flex items-center justify-between">
+                    <div>
+                      <label className="block text-sm text-white">
+                        {t('settings.ambientDryingEnabled')}
+                      </label>
+                      <p className="text-xs text-bambu-gray mt-0.5">
+                        {t('settings.ambientDryingEnabledDescription')}
+                      </p>
+                    </div>
+                    <label className="relative inline-flex items-center cursor-pointer">
+                      <input
+                        type="checkbox"
+                        checked={localSettings.ambient_drying_enabled ?? false}
+                        onChange={(e) => updateSetting('ambient_drying_enabled', e.target.checked)}
+                        className="sr-only peer"
+                      />
+                      <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                    </label>
+                  </div>
                   {/* Drying Presets Table — always visible since manual drying also uses these */}
                   {/* Drying Presets Table — always visible since manual drying also uses these */}
                   <div className="space-y-2">
                   <div className="space-y-2">
                     <p className="text-sm text-white font-medium">{t('settings.dryingPresets')}</p>
                     <p className="text-sm text-white font-medium">{t('settings.dryingPresets')}</p>

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-CJ70I8NI.js


+ 1 - 1
static/index.html

@@ -23,7 +23,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-Bevj-FAl.js"></script>
+    <script type="module" crossorigin src="/assets/index-CJ70I8NI.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BgeMokkR.css">
     <link rel="stylesheet" crossorigin href="/assets/index-BgeMokkR.css">
   </head>
   </head>
   <body>
   <body>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio