diff --git a/CHANGELOG.md b/CHANGELOG.md index 3683ad199..280e5a9bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ All notable changes to Bambuddy will be documented in this file. - **AMS Filament Backup status + control on the printer card** — New per-printer surface that mirrors BambuStudio's "AMS Filament Backup" checkbox (the per-AMS auto-switch to a second matching spool when one runs out). Until now Bambuddy had no read or write access to the printer-side backup state; the only way to change it was via the slicer or the printer's touchscreen, and Bambuddy's "Prefer lowest remaining filament" preference was ignorant of it (see the linked Fixed entry for #1766 — the two ship together). **Backend — parse the state.** New tri-state `PrinterState.ams_filament_backup: bool | None` populated from bit 18 of the top-level `print.cfg` hex string on every push_status (`bambu_mqtt.py::_process_message` ~line 1037). New module-level helper `parse_ams_filament_backup_from_cfg()` returns `None` on absent / non-hex / non-string input so old-protocol families (A1 / A1 Mini, which emit no `cfg`) preserve today's behaviour — the tri-state default applies the dispatcher's sort, never coerces to OFF, so A1 users see zero regression. Verified against OrcaSlicer source (`DeviceManager.cpp:4961` `SetAutoRefillEnabled(get_flag_bits(cfg, 18))`) and a live H2D ON/OFF capture during this work — the cfg flips exactly between `C0340FC219` (bit 18 set, ON) and `C0340BC219` (bit 18 clear, OFF), only the fifth nibble changing. **Backend — toggle.** New `POST /printers/{id}/ams-backup?enabled=` route gated on `Permission.PRINTERS_CONTROL` calls `client.set_ams_filament_backup(enabled)` which routes through `_set_print_option("auto_switch_filament", enabled)`. The MQTT payload shape `{"print": {"command": "print_option", "auto_switch_filament": , "sequence_id": "20000"}}` was verified by capturing BambuStudio's own command on the request topic with a temporary outbound diagnostic logger — single field at a time, never bundled with other `print_option` flags, so we never clobber other state. Optimistic local state update lives inside `_set_print_option` immediately after `_client.publish(...)`. **Hold-timer guard** (`_xcam_hold_start["print_option_auto_switch_filament"]`, 3 s window, mirrors the existing xcam pattern for spaghetti / first-layer detector settings): when the user just toggled via Bambuddy's badge, the next 1-2 push_status frames may still carry the printer's PRE-toggle cfg before the firmware reflects the change — without this gate the badge would flicker ON→OFF→ON on every toggle. The hold fires only when Bambuddy itself initiated the change; Studio-side or printer-display toggles propagate immediately. **Backend — inventory-remain endpoint.** New `GET /printers/{id}/inventory-remain` route exposes the same `Map` the dispatcher uses (via the existing `_build_inventory_remain_overrides` helper), so PrintModal's client-side "Prefer Lowest Remaining Filament" sort can apply the same two-tier ordering the backend would on dispatch. Internal AND Spoolman modes both work uniformly via the existing helper's mode branch — external / VT slots excluded, negative grams clamped to `max(0.0, label - used)`. JSON-keyed-as-string convention so the wire format is clean; client coerces back to Number on receive. Permission: `Permission.PRINTERS_READ` (same as reading printer status). **REST + WS response surface.** `printer_state_to_dict` and the `PrinterStatusResponse` Pydantic schema both extended with the new field; the printer's REST `/printers/{id}` response carries `ams_filament_backup`. `state.ams_filament_backup` added to the `status_key` dedup tuple in `main.py:1101` so backup toggles trigger an immediate WS broadcast and clients see live state changes whether the toggle came from Bambuddy, BambuStudio, or the printer's touchscreen. **Frontend — printer card badge.** Small icon button in the "Filaments" section header on each printer card (`PrintersPage.tsx`), placed beside the section label so the printer-wide nature reads correctly (the cfg bit is one per printer, not per AMS unit — the original draft put it per-AMS row, which would have duplicated the same state on multi-AMS printers and looked confusing). Three states: ON = blue circular-arrow icon (`Repeat` from lucide-react) on `bg-blue-500/20`; OFF = dim icon on `bg-bambu-dark`; unknown (A1 family / no cfg yet) = "?" character on dim background, click disabled. Click on a known state toggles via the new endpoint, with optimistic update and success toast (`AMS Filament Backup enabled/disabled`). The mutation invalidates BOTH `'printerStatus'` (camelCase) and `'printer-status'` (kebab-case) cache keys — the codebase has both conventions in active use (`useFilamentMapping`-related hooks use kebab, everything else uses camelCase), so only hitting one would leave PrintModal showing stale backup state if the user toggled from the printer card while the modal was open. **i18n.** 5 new keys in the `printers.amsBackup.*` namespace (`titleOn`, `titleOff`, `titleUnknown`, `toastEnabled`, `toastDisabled`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. **Tests.** 12 new backend cases — `test_bambu_mqtt_cfg_parse.py` (parser × 13: real H2D ON/OFF captures, X1C short hex, lowercase, isolated bit-18 set / clear, every malformed shape returns None safely — note: 1 case is a parametrized invalid-input set of 7 sub-cases so the test file shows 13 reported cases) and `test_bambu_mqtt.py::TestAmsFilamentBackupHoldTimer` (× 3: stale push during hold ignored, push after hold applies, same-value push during hold no-op). Two PrinterState SimpleNamespace stubs in `test_printer_offline_notification.py` and `test_printer_manager_status_broadcast.py` extended with `ams_filament_backup=None` to match the new `status_key` field; full pytest confirms no other stub needed updating. **What this does NOT do.** Cover A1 / A1 Mini: those models emit no `cfg` field in push_status so the badge shows `?` and the dispatcher's sort applies as before. Once we identify the A1-specific field (waiting on a future Discord owner with a clean ON/OFF capture) we'll populate it via a model-specific path; until then the tri-state default keeps zero regression. Affect downstream consumers of PrinterState: `mqtt_relay`, webhook routes, and Home Assistant integration enumerate fields explicitly, so adding `ams_filament_backup` doesn't change what they emit. Full backend pytest 6217/6217; full frontend vitest 2170/2170; ruff clean; `npm run build` clean; ESLint clean. +- **Per-filament humidity threshold for auto-drying + alarms (#1605, requested by @thenewguy)** — The single global `ams_humidity_fair` knob (default 60 %) drove both the queue / ambient auto-drying trigger AND the hourly humidity alarm — fine for a homogeneous AMS, brutal for the multi-material print farms the reporter described (one AMS dedicated to PLA fine at ~20 %, another holding Nylon that wants <10 %, ASA somewhere in between). The drying RUN parameters were already per-filament (`drying_presets` JSON with PLA / PETG / TPU / ABS / ASA / PA / PC / PVA × n3f / n3s temp + hours), but the TRIGGER was a single int. **New setting** `ams_humidity_thresholds` — JSON map of filament type to threshold percent, with an explicit `"default"` key for unknown / unmapped types. Empty / unset → both consumers fall back to the existing `ams_humidity_fair` value so the upgrade is silent (no behaviour change until the user opens the editor). **Mixed-AMS resolution: most-restrictive wins**, matching the conservative-params strategy `_get_conservative_drying_params` already uses for temp / hours selection. A unit loaded with PLA (threshold 60) + Nylon (threshold 20) drying-triggers + alarms at 20 — same lowest-wins logic. Empty / unloaded tray slots contribute no constraint; an entirely empty AMS falls through to the `default` key. **Two consumer sites rewired in lockstep** via a single new `PrintScheduler.resolve_humidity_threshold(trays, thresholds, fallback)` static method: (1) `print_scheduler.py::_check_auto_drying` — the per-AMS humidity comparison that decides whether to start drying, whether to stop drying after the 30-minute minimum, and whether to skip a unit entirely; (2) `main.py` AMS sensor / alarm worker — the hourly notifier that fires `on_ams_humidity_high` / `on_ams_ht_humidity_high`. Both sites read the same `ams_humidity_thresholds` setting through the same resolver so a config change can never make the drying scheduler and the alarm path disagree about whether a given AMS is "too humid." **UI.** New table in Settings → Workflow → Auto-Drying, immediately below the existing Drying Presets table — same visual idiom (filament-type column on the left, value column on the right, italicised "Default (unknown types)" row at the top so the catchall is visible, eight default rows for PLA / PETG / TPU / ABS / ASA / PA / PC / PVA pre-filled from the user's current `ams_humidity_fair` so the editor starts in a sensible state). Numeric inputs use a **draft-on-edit / commit-on-blur** pattern (transient `humidityDrafts: Record` state per row) so intermediate strings like `""`, `"3"`, `"7"` aren't eaten by the `[5, 95]` clamp while the user is mid-typing. The clamp fires once on `onBlur` (and `Enter` blurs the input). Empty value on blur clears that row's override, letting it fall back to the default row (or `ams_humidity_fair` for the default row itself) — gives the user a no-mystery way to reset to default. The naive controlled-input pattern was caught mid-PR by Maziggy's typing test: typing `30` into a field showing `60` snapped to `5` between keystrokes because `parseInt("3") | clamp([5, 95])` resolved to `5` before the user finished typing the second digit. **i18n.** Four new keys in the `settings.*` namespace (`humidityThresholds`, `humidityThresholdsDescription`, `humidityThresholdCol`, `humidityThresholdDefault`) — real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5232 leaves per locale, no English fallback. **Tests.** 12 new backend cases in `test_scheduler_auto_drying.py::TestResolveHumidityThreshold` + `TestGetHumidityThresholds` — empty overrides → caller fallback, single known type uses override, mixed load picks lowest, unknown type uses the `default` key (not the caller fallback), empty tray slots skipped, all-empty trays use `default`, "PLA Basic" / "pla basic" both normalize to PLA, missing `tray_type` field is treated as empty, plus the four DB-loader cases (missing setting → empty, empty value → empty, invalid JSON → empty, valid JSON normalizes lowercase filament keys to uppercase but preserves the literal `default` key). One updated integration test in `test_settings_ui_preferences.py` pins the new field in the public `_UI_PREFERENCE_FIELDS` allowlist so a future regression that drops it would fail the field-set assertion. 1 new frontend case in `SettingsPage.test.tsx` exercises the editor render on the Workflow tab. **Setting is in the public `/ui-preferences` allowlist** because it's a non-sensitive integer map needed for badge-color rendering on spool / AMS pages without granting `SETTINGS_READ` (same rationale as `drying_presets` and `ams_humidity_fair`). **What this does NOT do.** Provide per-tray override (the resolver runs at the AMS-unit granularity because both the firmware drying command + the alarm fire per AMS, not per slot); auto-detect the user's loaded filaments and pre-populate (the editor pre-fills from `ams_humidity_fair` and lets the user opt in to overrides); add "keep AMS heater running during print" (a separate firmware-dependent ask in the same issue — needs feasibility check with the reporter before scoping). Full backend `pytest -n 30` 6270/6270 in 73 s; ruff clean; `npm run build` clean; ESLint clean; i18n parity green. + - **Per-printer Maintenance Mode toggle (#1476, requested by @IndividualGhost1905 / Ferdi SEVER)** — Operator-flipped "out of service" state per printer, surfaced as a wrench icon + amber pill on the card and a checkbox in the Edit Printer dialog. Requested for three real-world scenarios that all share the same shape: (1) parallel Bambuddy installs (dev + prod, primary + warm spare) where the printer rejects concurrent MQTT clients except one, leaving the others in a flicker-online state burning CPU and network; (2) printers under repair / awaiting spare parts that shouldn't accept queue jobs but should remain visible on the dashboard so they aren't forgotten; (3) temporary suspension during maintenance work. **What was already there, what was missing.** The backend field `Printer.is_active: bool` has shipped since the initial Bambuddy release — toggling it via `PATCH /printers/{id}` already disconnects MQTT (`printer_manager.disconnect_printer` at `printers.py:366`), stops the printer from being eligible for queue dispatch (`print_scheduler.py:520, 1588`, `print_queue.py:383`), excludes it from model-based filament lookups (`printers.py:197`), excludes it from metrics + diagnostic snapshots + scheduled-backup runs (`metrics.py:105`, `diagnostic_snapshot.py:126`, `github_backup.py:333`, `maintenance.py:457`), and is already honoured by PrinterSelector (filtered with a "show inactive" override, greyed + "(inactive)" label when shown). All three of Ferdi's use cases were structurally supported by `is_active` from day one. **The missing piece was UI exposure.** `grep is_active` on `PrintersPage.tsx` returned zero hits — no menu item, no edit field, no toggle. The only way to flip it was a direct API call. This change adds the surfaces that should have been there all along. **Card UI — replacement, not addition.** Per Ferdi-conversation feedback, the maintenance state replaces the print-status / cover-image container rather than stacking above it, so card heights stay identical across the grid: in expanded mode the same `` header renders an amber panel (wrench icon + "In Maintenance" + subtitle + Exit button) where the cover + progress would normally be; in compact mode a single amber pill replaces the progress bar. The header connection pill is also swapped — instead of the red "Offline" pill (which would be misleading because the disconnect is deliberate) the card shows an amber "Maintenance" pill, and the "Run Diagnostic" CTA is suppressed (that's reserved for involuntary offline triage). HMS / Queue / Firmware status pills are still gated by `status?.connected` so they fall away naturally with the MQTT disconnect. **Three entry points.** (1) Printer card three-dot overflow menu — `Enter maintenance mode` / `Exit maintenance mode` with a wrench icon, adjacent to the Edit and Reconnect actions. (2) Exit button inside the in-card amber panel, so a user noticing the card from across the room can flip back without opening the menu. (3) Checkbox in the EditPrinterModal — `Maintenance mode` with the same subtitle as the help line, so the toggle is discoverable from the edit dialog too (the checkbox is the inverse of `is_active` because the user-facing concept is "is this in maintenance" not "is it active"). **Mid-print safety prompt.** Entering maintenance mode on a printer in `RUNNING` / `PAUSE` state triggers a confirmation dialog before the toggle fires — disconnecting MQTT mid-print stops progress tracking + completion notifications for the in-flight job, which is usually NOT what the operator wants (they probably meant "after this print finishes"). Idle / FINISH / FAILED states skip the dialog and toggle directly. **What this does NOT change.** No backend change (`is_active` was already wired everywhere); no new permission (uses existing `printers:update`); no behaviour change for any other consumer (queue dispatch, scheduler, metrics, picker, backup — all already honoured `is_active`). The card stays visible on the Printers page (greyed temps/controls/fans below the amber banner) so the printer doesn't disappear from the operator's mental map — Ferdi explicitly wanted to remember it's there. Doesn't auto-pause Smart Plug logic or notification providers (would be a sensible follow-up if Ferdi asks; out of scope here to keep the diff bounded to "expose the existing gate"). The scheduled-maintenance dashboard at `/maintenance` (interval-tracked rod-cleaning / lube / belt tasks via the existing `MaintenanceHistory` and `PrinterMaintenance` models) is conceptually adjacent but operationally distinct — the dashboard tracks "this printer is due for cleaning"; Maintenance Mode tracks "this printer is currently out of service." A future "perform maintenance task → optionally enter maintenance mode while you do it" link is the natural connection but isn't wired here. **i18n.** Twelve new keys under `printers.maintenance.*` (title / subtitle / pillLabel / exitButton / menuEnter / menuExit / toastEntered / toastExited / confirmMidPrintTitle / confirmMidPrintMessage / editFieldLabel / editFieldHelp) — real translations in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), parity 5228 leaves per locale, no English fallback. **Tests.** 4 new cases in `PrintersPage.test.tsx::'maintenance mode (#1476)'`: amber status panel renders with Exit button (and the regular "No active job" / "Ready to print" copy is absent — confirms the swap, not a stacked render); header pill swaps to amber Maintenance and the diagnostic CTA is suppressed; clicking Exit issues a `PATCH /printers/{id}` with `is_active: true`; active printers never show the maintenance panel. Existing test fixture (`mockPrinters`) got an explicit `is_active: true` to keep the existing 56 tests green on the new render path. **Type:** `PrinterCreate.is_active?: boolean` added to the TypeScript surface so the field flows cleanly through the existing `api.updatePrinter` helper. **Build + checks.** Full PrintersPage vitest 60/60 green; `npm run build` clean; ESLint clean; i18n parity 5228 × 11 locales green. ### Fixed diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index bad7fd6be..6de67f178 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -342,6 +342,7 @@ _UI_PREFERENCE_FIELDS: tuple[str, ...] = ( "time_format", "date_format", "drying_presets", + "ams_humidity_thresholds", "ams_humidity_good", "ams_humidity_fair", "ams_temp_good", diff --git a/backend/app/main.py b/backend/app/main.py index 4ba7d25bb..21418c673 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,5 @@ import asyncio +import json import logging import mimetypes as _mimetypes import os @@ -5110,6 +5111,29 @@ async def record_ams_history(): except (ValueError, TypeError): 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 for printer in printers: # Get current state from printer manager @@ -5181,8 +5205,18 @@ async def record_ams_history(): if not _ams_has_filament(ams_data): 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) - 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" last_alarm = _ams_alarm_cooldown.get(cooldown_key) now = datetime.now(timezone.utc) @@ -5192,17 +5226,27 @@ async def record_ams_history(): ): _ams_alarm_cooldown[cooldown_key] = now 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: # Call different notification method based on AMS type if is_ams_ht: 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: 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: logger.warning("Failed to send humidity alarm: %s", e) diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 441a0bf4a..866590493 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -92,6 +92,14 @@ class AppSettings(BaseModel): default="", 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) gcode_snippets: str = Field( @@ -413,6 +421,7 @@ class AppSettingsUpdate(BaseModel): queue_drying_block: bool | None = None ambient_drying_enabled: bool | None = None drying_presets: str | None = None + ams_humidity_thresholds: str | None = None per_printer_mapping_expanded: bool | None = None date_format: str | None = None time_format: str | None = None diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index ac683c0b7..17dcfed8f 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -1491,6 +1491,57 @@ class PrintScheduler: pass 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( self, trays: list[dict], module_type: str, presets: dict[str, dict[str, int]] ) -> tuple[int, int, str] | None: @@ -1573,10 +1624,14 @@ class PrintScheduler: await self._stop_drying(pid) return - # Get humidity threshold + # Get humidity threshold (global fallback) result = await db.execute(select(Settings).where(Settings.key == "ams_humidity_fair")) 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 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) 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) # Read humidity — prefer humidity_raw (actual %) over humidity (index 1-5) @@ -1701,7 +1764,6 @@ class PrintScheduler: continue # Get conservative drying params for mixed filaments - trays = ams_data.get("tray", []) params = self._get_conservative_drying_params(trays, module_type, presets) if not params: logger.debug( diff --git a/backend/tests/integration/test_settings_ui_preferences.py b/backend/tests/integration/test_settings_ui_preferences.py index 7dca6596a..7e176c5f3 100644 --- a/backend/tests/integration/test_settings_ui_preferences.py +++ b/backend/tests/integration/test_settings_ui_preferences.py @@ -83,6 +83,7 @@ class TestUiPreferencesEndpoint: "time_format", "date_format", "drying_presets", + "ams_humidity_thresholds", "ams_humidity_good", "ams_humidity_fair", "ams_temp_good", diff --git a/backend/tests/unit/test_scheduler_auto_drying.py b/backend/tests/unit/test_scheduler_auto_drying.py index 5a3c35f1a..621a2cb86 100644 --- a/backend/tests/unit/test_scheduler_auto_drying.py +++ b/backend/tests/unit/test_scheduler_auto_drying.py @@ -884,3 +884,124 @@ class TestBlockForDryingBugFix(_DryingTestBase): # Should NOT start drying — block mode with pending items 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} diff --git a/frontend/src/__tests__/pages/SettingsPage.test.tsx b/frontend/src/__tests__/pages/SettingsPage.test.tsx index e9383f635..9021dec78 100644 --- a/frontend/src/__tests__/pages/SettingsPage.test.tsx +++ b/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(); + + 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 () => { const user = userEvent.setup(); render(); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6d4d5046e..b50146f13 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1098,6 +1098,7 @@ export interface AppSettings { 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 + ams_humidity_thresholds: string; // JSON blob of per-filament humidity thresholds (#1605) gcode_snippets: string; // JSON: per-model G-code injection snippets // Scheduled local backup local_backup_enabled: boolean; @@ -4475,6 +4476,7 @@ export const api = { time_format?: 'system' | '12h' | '24h'; date_format?: string; drying_presets?: string; + ams_humidity_thresholds?: string; ams_humidity_good?: number; ams_humidity_fair?: number; ams_temp_good?: number; diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 85391a089..e7bf90303 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -1987,6 +1987,10 @@ export default { dryingPresets: 'Trocknungsvoreinstellungen', dryingPresetsDescription: 'Temperatur und Dauer pro Filamenttyp. AMS 2 Pro verwendet niedrigere Temperaturen, AMS-HT unterstützt höhere.', 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', expandCustomMapping: 'Benutzerdefinierte Zuordnung standardmäßig erweitern', expandCustomMappingDescription: 'Bei Druck auf mehrere Drucker die AMS-Zuordnung pro Drucker erweitert anzeigen', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 24d83724d..c9ac46a10 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2001,6 +2001,10 @@ export default { dryingPresets: 'Drying Presets', dryingPresetsDescription: 'Temperature and duration per filament type. AMS 2 Pro uses lower temps, AMS-HT supports higher temps.', 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', expandCustomMapping: 'Expand custom mapping by default', expandCustomMappingDescription: 'When printing to multiple printers, show per-printer AMS mapping expanded', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index ea7d0864f..ea44c9177 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -1990,6 +1990,10 @@ export default { 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.', 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', expandCustomMapping: 'Expandir el mapeo personalizado de forma predeterminada', expandCustomMappingDescription: 'Al imprimir en varias impresoras, mostrar el mapeo de AMS por impresora expandido', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index d6bf0e9bc..eb2514d1e 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -1943,6 +1943,10 @@ export default { 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.', 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', expandCustomMapping: 'Étendre le mapping personnalisé par défaut', expandCustomMappingDescription: 'Affiche le mapping AMS par imprimante étendu.', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 2ab71b801..22fe48ad5 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -1943,6 +1943,10 @@ export default { 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.', 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', expandCustomMapping: 'Espandi mapping personalizzato di default', expandCustomMappingDescription: 'Quando stampi su più stampanti, mostra mapping AMS per stampante espanso', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index f489d7e13..3241bf1b6 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -1986,6 +1986,10 @@ export default { dryingPresets: '乾燥プリセット', dryingPresetsDescription: 'フィラメントタイプごとの温度と時間。AMS 2 Proは低温、AMS-HTは高温に対応。', dryingFilament: 'フィラメント', + humidityThresholds: '湿度しきい値', + humidityThresholdsDescription: 'フィラメントタイプごとの自動乾燥・アラームのトリガー湿度。同一AMSで混在の場合は最も低いしきい値を使用。', + humidityThresholdCol: 'しきい値', + humidityThresholdDefault: 'デフォルト(未知のタイプ)', printModal: '印刷ダイアログ', expandCustomMapping: 'カスタムマッピングをデフォルトで展開', expandCustomMappingDescription: '複数プリンターに印刷する際、プリンターごとのAMSマッピングを展開表示', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index bd0c2ab79..b0330e1c1 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -1869,6 +1869,10 @@ export default { dryingPresets: '건조 프리셋', dryingPresetsDescription: '필라멘트 유형별 온도 및 시간. AMS 2 Pro는 낮은 온도, AMS-HT는 높은 온도를 지원합니다.', dryingFilament: '필라멘트', + humidityThresholds: '습도 임계값', + humidityThresholdsDescription: '자동 건조 및 알람용 필라멘트 유형별 습도 임계값. 한 AMS에 혼합 적재 시 가장 낮은 값을 사용합니다.', + humidityThresholdCol: '임계값', + humidityThresholdDefault: '기본값 (알 수 없는 유형)', printModal: '인쇄 모달', expandCustomMapping: '사용자 지정 매핑 기본 펼침', expandCustomMappingDescription: '여러 프린터에 인쇄할 때 프린터별 AMS 매핑을 펼친 상태로 표시', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 6885b59fc..1d7158b47 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -1943,6 +1943,10 @@ export default { 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.', 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', expandCustomMapping: 'Expandir mapeamento personalizado por padrão', expandCustomMappingDescription: 'Ao imprimir em várias impressoras, mostrar o mapeamento AMS por impressora expandido', diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 1cca68a8c..30a34e45b 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -1990,6 +1990,10 @@ export default { 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.', 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', 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', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index f5e46e4a3..7554eac6c 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -1988,6 +1988,10 @@ export default { dryingPresets: '干燥预设', dryingPresetsDescription: '每种耗材类型的温度和时长。AMS 2 Pro使用较低温度,AMS-HT支持较高温度。', dryingFilament: '耗材', + humidityThresholds: '湿度阈值', + humidityThresholdsDescription: '按耗材类型设置自动干燥和报警的湿度触发值。同一 AMS 混装时使用最低阈值。', + humidityThresholdCol: '阈值', + humidityThresholdDefault: '默认(未知类型)', printModal: '打印对话框', expandCustomMapping: '默认展开自定义映射', expandCustomMappingDescription: '打印到多台打印机时,默认展开显示每台打印机的 AMS 映射', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 36778666f..f5996afc1 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -1988,6 +1988,10 @@ export default { dryingPresets: '乾燥預設', dryingPresetsDescription: '每種耗材類型的溫度和時長。AMS 2 Pro使用較低溫度,AMS-HT支援較高溫度。', dryingFilament: '耗材', + humidityThresholds: '濕度閾值', + humidityThresholdsDescription: '依耗材類型設定自動乾燥與警報的濕度觸發值。同一 AMS 混載時採用最低閾值。', + humidityThresholdCol: '閾值', + humidityThresholdDefault: '預設(未知類型)', printModal: '列印對話方塊', expandCustomMapping: '預設展開自訂對應', expandCustomMappingDescription: '列印到多臺印表機時,預設展開顯示每臺印表機的 AMS 對應', diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 0c4be086a..fdc4728eb 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -169,6 +169,11 @@ export function SettingsPage() { setLightStyle, setLightBackground, setLightAccent, } = useTheme(); const [localSettings, setLocalSettings] = useState(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>({}); const [showPlugModal, setShowPlugModal] = useState(false); const [editingPlug, setEditingPlug] = useState(null); const [showNotificationModal, setShowNotificationModal] = useState(false); @@ -942,6 +947,7 @@ export function SettingsPage() { (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.ams_humidity_thresholds ?? '') !== (localSettings.ams_humidity_thresholds ?? '') || settings.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded || settings.date_format !== localSettings.date_format || settings.time_format !== localSettings.time_format || @@ -1033,6 +1039,7 @@ export function SettingsPage() { queue_drying_block: localSettings.queue_drying_block, ambient_drying_enabled: localSettings.ambient_drying_enabled, drying_presets: localSettings.drying_presets, + ams_humidity_thresholds: localSettings.ams_humidity_thresholds, per_printer_mapping_expanded: localSettings.per_printer_mapping_expanded, date_format: localSettings.date_format, time_format: localSettings.time_format, @@ -4662,6 +4669,108 @@ export function SettingsPage() { + {/* Per-Filament Humidity Thresholds (#1605) */} +
+

{t('settings.humidityThresholds')}

+

{t('settings.humidityThresholdsDescription')}

+
+ + + + + + + + + {(() => { + const defaultFair = localSettings.ams_humidity_fair ?? 60; + const filamentTypes = ['PLA', 'PETG', 'TPU', 'ABS', 'ASA', 'PA', 'PC', 'PVA']; + let thresholds: Record = {}; + 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 ( + + + + + ); + }); + })()} + +
{t('settings.dryingFilament')}{t('settings.humidityThresholdCol')}
{row.label} +
+ 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" + /> + % +
+
+
+