فهرست منبع

feat(humidity): per-filament humidity threshold for auto-drying + alarms (#1605)

  Reporter @thenewguy runs an engineering farm with one AMS per material
  (PLA, ASA, Nylon, PVB, HIPS) — Bambuddy's single global ams_humidity_fair
  threshold (default 60%) was driving both the queue / ambient auto-drying
  trigger AND the hourly humidity alarm uniformly, which is wrong for
  multi-material setups where Nylon wants <10% and PLA is fine at 60%.

  Drying RUN parameters were already per-filament via drying_presets;
  this commit adds the missing per-filament TRIGGER.

  New setting ams_humidity_thresholds — JSON map of filament-type to
  threshold percent with a "default" key for unknown / unmapped types.
  Empty / unset → both consumers fall back to ams_humidity_fair so the
  upgrade is silent.

  Resolver lives in PrintScheduler.resolve_humidity_threshold(trays,
  thresholds, fallback) — picks the lowest (most-restrictive) threshold
  across all loaded tray types, matching the conservative-params strategy
  _get_conservative_drying_params already uses for temp / hours. Empty
  tray slots contribute no constraint; all-empty AMS falls through to the
  "default" key. Filament names normalized to uppercase base (so
  "PLA Basic" / "pla basic" both map to PLA).

  Two consumer sites rewired through the same resolver so the scheduler
  and the alarm path can never disagree about whether an AMS is "too
  humid":
    - print_scheduler.py::_check_auto_drying — per-AMS humidity comparison
      for start / stop / skip decisions.
    - main.py AMS sensor / alarm worker — hourly humidity alarm notifier.

  UI: new table in Settings → Workflow → Auto-Drying, below the existing
  Drying Presets table. Default row + 8 default filament types
  (PLA / PETG / TPU / ABS / ASA / PA / PC / PVA) pre-filled from the
  current ams_humidity_fair value so the editor starts sensibly.

  Input pattern: draft-on-edit / commit-on-blur (transient humidityDrafts
  state per row). onChange only updates the draft; onBlur (and Enter)
  parses + clamps to [5, 95] + commits. Empty value on blur clears the
  override and falls back to default. Caught mid-PR via a typing test:
  the naive per-keystroke clamp snapped "3" → 5 before the user could
  type the second digit of "30".

  Setting is in the public _UI_PREFERENCE_FIELDS allowlist (same rationale
  as drying_presets and ams_humidity_fair — non-sensitive integer map,
  no SETTINGS_READ permission required for badge-color rendering).
maziggy 2 ماه پیش
والد
کامیت
68b9d741d9

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


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

@@ -342,6 +342,7 @@ _UI_PREFERENCE_FIELDS: tuple[str, ...] = (
     "time_format",
     "time_format",
     "date_format",
     "date_format",
     "drying_presets",
     "drying_presets",
+    "ams_humidity_thresholds",
     "ams_humidity_good",
     "ams_humidity_good",
     "ams_humidity_fair",
     "ams_humidity_fair",
     "ams_temp_good",
     "ams_temp_good",

+ 48 - 4
backend/app/main.py

@@ -1,4 +1,5 @@
 import asyncio
 import asyncio
+import json
 import logging
 import logging
 import mimetypes as _mimetypes
 import mimetypes as _mimetypes
 import os
 import os
@@ -5110,6 +5111,29 @@ async def record_ams_history():
                     except (ValueError, TypeError):
                     except (ValueError, TypeError):
                         pass  # Keep default threshold if stored value is invalid
                         pass  # Keep default threshold if stored value is invalid
 
 
+                # Per-filament humidity threshold overrides (#1605) — resolved
+                # per-AMS below from the loaded tray types. Reuses the same
+                # resolver as the auto-drying scheduler so behavior stays in
+                # lockstep across both consumers.
+                from backend.app.services.print_scheduler import PrintScheduler
+
+                per_type_humidity_thresholds: dict[str, int] = {}
+                result = await db.execute(select(Settings).where(Settings.key == "ams_humidity_thresholds"))
+                setting = result.scalar_one_or_none()
+                if setting and setting.value:
+                    try:
+                        raw = json.loads(setting.value)
+                        if isinstance(raw, dict):
+                            for k, v in raw.items():
+                                try:
+                                    per_type_humidity_thresholds[str(k).upper() if k != "default" else "default"] = int(
+                                        v
+                                    )
+                                except (TypeError, ValueError):
+                                    continue
+                    except (ValueError, TypeError):
+                        pass  # Invalid JSON → no overrides, fall through to global threshold
+
                 recorded_count = 0
                 recorded_count = 0
                 for printer in printers:
                 for printer in printers:
                     # Get current state from printer manager
                     # Get current state from printer manager
@@ -5181,8 +5205,18 @@ async def record_ams_history():
                         if not _ams_has_filament(ams_data):
                         if not _ams_has_filament(ams_data):
                             continue
                             continue
 
 
+                        # Resolve per-filament humidity threshold for this AMS
+                        # unit (#1605). Falls back to the global ams_humidity_fair
+                        # when no per-type overrides are configured.
+                        trays = ams_data.get("tray", []) or []
+                        effective_humidity_threshold = float(
+                            PrintScheduler.resolve_humidity_threshold(
+                                trays, per_type_humidity_thresholds, int(humidity_threshold)
+                            )
+                        )
+
                         # Check humidity alarm (only if above threshold)
                         # Check humidity alarm (only if above threshold)
-                        if humidity is not None and humidity > humidity_threshold:
+                        if humidity is not None and humidity > effective_humidity_threshold:
                             cooldown_key = f"{printer.id}:{ams_id}:humidity"
                             cooldown_key = f"{printer.id}:{ams_id}:humidity"
                             last_alarm = _ams_alarm_cooldown.get(cooldown_key)
                             last_alarm = _ams_alarm_cooldown.get(cooldown_key)
                             now = datetime.now(timezone.utc)
                             now = datetime.now(timezone.utc)
@@ -5192,17 +5226,27 @@ async def record_ams_history():
                             ):
                             ):
                                 _ams_alarm_cooldown[cooldown_key] = now
                                 _ams_alarm_cooldown[cooldown_key] = now
                                 logger.info(
                                 logger.info(
-                                    f"Sending humidity alarm for {printer.name} {ams_label}: {humidity}% > {humidity_threshold}%"
+                                    f"Sending humidity alarm for {printer.name} {ams_label}: {humidity}% > {effective_humidity_threshold}%"
                                 )
                                 )
                                 try:
                                 try:
                                     # Call different notification method based on AMS type
                                     # Call different notification method based on AMS type
                                     if is_ams_ht:
                                     if is_ams_ht:
                                         await notification_service.on_ams_ht_humidity_high(
                                         await notification_service.on_ams_ht_humidity_high(
-                                            printer.id, printer.name, ams_label, humidity, humidity_threshold, db
+                                            printer.id,
+                                            printer.name,
+                                            ams_label,
+                                            humidity,
+                                            effective_humidity_threshold,
+                                            db,
                                         )
                                         )
                                     else:
                                     else:
                                         await notification_service.on_ams_humidity_high(
                                         await notification_service.on_ams_humidity_high(
-                                            printer.id, printer.name, ams_label, humidity, humidity_threshold, db
+                                            printer.id,
+                                            printer.name,
+                                            ams_label,
+                                            humidity,
+                                            effective_humidity_threshold,
+                                            db,
                                         )
                                         )
                                 except Exception as e:
                                 except Exception as e:
                                     logger.warning("Failed to send humidity alarm: %s", e)
                                     logger.warning("Failed to send humidity alarm: %s", e)

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

@@ -92,6 +92,14 @@ class AppSettings(BaseModel):
         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)",
     )
     )
+    ams_humidity_thresholds: str = Field(
+        default="",
+        description=(
+            "JSON blob of per-filament-type humidity trigger thresholds for auto-drying and alarms. "
+            'Shape: {"default": int, "PLA": int, "ASA": int, ...}. '
+            "Empty = fall back to ams_humidity_fair for all types."
+        ),
+    )
 
 
     # Auto-print G-code injection (#422)
     # Auto-print G-code injection (#422)
     gcode_snippets: str = Field(
     gcode_snippets: str = Field(
@@ -413,6 +421,7 @@ class AppSettingsUpdate(BaseModel):
     queue_drying_block: bool | None = None
     queue_drying_block: bool | None = None
     ambient_drying_enabled: bool | None = None
     ambient_drying_enabled: bool | None = None
     drying_presets: str | None = None
     drying_presets: str | None = None
+    ams_humidity_thresholds: 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
     time_format: str | None = None
     time_format: str | None = None

+ 65 - 3
backend/app/services/print_scheduler.py

@@ -1491,6 +1491,57 @@ class PrintScheduler:
                 pass
                 pass
         return self.DEFAULT_DRYING_PRESETS
         return self.DEFAULT_DRYING_PRESETS
 
 
+    async def _get_humidity_thresholds(self, db: AsyncSession) -> dict[str, int]:
+        """Per-filament humidity thresholds (#1605).
+
+        Returns the user-configured overrides map keyed by normalized filament
+        type (uppercase base, e.g. ``PLA``, ``ASA``) plus a ``default`` key for
+        unknown / unmapped types. Empty / unset → empty dict, in which case
+        callers fall back to ``ams_humidity_fair``.
+        """
+        result = await db.execute(select(Settings).where(Settings.key == "ams_humidity_thresholds"))
+        setting = result.scalar_one_or_none()
+        if not setting or not setting.value:
+            return {}
+        try:
+            data = json.loads(setting.value)
+        except json.JSONDecodeError:
+            return {}
+        if not isinstance(data, dict):
+            return {}
+        out: dict[str, int] = {}
+        for key, value in data.items():
+            try:
+                out[str(key).upper() if key != "default" else "default"] = int(value)
+            except (TypeError, ValueError):
+                continue
+        return out
+
+    @staticmethod
+    def resolve_humidity_threshold(trays: list[dict], thresholds: dict[str, int], fallback: int) -> int:
+        """Resolve the effective humidity threshold for an AMS unit (#1605).
+
+        For mixed filament types loaded into one AMS, returns the most
+        restrictive (lowest) threshold across all loaded tray types — matches
+        the conservative-params strategy already used for drying temp/hours.
+        Empty / unloaded trays contribute no constraint. Unknown types use the
+        ``default`` key, falling through to ``fallback`` (= ``ams_humidity_fair``)
+        when no per-type map is configured at all.
+        """
+        default = thresholds.get("default", fallback)
+        if not thresholds:
+            return fallback
+        candidates: list[int] = []
+        for tray in trays:
+            tray_type = str(tray.get("tray_type") or "").strip()
+            if not tray_type:
+                continue
+            base_type = tray_type.split()[0].upper()
+            candidates.append(thresholds.get(base_type, default))
+        if not candidates:
+            return default
+        return min(candidates)
+
     def _get_conservative_drying_params(
     def _get_conservative_drying_params(
         self, trays: list[dict], module_type: str, presets: dict[str, dict[str, int]]
         self, trays: list[dict], module_type: str, presets: dict[str, dict[str, int]]
     ) -> tuple[int, int, str] | None:
     ) -> tuple[int, int, str] | None:
@@ -1573,10 +1624,14 @@ class PrintScheduler:
                 await self._stop_drying(pid)
                 await self._stop_drying(pid)
             return
             return
 
 
-        # Get humidity threshold
+        # Get humidity threshold (global fallback)
         result = await db.execute(select(Settings).where(Settings.key == "ams_humidity_fair"))
         result = await db.execute(select(Settings).where(Settings.key == "ams_humidity_fair"))
         setting = result.scalar_one_or_none()
         setting = result.scalar_one_or_none()
-        humidity_threshold = int(setting.value) if setting else 60
+        global_humidity_threshold = int(setting.value) if setting else 60
+
+        # Per-filament humidity threshold overrides (#1605). Empty → fall back
+        # to the global threshold for every AMS unit.
+        per_type_thresholds = await self._get_humidity_thresholds(db)
 
 
         # Get drying presets
         # Get drying presets
         presets = await self._get_drying_presets(db)
         presets = await self._get_drying_presets(db)
@@ -1632,6 +1687,14 @@ class PrintScheduler:
                     logger.debug("Auto-drying: printer %d AMS %d skipped — module_type=%s", pid, ams_id, module_type)
                     logger.debug("Auto-drying: printer %d AMS %d skipped — module_type=%s", pid, ams_id, module_type)
                     continue
                     continue
 
 
+                # Resolve per-filament humidity threshold for this AMS unit (#1605).
+                # Most-restrictive of all loaded tray types; falls back to the
+                # global threshold when no overrides are configured.
+                trays = ams_data.get("tray", []) or []
+                humidity_threshold = self.resolve_humidity_threshold(
+                    trays, per_type_thresholds, global_humidity_threshold
+                )
+
                 dry_time = int(ams_data.get("dry_time") or 0)
                 dry_time = int(ams_data.get("dry_time") or 0)
 
 
                 # Read humidity — prefer humidity_raw (actual %) over humidity (index 1-5)
                 # Read humidity — prefer humidity_raw (actual %) over humidity (index 1-5)
@@ -1701,7 +1764,6 @@ class PrintScheduler:
                     continue
                     continue
 
 
                 # Get conservative drying params for mixed filaments
                 # Get conservative drying params for mixed filaments
-                trays = ams_data.get("tray", [])
                 params = self._get_conservative_drying_params(trays, module_type, presets)
                 params = self._get_conservative_drying_params(trays, module_type, presets)
                 if not params:
                 if not params:
                     logger.debug(
                     logger.debug(

+ 1 - 0
backend/tests/integration/test_settings_ui_preferences.py

@@ -83,6 +83,7 @@ class TestUiPreferencesEndpoint:
             "time_format",
             "time_format",
             "date_format",
             "date_format",
             "drying_presets",
             "drying_presets",
+            "ams_humidity_thresholds",
             "ams_humidity_good",
             "ams_humidity_good",
             "ams_humidity_fair",
             "ams_humidity_fair",
             "ams_temp_good",
             "ams_temp_good",

+ 121 - 0
backend/tests/unit/test_scheduler_auto_drying.py

@@ -884,3 +884,124 @@ class TestBlockForDryingBugFix(_DryingTestBase):
 
 
         # Should NOT start drying — block mode with pending items
         # Should NOT start drying — block mode with pending items
         mock_pm.send_drying_command.assert_not_called()
         mock_pm.send_drying_command.assert_not_called()
+
+
+class TestResolveHumidityThreshold:
+    """Per-filament humidity threshold resolver (#1605).
+
+    Resolves the trigger threshold for an AMS unit from the loaded tray types.
+    Mixed loads use the lowest (most restrictive) value. Empty / unloaded trays
+    contribute no constraint; falls back to the global ``ams_humidity_fair``
+    when no per-type overrides are configured.
+    """
+
+    def test_no_overrides_falls_back_to_global(self):
+        """Empty overrides map → caller's global fallback is used verbatim."""
+        result = PrintScheduler.resolve_humidity_threshold([{"tray_type": "PLA"}], {}, 60)
+        assert result == 60
+
+    def test_single_known_type_uses_override(self):
+        """Single PLA tray with override = 50 returns 50."""
+        result = PrintScheduler.resolve_humidity_threshold(
+            [{"tray_type": "PLA Basic"}],
+            {"default": 60, "PLA": 50},
+            60,
+        )
+        assert result == 50
+
+    def test_mixed_load_picks_lowest(self):
+        """Mixed PLA (60) + Nylon (20) → most restrictive = 20."""
+        result = PrintScheduler.resolve_humidity_threshold(
+            [{"tray_type": "PLA Basic"}, {"tray_type": "PA Glass"}],
+            {"default": 60, "PLA": 60, "PA": 20},
+            60,
+        )
+        assert result == 20
+
+    def test_unknown_type_uses_default_key(self):
+        """Tray type not in the map falls back to the 'default' key, not the
+        caller fallback. Lets the user tune unknown-filament behavior."""
+        result = PrintScheduler.resolve_humidity_threshold(
+            [{"tray_type": "EXOTIC_WOOD"}],
+            {"default": 40, "PLA": 60},
+            999,
+        )
+        assert result == 40
+
+    def test_empty_tray_slots_skipped(self):
+        """Empty tray_type strings (unloaded slots) contribute no constraint."""
+        result = PrintScheduler.resolve_humidity_threshold(
+            [{"tray_type": ""}, {"tray_type": "PLA"}],
+            {"default": 30, "PLA": 50},
+            60,
+        )
+        assert result == 50
+
+    def test_all_empty_trays_uses_default_key(self):
+        """No loaded trays at all → falls back to default key (or fallback if
+        no overrides). Matches the empty-AMS behavior of the existing alarm
+        site so an empty AMS still alarms at the user's default rate."""
+        result = PrintScheduler.resolve_humidity_threshold(
+            [{"tray_type": ""}, {}],
+            {"default": 30, "PLA": 50},
+            60,
+        )
+        assert result == 30
+
+    def test_filament_name_normalized(self):
+        """Tray types like 'PLA Basic', 'pla basic' all normalize to 'PLA'."""
+        result = PrintScheduler.resolve_humidity_threshold(
+            [{"tray_type": "pla basic"}],
+            {"default": 60, "PLA": 25},
+            60,
+        )
+        assert result == 25
+
+    def test_no_tray_type_field_skipped(self):
+        """Missing tray_type field is treated as empty (unloaded)."""
+        result = PrintScheduler.resolve_humidity_threshold(
+            [{}, {"tray_type": "ASA"}],
+            {"default": 60, "ASA": 30},
+            60,
+        )
+        assert result == 30
+
+
+class TestGetHumidityThresholds:
+    """The DB-loading helper for ``ams_humidity_thresholds`` (#1605)."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    @pytest.mark.asyncio
+    async def test_missing_setting_returns_empty(self, scheduler):
+        db = AsyncMock()
+        db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None)))
+        result = await scheduler._get_humidity_thresholds(db)
+        assert result == {}
+
+    @pytest.mark.asyncio
+    async def test_empty_value_returns_empty(self, scheduler):
+        db = AsyncMock()
+        setting = MagicMock(value="")
+        db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=setting)))
+        result = await scheduler._get_humidity_thresholds(db)
+        assert result == {}
+
+    @pytest.mark.asyncio
+    async def test_invalid_json_returns_empty(self, scheduler):
+        db = AsyncMock()
+        setting = MagicMock(value="not json{")
+        db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=setting)))
+        result = await scheduler._get_humidity_thresholds(db)
+        assert result == {}
+
+    @pytest.mark.asyncio
+    async def test_valid_json_normalizes_keys(self, scheduler):
+        """Filament-type keys uppercase; 'default' preserved."""
+        db = AsyncMock()
+        setting = MagicMock(value='{"default": 60, "pla": 50, "ASA": 30, "garbage": "x"}')
+        db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=setting)))
+        result = await scheduler._get_humidity_thresholds(db)
+        assert result == {"default": 60, "PLA": 50, "ASA": 30}

+ 23 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -634,6 +634,29 @@ describe('SettingsPage', () => {
       });
       });
     });
     });
 
 
+    it('shows per-filament humidity threshold editor on Workflow tab (#1605)', async () => {
+      const user = userEvent.setup();
+      render(<SettingsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Workflow')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Workflow'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Humidity Thresholds')).toBeInTheDocument();
+        // Default row is unique to the humidity editor (drying presets has no
+        // default row), so we can pin it without disambiguating from the
+        // adjacent drying-presets table that also lists PLA/ASA/etc.
+        expect(screen.getByText('Default (unknown types)')).toBeInTheDocument();
+        // Filament rows render in both tables — assert by count instead of
+        // a single getByText. 8 default filaments × 2 tables = 16 PLAs etc.
+        expect(screen.getAllByText('PLA').length).toBeGreaterThanOrEqual(2);
+        expect(screen.getAllByText('ASA').length).toBeGreaterThanOrEqual(2);
+      });
+    });
+
     it('shows default print options on Workflow tab', async () => {
     it('shows default print options on Workflow tab', async () => {
       const user = userEvent.setup();
       const user = userEvent.setup();
       render(<SettingsPage />);
       render(<SettingsPage />);

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

@@ -1098,6 +1098,7 @@ export interface AppSettings {
   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
   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
+  ams_humidity_thresholds: string;  // JSON blob of per-filament humidity thresholds (#1605)
   gcode_snippets: string;  // JSON: per-model G-code injection snippets
   gcode_snippets: string;  // JSON: per-model G-code injection snippets
   // Scheduled local backup
   // Scheduled local backup
   local_backup_enabled: boolean;
   local_backup_enabled: boolean;
@@ -4475,6 +4476,7 @@ export const api = {
       time_format?: 'system' | '12h' | '24h';
       time_format?: 'system' | '12h' | '24h';
       date_format?: string;
       date_format?: string;
       drying_presets?: string;
       drying_presets?: string;
+      ams_humidity_thresholds?: string;
       ams_humidity_good?: number;
       ams_humidity_good?: number;
       ams_humidity_fair?: number;
       ams_humidity_fair?: number;
       ams_temp_good?: number;
       ams_temp_good?: number;

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

@@ -1987,6 +1987,10 @@ export default {
     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',
+    humidityThresholds: 'Feuchtigkeitsschwellen',
+    humidityThresholdsDescription: 'Feuchtigkeitsauslöser pro Filamenttyp für Auto-Trocknung und Alarme. Bei gemischter Bestückung gilt der niedrigste Wert.',
+    humidityThresholdCol: 'Schwellenwert',
+    humidityThresholdDefault: 'Standard (unbekannte Typen)',
     printModal: 'Druckdialog',
     printModal: 'Druckdialog',
     expandCustomMapping: 'Benutzerdefinierte Zuordnung standardmäßig erweitern',
     expandCustomMapping: 'Benutzerdefinierte Zuordnung standardmäßig erweitern',
     expandCustomMappingDescription: 'Bei Druck auf mehrere Drucker die AMS-Zuordnung pro Drucker erweitert anzeigen',
     expandCustomMappingDescription: 'Bei Druck auf mehrere Drucker die AMS-Zuordnung pro Drucker erweitert anzeigen',

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

@@ -2001,6 +2001,10 @@ export default {
     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',
+    humidityThresholds: 'Humidity Thresholds',
+    humidityThresholdsDescription: 'Per-filament humidity trigger for auto-drying and alarms. Mixed loads in one AMS use the lowest threshold.',
+    humidityThresholdCol: 'Threshold',
+    humidityThresholdDefault: 'Default (unknown types)',
     printModal: 'Print Modal',
     printModal: 'Print Modal',
     expandCustomMapping: 'Expand custom mapping by default',
     expandCustomMapping: 'Expand custom mapping by default',
     expandCustomMappingDescription: 'When printing to multiple printers, show per-printer AMS mapping expanded',
     expandCustomMappingDescription: 'When printing to multiple printers, show per-printer AMS mapping expanded',

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

@@ -1990,6 +1990,10 @@ export default {
     dryingPresets: 'Preajustes de secado',
     dryingPresets: 'Preajustes de secado',
     dryingPresetsDescription: 'Temperatura y duración por tipo de filamento. El AMS 2 Pro usa temperaturas más bajas; el AMS-HT admite temperaturas más altas.',
     dryingPresetsDescription: 'Temperatura y duración por tipo de filamento. El AMS 2 Pro usa temperaturas más bajas; el AMS-HT admite temperaturas más altas.',
     dryingFilament: 'Filamento',
     dryingFilament: 'Filamento',
+    humidityThresholds: 'Umbrales de humedad',
+    humidityThresholdsDescription: 'Disparador de humedad por tipo de filamento para autosecado y alarmas. Cargas mixtas en un AMS usan el umbral más bajo.',
+    humidityThresholdCol: 'Umbral',
+    humidityThresholdDefault: 'Predeterminado (tipos desconocidos)',
     printModal: 'Ventana de impresión',
     printModal: 'Ventana de impresión',
     expandCustomMapping: 'Expandir el mapeo personalizado de forma predeterminada',
     expandCustomMapping: 'Expandir el mapeo personalizado de forma predeterminada',
     expandCustomMappingDescription: 'Al imprimir en varias impresoras, mostrar el mapeo de AMS por impresora expandido',
     expandCustomMappingDescription: 'Al imprimir en varias impresoras, mostrar el mapeo de AMS por impresora expandido',

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

@@ -1943,6 +1943,10 @@ export default {
     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',
+    humidityThresholds: 'Seuils d\'humidité',
+    humidityThresholdsDescription: 'Déclencheur d\'humidité par type de filament pour le séchage auto et les alarmes. Charges mixtes dans un AMS utilisent le seuil le plus bas.',
+    humidityThresholdCol: 'Seuil',
+    humidityThresholdDefault: 'Par défaut (types inconnus)',
     printModal: 'Fenêtre d\'impression',
     printModal: 'Fenêtre d\'impression',
     expandCustomMapping: 'Étendre le mapping personnalisé par défaut',
     expandCustomMapping: 'Étendre le mapping personnalisé par défaut',
     expandCustomMappingDescription: 'Affiche le mapping AMS par imprimante étendu.',
     expandCustomMappingDescription: 'Affiche le mapping AMS par imprimante étendu.',

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

@@ -1943,6 +1943,10 @@ export default {
     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',
+    humidityThresholds: 'Soglie di umidità',
+    humidityThresholdsDescription: 'Soglia di umidità per tipo di filamento per asciugatura automatica e allarmi. Carichi misti in un AMS usano la soglia più bassa.',
+    humidityThresholdCol: 'Soglia',
+    humidityThresholdDefault: 'Predefinito (tipi sconosciuti)',
     printModal: 'Modale stampa',
     printModal: 'Modale stampa',
     expandCustomMapping: 'Espandi mapping personalizzato di default',
     expandCustomMapping: 'Espandi mapping personalizzato di default',
     expandCustomMappingDescription: 'Quando stampi su più stampanti, mostra mapping AMS per stampante espanso',
     expandCustomMappingDescription: 'Quando stampi su più stampanti, mostra mapping AMS per stampante espanso',

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

@@ -1986,6 +1986,10 @@ export default {
     dryingPresets: '乾燥プリセット',
     dryingPresets: '乾燥プリセット',
     dryingPresetsDescription: 'フィラメントタイプごとの温度と時間。AMS 2 Proは低温、AMS-HTは高温に対応。',
     dryingPresetsDescription: 'フィラメントタイプごとの温度と時間。AMS 2 Proは低温、AMS-HTは高温に対応。',
     dryingFilament: 'フィラメント',
     dryingFilament: 'フィラメント',
+    humidityThresholds: '湿度しきい値',
+    humidityThresholdsDescription: 'フィラメントタイプごとの自動乾燥・アラームのトリガー湿度。同一AMSで混在の場合は最も低いしきい値を使用。',
+    humidityThresholdCol: 'しきい値',
+    humidityThresholdDefault: 'デフォルト(未知のタイプ)',
     printModal: '印刷ダイアログ',
     printModal: '印刷ダイアログ',
     expandCustomMapping: 'カスタムマッピングをデフォルトで展開',
     expandCustomMapping: 'カスタムマッピングをデフォルトで展開',
     expandCustomMappingDescription: '複数プリンターに印刷する際、プリンターごとのAMSマッピングを展開表示',
     expandCustomMappingDescription: '複数プリンターに印刷する際、プリンターごとのAMSマッピングを展開表示',

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

@@ -1869,6 +1869,10 @@ export default {
     dryingPresets: '건조 프리셋',
     dryingPresets: '건조 프리셋',
     dryingPresetsDescription: '필라멘트 유형별 온도 및 시간. AMS 2 Pro는 낮은 온도, AMS-HT는 높은 온도를 지원합니다.',
     dryingPresetsDescription: '필라멘트 유형별 온도 및 시간. AMS 2 Pro는 낮은 온도, AMS-HT는 높은 온도를 지원합니다.',
     dryingFilament: '필라멘트',
     dryingFilament: '필라멘트',
+    humidityThresholds: '습도 임계값',
+    humidityThresholdsDescription: '자동 건조 및 알람용 필라멘트 유형별 습도 임계값. 한 AMS에 혼합 적재 시 가장 낮은 값을 사용합니다.',
+    humidityThresholdCol: '임계값',
+    humidityThresholdDefault: '기본값 (알 수 없는 유형)',
     printModal: '인쇄 모달',
     printModal: '인쇄 모달',
     expandCustomMapping: '사용자 지정 매핑 기본 펼침',
     expandCustomMapping: '사용자 지정 매핑 기본 펼침',
     expandCustomMappingDescription: '여러 프린터에 인쇄할 때 프린터별 AMS 매핑을 펼친 상태로 표시',
     expandCustomMappingDescription: '여러 프린터에 인쇄할 때 프린터별 AMS 매핑을 펼친 상태로 표시',

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

@@ -1943,6 +1943,10 @@ export default {
     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',
+    humidityThresholds: 'Limites de umidade',
+    humidityThresholdsDescription: 'Gatilho de umidade por tipo de filamento para secagem automática e alarmes. Cargas mistas em um AMS usam o limite mais baixo.',
+    humidityThresholdCol: 'Limite',
+    humidityThresholdDefault: 'Padrão (tipos desconhecidos)',
     printModal: 'Modal de Impressão',
     printModal: 'Modal de Impressão',
     expandCustomMapping: 'Expandir mapeamento personalizado por padrão',
     expandCustomMapping: 'Expandir mapeamento personalizado por padrão',
     expandCustomMappingDescription: 'Ao imprimir em várias impressoras, mostrar o mapeamento AMS por impressora expandido',
     expandCustomMappingDescription: 'Ao imprimir em várias impressoras, mostrar o mapeamento AMS por impressora expandido',

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

@@ -1990,6 +1990,10 @@ export default {
     dryingPresets: 'Kurutma Ön Ayarları',
     dryingPresets: 'Kurutma Ön Ayarları',
     dryingPresetsDescription: 'Filament türü başına sıcaklık ve süre. AMS 2 Pro daha düşük sıcaklıklar kullanır, AMS-HT daha yüksek sıcaklıkları destekler.',
     dryingPresetsDescription: 'Filament türü başına sıcaklık ve süre. AMS 2 Pro daha düşük sıcaklıklar kullanır, AMS-HT daha yüksek sıcaklıkları destekler.',
     dryingFilament: 'Filament',
     dryingFilament: 'Filament',
+    humidityThresholds: 'Nem Eşikleri',
+    humidityThresholdsDescription: 'Otomatik kurutma ve alarmlar için filament türüne göre nem tetikleyici. Tek AMS\'te karışık yükler en düşük eşiği kullanır.',
+    humidityThresholdCol: 'Eşik',
+    humidityThresholdDefault: 'Varsayılan (bilinmeyen türler)',
     printModal: 'Baskı Modali',
     printModal: 'Baskı Modali',
     expandCustomMapping: 'Özel eşlemeyi varsayılan olarak genişlet',
     expandCustomMapping: 'Özel eşlemeyi varsayılan olarak genişlet',
     expandCustomMappingDescription: 'Birden fazla yazıcıya yazdırırken, yazıcı başına AMS eşlemesini genişletilmiş olarak göster',
     expandCustomMappingDescription: 'Birden fazla yazıcıya yazdırırken, yazıcı başına AMS eşlemesini genişletilmiş olarak göster',

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

@@ -1988,6 +1988,10 @@ export default {
     dryingPresets: '干燥预设',
     dryingPresets: '干燥预设',
     dryingPresetsDescription: '每种耗材类型的温度和时长。AMS 2 Pro使用较低温度,AMS-HT支持较高温度。',
     dryingPresetsDescription: '每种耗材类型的温度和时长。AMS 2 Pro使用较低温度,AMS-HT支持较高温度。',
     dryingFilament: '耗材',
     dryingFilament: '耗材',
+    humidityThresholds: '湿度阈值',
+    humidityThresholdsDescription: '按耗材类型设置自动干燥和报警的湿度触发值。同一 AMS 混装时使用最低阈值。',
+    humidityThresholdCol: '阈值',
+    humidityThresholdDefault: '默认(未知类型)',
     printModal: '打印对话框',
     printModal: '打印对话框',
     expandCustomMapping: '默认展开自定义映射',
     expandCustomMapping: '默认展开自定义映射',
     expandCustomMappingDescription: '打印到多台打印机时,默认展开显示每台打印机的 AMS 映射',
     expandCustomMappingDescription: '打印到多台打印机时,默认展开显示每台打印机的 AMS 映射',

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

@@ -1988,6 +1988,10 @@ export default {
     dryingPresets: '乾燥預設',
     dryingPresets: '乾燥預設',
     dryingPresetsDescription: '每種耗材類型的溫度和時長。AMS 2 Pro使用較低溫度,AMS-HT支援較高溫度。',
     dryingPresetsDescription: '每種耗材類型的溫度和時長。AMS 2 Pro使用較低溫度,AMS-HT支援較高溫度。',
     dryingFilament: '耗材',
     dryingFilament: '耗材',
+    humidityThresholds: '濕度閾值',
+    humidityThresholdsDescription: '依耗材類型設定自動乾燥與警報的濕度觸發值。同一 AMS 混載時採用最低閾值。',
+    humidityThresholdCol: '閾值',
+    humidityThresholdDefault: '預設(未知類型)',
     printModal: '列印對話方塊',
     printModal: '列印對話方塊',
     expandCustomMapping: '預設展開自訂對應',
     expandCustomMapping: '預設展開自訂對應',
     expandCustomMappingDescription: '列印到多臺印表機時,預設展開顯示每臺印表機的 AMS 對應',
     expandCustomMappingDescription: '列印到多臺印表機時,預設展開顯示每臺印表機的 AMS 對應',

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

@@ -169,6 +169,11 @@ export function SettingsPage() {
     setLightStyle, setLightBackground, setLightAccent,
     setLightStyle, setLightBackground, setLightAccent,
   } = useTheme();
   } = useTheme();
   const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
   const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
+  // Transient typed strings for the per-filament humidity threshold inputs
+  // (#1605). Committed back to localSettings.ams_humidity_thresholds on blur
+  // so intermediate values ("", "3", "5") are not eaten by the [5, 95] clamp
+  // while the user is mid-typing.
+  const [humidityDrafts, setHumidityDrafts] = useState<Record<string, string>>({});
   const [showPlugModal, setShowPlugModal] = useState(false);
   const [showPlugModal, setShowPlugModal] = useState(false);
   const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
   const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
   const [showNotificationModal, setShowNotificationModal] = useState(false);
   const [showNotificationModal, setShowNotificationModal] = useState(false);
@@ -942,6 +947,7 @@ export function SettingsPage() {
       (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.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) ||
       (settings.drying_presets ?? '') !== (localSettings.drying_presets ?? '') ||
       (settings.drying_presets ?? '') !== (localSettings.drying_presets ?? '') ||
+      (settings.ams_humidity_thresholds ?? '') !== (localSettings.ams_humidity_thresholds ?? '') ||
       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 ||
       settings.time_format !== localSettings.time_format ||
       settings.time_format !== localSettings.time_format ||
@@ -1033,6 +1039,7 @@ export function SettingsPage() {
         queue_drying_block: localSettings.queue_drying_block,
         queue_drying_block: localSettings.queue_drying_block,
         ambient_drying_enabled: localSettings.ambient_drying_enabled,
         ambient_drying_enabled: localSettings.ambient_drying_enabled,
         drying_presets: localSettings.drying_presets,
         drying_presets: localSettings.drying_presets,
+        ams_humidity_thresholds: localSettings.ams_humidity_thresholds,
         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,
         time_format: localSettings.time_format,
         time_format: localSettings.time_format,
@@ -4662,6 +4669,108 @@ export function SettingsPage() {
                   </table>
                   </table>
                 </div>
                 </div>
               </div>
               </div>
+              {/* Per-Filament Humidity Thresholds (#1605) */}
+              <div className="space-y-2">
+                <p className="text-sm text-white font-medium">{t('settings.humidityThresholds')}</p>
+                <p className="text-xs text-bambu-gray">{t('settings.humidityThresholdsDescription')}</p>
+                <div className="overflow-x-auto">
+                  <table className="w-full text-xs">
+                    <thead>
+                      <tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
+                        <th className="text-left py-1.5">{t('settings.dryingFilament')}</th>
+                        <th className="text-right py-1.5 pr-2">{t('settings.humidityThresholdCol')}</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      {(() => {
+                        const defaultFair = localSettings.ams_humidity_fair ?? 60;
+                        const filamentTypes = ['PLA', 'PETG', 'TPU', 'ABS', 'ASA', 'PA', 'PC', 'PVA'];
+                        let thresholds: Record<string, number> = {};
+                        try {
+                          if (localSettings.ams_humidity_thresholds) {
+                            const parsed = JSON.parse(localSettings.ams_humidity_thresholds);
+                            if (typeof parsed === 'object' && parsed !== null) {
+                              thresholds = parsed;
+                            }
+                          }
+                        } catch { /* invalid → empty */ }
+
+                        const rows: Array<{ key: string; label: string; value: number; isDefault: boolean }> = [
+                          {
+                            key: 'default',
+                            label: t('settings.humidityThresholdDefault'),
+                            value: Number(thresholds.default ?? defaultFair),
+                            isDefault: true,
+                          },
+                          ...filamentTypes.map((fil) => ({
+                            key: fil,
+                            label: fil,
+                            value: Number(thresholds[fil] ?? thresholds.default ?? defaultFair),
+                            isDefault: false,
+                          })),
+                        ];
+
+                        const commitThreshold = (key: string, raw: string) => {
+                          // Empty / blank → drop the override, falling back to
+                          // the default (or to ams_humidity_fair for the
+                          // default row itself).
+                          if (raw.trim() === '') {
+                            const next = { ...thresholds };
+                            delete next[key];
+                            updateSetting('ams_humidity_thresholds', JSON.stringify(next));
+                            return;
+                          }
+                          const parsed = parseInt(raw, 10);
+                          if (Number.isNaN(parsed)) {
+                            return;
+                          }
+                          const clamped = Math.max(5, Math.min(95, parsed));
+                          const next = { ...thresholds, [key]: clamped };
+                          updateSetting('ams_humidity_thresholds', JSON.stringify(next));
+                        };
+
+                        return rows.map((row) => {
+                          // Show the draft string if the user is mid-edit;
+                          // otherwise fall through to the resolved row value.
+                          const draft = humidityDrafts[row.key];
+                          const displayValue = draft !== undefined ? draft : String(row.value);
+                          return (
+                            <tr key={row.key} className="border-b border-bambu-dark-tertiary/50">
+                              <td className={`py-1.5 pr-2 font-medium ${row.isDefault ? 'text-bambu-gray italic' : 'text-white'}`}>{row.label}</td>
+                              <td className="py-1 pr-2">
+                                <div className="flex items-center justify-end gap-1">
+                                  <input
+                                    type="number"
+                                    min={5}
+                                    max={95}
+                                    value={displayValue}
+                                    onChange={(e) => setHumidityDrafts((prev) => ({ ...prev, [row.key]: e.target.value }))}
+                                    onBlur={(e) => {
+                                      commitThreshold(row.key, e.target.value);
+                                      setHumidityDrafts((prev) => {
+                                        const next = { ...prev };
+                                        delete next[row.key];
+                                        return next;
+                                      });
+                                    }}
+                                    onKeyDown={(e) => {
+                                      if (e.key === 'Enter') {
+                                        (e.currentTarget as HTMLInputElement).blur();
+                                      }
+                                    }}
+                                    className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
+                                  />
+                                  <span className="text-bambu-gray">%</span>
+                                </div>
+                              </td>
+                            </tr>
+                          );
+                        });
+                      })()}
+                    </tbody>
+                  </table>
+                </div>
+              </div>
             </CardContent>
             </CardContent>
           </Card>
           </Card>
           </div>
           </div>

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


+ 1 - 1
static/index.html

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

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