From a37dfaf7fb68cb232ff8e33aba58654c25ea98fa Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 15 Feb 2026 08:04:01 +0100 Subject: [PATCH] feat: dual external spool support, AMS slot model filtering & pre-population MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add dual external spool support for H2D (vt_tray as list: Ext-L/Ext-R) - Add cloud filament ID map endpoint (/cloud/filament-id-map) - Fix RFID spool data erased by periodic AMS updates (skip tag matcher for RFID-tagged trays) - Fix AMS slot config overwrites RFID spool state - Fix K-profile selection corrupts existing profiles on X1C/P1S - Resolve K-profiles filament name via cloud filament ID map - Update print scheduler and usage tracker for dual external spools Frontend: - Add printer model filtering to ConfigureAmsSlotModal (cloud/local/builtin presets filtered by @BBL model suffix and compatible_printers) - Add pre-population for configured slots (preset, color, K-profile) - Add K-Profiles view with accurate filament name resolution - Internationalize all ConfigureAmsSlotModal strings (en/de/fr/it/ja — 21 keys) - Add 5 new ConfigureAmsSlotModal tests (model filtering, pre-selection, color pre-population, i18n) - Update PrintersPage for dual external spool rendering Docs: - Update CHANGELOG, README, website features, and wiki AMS docs --- CHANGELOG.md | 6 + README.md | 3 +- backend/app/api/routes/cloud.py | 61 + backend/app/api/routes/inventory.py | 52 +- backend/app/api/routes/kprofiles.py | 3 + backend/app/api/routes/printers.py | 268 +- backend/app/api/routes/support.py | 2 +- backend/app/main.py | 5 +- backend/app/schemas/printer.py | 2 +- backend/app/services/bambu_mqtt.py | 208 +- backend/app/services/print_scheduler.py | 42 +- backend/app/services/printer_manager.py | 61 +- backend/app/services/spool_tag_matcher.py | 68 +- backend/app/services/spoolman_tracking.py | 17 +- backend/app/services/usage_tracker.py | 13 +- .../unit/services/test_printer_manager.py | 27 +- .../unit/services/test_spoolman_tracking.py | 4 +- .../tests/unit/test_scheduler_ams_mapping.py | 8 +- .../components/AddPrinterDiscovery.test.tsx | 1 + .../components/ConfigureAmsSlotModal.test.tsx | 77 + .../__tests__/components/PrintModal.test.tsx | 2 +- .../hooks/useFilamentMapping.test.ts | 6 +- .../src/__tests__/pages/PrintersPage.test.tsx | 1 + frontend/src/api/client.ts | 6 +- frontend/src/components/AssignSpoolModal.tsx | 8 +- .../src/components/ConfigureAmsSlotModal.tsx | 219 +- frontend/src/components/KProfilesView.tsx | 71 +- frontend/src/hooks/useFilamentMapping.ts | 36 +- frontend/src/i18n/locales/de.ts | 24 + frontend/src/i18n/locales/en.ts | 24 + frontend/src/i18n/locales/fr.ts | 24 + frontend/src/i18n/locales/it.ts | 37 + frontend/src/i18n/locales/ja.ts | 24 + frontend/src/pages/PrintersPage.tsx | 396 +- frontend/src/utils/amsHelpers.ts | 2 +- static/assets/index-BOd5pCVD.js | 8684 ----------------- static/assets/index-CS3Lw7Ok.js | 8684 +++++++++++++++++ static/assets/index-D--TAtCz.css | 1 + static/assets/index-DZOdnZuT.css | 1 - static/index.html | 4 +- 40 files changed, 10007 insertions(+), 9175 deletions(-) delete mode 100644 static/assets/index-BOd5pCVD.js create mode 100644 static/assets/index-CS3Lw7Ok.js create mode 100644 static/assets/index-D--TAtCz.css delete mode 100644 static/assets/index-DZOdnZuT.css diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d8a6c6a8..177709b6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ All notable changes to Bambuddy will be documented in this file. - **Notification Templates — Filament Usage Variables** ([#336](https://github.com/maziggy/bambuddy/issues/336)) — `print_complete`, `print_failed`, and `print_stopped` notification events now expose `{filament_grams}` (total grams, scaled by progress for partial prints), `{filament_details}` (per-filament breakdown with AMS slot info, e.g. "AMS-A T1 PLA: 12.4g | AMS-A T3 PETG: 2.8g"), and `{progress}` (completion percentage for failed/stopped prints). The `{filament_details}` variable includes the AMS unit and tray position for each filament used, with "Ext" shown for external spool holders. Falls back to type-only format (e.g. "PLA: 10.0g") when usage tracking data is unavailable. Webhook payloads include `filament_used`, `filament_details`, and `progress` fields. Per-slot filament data is stored in archive `extra_data` for downstream use. - **Printer Status Summary Bar — Next Available & Availability Count** ([#354](https://github.com/maziggy/bambuddy/issues/354)) — The status bar on the Printers page now shows an availability count ("X available") alongside the printing/offline counts, and a "Next available" indicator showing which printing printer will finish soonest — with printer name, mini progress bar, completion percentage, and remaining time. Useful for print farms to quickly identify the next free printer. Updates in real-time via WebSocket. Translated in all 4 locales (en, de, ja, it). - **Nozzle-Aware AMS Filament Mapping for Dual-Nozzle Printers** ([#318](https://github.com/maziggy/bambuddy/issues/318)) — On dual-nozzle printers (H2D, H2D Pro), each AMS unit is physically connected to either the left or right nozzle. Bambuddy now reads nozzle assignments from the 3MF file (`filament_nozzle_map` + `physical_extruder_map` in `project_settings.config`) and constrains filament matching to only AMS trays connected to the correct nozzle via `ams_extruder_map`. Applies to the print scheduler, reprint modal, queue modal, and multi-printer selection. Falls back gracefully to unfiltered matching when no trays exist on the target nozzle. The filament mapping UI shows L/R nozzle badges for dual-nozzle prints. Translated in all 4 locales (en, de, ja, it). +- **Dual External Spool Support for H2D** — H2-series printers with two external spool holders (Ext-L and Ext-R) are now fully supported. The external spool section renders as a grid with both slots, each showing filament type, color, fill level, and hover card details. Previously only a single external spool was displayed. Applies to the printer card, filament mapping, print scheduler, usage tracking, and inventory assignment. The `vt_tray` field is now an array across the entire stack (MQTT, API, WebSocket, frontend). +- **AMS Slot Configuration — Model Filtering & Pre-Population** — The Configure AMS Slot modal now filters filament presets by the connected printer model. Only presets matching the printer (e.g., "@BBL X1C" presets for X1C printers) and generic presets without a model suffix are shown. Local presets are filtered by their `compatible_printers` field. When re-configuring an already-configured slot, the modal pre-selects the saved preset, pre-populates the color, and auto-selects the active K-profile. The preset list auto-scrolls to the selected item. All modal strings are now fully translated in 5 locales (en, de, fr, it, ja). +- **K-Profiles View — Accurate Filament Name Resolution** — K-profile filament names are now resolved from builtin filament tables and user cloud presets (via new `/cloud/filament-id-map` endpoint) instead of showing raw IDs like "GFU99" or "P4d64437". Falls back to extracting names from the profile name field. ### Fixed - **Bulk Archive Delete Leaves Orphaned Database Records** — When bulk-deleting archives, the files were removed from disk before the database commit. If concurrent SQLite writes caused a lock timeout, the commit failed and rolled back — leaving database records pointing to deleted files (broken thumbnails, 404 errors). Fixed by deleting the database record first and only removing files after a successful commit. @@ -18,6 +21,9 @@ All notable changes to Bambuddy will be documented in this file. - **AMS Slot Configuration Overwritten on Startup** — Bambuddy was resetting AMS slot filament presets on every startup and reconnection. The `on_ams_change` callback unconditionally unlinked Bambu Lab spool assignments on each MQTT push-all response, then re-assigned them by sending `ams_filament_setting` without a `setting_id`, which cleared the printer's filament preset. Now compares spool RFID identifiers (`tray_uuid` / `tag_uid`) before unlinking — if the same spool is still in the slot, the assignment is preserved and no `ams_filament_setting` command is sent. - **Bambu Lab Spool Detection False Positives** — The `is_bambu_lab_spool()` function (backend) and `isBambuLabSpool()` (frontend) incorrectly identified third-party spools as Bambu Lab spools when they used Bambu generic filament presets (e.g., "Generic PLA"). The `tray_info_idx` field (e.g., "GFA00") identifies the filament *type*, not the spool manufacturer — third-party spools using Bambu presets also have GF-prefixed values. Removed `tray_info_idx` from detection logic; now uses only hardware RFID identifiers (`tray_uuid` and `tag_uid`) which are physically embedded in genuine Bambu Lab spools. - **FTP Disconnect Raises EOFError When Server Dies** — `BambuFTPClient.disconnect()` only caught `OSError` and `ftplib.Error`, but `quit()` raises `EOFError` when the server has closed the connection mid-session. `EOFError` is not a subclass of either, so it propagated to callers. Now caught alongside the other exception types for clean best-effort disconnect. +- **RFID Spool Data Erased by Periodic AMS Updates** — Periodic MQTT push-all responses cleared `tag_uid` and `tray_uuid` fields because they were included in the "always update" list. These fields are now preserved during updates and only cleared when a spool is physically removed (slot clearing detected by empty `tray_type`). This fixes the AMS "eye" icon disappearing for RFID spools after startup. +- **AMS Slot Configuration Overwrites RFID Spool State** — Configuring an AMS slot for an RFID-detected Bambu Lab spool sent `ams_set_filament_setting`, which replaced the firmware's RFID-managed filament config with a manual one — causing the slicer's "eye" icon to change to a "pen" icon. Now detects RFID spools and skips the filament setting command, only sending K-profile selection. +- **K-Profile Selection Corrupts Existing Profiles on X1C/P1S** — The `extrusion_cali_sel` command included a `setting_id` field that BambuStudio never sends, causing firmware to mislink calibration data. The `extrusion_cali_set` command was sent unconditionally, overwriting existing profile metadata. Now `setting_id` is removed from selection commands, and `extrusion_cali_set` is only sent when no existing profile is selected (`cali_idx < 0`). ### Improved - **SQLite WAL Mode for Database Reliability** — Database now uses Write-Ahead Logging (WAL) mode with a 5-second busy timeout, reducing "database is locked" errors under concurrent access. WAL mode allows simultaneous reads during writes, improving responsiveness for multi-printer setups. Automatically enabled on startup. diff --git a/README.md b/README.md index 5c07cd1bd..1c139a6f7 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,8 @@ Perfect for remote print farms, traveling makers, or accessing your home printer - Resizable printer cards (S/M/L/XL) - Skip objects during print - AMS slot RFID re-read -- AMS slot configuration (custom presets, K profiles, color picker) +- AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots) +- Dual external spool support for H2D (Ext-L / Ext-R) - HMS error monitoring with history - Print success rates & trends - Filament usage tracking diff --git a/backend/app/api/routes/cloud.py b/backend/app/api/routes/cloud.py index 898b731f2..439dafba0 100644 --- a/backend/app/api/routes/cloud.py +++ b/backend/app/api/routes/cloud.py @@ -889,6 +889,67 @@ async def get_builtin_filaments( return [{"filament_id": fid, "name": name} for fid, name in _BUILTIN_FILAMENT_NAMES.items()] +# Cache for filament_id → name mapping (resolved from cloud preset details) +_filament_id_name_cache: dict[str, str] = {} +_filament_id_name_cache_time: float = 0 + + +@router.get("/filament-id-map") +async def get_filament_id_map( + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ), +): + """ + Get filament_id → name mapping for user cloud presets. + + K-profiles store a filament_id (e.g., "P4d64437") which is different from + the cloud preset setting_id (e.g., "PFUS9ac902733670a9"). This endpoint + fetches details for all custom presets and returns the mapping. + Cached for 5 minutes. + """ + import time + + global _filament_id_name_cache, _filament_id_name_cache_time + + if _filament_id_name_cache and time.time() - _filament_id_name_cache_time < FILAMENT_CACHE_TTL: + return _filament_id_name_cache + + token, _ = await get_stored_token(db) + if not token: + return _filament_id_name_cache or {} + + cloud = get_cloud_service() + cloud.set_token(token) + if not cloud.is_authenticated: + return _filament_id_name_cache or {} + + try: + data = await cloud.get_slicer_settings() + custom_presets = data.get("filament", {}).get("private", []) + + result: dict[str, str] = {} + for preset in custom_presets: + setting_id = preset.get("setting_id", "") + if not setting_id: + continue + try: + detail = await cloud.get_setting_detail(setting_id) + fid = detail.get("filament_id", "") + name = detail.get("name", "") + if fid and name: + # Strip printer/nozzle suffix: "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle" → "Devil Design PLA Basic" + clean_name = name.split(" @")[0].strip() if " @" in name else name + result[fid] = clean_name + except Exception: + pass + + _filament_id_name_cache = result + _filament_id_name_cache_time = time.time() + return result + except Exception: + return _filament_id_name_cache or {} + + @router.get("/fields/{preset_type}") async def get_preset_fields( preset_type: Literal["filament", "print", "process", "printer"], diff --git a/backend/app/api/routes/inventory.py b/backend/app/api/routes/inventory.py index 813a57f12..22f90bd64 100644 --- a/backend/app/api/routes/inventory.py +++ b/backend/app/api/routes/inventory.py @@ -645,18 +645,32 @@ async def assign_spool( fingerprint_type = None state = printer_manager.get_status(data.printer_id) if state and state.raw_data: - ams_data = state.raw_data.get("ams", {}) - ams_list = ( - ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else [] - ) - tray = _find_tray_in_ams_data( - ams_list, - data.ams_id, - data.tray_id, - ) - if tray: - fingerprint_color = tray.get("tray_color", "") - fingerprint_type = tray.get("tray_type", "") + if data.ams_id == 255: + # External slot: look up tray from vt_tray by global ID + vt_tray = state.raw_data.get("vt_tray") or [] + ext_id = data.tray_id + 254 # 0→254, 1→255 + for vt in vt_tray: + if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id: + fingerprint_color = vt.get("tray_color", "") + fingerprint_type = vt.get("tray_type", "") + break + else: + ams_data = state.raw_data.get("ams", {}) + ams_list = ( + ams_data.get("ams", []) + if isinstance(ams_data, dict) + else ams_data + if isinstance(ams_data, list) + else [] + ) + tray = _find_tray_in_ams_data( + ams_list, + data.ams_id, + data.tray_id, + ) + if tray: + fingerprint_color = tray.get("tray_color", "") + fingerprint_type = tray.get("tray_type", "") # 3. Upsert assignment (replace if same printer+ams+tray) existing = await db.execute( @@ -715,16 +729,27 @@ async def assign_spool( setting_id=setting_id, ) - # b. Look up K-profile for this spool + printer + nozzle + # b. Look up K-profile for this spool + printer + nozzle + extruder nozzle_diameter = "0.4" if state and state.nozzles: nd = state.nozzles[0].nozzle_diameter if nd: nozzle_diameter = nd + # Determine slot's extruder from ams_extruder_map + slot_extruder = None + if state and state.ams_extruder_map: + if data.ams_id == 255: + # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0 + slot_extruder = 1 - data.tray_id # 0→1, 1→0 + else: + slot_extruder = state.ams_extruder_map.get(str(data.ams_id)) + matching_kp = None for kp in spool.k_profiles: if kp.printer_id == data.printer_id and kp.nozzle_diameter == nozzle_diameter: + if slot_extruder is not None and kp.extruder_id is not None and kp.extruder_id != slot_extruder: + continue matching_kp = kp break @@ -735,7 +760,6 @@ async def assign_spool( cali_idx=matching_kp.cali_idx, filament_id=tray_info_idx, nozzle_diameter=nozzle_diameter, - setting_id=matching_kp.setting_id, ) configured = True diff --git a/backend/app/api/routes/kprofiles.py b/backend/app/api/routes/kprofiles.py index 4b009bb60..0fbe45e31 100644 --- a/backend/app/api/routes/kprofiles.py +++ b/backend/app/api/routes/kprofiles.py @@ -278,6 +278,9 @@ async def delete_kprofile( if not success: raise HTTPException(500, "Failed to send K-profile delete command") + # Wait for printer to process the delete before frontend refetches + await asyncio.sleep(0.5) + return {"success": True, "message": "K-profile deleted successfully"} diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index b2d50bca9..cde182c02 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -237,7 +237,7 @@ async def get_printer_status( # Parse AMS data from raw_data ams_units = [] - vt_tray = None + vt_tray = [] ams_exists = False raw_data = state.raw_data or {} @@ -319,38 +319,41 @@ async def get_printer_status( ) ) - # Virtual tray (external spool holder) - comes from vt_tray in raw_data + # Virtual tray (external spool holder) - comes from vt_tray in raw_data (list) if "vt_tray" in raw_data: - vt_data = raw_data["vt_tray"] - # Filter out empty/invalid tag values for vt_tray - vt_tag_uid = vt_data.get("tag_uid", "") - if vt_tag_uid in ("", "0000000000000000"): - vt_tag_uid = None - vt_tray_uuid = vt_data.get("tray_uuid", "") - if vt_tray_uuid in ("", "00000000000000000000000000000000"): - vt_tray_uuid = None + for vt_data in raw_data["vt_tray"]: + # Filter out empty/invalid tag values for vt_tray + vt_tag_uid = vt_data.get("tag_uid", "") + if vt_tag_uid in ("", "0000000000000000"): + vt_tag_uid = None + vt_tray_uuid = vt_data.get("tray_uuid", "") + if vt_tray_uuid in ("", "00000000000000000000000000000000"): + vt_tray_uuid = None - # Get K value: first try tray's k field, then lookup from K-profiles - vt_k_value = vt_data.get("k") - vt_cali_idx = vt_data.get("cali_idx") - if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map: - vt_k_value = kprofile_map[vt_cali_idx] + # Get K value: first try tray's k field, then lookup from K-profiles + vt_k_value = vt_data.get("k") + vt_cali_idx = vt_data.get("cali_idx") + if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map: + vt_k_value = kprofile_map[vt_cali_idx] - vt_tray = AMSTray( - id=254, # Virtual tray ID - tray_color=vt_data.get("tray_color"), - tray_type=vt_data.get("tray_type"), - tray_sub_brands=vt_data.get("tray_sub_brands"), - tray_id_name=vt_data.get("tray_id_name"), - tray_info_idx=vt_data.get("tray_info_idx"), - remain=vt_data.get("remain", 0), - k=vt_k_value, - cali_idx=vt_cali_idx, - tag_uid=vt_tag_uid, - tray_uuid=vt_tray_uuid, - nozzle_temp_min=vt_data.get("nozzle_temp_min"), - nozzle_temp_max=vt_data.get("nozzle_temp_max"), - ) + tray_id = int(vt_data.get("id", 254)) + vt_tray.append( + AMSTray( + id=tray_id, + tray_color=vt_data.get("tray_color"), + tray_type=vt_data.get("tray_type"), + tray_sub_brands=vt_data.get("tray_sub_brands"), + tray_id_name=vt_data.get("tray_id_name"), + tray_info_idx=vt_data.get("tray_info_idx"), + remain=vt_data.get("remain", 0), + k=vt_k_value, + cali_idx=vt_cali_idx, + tag_uid=vt_tag_uid, + tray_uuid=vt_tray_uuid, + nozzle_temp_min=vt_data.get("nozzle_temp_min"), + nozzle_temp_max=vt_data.get("nozzle_temp_max"), + ) + ) # Convert nozzle info to response format nozzles = [ @@ -1637,40 +1640,72 @@ async def configure_ams_slot( if not client: raise HTTPException(status_code=400, detail="Printer not connected") - # Send the filament setting command (type, color, temp) - success = client.ams_set_filament_setting( - ams_id=ams_id, - tray_id=tray_id, - tray_info_idx=tray_info_idx, - tray_type=tray_type, - tray_sub_brands=tray_sub_brands, - tray_color=tray_color, - nozzle_temp_min=nozzle_temp_min, - nozzle_temp_max=nozzle_temp_max, - setting_id=setting_id, - ) + # Detect RFID spool before sending commands + is_rfid_spool = False + state = printer_manager.get_status(printer_id) + if state and state.raw_data: + from backend.app.api.routes.inventory import _find_tray_in_ams_data + from backend.app.services.spool_tag_matcher import is_valid_tag - if not success: - raise HTTPException(status_code=500, detail="Failed to send filament configuration command") + ams_data = state.raw_data.get("ams", {}) + ams_list = ( + ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else [] + ) + current_tray = _find_tray_in_ams_data(ams_list, ams_id, tray_id) + if current_tray: + is_rfid_spool = is_valid_tag( + current_tray.get("tag_uid", ""), + current_tray.get("tray_uuid", ""), + ) - # Send the calibration/K-profile commands - # Use the K profile's filament_id if provided, otherwise use tray_info_idx + # Send filament setting + K-profile commands filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else tray_info_idx + if is_rfid_spool: + # RFID spool: skip ams_set_filament_setting to preserve RFID state (eye icon). + # The firmware already has filament config from the RFID tag. + logger.info("[configure_ams_slot] RFID spool detected — skipping ams_set_filament_setting") + else: + # Non-RFID spool: send filament setting (type, color, temp) + # When a K-profile is selected, use the K-profile's filament_id as + # tray_info_idx so BambuStudio queries the right PA history table. + # But always use the PRESET's setting_id (not the K-profile's) — + # BambuStudio uses setting_id to identify the filament preset and + # overriding it with the K-profile's setting_id confuses the slicer. + effective_tray_info_idx = filament_id_for_kprofile if cali_idx >= 0 else tray_info_idx + success = client.ams_set_filament_setting( + ams_id=ams_id, + tray_id=tray_id, + tray_info_idx=effective_tray_info_idx, + tray_type=tray_type, + tray_sub_brands=tray_sub_brands, + tray_color=tray_color, + nozzle_temp_min=nozzle_temp_min, + nozzle_temp_max=nozzle_temp_max, + setting_id=setting_id, + ) + + if not success: + raise HTTPException(status_code=500, detail="Failed to send filament configuration command") + # Method 1: Select existing calibration profile by cali_idx - # IMPORTANT: Only pass setting_id if the K profile itself has one (from kprofile_setting_id) - # Do NOT use the preset's setting_id as fallback - it breaks the K profile linking in the slicer + # Do NOT include setting_id — BambuStudio never sends it in extrusion_cali_sel, + # and including it causes the firmware to mislink the profile on X1C/P1S. client.extrusion_cali_sel( ams_id=ams_id, tray_id=tray_id, cali_idx=cali_idx, filament_id=filament_id_for_kprofile, nozzle_diameter=nozzle_diameter, - setting_id=kprofile_setting_id if kprofile_setting_id else None, ) - # Method 2: Also directly set the K value if provided (for better compatibility) - if k_value > 0: + # Method 2: Only send extrusion_cali_set when NO existing profile was selected + # (cali_idx == -1). When cali_idx >= 0, extrusion_cali_sel already selected the + # correct profile. Sending extrusion_cali_set with the same cali_idx would MODIFY + # the existing profile's metadata (extruder_id, nozzle_id, name, setting_id), + # corrupting it — e.g., overwriting a High Flow extruder 1 profile with + # hardcoded extruder_id=0 and nozzle_id=HS00. + if k_value > 0 and cali_idx < 0: # Calculate global tray ID for extrusion_cali_set if ams_id <= 3: global_tray_id = ams_id * 4 + tray_id @@ -1682,11 +1717,12 @@ async def configure_ams_slot( client.extrusion_cali_set( tray_id=global_tray_id, k_value=k_value, - n_coef=0.0, nozzle_diameter=nozzle_diameter, - bed_temp=60, nozzle_temp=nozzle_temp_max, - max_volumetric_speed=20.0, + filament_id=filament_id_for_kprofile, + setting_id=kprofile_setting_id or "", + name=tray_sub_brands or "", + cali_idx=cali_idx, ) # Request fresh status push from printer so frontend gets updated data via WebSocket @@ -2109,9 +2145,133 @@ async def refresh_ams_slot( if not success: raise HTTPException(400, message) + # Apply PA profile after delay (RFID re-read takes a few seconds) + asyncio.create_task(_apply_pa_after_refresh(printer_id, ams_id, slot_id)) + return {"success": True, "message": message} +async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int): + """Apply PA profile after RFID re-read completes. + + Waits for the printer to finish processing the RFID data, then selects + the K-profile via extrusion_cali_sel. Does NOT re-send ams_set_filament_setting + because that would overwrite the RFID-provided filament data. + """ + await asyncio.sleep(5) + try: + from backend.app.api.routes.inventory import _find_tray_in_ams_data + from backend.app.core.database import async_session + from backend.app.models.spool import Spool + from backend.app.models.spool_assignment import SpoolAssignment as SA + from backend.app.services.spool_tag_matcher import is_bambu_tag + + client = printer_manager.get_client(printer_id) + if not client: + return + + state = printer_manager.get_status(printer_id) + if not state or not state.raw_data: + return + + # Find current tray data (should have RFID data by now) + ams_data = state.raw_data.get("ams", {}) + ams_list = ( + ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else [] + ) + tray = _find_tray_in_ams_data(ams_list, ams_id, slot_id) + if not tray or not tray.get("tray_type"): + logger.debug("PA re-apply: no tray data for AMS%d-T%d", ams_id, slot_id) + return + + tag_uid = tray.get("tag_uid", "") + tray_uuid = tray.get("tray_uuid", "") + tray_info_idx = tray.get("tray_info_idx", "") + if not is_bambu_tag(tag_uid, tray_uuid, tray_info_idx): + return + + async with async_session() as db: + from sqlalchemy import select as sa_select + from sqlalchemy.orm import selectinload + + result = await db.execute( + sa_select(SA) + .options(selectinload(SA.spool).selectinload(Spool.k_profiles)) + .where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == slot_id) + ) + assignment = result.scalar_one_or_none() + if not assignment or not assignment.spool or not assignment.spool.k_profiles: + return + + spool = assignment.spool + nozzle_diameter = "0.4" + if state.nozzles: + nd = state.nozzles[0].nozzle_diameter + if nd: + nozzle_diameter = nd + + # Determine slot's extruder from ams_extruder_map + slot_extruder = None + if state.ams_extruder_map: + if ams_id == 255: + # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0 + slot_extruder = 1 - slot_id # 0→1, 1→0 + else: + slot_extruder = state.ams_extruder_map.get(str(ams_id)) + + matching_kp = None + for kp in spool.k_profiles: + if kp.printer_id == printer_id and kp.nozzle_diameter == nozzle_diameter: + if slot_extruder is not None and kp.extruder_id is not None and kp.extruder_id != slot_extruder: + continue + matching_kp = kp + break + + if not matching_kp or matching_kp.cali_idx is None: + return + + # The filament_id in extrusion_cali_sel must match the filament preset + # under which the K-profile was calibrated. Use spool.slicer_filament + # (the preset assigned in inventory), falling back to tray's RFID value. + kp_filament_id = spool.slicer_filament or tray_info_idx + + logger.info( + "PA re-apply AMS%d-T%d: cali_idx=%d, filament_id=%s", + ams_id, + slot_id, + matching_kp.cali_idx, + kp_filament_id, + ) + + # 1. Select K-profile + # NOTE: Do NOT send ams_set_filament_setting here — it tells the firmware + # "this is a manual config" which destroys the RFID-detected spool state + # (changes eye icon to pen icon in slicer). + client.extrusion_cali_sel( + ams_id=ams_id, + tray_id=slot_id, + cali_idx=matching_kp.cali_idx, + filament_id=kp_filament_id, + nozzle_diameter=nozzle_diameter, + ) + + # NOTE: Do NOT send extrusion_cali_set here. extrusion_cali_sel already + # selected the correct profile by cali_idx. Sending extrusion_cali_set with + # the same cali_idx would MODIFY the existing profile's metadata (extruder_id, + # nozzle_id, name), corrupting it. + + logger.info( + "Applied PA profile cali_idx=%d k=%.3f to printer %d AMS%d-T%d", + matching_kp.cali_idx, + matching_kp.k_value or 0, + printer_id, + ams_id, + slot_id, + ) + except Exception as e: + logger.warning("Failed to apply PA profile after RFID re-read: %s", e) + + @router.get("/{printer_id}/runtime-debug") async def get_runtime_debug( printer_id: int, diff --git a/backend/app/api/routes/support.py b/backend/app/api/routes/support.py index 48a73141a..c86dd4378 100644 --- a/backend/app/api/routes/support.py +++ b/backend/app/api/routes/support.py @@ -473,7 +473,7 @@ async def _collect_support_info() -> dict: for unit in ams_units: trays = unit.get("tray", []) ams_tray_count += len([t for t in trays if t.get("tray_type")]) - has_vt_tray = state.raw_data.get("vt_tray") is not None + has_vt_tray = bool(state.raw_data.get("vt_tray")) info["printers"].append( { diff --git a/backend/app/main.py b/backend/app/main.py index 751b9a816..94400e70c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -335,12 +335,14 @@ async def on_printer_status_change(printer_id: int, state: PrinterState): bed_target = round(temps.get("bed_target", 0)) nozzle_target = round(temps.get("nozzle_target", 0)) + # Include tray_now and vt_tray hash so external spool changes trigger broadcasts + vt_tray_key = hash(str(state.raw_data.get("vt_tray", []))) if state.raw_data else 0 status_key = ( f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:" f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:" f"{state.stg_cur}:{bed_target}:{nozzle_target}:" f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:" - f"{state.chamber_light}:{state.active_extruder}" + f"{state.chamber_light}:{state.active_extruder}:{state.tray_now}:{vt_tray_key}" ) # MQTT relay - publish status (before dedup check - always publish to MQTT) @@ -707,6 +709,7 @@ async def on_ams_change(printer_id: int, ams_data: list): spool, printer_manager, db, + tray_info_idx=tray_info_idx, ) await db.commit() await ws_manager.broadcast( diff --git a/backend/app/schemas/printer.py b/backend/app/schemas/printer.py index 5c0864837..34e4cedc4 100644 --- a/backend/app/schemas/printer.py +++ b/backend/app/schemas/printer.py @@ -199,7 +199,7 @@ class PrinterStatus(BaseModel): hms_errors: list[HMSErrorResponse] = [] ams: list[AMSUnit] = [] ams_exists: bool = False - vt_tray: AMSTray | None = None # Virtual tray / external spool + vt_tray: list[AMSTray] = [] # Virtual tray / external spool(s) sdcard: bool = False # SD card inserted store_to_sdcard: bool = False # Store sent files on SD card timelapse: bool = False # Timelapse recording active diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index b8e5e774d..519e0766e 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -128,7 +128,7 @@ class PrinterState: chamber_light: bool = False # Active extruder for dual nozzle (0=right, 1=left) - from device.extruder.info[X].hnow active_extruder: int = 0 - # Currently loaded tray (global ID): 254 = external spool, 255 = no filament + # Currently loaded tray (global ID): 254/255 = external spools, 255 = no filament on legacy printers tray_now: int = 255 # Pending load target - used to track what tray we're loading for H2D disambiguation pending_tray_target: int | None = None @@ -451,14 +451,33 @@ class BambuMQTTClient: except Exception as e: logger.error("[%s] Error handling AMS data from print: %s", self.serial_number, e) + # Handle vir_slot (H2-series external spool data) — list of external trays + # Process vir_slot FIRST so it takes priority over vt_tray + if "vir_slot" in print_data: + vir_slot = print_data["vir_slot"] + if isinstance(vir_slot, list) and vir_slot: + # Fix: single-nozzle printers (X1C, P1S, A1) report their single + # external slot with id=255 in vir_slot, but tray_now=254 when active. + # Remap id=255→254 for single-slot printers so active detection works. + # Dual-nozzle (H2D) has 2 slots: id=254 (Ext-L) and id=255 (Ext-R). + if len(vir_slot) == 1 and str(vir_slot[0].get("id", "")) == "255": + vir_slot[0]["id"] = "254" + self.state.raw_data["vt_tray"] = vir_slot + # Handle vt_tray (virtual tray / external spool) data - if "vt_tray" in print_data: + # Only use vt_tray if vir_slot is NOT in this message AND we don't already + # have vir_slot data (H2-series sends vt_tray as a single active spool dict + # which would overwrite the correct multi-slot vir_slot data) + if "vt_tray" in print_data and "vir_slot" not in print_data: vt_tray = print_data["vt_tray"] - self.state.raw_data["vt_tray"] = vt_tray - # Log vt_tray to investigate per-extruder data for H2D - if not hasattr(self, "_vt_tray_logged") or not self._vt_tray_logged: - logger.info("[%s] vt_tray data: %s", self.serial_number, vt_tray) - self._vt_tray_logged = True + existing = self.state.raw_data.get("vt_tray") + # Don't let a single-spool vt_tray dict overwrite multi-slot vir_slot data + if isinstance(vt_tray, dict) and isinstance(existing, list) and len(existing) > 1: + pass # Keep the vir_slot data + else: + if isinstance(vt_tray, dict): + vt_tray = [vt_tray] + self.state.raw_data["vt_tray"] = vt_tray # Parse ams_status directly from print data (NOT from print.ams) # ams_status is a combined value: lower 8 bits = sub status, bits 8-15 = main status @@ -486,7 +505,10 @@ class BambuMQTTClient: # Check for K-profile response (extrusion_cali) if "command" in print_data: - logger.debug("[%s] Received command response: %s", self.serial_number, print_data.get("command")) + cmd = print_data.get("command") + logger.debug("[%s] Received command response: %s", self.serial_number, cmd) + if cmd in ("extrusion_cali_sel", "extrusion_cali_set", "extrusion_cali_del", "ams_filament_setting"): + logger.info("[%s] %s response: %s", self.serial_number, cmd, print_data) if "command" in print_data and print_data.get("command") == "extrusion_cali_get": self._handle_kprofile_response(print_data) @@ -941,13 +963,20 @@ class BambuMQTTClient: if tray_id is not None and tray_id in existing_trays: # Merge: start with existing, update with new non-empty values merged_tray = existing_trays[tray_id].copy() + # Detect slot-clearing updates (spool removal): + # When tray_type is explicitly empty, clear everything + # including RFID data (tag_uid/tray_uuid). + slot_clearing = new_tray.get("tray_type") == "" for key, value in new_tray.items(): # Fields that should always be updated (even with empty/zero values): # - remain, k, id, cali_idx: status indicators where 0 is valid - # - tray_type, tray_sub_brands, tag_uid, tray_uuid, tray_info_idx, - # tray_color, tray_id_name: slot content indicators that must - # be cleared when a spool is removed (fixes #147 - old AMS - # empty slot) + # - tray_type, tray_sub_brands, tray_info_idx, tray_color, + # tray_id_name: slot content indicators that must be cleared + # when a spool is removed (fixes #147 - old AMS empty slot) + # NOTE: tag_uid and tray_uuid are NOT in always_update_fields. + # They are only cleared during spool removal (slot_clearing=True). + # Periodic AMS updates often include empty RFID fields which + # would overwrite valid data from the initial pushall. always_update_fields = ( "remain", "k", @@ -955,17 +984,20 @@ class BambuMQTTClient: "cali_idx", "tray_type", "tray_sub_brands", - "tag_uid", - "tray_uuid", "tray_info_idx", "tray_color", "tray_id_name", ) - if key in always_update_fields or value not in ( - None, - "", - "0000000000000000", - "00000000000000000000000000000000", + if ( + key in always_update_fields + or slot_clearing + or value + not in ( + None, + "", + "0000000000000000", + "00000000000000000000000000000000", + ) ): merged_tray[key] = value merged_trays.append(merged_tray) @@ -2424,7 +2456,7 @@ class BambuMQTTClient: def _handle_kprofile_response(self, data: dict): """Handle K-profile response from printer.""" response_nozzle = data.get("nozzle_diameter") - _response_seq_id = data.get("sequence_id", "?") + response_seq_id = data.get("sequence_id", "?") filaments = data.get("filaments", []) expected_nozzle = getattr(self, "_expected_kprofile_nozzle", None) has_pending_request = self._pending_kprofile_response is not None @@ -2432,7 +2464,8 @@ class BambuMQTTClient: # Log all incoming responses when we have a pending request (for debugging) if has_pending_request: logger.info( - f"[{self.serial_number}] K-profile response: nozzle={response_nozzle}, {len(filaments)} profiles, expected={expected_nozzle}" + f"[{self.serial_number}] K-profile response: nozzle={response_nozzle}, " + f"seq_id={response_seq_id}, {len(filaments)} profiles, expected={expected_nozzle}" ) # If we have a pending request, only accept responses with matching nozzle_diameter @@ -3305,6 +3338,7 @@ class BambuMQTTClient: command = {"print": {"command": "ams_get_rfid", "ams_id": ams_id, "slot_id": tray_id, "sequence_id": "0"}} self._client.publish(self.topic_publish, json.dumps(command), qos=1) logger.info("[%s] Triggering RFID re-read: AMS %s, slot %s", self.serial_number, ams_id, tray_id) + return True, f"Refreshing AMS {ams_id} tray {tray_id}" def ams_set_filament_setting( @@ -3341,18 +3375,33 @@ class BambuMQTTClient: logger.warning("[%s] Cannot set AMS filament setting: not connected", self.serial_number) return False - # Calculate slot_id based on AMS type - if ams_id <= 3: + # Calculate mqtt IDs based on AMS type + if ams_id == 255: + vt_tray = self.state.raw_data.get("vt_tray", []) if self.state.raw_data else [] + if len(vt_tray) > 1: + # Dual external slots (H2D): each ext slot is its own virtual AMS unit + # (254=ext-L / slot 0, 255=ext-R / slot 1) + mqtt_ams_id = 254 + tray_id + else: + # Single external slot (X1C, P1S, A1): always ams_id=255 + mqtt_ams_id = 255 + mqtt_tray_id = 0 + slot_id = 0 + elif ams_id <= 3: + mqtt_ams_id = ams_id + mqtt_tray_id = tray_id slot_id = tray_id else: - # AMS-HT or external: slot_id = 0 + # AMS-HT: single tray per unit + mqtt_ams_id = ams_id + mqtt_tray_id = tray_id slot_id = 0 command = { "print": { "command": "ams_filament_setting", - "ams_id": ams_id, - "tray_id": tray_id, + "ams_id": mqtt_ams_id, + "tray_id": mqtt_tray_id, "slot_id": slot_id, "tray_info_idx": tray_info_idx, "tray_type": tray_type, @@ -3390,17 +3439,32 @@ class BambuMQTTClient: logger.warning("[%s] Cannot reset AMS slot: not connected", self.serial_number) return False - # Calculate slot_id based on AMS type - if ams_id <= 3: + # Calculate mqtt IDs based on AMS type + if ams_id == 255: + vt_tray = self.state.raw_data.get("vt_tray", []) if self.state.raw_data else [] + if len(vt_tray) > 1: + # Dual external slots (H2D): each ext slot is its own virtual AMS unit + mqtt_ams_id = 254 + tray_id + else: + # Single external slot (X1C, P1S, A1): always ams_id=255 + mqtt_ams_id = 255 + mqtt_tray_id = 0 + slot_id = 0 + elif ams_id <= 3: + mqtt_ams_id = ams_id + mqtt_tray_id = tray_id slot_id = tray_id else: + # AMS-HT: single tray per unit + mqtt_ams_id = ams_id + mqtt_tray_id = tray_id slot_id = 0 command = { "print": { "command": "ams_filament_setting", - "ams_id": ams_id, - "tray_id": tray_id, + "ams_id": mqtt_ams_id, + "tray_id": mqtt_tray_id, "slot_id": slot_id, "tray_info_idx": "", "tray_type": "", @@ -3425,20 +3489,21 @@ class BambuMQTTClient: cali_idx: int, filament_id: str, nozzle_diameter: str = "0.4", - setting_id: str | None = None, ) -> bool: """Set calibration profile (K value) for an AMS slot. This command selects a K profile from the printer's calibration list. Use cali_idx=-1 to use the default K value (0.020). + Note: Do NOT send setting_id in this command — BambuStudio never includes + it, and adding it causes the firmware to mislink the profile on X1C/P1S. + Args: ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS) tray_id: Tray ID within the AMS (0-3) cali_idx: Calibration profile index (-1 for default) filament_id: Filament preset ID (same as tray_info_idx) nozzle_diameter: Nozzle diameter string (e.g., "0.4") - setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional Returns: True if command was sent, False otherwise @@ -3447,13 +3512,34 @@ class BambuMQTTClient: logger.warning("[%s] Cannot set calibration: not connected", self.serial_number) return False - # Calculate slot_id based on AMS type - # tray_id in the command should be the local tray index (0-3) - if ams_id <= 3: + # Calculate mqtt IDs based on AMS type. + # IMPORTANT: extrusion_cali_sel uses GLOBAL tray_id (unlike ams_filament_setting + # which uses LOCAL). BambuStudio confirms: tray_id = ams_id * 4 + slot. + if ams_id == 255: + # External spool: extrusion_cali_sel uses GLOBAL tray_id (unlike + # ams_filament_setting which uses LOCAL tray_id=0). + vt_tray = self.state.raw_data.get("vt_tray", []) if self.state.raw_data else [] + if len(vt_tray) > 1: + # Dual external slots (H2D): each ext slot is its own virtual AMS unit + # Confirmed from BambuStudio logs: ext-R sends ams_id=255, tray_id=255 + mqtt_ams_id = 254 + tray_id + mqtt_tray_id = 254 + tray_id + else: + # Single external slot (X1C, P1S, A1): global tray_id=254 + mqtt_ams_id = 254 + mqtt_tray_id = 254 + slot_id = 0 + elif ams_id <= 3: + mqtt_ams_id = ams_id + mqtt_tray_id = ams_id * 4 + tray_id slot_id = tray_id elif ams_id >= 128 and ams_id <= 135: + mqtt_ams_id = ams_id + mqtt_tray_id = tray_id slot_id = 0 else: + mqtt_ams_id = ams_id + mqtt_tray_id = tray_id slot_id = 0 command = { @@ -3462,20 +3548,16 @@ class BambuMQTTClient: "cali_idx": cali_idx, "filament_id": filament_id, "nozzle_diameter": nozzle_diameter, - "ams_id": ams_id, - "tray_id": tray_id, # Local tray index (0-3), not global + "ams_id": mqtt_ams_id, + "tray_id": mqtt_tray_id, "slot_id": slot_id, "sequence_id": "0", } } - # Include setting_id if provided (helps slicer show correct K profile) - if setting_id: - command["print"]["setting_id"] = setting_id - command_json = json.dumps(command) logger.info( - f"[{self.serial_number}] Publishing extrusion_cali_sel: AMS {ams_id}, tray {tray_id}, cali_idx={cali_idx}, setting_id={setting_id}" + f"[{self.serial_number}] Publishing extrusion_cali_sel: AMS {ams_id}, tray {tray_id}, cali_idx={cali_idx}" ) logger.debug("[%s] extrusion_cali_sel command: %s", self.serial_number, command_json) self._client.publish(self.topic_publish, command_json, qos=1) @@ -3485,25 +3567,26 @@ class BambuMQTTClient: self, tray_id: int, k_value: float, - n_coef: float = 0.0, nozzle_diameter: str = "0.4", - bed_temp: int = 60, nozzle_temp: int = 220, - max_volumetric_speed: float = 20.0, + filament_id: str = "", + setting_id: str = "", + name: str = "", + cali_idx: int = -1, ) -> bool: """Directly set K value (pressure advance) for a tray. - This command sets the K value directly without selecting from stored profiles. - Use this when you want to apply a specific K value to a tray. + Uses the filaments array format required by current firmware. Args: tray_id: Global tray ID (ams_id * 4 + slot) k_value: Pressure advance K value (e.g., 0.020) - n_coef: N coefficient (usually 0.0 for manual, 1.4 for auto-calibration) nozzle_diameter: Nozzle diameter string (e.g., "0.4") - bed_temp: Bed temperature for calibration reference nozzle_temp: Nozzle temperature for calibration reference - max_volumetric_speed: Max volumetric speed for calibration reference + filament_id: Filament preset ID (e.g., "GFA02") + setting_id: Setting ID (e.g., "GFSA02_07") + name: Profile display name + cali_idx: Calibration index (-1 for new) Returns: True if command was sent, False otherwise @@ -3512,17 +3595,28 @@ class BambuMQTTClient: logger.warning("[%s] Cannot set K value: not connected", self.serial_number) return False + nozzle_id = f"HS00-{nozzle_diameter}" + + filament_entry = { + "ams_id": 0, + "cali_idx": cali_idx, + "extruder_id": 0, + "filament_id": filament_id, + "k_value": f"{k_value:.6f}", + "n_coef": "1.400000", + "name": name, + "nozzle_diameter": nozzle_diameter, + "nozzle_id": nozzle_id, + "setting_id": setting_id, + "tray_id": tray_id, + } + command = { "print": { "command": "extrusion_cali_set", - "tray_id": tray_id, - "k_value": k_value, - "n_coef": n_coef, + "filaments": [filament_entry], "nozzle_diameter": nozzle_diameter, - "bed_temp": bed_temp, - "nozzle_temp": nozzle_temp, - "max_volumetric_speed": max_volumetric_speed, - "sequence_id": "0", + "sequence_id": str(self._sequence_id), } } diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 7c28ebdcb..3a1e3aec5 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -333,10 +333,9 @@ class PrintScheduler: if tray_type: loaded_types.add(tray_type.upper()) - # Check external spool (virtual tray, stored in raw_data["vt_tray"]) - vt_tray = status.raw_data.get("vt_tray") - if vt_tray: - vt_type = vt_tray.get("tray_type") + # Check external spool(s) (virtual tray, stored in raw_data["vt_tray"] as list) + for vt in status.raw_data.get("vt_tray") or []: + vt_type = vt.get("tray_type") if vt_type: loaded_types.add(vt_type.upper()) @@ -538,23 +537,24 @@ class PrintScheduler: } ) - # Check external spool (vt_tray) - vt_tray = status.raw_data.get("vt_tray") - if vt_tray and vt_tray.get("tray_type"): - color = self._normalize_color(vt_tray.get("tray_color", "")) - filaments.append( - { - "type": vt_tray["tray_type"], - "color": color, - "tray_info_idx": vt_tray.get("tray_info_idx", ""), - "ams_id": -1, - "tray_id": 0, - "is_ht": False, - "is_external": True, - "global_tray_id": 254, - "extruder_id": 0 if ams_extruder_map else None, - } - ) + # Check external spool(s) (vt_tray is a list) + for idx, vt in enumerate(status.raw_data.get("vt_tray") or []): + if vt.get("tray_type"): + color = self._normalize_color(vt.get("tray_color", "")) + tray_id = int(vt.get("id", 254)) + filaments.append( + { + "type": vt["tray_type"], + "color": color, + "tray_info_idx": vt.get("tray_info_idx", ""), + "ams_id": -1, + "tray_id": idx, + "is_ht": False, + "is_external": True, + "global_tray_id": tray_id, + "extruder_id": (tray_id - 254) if ams_extruder_map else None, + } + ) return filaments diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index 9c3a5d00f..809cdc757 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -502,7 +502,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo """ # Parse AMS data from raw_data ams_units = [] - vt_tray = None + vt_tray = [] raw_data = state.raw_data or {} # Build K-profile lookup map: cali_idx -> k_value @@ -578,37 +578,40 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo } ) - # Parse virtual tray (external spool) + # Parse virtual tray (external spool) — now a list if "vt_tray" in raw_data: - vt_data = raw_data["vt_tray"] - vt_tag_uid = vt_data.get("tag_uid") - if vt_tag_uid in ("", "0000000000000000"): - vt_tag_uid = None - vt_tray_uuid = vt_data.get("tray_uuid") - if vt_tray_uuid in ("", "00000000000000000000000000000000"): - vt_tray_uuid = None + for vt_data in raw_data["vt_tray"]: + vt_tag_uid = vt_data.get("tag_uid") + if vt_tag_uid in ("", "0000000000000000"): + vt_tag_uid = None + vt_tray_uuid = vt_data.get("tray_uuid") + if vt_tray_uuid in ("", "00000000000000000000000000000000"): + vt_tray_uuid = None - # Get K value for vt_tray - vt_k_value = vt_data.get("k") - vt_cali_idx = vt_data.get("cali_idx") - if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map: - vt_k_value = kprofile_map[vt_cali_idx] + # Get K value for vt_tray + vt_k_value = vt_data.get("k") + vt_cali_idx = vt_data.get("cali_idx") + if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map: + vt_k_value = kprofile_map[vt_cali_idx] - vt_tray = { - "id": 254, - "tray_color": vt_data.get("tray_color"), - "tray_type": vt_data.get("tray_type"), - "tray_sub_brands": vt_data.get("tray_sub_brands"), - "tray_id_name": vt_data.get("tray_id_name"), - "tray_info_idx": vt_data.get("tray_info_idx"), - "remain": vt_data.get("remain", 0), - "k": vt_k_value, - "cali_idx": vt_cali_idx, - "tag_uid": vt_tag_uid, - "tray_uuid": vt_tray_uuid, - "nozzle_temp_min": vt_data.get("nozzle_temp_min"), - "nozzle_temp_max": vt_data.get("nozzle_temp_max"), - } + tray_id = int(vt_data.get("id", 254)) + vt_tray.append( + { + "id": tray_id, + "tray_color": vt_data.get("tray_color"), + "tray_type": vt_data.get("tray_type"), + "tray_sub_brands": vt_data.get("tray_sub_brands"), + "tray_id_name": vt_data.get("tray_id_name"), + "tray_info_idx": vt_data.get("tray_info_idx"), + "remain": vt_data.get("remain", 0), + "k": vt_k_value, + "cali_idx": vt_cali_idx, + "tag_uid": vt_tag_uid, + "tray_uuid": vt_tray_uuid, + "nozzle_temp_min": vt_data.get("nozzle_temp_min"), + "nozzle_temp_max": vt_data.get("nozzle_temp_max"), + } + ) # Get ams_extruder_map from raw_data (populated by MQTT handler from AMS info field) ams_extruder_map = raw_data.get("ams_extruder_map", {}) diff --git a/backend/app/services/spool_tag_matcher.py b/backend/app/services/spool_tag_matcher.py index e8ba3013c..812f1a4d2 100644 --- a/backend/app/services/spool_tag_matcher.py +++ b/backend/app/services/spool_tag_matcher.py @@ -196,16 +196,19 @@ async def auto_assign_spool( spool: Spool, printer_manager, db: AsyncSession, + tray_info_idx: str = "", ) -> SpoolAssignment: """Create a SpoolAssignment and auto-configure the AMS slot via MQTT. - Reuses the same MQTT configuration logic as the manual assign endpoint. + For BL spools (RFID-detected), only K-profile commands are sent. + ams_set_filament_setting is NOT sent because the firmware already has + filament configuration from the RFID tag, and sending it would destroy + the RFID-detected state (eye → pen icon in BambuStudio). """ - from backend.app.api.routes.inventory import MATERIAL_TEMPS - # Get current tray state for fingerprint fingerprint_color = None fingerprint_type = None + tray = None state = printer_manager.get_status(printer_id) if state and state.raw_data: from backend.app.api.routes.inventory import _find_tray_in_ams_data @@ -246,34 +249,14 @@ async def auto_assign_spool( db.add(assignment) await db.flush() - # Auto-configure AMS slot via MQTT + # Apply K-profile via MQTT (if available) + # NOTE: Do NOT send ams_set_filament_setting here. This function is only + # called for BL spools (RFID-detected). The firmware already has the filament + # configuration from the RFID tag. Sending ams_set_filament_setting would + # destroy the RFID-detected state (eye → pen icon in BambuStudio/OrcaSlicer). try: client = printer_manager.get_client(printer_id) if client: - tray_type = spool.material - tray_sub_brands = f"{spool.material} {spool.subtype}" if spool.subtype else spool.material - tray_color = spool.rgba or "FFFFFFFF" - tray_info_idx = spool.slicer_filament or "" - setting_id = "" - - temp_min, temp_max = MATERIAL_TEMPS.get(spool.material.upper(), (200, 240)) - if spool.nozzle_temp_min is not None: - temp_min = spool.nozzle_temp_min - if spool.nozzle_temp_max is not None: - temp_max = spool.nozzle_temp_max - - client.ams_set_filament_setting( - ams_id=ams_id, - tray_id=tray_id, - tray_info_idx=tray_info_idx, - tray_type=tray_type, - tray_sub_brands=tray_sub_brands, - tray_color=tray_color, - nozzle_temp_min=temp_min, - nozzle_temp_max=temp_max, - setting_id=setting_id, - ) - # Apply K-profile if available nozzle_diameter = "0.4" if state and state.nozzles: @@ -288,23 +271,40 @@ async def auto_assign_spool( break if matching_kp and matching_kp.cali_idx is not None: + # The filament_id in extrusion_cali_sel must match the filament preset + # under which the K-profile was calibrated. Use spool.slicer_filament + # (the preset assigned in inventory), falling back to tray's RFID value. + cali_filament_id = spool.slicer_filament or tray_info_idx or "" client.extrusion_cali_sel( ams_id=ams_id, tray_id=tray_id, cali_idx=matching_kp.cali_idx, - filament_id=tray_info_idx, + filament_id=cali_filament_id, nozzle_diameter=nozzle_diameter, - setting_id=matching_kp.setting_id, + ) + + # NOTE: Do NOT send extrusion_cali_set here. extrusion_cali_sel already + # selected the correct profile by cali_idx. Sending extrusion_cali_set + # with the same cali_idx would MODIFY the existing profile's metadata + # (extruder_id, nozzle_id, name), corrupting it. + + logger.info( + "Applied K-profile cali_idx=%d for spool %d on printer %d AMS%d-T%d", + matching_kp.cali_idx, + spool.id, + printer_id, + ams_id, + tray_id, ) logger.info( - "Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d (RFID match)", - ams_id, - tray_id, + "Auto-assigned spool %d to printer %d AMS%d-T%d (RFID match)", spool.id, printer_id, + ams_id, + tray_id, ) except Exception as e: - logger.warning("MQTT auto-configure failed for spool %d (RFID match): %s", spool.id, e) + logger.warning("K-profile apply failed for spool %d (RFID match): %s", spool.id, e) return assignment diff --git a/backend/app/services/spoolman_tracking.py b/backend/app/services/spoolman_tracking.py index b02d24a1a..9128dfed6 100644 --- a/backend/app/services/spoolman_tracking.py +++ b/backend/app/services/spoolman_tracking.py @@ -66,14 +66,15 @@ def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]: "tray_type": tray.get("tray_type", ""), } - # External spool (global_tray_id = 254) - vt_tray = raw_data.get("vt_tray") - if vt_tray and vt_tray.get("tray_type"): - lookup[254] = { - "tray_uuid": vt_tray.get("tray_uuid", ""), - "tag_uid": vt_tray.get("tag_uid", ""), - "tray_type": vt_tray.get("tray_type", ""), - } + # External spool(s) (vt_tray is a list, global_tray_id from each entry's "id") + for vt in raw_data.get("vt_tray") or []: + if vt.get("tray_type"): + tray_id = int(vt.get("id", 254)) + lookup[tray_id] = { + "tray_uuid": vt.get("tray_uuid", ""), + "tag_uid": vt.get("tag_uid", ""), + "tray_type": vt.get("tray_type", ""), + } return lookup diff --git a/backend/app/services/usage_tracker.py b/backend/app/services/usage_tracker.py index 8c24fe9f1..67cfd7e31 100644 --- a/backend/app/services/usage_tracker.py +++ b/backend/app/services/usage_tracker.py @@ -263,8 +263,13 @@ async def _track_from_3mf( tray_now_override: int | None = None if not slot_to_tray and len(nonzero_slots) == 1: state = printer_manager.get_status(printer_id) - if state and state.tray_now < 255: + if state and 0 <= state.tray_now <= 254: tray_now_override = state.tray_now + elif state and state.tray_now == 255: + # 255 = "no filament" on legacy printers, but valid 2nd external spool on H2-series + vt_tray = state.raw_data.get("vt_tray") or [] + if any(int(vt.get("id", 0)) == 255 for vt in vt_tray if isinstance(vt, dict)): + tray_now_override = state.tray_now # Scale factor for partial prints (failed/aborted) if status == "completed": @@ -322,7 +327,11 @@ async def _track_from_3mf( if isinstance(mapped, int) and mapped >= 0: global_tray_id = mapped - if global_tray_id >= 128: + if global_tray_id >= 254: + # External spool: ams_id=255 (sentinel), tray_id=slot index (0 or 1) + ams_id = 255 + tray_id = global_tray_id - 254 + elif global_tray_id >= 128: ams_id = global_tray_id tray_id = 0 else: diff --git a/backend/tests/unit/services/test_printer_manager.py b/backend/tests/unit/services/test_printer_manager.py index 51e434207..e3d401419 100644 --- a/backend/tests/unit/services/test_printer_manager.py +++ b/backend/tests/unit/services/test_printer_manager.py @@ -734,23 +734,26 @@ class TestPrinterStateToDict: assert result["ams"][0]["tray"][0]["tag_uid"] is None def test_vt_tray_parsing(self, mock_state): - """Verify virtual tray is parsed correctly.""" + """Verify virtual tray is parsed correctly as a list.""" mock_state.raw_data = { - "vt_tray": { - "tray_color": "00FF00", - "tray_type": "PETG", - "tray_sub_brands": "Generic", - "remain": 60, - "tag_uid": "VT123", - } + "vt_tray": [ + { + "tray_color": "00FF00", + "tray_type": "PETG", + "tray_sub_brands": "Generic", + "remain": 60, + "tag_uid": "VT123", + } + ] } result = printer_state_to_dict(mock_state) - assert result["vt_tray"] is not None - assert result["vt_tray"]["id"] == 254 - assert result["vt_tray"]["tray_color"] == "00FF00" - assert result["vt_tray"]["tray_type"] == "PETG" + assert isinstance(result["vt_tray"], list) + assert len(result["vt_tray"]) == 1 + assert result["vt_tray"][0]["id"] == 254 + assert result["vt_tray"][0]["tray_color"] == "00FF00" + assert result["vt_tray"][0]["tray_type"] == "PETG" def test_hms_errors_conversion(self, mock_state): """Verify HMS errors are converted correctly.""" diff --git a/backend/tests/unit/services/test_spoolman_tracking.py b/backend/tests/unit/services/test_spoolman_tracking.py index 0a854d79d..cf41b50d2 100644 --- a/backend/tests/unit/services/test_spoolman_tracking.py +++ b/backend/tests/unit/services/test_spoolman_tracking.py @@ -99,14 +99,14 @@ class TestBuildAmsTrayLookup: def test_external_spool(self): raw = { "ams": [], - "vt_tray": {"tray_uuid": "EXT", "tag_uid": "X", "tray_type": "TPU"}, + "vt_tray": [{"tray_uuid": "EXT", "tag_uid": "X", "tray_type": "TPU"}], } lookup = build_ams_tray_lookup(raw) assert 254 in lookup assert lookup[254]["tray_type"] == "TPU" def test_empty_external_spool_skipped(self): - raw = {"ams": [], "vt_tray": {"tray_type": ""}} + raw = {"ams": [], "vt_tray": [{"tray_type": ""}]} lookup = build_ams_tray_lookup(raw) assert 254 not in lookup diff --git a/backend/tests/unit/test_scheduler_ams_mapping.py b/backend/tests/unit/test_scheduler_ams_mapping.py index 47fff82f7..f2ecd9320 100644 --- a/backend/tests/unit/test_scheduler_ams_mapping.py +++ b/backend/tests/unit/test_scheduler_ams_mapping.py @@ -140,7 +140,7 @@ class TestBuildLoadedFilaments: """Should include external spool.""" class MockStatus: - raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF"}} + raw_data = {"vt_tray": [{"tray_type": "TPU", "tray_color": "0000FF"}]} result = scheduler._build_loaded_filaments(MockStatus()) assert len(result) == 1 @@ -466,7 +466,7 @@ class TestBuildLoadedFilamentsTrayInfoIdx: """Should extract tray_info_idx from external spool.""" class MockStatus: - raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF", "tray_info_idx": "P4d64437"}} + raw_data = {"vt_tray": [{"tray_type": "TPU", "tray_color": "0000FF", "tray_info_idx": "P4d64437"}]} result = scheduler._build_loaded_filaments(MockStatus()) assert len(result) == 1 @@ -624,7 +624,7 @@ class TestNozzleAwareMapping: class MockStatus: raw_data = { - "vt_tray": {"tray_type": "TPU", "tray_color": "0000FF"}, + "vt_tray": [{"tray_type": "TPU", "tray_color": "0000FF"}], "ams_extruder_map": {"0": 0}, } @@ -637,7 +637,7 @@ class TestNozzleAwareMapping: """External spool extruder_id should be None without ams_extruder_map.""" class MockStatus: - raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF"}} + raw_data = {"vt_tray": [{"tray_type": "TPU", "tray_color": "0000FF"}]} result = scheduler._build_loaded_filaments(MockStatus()) assert len(result) == 1 diff --git a/frontend/src/__tests__/components/AddPrinterDiscovery.test.tsx b/frontend/src/__tests__/components/AddPrinterDiscovery.test.tsx index 7ad6c60e8..02b202290 100644 --- a/frontend/src/__tests__/components/AddPrinterDiscovery.test.tsx +++ b/frontend/src/__tests__/components/AddPrinterDiscovery.test.tsx @@ -38,6 +38,7 @@ const mockPrinterStatus = { remaining_time: 0, filename: null, wifi_signal: -50, + vt_tray: [], }; describe('AddPrinterModal Discovery', () => { diff --git a/frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx b/frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx index a22d56673..2de3692bf 100644 --- a/frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx +++ b/frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx @@ -18,6 +18,11 @@ vi.mock('../../api/client', () => ({ saveSlotPreset: vi.fn(), getSettings: vi.fn().mockResolvedValue({}), updateSettings: vi.fn().mockResolvedValue({}), + getLocalPresets: vi.fn(), + getBuiltinFilaments: vi.fn(), + searchColors: vi.fn(), + getColorCatalog: vi.fn(), + resetAmsSlot: vi.fn(), }, })); @@ -69,10 +74,17 @@ const defaultProps = { describe('ConfigureAmsSlotModal', () => { beforeEach(() => { vi.clearAllMocks(); + // Mock scrollIntoView which is not available in jsdom + Element.prototype.scrollIntoView = vi.fn(); (api.getCloudSettings as ReturnType).mockResolvedValue(mockCloudSettings); (api.getKProfiles as ReturnType).mockResolvedValue(mockKProfiles); (api.configureAmsSlot as ReturnType).mockResolvedValue({ success: true }); (api.saveSlotPreset as ReturnType).mockResolvedValue({ success: true }); + (api.getLocalPresets as ReturnType).mockResolvedValue({ filament: [] }); + (api.getBuiltinFilaments as ReturnType).mockResolvedValue([]); + (api.searchColors as ReturnType).mockResolvedValue([]); + (api.getColorCatalog as ReturnType).mockResolvedValue([]); + (api.resetAmsSlot as ReturnType).mockResolvedValue({ success: true, message: 'ok' }); }); it('renders nothing visible when closed', () => { @@ -204,4 +216,69 @@ describe('ConfigureAmsSlotModal', () => { const configureButton = screen.getByRole('button', { name: /Configure Slot/i }); expect(configureButton).toBeInTheDocument(); }); + + it('filters presets by printer model', async () => { + // Render with printerModel="H2D" + render(); + // Wait for presets to load - the H2D preset should be visible + await waitFor(() => { + expect(screen.getByText(/Overture Matte PLA/)).toBeInTheDocument(); + }); + // The X1C preset should NOT be visible (filtered out by model) + expect(screen.queryByText(/Bambu PLA Basic @BBL X1C/)).not.toBeInTheDocument(); + }); + + it('shows current preset even when it does not match model filter', async () => { + // Render with printerModel="H2D" but savedPresetId pointing to the X1C preset + const slotInfo = { + ...defaultProps.slotInfo, + savedPresetId: 'GFSL05_09', // X1C preset + }; + render(); + await waitFor(() => { + // Both should be visible - H2D matches model, X1C is saved preset + // Use the full preset name to match the list item (not the "Filtering for" label) + expect(screen.getByText('Bambu PLA Basic @BBL X1C')).toBeInTheDocument(); + expect(screen.getByText(/Overture Matte PLA/)).toBeInTheDocument(); + }); + }); + + it('pre-selects saved preset when opening configured slot', async () => { + const slotInfo = { + ...defaultProps.slotInfo, + savedPresetId: 'GFSL05_09', + }; + render(); + await waitFor(() => { + // The saved preset should have the selected style (green border) + // Use the full preset name to avoid matching the "Filtering for" label + const presetButton = screen.getByText('Bambu PLA Basic @BBL X1C').closest('button'); + expect(presetButton).toHaveClass('bg-bambu-green/20'); + }); + }); + + it('pre-populates color from trayColor', async () => { + const slotInfo = { + ...defaultProps.slotInfo, + trayColor: 'FF0000FF', // Red with alpha + }; + render(); + await waitFor(() => { + expect(screen.getByTitle('White')).toBeInTheDocument(); + }); + // The hex display should show the pre-populated color + expect(screen.getByText('Hex: #FF0000', { exact: false })).toBeInTheDocument(); + }); + + it('uses translated text for modal elements', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Configure AMS Slot')).toBeInTheDocument(); + expect(screen.getByText('Filament Profile')).toBeInTheDocument(); + }); + // Check footer buttons + expect(screen.getByRole('button', { name: /Configure Slot/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Reset Slot/i })).toBeInTheDocument(); + }); }); diff --git a/frontend/src/__tests__/components/PrintModal.test.tsx b/frontend/src/__tests__/components/PrintModal.test.tsx index 8db3431fb..2e829039c 100644 --- a/frontend/src/__tests__/components/PrintModal.test.tsx +++ b/frontend/src/__tests__/components/PrintModal.test.tsx @@ -67,7 +67,7 @@ describe('PrintModal', () => { return HttpResponse.json({ filaments: [] }); }), http.get('/api/v1/printers/:id/status', () => { - return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: null }); + return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] }); }), http.post('/api/v1/archives/:id/reprint', () => { return HttpResponse.json({ success: true }); diff --git a/frontend/src/__tests__/hooks/useFilamentMapping.test.ts b/frontend/src/__tests__/hooks/useFilamentMapping.test.ts index 68e997593..93dfa20c1 100644 --- a/frontend/src/__tests__/hooks/useFilamentMapping.test.ts +++ b/frontend/src/__tests__/hooks/useFilamentMapping.test.ts @@ -13,7 +13,7 @@ import { import type { PrinterStatus } from '../../api/client'; // Helper to create a minimal printer status with AMS data -function createPrinterStatus(ams: PrinterStatus['ams'], vt_tray?: PrinterStatus['vt_tray']): PrinterStatus { +function createPrinterStatus(ams: PrinterStatus['ams'], vt_tray: PrinterStatus['vt_tray'] = []): PrinterStatus { return { ams, vt_tray, @@ -89,7 +89,7 @@ describe('buildLoadedFilaments', () => { it('extracts external spool with tray_info_idx', () => { const status = createPrinterStatus( [], - { tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' } + [{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }] ); const result = buildLoadedFilaments(status); @@ -339,7 +339,7 @@ describe('computeAmsMapping', () => { }; const status = createPrinterStatus( [], - { tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' } + [{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }] ); const result = computeAmsMapping(reqs, status); diff --git a/frontend/src/__tests__/pages/PrintersPage.test.tsx b/frontend/src/__tests__/pages/PrintersPage.test.tsx index 8bf74b711..c39dac597 100644 --- a/frontend/src/__tests__/pages/PrintersPage.test.tsx +++ b/frontend/src/__tests__/pages/PrintersPage.test.tsx @@ -56,6 +56,7 @@ const mockPrinterStatus = { remaining_time: 0, filename: null, wifi_signal: -50, + vt_tray: [], }; describe('PrintersPage', () => { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4e7bbbc13..6be62e627 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -204,7 +204,7 @@ export interface PrinterStatus { hms_errors: HMSError[]; ams: AMSUnit[]; ams_exists: boolean; - vt_tray: AMSTray | null; // Virtual tray / external spool + vt_tray: AMSTray[]; // Virtual tray / external spool(s) sdcard: boolean; // SD card inserted store_to_sdcard: boolean; // Store sent files on SD card timelapse: boolean; // Timelapse recording active @@ -3028,6 +3028,8 @@ export const api = { request(`/cloud/settings?version=${version}`), getBuiltinFilaments: () => request('/cloud/builtin-filaments'), + getFilamentIdMap: () => + request>('/cloud/filament-id-map'), getCloudSettingDetail: (settingId: string) => request(`/cloud/settings/${settingId}`), createCloudSetting: (data: SlicerSettingCreate) => @@ -3425,6 +3427,8 @@ export const api = { request<{ status: string }>('/inventory/colors/reset', { method: 'POST' }), lookupColor: (manufacturer: string, colorName: string, material?: string) => request(`/inventory/colors/lookup?manufacturer=${encodeURIComponent(manufacturer)}&color_name=${encodeURIComponent(colorName)}${material ? `&material=${encodeURIComponent(material)}` : ''}`), + searchColors: (manufacturer?: string, material?: string) => + request(`/inventory/colors/search?${manufacturer ? `manufacturer=${encodeURIComponent(manufacturer)}` : ''}${manufacturer && material ? '&' : ''}${material ? `material=${encodeURIComponent(material)}` : ''}`), linkTagToSpool: (spoolId: number, data: { tag_uid?: string; tray_uuid?: string; tag_type?: string; data_origin?: string }) => request(`/inventory/spools/${spoolId}/link-tag`, { method: 'PATCH', diff --git a/frontend/src/components/AssignSpoolModal.tsx b/frontend/src/components/AssignSpoolModal.tsx index 6ec9b0ca2..10011af81 100644 --- a/frontend/src/components/AssignSpoolModal.tsx +++ b/frontend/src/components/AssignSpoolModal.tsx @@ -62,15 +62,17 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr if (!isOpen) return null; - // Filter out Bambu Lab spools (identified by RFID tag_uid or tray_uuid) - // and spools already assigned to other slots + // Filter out spools already assigned to other slots const assignedSpoolIds = new Set( (assignments || []) .filter(a => !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId)) .map(a => a.spool_id) ); + // External slots (amsId 254 or 255) have no RFID reader, so show all spools. + // AMS slots only show manual spools (no tag_uid or tray_uuid). + const isExternalSlot = amsId === 254 || amsId === 255; const manualSpools = spools?.filter((spool: InventorySpool) => - !spool.tag_uid && !spool.tray_uuid && !assignedSpoolIds.has(spool.id) + !assignedSpoolIds.has(spool.id) && (isExternalSlot || (!spool.tag_uid && !spool.tray_uuid)) ); const filteredSpools = manualSpools?.filter((spool: InventorySpool) => { diff --git a/frontend/src/components/ConfigureAmsSlotModal.tsx b/frontend/src/components/ConfigureAmsSlotModal.tsx index 9cdc7248a..75497ffa4 100644 --- a/frontend/src/components/ConfigureAmsSlotModal.tsx +++ b/frontend/src/components/ConfigureAmsSlotModal.tsx @@ -14,6 +14,9 @@ interface SlotInfo { trayColor?: string; traySubBrands?: string; trayInfoIdx?: string; + extruderId?: number; + caliIdx?: number | null; + savedPresetId?: string; } // Get proper AMS label (handles HT AMS with ID 128+) @@ -72,6 +75,7 @@ interface ConfigureAmsSlotModalProps { printerId: number; slotInfo: SlotInfo; nozzleDiameter?: string; + printerModel?: string; onSuccess?: () => void; } @@ -209,12 +213,23 @@ function colorNameToHex(name: string): string | null { return COLOR_NAME_MAP[normalized] || null; } +// Extract printer model from preset name suffix "@BBL X1C 0.4 nozzle" → "X1C" +function extractPresetModel(name: string): string | null { + const atIdx = name.indexOf('@'); + if (atIdx < 0) return null; + const suffix = name.slice(atIdx + 1).trim(); + const bblMatch = suffix.match(/^BBL\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i); + if (bblMatch) return bblMatch[1].trim(); + return null; +} + export function ConfigureAmsSlotModal({ isOpen, onClose, printerId, slotInfo, nozzleDiameter = '0.4', + printerModel, onSuccess, }: ConfigureAmsSlotModalProps) { const { t } = useTranslation(); @@ -256,6 +271,14 @@ export function ConfigureAmsSlotModal({ enabled: isOpen && !!printerId, }); + // Fetch color catalog + const { data: colorCatalog } = useQuery({ + queryKey: ['colorCatalog'], + queryFn: () => api.getColorCatalog(), + enabled: isOpen, + staleTime: Infinity, + }); + // Configure slot mutation const configureMutation = useMutation({ mutationFn: async () => { @@ -435,22 +458,44 @@ export function ConfigureAmsSlotModal({ // Collect IDs already covered by cloud and local to avoid duplicates in fallback const coveredIds = new Set(); + // Currently-configured preset should always be shown (bypass model filter) + const savedId = slotInfo.savedPresetId; + const trayIdx = slotInfo.trayInfoIdx; + // 1. Cloud presets if (cloudSettings?.filament) { for (const cp of cloudSettings.filament) { coveredIds.add(cp.setting_id); - if (!query || cp.name.toLowerCase().includes(query)) { - items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) }); + // Keep preset if it matches the slot's saved mapping or current tray_info_idx + const isCurrentPreset = savedId === cp.setting_id + || (trayIdx && (cp.setting_id === trayIdx || convertToTrayInfoIdx(cp.setting_id) === trayIdx)); + if (!isCurrentPreset && query && !cp.name.toLowerCase().includes(query)) continue; + // Filter by printer model if set (skip for current preset) + if (!isCurrentPreset && printerModel) { + const presetModel = extractPresetModel(cp.name); + if (presetModel && presetModel.toUpperCase() !== printerModel.toUpperCase()) continue; } + items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) }); } } // 2. Local presets if (localPresets?.filament) { for (const lp of localPresets.filament) { - if (!query || lp.name.toLowerCase().includes(query)) { - items.push({ id: `local_${lp.id}`, name: lp.name, source: 'local', isUser: false }); + const localId = `local_${lp.id}`; + const isSaved = savedId === localId; + if (!isSaved && query && !lp.name.toLowerCase().includes(query)) continue; + // Filter by compatible_printers if set (skip for saved preset) + if (!isSaved && printerModel && lp.compatible_printers) { + const compatModels = lp.compatible_printers.split(';').map(p => { + // Extract model from "BBL X1C" → "X1C" + const trimmed = p.trim(); + const bblMatch = trimmed.match(/^BBL\s+(.+)/i); + return bblMatch ? bblMatch[1].trim().toUpperCase() : trimmed.toUpperCase(); + }).filter(Boolean); + if (compatModels.length > 0 && !compatModels.includes(printerModel.toUpperCase())) continue; } + items.push({ id: localId, name: lp.name, source: 'local', isUser: false }); } } @@ -478,7 +523,7 @@ export function ConfigureAmsSlotModal({ if (!a.isUser && b.isUser) return 1; return a.name.localeCompare(b.name); }); - }, [cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery]); + }, [cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery, printerModel, slotInfo.savedPresetId, slotInfo.trayInfoIdx]); // Get full preset name for K profile filtering (brand + material, without printer suffix) const selectedPresetInfo = useMemo(() => { @@ -518,6 +563,41 @@ export function ConfigureAmsSlotModal({ // For backwards compatibility with the label const selectedMaterial = selectedPresetInfo?.fullName || ''; + // Filter color catalog entries matching the selected preset's brand + material + const catalogColors = useMemo(() => { + if (!colorCatalog || !selectedPresetInfo) return []; + + const { fullName, brand } = selectedPresetInfo; + + // Try to find colors matching the full preset name (e.g., "PLA Metal") + // The catalog uses the variant as part of the material field (e.g., material="PLA Metal") + // Extract the full material+variant from the preset name + const materialVariant = fullName.replace(/^(Bambu\s*(Lab)?|eSUN|Polymaker|Overture|Sunlu|Hatchbox)\s*/i, '').trim(); + + return colorCatalog.filter(entry => { + const entryMaterial = (entry.material || '').toUpperCase(); + const entryManufacturer = entry.manufacturer.toUpperCase(); + + // Match material: try full material+variant first, then just material type + const materialMatch = entryMaterial === materialVariant.toUpperCase() + || entryMaterial.includes(materialVariant.toUpperCase()) + || materialVariant.toUpperCase().includes(entryMaterial); + + if (!materialMatch) return false; + + // If brand is present, also match manufacturer + if (brand) { + const upperBrand = brand.toUpperCase(); + // Fuzzy match: "Bambu" matches "Bambu Lab", etc. + if (!entryManufacturer.includes(upperBrand) && !upperBrand.includes(entryManufacturer)) { + return false; + } + } + + return true; + }); + }, [colorCatalog, selectedPresetInfo]); + const matchingKProfiles = useMemo(() => { if (!kprofilesData?.profiles || !selectedPresetInfo) return []; @@ -575,34 +655,52 @@ export function ConfigureAmsSlotModal({ }); // Deduplicate profiles with same name and k_value (multi-nozzle printers have duplicates) - // Prefer extruder_id=1 (High Flow) profiles as they're more commonly used on H2D + // Prefer the profile matching the slot's extruder (e.g. ext-R uses extruder 0, ext-L uses extruder 1) const seen = new Map(); for (const profile of filtered) { const key = `${profile.name}|${profile.k_value}`; const existing = seen.get(key); if (!existing) { seen.set(key, profile); - } else if (profile.extruder_id === 1 && existing.extruder_id === 0) { - // Replace extruder_id=0 profile with extruder_id=1 (High Flow) profile + } else if (slotInfo.extruderId !== undefined && profile.extruder_id === slotInfo.extruderId && existing.extruder_id !== slotInfo.extruderId) { + // Replace with profile matching slot's extruder seen.set(key, profile); } } return Array.from(seen.values()); - }, [kprofilesData?.profiles, selectedPresetInfo]); + }, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId]); // Pre-select current profile when modal opens, reset when closes useEffect(() => { - if (isOpen && cloudSettings?.filament) { - // Try to pre-select current profile based on trayInfoIdx - if (slotInfo.trayInfoIdx) { - const currentPreset = cloudSettings.filament.find( + if (isOpen) { + // Pre-populate from saved preset mapping (most reliable) + if (slotInfo.savedPresetId) { + setSelectedPresetId(slotInfo.savedPresetId); + } else if (slotInfo.trayInfoIdx && cloudSettings?.filament) { + // Fallback: try to match by tray_info_idx in cloud presets + // First try exact match on setting_id + let currentPreset = cloudSettings.filament.find( p => p.setting_id === slotInfo.trayInfoIdx ); + // Then try matching by converting setting_id → filament_id format + if (!currentPreset) { + currentPreset = cloudSettings.filament.find( + p => convertToTrayInfoIdx(p.setting_id) === slotInfo.trayInfoIdx + ); + } if (currentPreset) { setSelectedPresetId(currentPreset.setting_id); } } - } else if (!isOpen) { + + // Pre-populate color from current slot + if (slotInfo.trayColor) { + const hex = slotInfo.trayColor.slice(0, 6); + if (hex && hex !== '000000') { + setColorHex(hex); + } + } + } else { // Reset when modal closes setSelectedPresetId(''); setSelectedKProfile(null); @@ -611,17 +709,25 @@ export function ConfigureAmsSlotModal({ setSearchQuery(''); setShowSuccess(false); } - }, [isOpen, cloudSettings?.filament, slotInfo.trayInfoIdx]); + }, [isOpen, slotInfo.savedPresetId, slotInfo.trayInfoIdx, slotInfo.trayColor, cloudSettings?.filament]); // Auto-select best matching K profile when preset changes useEffect(() => { if (matchingKProfiles.length > 0) { - // Auto-select first matching profile + // Prefer the currently-active K-profile (by cali_idx) if available + if (slotInfo.caliIdx != null && slotInfo.caliIdx > 0) { + const active = matchingKProfiles.find(p => p.slot_id === slotInfo.caliIdx); + if (active) { + setSelectedKProfile(active); + return; + } + } + // Fallback: first matching profile setSelectedKProfile(matchingKProfiles[0]); } else { setSelectedKProfile(null); } - }, [selectedPresetId, matchingKProfiles]); + }, [selectedPresetId, matchingKProfiles, slotInfo.caliIdx]); // Escape key handler const handleKeyDown = useCallback((e: KeyboardEvent) => { @@ -659,7 +765,7 @@ export function ConfigureAmsSlotModal({
-

Configure AMS Slot

+

{t('configureAmsSlot.title')}

@@ -684,7 +790,7 @@ export function ConfigureAmsSlotModal({ {/* Slot info */}
-

Configuring slot:

+

{t('configureAmsSlot.configuringSlot')}

{slotInfo.trayColor && ( )} - {getAmsLabel(slotInfo.amsId, slotInfo.trayCount)} Slot {slotInfo.trayId + 1} + {t('configureAmsSlot.slotLabel', { ams: getAmsLabel(slotInfo.amsId, slotInfo.trayCount), slot: slotInfo.trayId + 1 })} {slotInfo.traySubBrands && ( ({slotInfo.traySubBrands}) @@ -710,7 +816,7 @@ export function ConfigureAmsSlotModal({ {/* Filament Profile Select */}
{(cloudSettings?.filament?.length === 0 && !localPresets?.filament?.length) - ? 'No presets available. Login to Bambu Cloud or import local profiles.' - : 'No matching presets found.'} + ? t('configureAmsSlot.noPresetsAvailable') + : t('configureAmsSlot.noMatchingPresets')}

) : ( filteredPresets.map((preset) => (
@@ -813,8 +922,40 @@ export function ConfigureAmsSlotModal({ {/* Optional: Custom color */}
+ {/* Catalog colors matching selected preset */} + {catalogColors.length > 0 && ( +
+

+ {t('configureAmsSlot.presetColors', { name: selectedPresetInfo?.fullName })} +

+
+ {catalogColors.map((entry) => ( + + ))} +
+
+ )} {/* Quick color buttons */}
{QUICK_COLORS_BASIC.map((color) => ( @@ -836,7 +977,7 @@ export function ConfigureAmsSlotModal({ @@ -902,13 +1043,13 @@ export function ConfigureAmsSlotModal({ className="px-2 py-1 text-xs text-bambu-gray hover:text-white bg-bambu-dark-tertiary rounded" title={t('configureAmsSlot.clearCustomColor')} > - Clear + {t('configureAmsSlot.clear')} )}
{colorHex && (

- Hex: #{colorHex} + {t('configureAmsSlot.hexLabel', { hex: colorHex })}

)}
@@ -928,19 +1069,19 @@ export function ConfigureAmsSlotModal({ {resetMutation.isPending ? ( <> - Resetting... + {t('configureAmsSlot.resetting')} ) : ( <> - Reset Slot + {t('configureAmsSlot.resetSlot')} )} {/* Cancel and Configure buttons on the right */}
diff --git a/frontend/src/components/KProfilesView.tsx b/frontend/src/components/KProfilesView.tsx index c7ed1f22a..7d5c15a9b 100644 --- a/frontend/src/components/KProfilesView.tsx +++ b/frontend/src/components/KProfilesView.tsx @@ -150,6 +150,7 @@ interface KProfileModalProps { printerId: number; nozzleDiameter: string; existingProfiles?: KProfile[]; // Existing profiles for filament selection + builtinFilaments?: { filament_id: string; name: string }[]; // Filament ID → name lookup isDualNozzle?: boolean; // Whether this is a dual-nozzle printer initialNote?: string; // Initial note value for the profile initialNoteKey?: string | null; // Key the note was stored under (for clearing) @@ -164,6 +165,7 @@ function KProfileModal({ printerId, nozzleDiameter, existingProfiles = [], + builtinFilaments = [], isDualNozzle = false, initialNote = '', initialNoteKey = null, @@ -197,12 +199,21 @@ function KProfileModal({ const [note, setNote] = useState(initialNote); // Extract unique filaments from existing K-profiles on the printer - // These have valid filament_ids that the printer recognizes + // Use builtin filament table for accurate name resolution (filament_id → name) + // Falls back to extracting from profile name for custom/unknown presets const knownFilaments = React.useMemo(() => { + // Build lookup map from builtin filament names (includes cloud presets from parent) + const builtinMap = new Map(); + for (const bf of builtinFilaments) { + builtinMap.set(bf.filament_id, bf.name); + } + const filamentMap = new Map(); for (const p of existingProfiles) { if (p.filament_id && !filamentMap.has(p.filament_id)) { - const filamentName = extractFilamentName(p.name || ''); + // Prefer builtin name (accurate), fall back to extracting from profile name + const builtinName = builtinMap.get(p.filament_id); + const filamentName = builtinName || extractFilamentName(p.name || ''); filamentMap.set(p.filament_id, { id: p.filament_id, name: filamentName || p.filament_id, @@ -212,7 +223,7 @@ function KProfileModal({ return Array.from(filamentMap.values()).sort((a, b) => a.name.localeCompare(b.name) ); - }, [existingProfiles]); + }, [existingProfiles, builtinFilaments]); const saveMutation = useMutation({ mutationFn: (data: KProfileCreate) => { @@ -769,6 +780,20 @@ export function KProfilesView() { staleTime: 60000, // Cache for 1 minute }); + // Fetch builtin filament names for accurate filament_id → name resolution + const { data: builtinFilaments } = useQuery({ + queryKey: ['builtinFilaments'], + queryFn: () => api.getBuiltinFilaments(), + staleTime: 300000, // Cache for 5 minutes (static data) + }); + + // Fetch filament_id → name mapping for user cloud presets (P* IDs) + const { data: filamentIdMap } = useQuery({ + queryKey: ['filamentIdMap'], + queryFn: () => api.getFilamentIdMap(), + staleTime: 300000, // Cache for 5 minutes + }); + // Fetch K-profile notes (stored locally) const { data: notesData, @@ -807,6 +832,39 @@ export function KProfilesView() { // Get connected printers for display const connectedPrinters = printers?.filter((p) => p.is_active) || []; + // Build filament lookup for name resolution (builtin + user cloud presets) + const builtinFilamentMap = React.useMemo(() => { + const map = new Map(); + if (builtinFilaments) { + for (const bf of builtinFilaments) { + map.set(bf.filament_id, bf.name); + } + } + // Also add user cloud presets (P* filament_ids resolved from cloud details) + if (filamentIdMap) { + for (const [fid, name] of Object.entries(filamentIdMap)) { + if (!map.has(fid)) { + map.set(fid, name); + } + } + } + return map; + }, [builtinFilaments, filamentIdMap]); + + // Enriched builtin filaments array (builtin + cloud presets merged) + // Pass this to modals so they have the full filament name lookup + const enrichedBuiltinFilaments = React.useMemo(() => { + return Array.from(builtinFilamentMap.entries()).map(([fid, name]) => ({ + filament_id: fid, + name, + })); + }, [builtinFilamentMap]); + + // Resolve filament name: builtin table first, then extract from profile name + const resolveFilamentName = React.useCallback((profile: KProfile) => { + return builtinFilamentMap.get(profile.filament_id) || extractFilamentName(profile.name); + }, [builtinFilamentMap]); + // Filter and sort profiles // Note: nozzle diameter filtering is done server-side via MQTT request const filteredProfiles = React.useMemo(() => { @@ -841,13 +899,13 @@ export function KProfilesView() { case 'k_value': return parseFloat(a.k_value) - parseFloat(b.k_value); case 'filament': - return extractFilamentName(a.name).localeCompare(extractFilamentName(b.name)); + return resolveFilamentName(a).localeCompare(resolveFilamentName(b)); case 'name': default: return a.name.localeCompare(b.name); } }); - }, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter, sortOption]); + }, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter, sortOption, resolveFilamentName]); // Check if selected printer is dual-nozzle (auto-detected from MQTT temperature data) const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter); @@ -1394,6 +1452,7 @@ export function KProfilesView() { printerId={selectedPrinter} nozzleDiameter={nozzleDiameter} existingProfiles={allProfiles?.profiles || kprofiles?.profiles} + builtinFilaments={enrichedBuiltinFilaments} isDualNozzle={isDualNozzle} initialNote={note} initialNoteKey={key} @@ -1418,6 +1477,7 @@ export function KProfilesView() { printerId={selectedPrinter} nozzleDiameter={nozzleDiameter} existingProfiles={allProfiles?.profiles || kprofiles?.profiles} + builtinFilaments={enrichedBuiltinFilaments} isDualNozzle={isDualNozzle} onSaveNote={handleSaveNote} hasPermission={hasPermission} @@ -1438,6 +1498,7 @@ export function KProfilesView() { printerId={selectedPrinter} nozzleDiameter={nozzleDiameter} existingProfiles={allProfiles?.profiles || kprofiles?.profiles} + builtinFilaments={enrichedBuiltinFilaments} isDualNozzle={isDualNozzle} onSaveNote={handleSaveNote} hasPermission={hasPermission} diff --git a/frontend/src/hooks/useFilamentMapping.ts b/frontend/src/hooks/useFilamentMapping.ts index bb79c80c3..a23f9e5b0 100644 --- a/frontend/src/hooks/useFilamentMapping.ts +++ b/frontend/src/hooks/useFilamentMapping.ts @@ -41,22 +41,26 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined): }); }); - // Add external spool if loaded - if (printerStatus?.vt_tray?.tray_type) { - const color = normalizeColor(printerStatus.vt_tray.tray_color); - filaments.push({ - type: printerStatus.vt_tray.tray_type, - color, - colorName: getColorName(color), - amsId: -1, - trayId: 0, - isHt: false, - isExternal: true, - label: 'External', - globalTrayId: 254, - trayInfoIdx: printerStatus.vt_tray.tray_info_idx || '', - extruderId: hasDualNozzle ? 0 : undefined, - }); + // Add external spool(s) if loaded + for (const extTray of printerStatus?.vt_tray ?? []) { + if (extTray.tray_type) { + const color = normalizeColor(extTray.tray_color); + const trayId = extTray.id ?? 254; + const hasDualExternal = (printerStatus?.vt_tray?.length ?? 0) > 1; + filaments.push({ + type: extTray.tray_type, + color, + colorName: getColorName(color), + amsId: -1, + trayId: trayId - 254, + isHt: false, + isExternal: true, + label: hasDualExternal ? (trayId === 254 ? 'Ext-L' : 'Ext-R') : 'External', + globalTrayId: trayId, + trayInfoIdx: extTray.tray_info_idx || '', + extruderId: hasDualNozzle ? (trayId - 254) : undefined, + }); + } } return filaments; diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 52bf72e7e..c166b4347 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -171,6 +171,8 @@ export default { // Printer card readyToPrint: 'Druckbereit', external: 'Extern', + extL: 'Ext-L', + extR: 'Ext-R', deleteArchives: 'Druckarchive löschen', noLabel: 'Keine Bezeichnung', printPreview: 'Druckvorschau', @@ -3204,15 +3206,37 @@ export default { // Configure AMS Slot Modal configureAmsSlot: { + title: 'AMS-Slot konfigurieren', + slotConfigured: 'Slot konfiguriert!', + configuringSlot: 'Slot wird konfiguriert:', + slotLabel: '{{ams}} Slot {{slot}}', searchPresets: 'Voreinstellungen suchen...', colorPlaceholder: 'Farbname oder Hex (z.B. braun, FF8800)', clearCustomColor: 'Benutzerdefinierte Farbe löschen', noCloudPresets: 'Keine Cloud-Voreinstellungen. Melden Sie sich bei Bambu Cloud an, um zu synchronisieren.', + noPresetsAvailable: 'Keine Voreinstellungen verfügbar. Melden Sie sich bei Bambu Cloud an oder importieren Sie lokale Profile.', noMatchingPresets: 'Keine passenden Voreinstellungen gefunden.', custom: 'Benutzerdefiniert', builtin: 'Integriert', settingsSentToPrinter: 'Einstellungen an Drucker gesendet', filamentProfile: 'Filamentprofil', + kProfileLabel: 'K-Profil (Pressure Advance)', + filteringFor: 'Filtern nach: {{material}}', + noKProfile: 'Kein K-Profil (Standard 0.020 verwenden)', + noMatchingKProfiles: 'Keine passenden K-Profile gefunden. Standard K=0.020 wird verwendet.', + selectFilamentFirst: 'Zuerst ein Filamentprofil auswählen', + kFromCalibration: 'K={{value}} aus Druckerkalibrierung', + customColorLabel: 'Benutzerdefinierte Farbe (optional)', + presetColors: '{{name}} Farben:', + showLessColors: 'Weniger Farben anzeigen', + showMoreColors: 'Mehr Farben anzeigen', + clear: 'Löschen', + hexLabel: 'Hex: #{{hex}}', + resetting: 'Wird zurückgesetzt...', + resetSlot: 'Slot zurücksetzen', + cancel: 'Abbrechen', + configuring: 'Wird konfiguriert...', + configureSlot: 'Slot konfigurieren', }, // GitHub Backup Settings diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 008348aae..b1e1d9bbf 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -171,6 +171,8 @@ export default { // Printer card readyToPrint: 'Ready to print', external: 'External', + extL: 'Ext-L', + extR: 'Ext-R', deleteArchives: 'Delete print archives', noLabel: 'No label', printPreview: 'Print preview', @@ -3209,15 +3211,37 @@ export default { // Configure AMS Slot Modal configureAmsSlot: { + title: 'Configure AMS Slot', + slotConfigured: 'Slot Configured!', + configuringSlot: 'Configuring slot:', + slotLabel: '{{ams}} Slot {{slot}}', searchPresets: 'Search presets...', colorPlaceholder: 'Color name or hex (e.g., brown, FF8800)', clearCustomColor: 'Clear custom color', noCloudPresets: 'No cloud presets. Login to Bambu Cloud to sync.', + noPresetsAvailable: 'No presets available. Login to Bambu Cloud or import local profiles.', noMatchingPresets: 'No matching presets found.', custom: 'Custom', builtin: 'Built-in', settingsSentToPrinter: 'Settings sent to printer', filamentProfile: 'Filament Profile', + kProfileLabel: 'K Profile (Pressure Advance)', + filteringFor: 'Filtering for: {{material}}', + noKProfile: 'No K profile (use default 0.020)', + noMatchingKProfiles: 'No matching K profiles found. Default K=0.020 will be used.', + selectFilamentFirst: 'Select a filament profile first', + kFromCalibration: 'K={{value}} from printer calibration', + customColorLabel: 'Custom Color (optional)', + presetColors: '{{name}} colors:', + showLessColors: 'Show less colors', + showMoreColors: 'Show more colors', + clear: 'Clear', + hexLabel: 'Hex: #{{hex}}', + resetting: 'Resetting...', + resetSlot: 'Reset Slot', + cancel: 'Cancel', + configuring: 'Configuring...', + configureSlot: 'Configure Slot', }, // GitHub Backup Settings diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 96acda383..766ce4c2b 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -171,6 +171,8 @@ export default { // Printer card readyToPrint: 'Prête à imprimer', external: 'Externe', + extL: 'Ext-L', + extR: 'Ext-R', deleteArchives: 'Supprimer les archives d\'impression', noLabel: 'Pas d\'étiquette', printPreview: 'Aperçu avant impression', @@ -3205,15 +3207,37 @@ export default { // Configure AMS Slot Modal configureAmsSlot: { + title: 'Configurer le slot AMS', + slotConfigured: 'Slot configuré !', + configuringSlot: 'Configuration du slot :', + slotLabel: '{{ams}} Slot {{slot}}', searchPresets: 'Chercher presets...', colorPlaceholder: 'Nom couleur ou hex (ex: brown, FF8800)', clearCustomColor: 'Effacer couleur perso', noCloudPresets: 'Profils Cloud absents. Connectez-vous.', + noPresetsAvailable: 'Aucun preset disponible. Connectez-vous à Bambu Cloud ou importez des profils locaux.', noMatchingPresets: 'Aucun profil trouvé.', custom: 'Perso', builtin: 'Inclus', settingsSentToPrinter: 'Réglages envoyés', filamentProfile: 'Profil Filament', + kProfileLabel: 'Profil K (Pressure Advance)', + filteringFor: 'Filtrage pour : {{material}}', + noKProfile: 'Pas de profil K (utiliser défaut 0.020)', + noMatchingKProfiles: 'Aucun profil K trouvé. K=0.020 par défaut sera utilisé.', + selectFilamentFirst: 'Sélectionnez d\'abord un profil filament', + kFromCalibration: 'K={{value}} de la calibration imprimante', + customColorLabel: 'Couleur personnalisée (optionnel)', + presetColors: 'Couleurs {{name}} :', + showLessColors: 'Moins de couleurs', + showMoreColors: 'Plus de couleurs', + clear: 'Effacer', + hexLabel: 'Hex : #{{hex}}', + resetting: 'Réinitialisation...', + resetSlot: 'Réinitialiser le slot', + cancel: 'Annuler', + configuring: 'Configuration...', + configureSlot: 'Configurer le slot', }, // GitHub Backup Settings diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index f80d4b4e2..4b3b5d16e 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -168,6 +168,8 @@ export default { // Printer card readyToPrint: 'Pronta a stampare', external: 'Esterna', + extL: 'Ext-L', + extR: 'Ext-R', deleteArchives: 'Elimina archivi stampa', noLabel: 'Nessuna etichetta', printPreview: 'Anteprima stampa', @@ -2732,4 +2734,39 @@ export default { replaceCarbonFilter: 'Sostituisci filtro a carbone attivo', lubricateLeftNozzleRail: 'Lubrifica guida ugello sinistro (serie H2)', }, + + // Configure AMS Slot Modal + configureAmsSlot: { + title: 'Configura Slot AMS', + slotConfigured: 'Slot configurato!', + configuringSlot: 'Configurazione slot:', + slotLabel: '{{ams}} Slot {{slot}}', + searchPresets: 'Cerca preset...', + colorPlaceholder: 'Nome colore o hex (es. marrone, FF8800)', + clearCustomColor: 'Cancella colore personalizzato', + noCloudPresets: 'Nessun preset cloud. Accedi a Bambu Cloud per sincronizzare.', + noPresetsAvailable: 'Nessun preset disponibile. Accedi a Bambu Cloud o importa profili locali.', + noMatchingPresets: 'Nessun preset corrispondente trovato.', + custom: 'Personalizzato', + builtin: 'Integrato', + settingsSentToPrinter: 'Impostazioni inviate alla stampante', + filamentProfile: 'Profilo filamento', + kProfileLabel: 'Profilo K (Pressure Advance)', + filteringFor: 'Filtrando per: {{material}}', + noKProfile: 'Nessun profilo K (usa predefinito 0.020)', + noMatchingKProfiles: 'Nessun profilo K corrispondente. Verrà usato K=0.020 predefinito.', + selectFilamentFirst: 'Seleziona prima un profilo filamento', + kFromCalibration: 'K={{value}} dalla calibrazione stampante', + customColorLabel: 'Colore personalizzato (opzionale)', + presetColors: 'Colori {{name}}:', + showLessColors: 'Mostra meno colori', + showMoreColors: 'Mostra più colori', + clear: 'Cancella', + hexLabel: 'Hex: #{{hex}}', + resetting: 'Ripristino...', + resetSlot: 'Ripristina slot', + cancel: 'Annulla', + configuring: 'Configurazione...', + configureSlot: 'Configura slot', + }, }; diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index c3d26fa22..8adb9d74f 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -173,6 +173,8 @@ export default { noPrintersConfigured: 'プリンターが設定されていません', readyToPrint: '印刷可能', external: '外部', + extL: 'Ext-L', + extR: 'Ext-R', deleteArchives: '印刷アーカイブを削除', willBeSkipped: 'スキップされます', name: '名前', @@ -2920,15 +2922,37 @@ export default { // Configure AMS Slot Modal configureAmsSlot: { + title: 'AMSスロットの設定', + slotConfigured: 'スロットを設定しました!', + configuringSlot: 'スロットを設定中:', + slotLabel: '{{ams}} スロット {{slot}}', searchPresets: 'プリセットを検索...', colorPlaceholder: '色名またはHex(例: 茶色、FF8800)', clearCustomColor: 'カスタム色をクリア', noCloudPresets: 'クラウドプリセットがありません。Bambu Cloudにログインして同期してください。', + noPresetsAvailable: 'プリセットがありません。Bambu Cloudにログインするか、ローカルプロファイルをインポートしてください。', noMatchingPresets: '一致するプリセットが見つかりません。', custom: 'カスタム', builtin: '内蔵', settingsSentToPrinter: '設定をプリンターに送信しました', filamentProfile: 'フィラメントプロファイル', + kProfileLabel: 'Kプロファイル(Pressure Advance)', + filteringFor: 'フィルター中: {{material}}', + noKProfile: 'Kプロファイルなし(デフォルト0.020を使用)', + noMatchingKProfiles: '一致するKプロファイルが見つかりません。デフォルトK=0.020が使用されます。', + selectFilamentFirst: 'まずフィラメントプロファイルを選択してください', + kFromCalibration: 'K={{value}}(プリンターキャリブレーションから)', + customColorLabel: 'カスタム色(オプション)', + presetColors: '{{name}}の色:', + showLessColors: '色を減らす', + showMoreColors: '色をもっと表示', + clear: 'クリア', + hexLabel: 'Hex: #{{hex}}', + resetting: 'リセット中...', + resetSlot: 'スロットをリセット', + cancel: 'キャンセル', + configuring: '設定中...', + configureSlot: 'スロットを設定', }, // Email Settings diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 55b38d188..0e39749e9 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -1384,6 +1384,44 @@ function getStatusDisplay(state: string | null | undefined, stg_cur_name: string } } +// Map SSDP model codes to display names +function mapModelCode(ssdpModel: string | null): string { + if (!ssdpModel) return ''; + const modelMap: Record = { + // H2 Series + 'O1D': 'H2D', + 'O1E': 'H2D Pro', + 'O2D': 'H2D Pro', + 'O1C': 'H2C', + 'O1S': 'H2S', + // X1 Series + 'BL-P001': 'X1C', + 'BL-P002': 'X1', + 'BL-P003': 'X1E', + // P Series + 'C11': 'P1S', + 'C12': 'P1P', + 'C13': 'P2S', + // A1 Series + 'N2S': 'A1', + 'N1': 'A1 Mini', + // Direct matches + 'X1C': 'X1C', + 'X1': 'X1', + 'X1E': 'X1E', + 'P1S': 'P1S', + 'P1P': 'P1P', + 'P2S': 'P2S', + 'A1': 'A1', + 'A1 Mini': 'A1 Mini', + 'H2D': 'H2D', + 'H2D Pro': 'H2D Pro', + 'H2C': 'H2C', + 'H2S': 'H2S', + }; + return modelMap[ssdpModel] || ssdpModel; +} + function PrinterCard({ printer, hideIfDisconnected, @@ -1469,6 +1507,9 @@ function PrinterCard({ trayColor?: string; traySubBrands?: string; trayInfoIdx?: string; + extruderId?: number; + caliIdx?: number | null; + savedPresetId?: string; } | null>(null); const [showFirmwareModal, setShowFirmwareModal] = useState(false); const [plateCheckResult, setPlateCheckResult] = useState<{ @@ -1516,8 +1557,8 @@ function PrinterCard({ } } } - if (status?.vt_tray?.tray_info_idx) { - ids.add(status.vt_tray.tray_info_idx); + for (const vt of status?.vt_tray ?? []) { + if (vt.tray_info_idx) ids.add(vt.tray_info_idx); } if (status?.nozzle_rack) { for (const slot of status.nozzle_rack) { @@ -1582,18 +1623,20 @@ function PrinterCard({ }, [status?.ams]); const amsData = (status?.ams && status.ams.length > 0) ? status.ams : cachedAmsData.current; - // Cache tray_now to prevent flickering when 255 (unloaded) or undefined values come in - // Only update cache when we get a valid tray ID (0-253 or 254 for external) - const cachedTrayNow = useRef(255); + // Cache tray_now to prevent flickering when undefined values come in + // Valid tray IDs: 0-253 for AMS, 254 for external spool + // tray_now=255 means "no tray loaded" (Bambu protocol sentinel) — never active + const cachedTrayNow = useRef(undefined); const currentTrayNow = status?.tray_now; - // Update cache synchronously during render if we have a valid value + // Update cache: 255 means "no tray" so clear cache; valid values get cached if (currentTrayNow !== undefined && currentTrayNow !== 255) { cachedTrayNow.current = currentTrayNow; + } else if (currentTrayNow === 255) { + cachedTrayNow.current = undefined; } - // Use cached value if current is 255/undefined but we had a valid value before - const effectiveTrayNow = (currentTrayNow === undefined || currentTrayNow === 255) - ? cachedTrayNow.current - : currentTrayNow; + const effectiveTrayNow = (currentTrayNow !== undefined && currentTrayNow !== 255) + ? currentTrayNow + : cachedTrayNow.current; // Fetch smart plug for this printer const { data: smartPlug } = useQuery({ @@ -2658,7 +2701,7 @@ function PrinterCard({ })()} {/* AMS Units - 2-Column Grid Layout */} - {amsData && amsData.length > 0 && viewMode === 'expanded' && (() => { + {(amsData?.length > 0 || status.vt_tray.length > 0) && viewMode === 'expanded' && (() => { // Separate regular AMS (4-tray) from HT AMS (1-tray) const regularAms = amsData.filter(ams => ams.tray.length > 1); const htAms = amsData.filter(ams => ams.tray.length === 1); @@ -2915,6 +2958,9 @@ function PrinterCard({ trayColor: tray?.tray_color || undefined, traySubBrands: tray?.tray_sub_brands || undefined, trayInfoIdx: tray?.tray_info_idx || undefined, + extruderId: mappedExtruderId, + caliIdx: tray?.cali_idx, + savedPresetId: slotPreset?.preset_id, }), }} > @@ -2928,6 +2974,7 @@ function PrinterCard({ amsId: ams.id, trayId: slotIdx, trayCount: ams.tray.length, + extruderId: mappedExtruderId, }), }} > @@ -2945,7 +2992,7 @@ function PrinterCard({ )} {/* Row 3: HT AMS + External spools (same style as regular AMS, 4 across) */} - {(htAms.length > 0 || (status.vt_tray && status.vt_tray.tray_type)) && ( + {(htAms.length > 0 || status.vt_tray.length > 0) && (
{/* HT AMS units - name/badge top, slot left, stats right */} {htAms.map((ams) => { @@ -3149,6 +3196,9 @@ function PrinterCard({ trayColor: tray?.tray_color || undefined, traySubBrands: tray?.tray_sub_brands || undefined, trayInfoIdx: tray?.tray_info_idx || undefined, + extruderId: mappedExtruderId, + caliIdx: tray?.cali_idx, + savedPresetId: slotPreset?.preset_id, }), }} > @@ -3162,6 +3212,7 @@ function PrinterCard({ amsId: ams.id, trayId: htSlotId, trayCount: ams.tray.length, + extruderId: mappedExtruderId, }), }} > @@ -3204,138 +3255,162 @@ function PrinterCard({
); })} - {/* External spool - name top, slot below (no stats) */} - {status.vt_tray && status.vt_tray.tray_type && (() => { - const extTray = status.vt_tray; - // Check if external spool is active (tray_now = 254) - const isExtActive = effectiveTrayNow === 254; - // Get cloud preset info if available - const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null; - // Get saved slot preset mapping (external spool uses amsId=255, trayId=0) - const extSlotPreset = slotPresets?.[255 * 4 + 0]; - - // Fill level fallback chain: Spoolman → Inventory spool - const extTrayTag = extTray.tray_uuid?.toUpperCase(); - const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined; - const extSpoolmanFill = getSpoolmanFillLevel(extLinkedSpool); - const extInventoryAssignment = onGetAssignment?.(printer.id, 255, 0); - const extInventoryFill = (() => { - const sp = extInventoryAssignment?.spool; - if (sp && sp.label_weight > 0 && sp.weight_used > 0) { - return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100); - } - return null; - })(); - const extEffectiveFill = extSpoolmanFill ?? extInventoryFill ?? null; - - // Build filament data for hover card - const extFilamentData = { - vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic', - profile: extCloudInfo?.name || extSlotPreset?.preset_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown', - colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color), - colorHex: extTray.tray_color || null, - kFactor: formatKValue(extTray.k), - fillLevel: extEffectiveFill, - trayUuid: extTray.tray_uuid || null, - tagUid: extTray.tag_uid || null, - fillSource: extSpoolmanFill !== null ? 'spoolman' as const - : extInventoryFill !== null ? 'inventory' as const - : undefined, - }; - - const extSlotContent = ( -
-
-
- {extTray.tray_type || 'Spool'} -
- {/* Fill bar - use Spoolman data if available */} -
- {extSpoolmanFill !== null ? ( -
- ) : ( -
- )} -
+ {/* External spool(s) - grouped in one card like regular AMS */} + {status.vt_tray.length > 0 && ( +
+
+ {t('printers.external')}
- ); +
1 ? 'grid-cols-2' : 'grid-cols-1'} gap-1.5`}> + {[...status.vt_tray].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)).map((extTray) => { + const extTrayId = extTray.id ?? 254; + const isExtActive = effectiveTrayNow === extTrayId; + const slotTrayId = extTrayId - 254; // 0 or 1 + const extLabel = isDualNozzle + ? (extTrayId === 254 ? t('printers.extL') : t('printers.extR')) + : ''; + const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null; + const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId]; - return ( -
- {/* Row 1: Label */} -
- {t('printers.external')} -
- {/* Row 2: Slot (full width since no stats) */} - { - setLinkSpoolModal({ - tagUid: extFilamentData.tagUid || '', - trayUuid: uuid, - printerId: printer.id, - amsId: 255, - trayId: 0, - }); - } : undefined, - }} - inventory={(() => { - const assignment = onGetAssignment?.(printer.id, 255, 0); - return { - assignedSpool: assignment?.spool ? { - id: assignment.spool.id, - material: assignment.spool.material, - brand: assignment.spool.brand, - color_name: assignment.spool.color_name, - } : null, - onAssignSpool: extFilamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({ - printerId: printer.id, - amsId: 255, - trayId: 0, - trayInfo: { - type: extFilamentData.profile, - color: extFilamentData.colorHex || '', - location: 'External Spool', - }, - }) : undefined, - onUnassignSpool: assignment && extFilamentData.vendor !== 'Bambu Lab' ? () => onUnassignSpool?.(printer.id, 255, 0) : undefined, - }; - })()} - configureSlot={{ - enabled: hasPermission('printers:control'), - onConfigure: () => setConfigureSlotModal({ - amsId: 255, // External spool indicator - trayId: 0, - trayCount: 1, // External = single slot - trayType: extTray.tray_type || undefined, - trayColor: extTray.tray_color || undefined, - traySubBrands: extTray.tray_sub_brands || undefined, - trayInfoIdx: extTray.tray_info_idx || undefined, - }), - }} - > - {extSlotContent} - + const extTrayTag = extTray.tray_uuid?.toUpperCase(); + const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined; + const extSpoolmanFill = getSpoolmanFillLevel(extLinkedSpool); + const extInventoryAssignment = onGetAssignment?.(printer.id, 255, slotTrayId); + const extInventoryFill = (() => { + const sp = extInventoryAssignment?.spool; + if (sp && sp.label_weight > 0 && sp.weight_used > 0) { + return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100); + } + return null; + })(); + const extEffectiveFill = extSpoolmanFill ?? extInventoryFill ?? null; + + const extFilamentData = { + vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic', + profile: extCloudInfo?.name || extSlotPreset?.preset_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown', + colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color), + colorHex: extTray.tray_color || null, + kFactor: formatKValue(extTray.k), + fillLevel: extEffectiveFill, + trayUuid: extTray.tray_uuid || null, + tagUid: extTray.tag_uid || null, + fillSource: extSpoolmanFill !== null ? 'spoolman' as const + : extInventoryFill !== null ? 'inventory' as const + : undefined, + }; + + const isEmpty = !extTray.tray_type; + const extSlotContent = ( +
+
+
+ {extTray.tray_type || '—'} +
+
+ {extEffectiveFill !== null && extEffectiveFill >= 0 && !isEmpty ? ( +
+ ) : !isEmpty ? ( +
+ ) : null} +
+ {extLabel &&
{extLabel}
} +
+ ); + + return ( +
+ {!isEmpty ? ( + { + setLinkSpoolModal({ + tagUid: extFilamentData.tagUid || '', + trayUuid: uuid, + printerId: printer.id, + amsId: 255, + trayId: slotTrayId, + }); + } : undefined, + }} + inventory={(() => { + const assignment = onGetAssignment?.(printer.id, 255, slotTrayId); + return { + assignedSpool: assignment?.spool ? { + id: assignment.spool.id, + material: assignment.spool.material, + brand: assignment.spool.brand, + color_name: assignment.spool.color_name, + } : null, + onAssignSpool: () => setAssignSpoolModal({ + printerId: printer.id, + amsId: 255, + trayId: slotTrayId, + trayInfo: { + type: extFilamentData.profile, + color: extFilamentData.colorHex || '', + location: extLabel || t('printers.external'), + }, + }), + onUnassignSpool: assignment ? () => onUnassignSpool?.(printer.id, 255, slotTrayId) : undefined, + }; + })()} + configureSlot={{ + enabled: hasPermission('printers:control'), + onConfigure: () => setConfigureSlotModal({ + amsId: 255, + trayId: slotTrayId, + trayCount: 1, + trayType: extTray.tray_type || undefined, + trayColor: extTray.tray_color || undefined, + traySubBrands: extTray.tray_sub_brands || undefined, + trayInfoIdx: extTray.tray_info_idx || undefined, + extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined, + caliIdx: extTray.cali_idx, + savedPresetId: extSlotPreset?.preset_id, + }), + }} + > + {extSlotContent} + + ) : ( + setConfigureSlotModal({ + amsId: 255, + trayId: slotTrayId, + trayCount: 1, + extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined, + }), + }} + > + {extSlotContent} + + )} +
+ ); + })}
- ); - })()} +
+ )}
)}
@@ -3974,6 +4049,7 @@ function PrinterCard({ onClose={() => setConfigureSlotModal(null)} printerId={printer.id} slotInfo={configureSlotModal} + printerModel={mapModelCode(printer.model) || undefined} onSuccess={() => { // Refresh slot presets to show updated profile name queryClient.invalidateQueries({ queryKey: ['slotPresets', printer.id] }); @@ -4128,43 +4204,7 @@ function AddPrinterModal({ } }; - // Map SSDP model codes to dropdown values - const mapModelCode = (ssdpModel: string | null): string => { - if (!ssdpModel) return ''; - const modelMap: Record = { - // H2 Series - 'O1D': 'H2D', - 'O1E': 'H2D Pro', // Some devices report O1E - 'O2D': 'H2D Pro', // Some devices report O2D - 'O1C': 'H2C', - 'O1S': 'H2S', - // X1 Series - 'BL-P001': 'X1C', - 'BL-P002': 'X1', - 'BL-P003': 'X1E', - // P Series - 'C11': 'P1S', - 'C12': 'P1P', - 'C13': 'P2S', - // A1 Series - 'N2S': 'A1', - 'N1': 'A1 Mini', - // Direct matches - 'X1C': 'X1C', - 'X1': 'X1', - 'X1E': 'X1E', - 'P1S': 'P1S', - 'P1P': 'P1P', - 'P2S': 'P2S', - 'A1': 'A1', - 'A1 Mini': 'A1 Mini', - 'H2D': 'H2D', - 'H2D Pro': 'H2D Pro', - 'H2C': 'H2C', - 'H2S': 'H2S', - }; - return modelMap[ssdpModel] || ssdpModel; - }; + // Reuse module-level mapModelCode const selectPrinter = (printer: DiscoveredPrinter) => { // Don't pre-fill serial if it's a placeholder (unknown-*) - user needs to enter actual serial diff --git a/frontend/src/utils/amsHelpers.ts b/frontend/src/utils/amsHelpers.ts index 5e4dbe946..e1af13e06 100644 --- a/frontend/src/utils/amsHelpers.ts +++ b/frontend/src/utils/amsHelpers.ts @@ -88,7 +88,7 @@ export function getGlobalTrayId( trayId: number, isExternal: boolean ): number { - if (isExternal) return 254; + if (isExternal) return 254 + trayId; // AMS-HT units have IDs starting at 128 with a single tray — use ID directly if (amsId >= 128) return amsId; return amsId * 4 + trayId; diff --git a/static/assets/index-BOd5pCVD.js b/static/assets/index-BOd5pCVD.js deleted file mode 100644 index 062cc45fd..000000000 --- a/static/assets/index-BOd5pCVD.js +++ /dev/null @@ -1,8684 +0,0 @@ -function pse(t,e){for(var n=0;nr[i]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))r(i);new MutationObserver(i=>{for(const s of i)if(s.type==="childList")for(const o of s.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(i){const s={};return i.integrity&&(s.integrity=i.integrity),i.referrerPolicy&&(s.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?s.credentials="include":i.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function r(i){if(i.ep)return;i.ep=!0;const s=n(i);fetch(i.href,s)}})();var qw=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function wl(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var uT={exports:{}},zx={};var gU;function gse(){if(gU)return zx;gU=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.fragment");function n(r,i,s){var o=null;if(s!==void 0&&(o=""+s),i.key!==void 0&&(o=""+i.key),"key"in i){s={};for(var l in i)l!=="key"&&(s[l]=i[l])}else s=i;return i=s.ref,{$$typeof:t,type:r,key:o,ref:i!==void 0?i:null,props:s}}return zx.Fragment=e,zx.jsx=n,zx.jsxs=n,zx}var bU;function bse(){return bU||(bU=1,uT.exports=gse()),uT.exports}var a=bse(),dT={exports:{}},Nn={};var xU;function xse(){if(xU)return Nn;xU=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.portal"),n=Symbol.for("react.fragment"),r=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),s=Symbol.for("react.consumer"),o=Symbol.for("react.context"),l=Symbol.for("react.forward_ref"),c=Symbol.for("react.suspense"),u=Symbol.for("react.memo"),d=Symbol.for("react.lazy"),h=Symbol.for("react.activity"),p=Symbol.iterator;function f(V){return V===null||typeof V!="object"?null:(V=p&&V[p]||V["@@iterator"],typeof V=="function"?V:null)}var x={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},v=Object.assign,b={};function y(V,W,ie){this.props=V,this.context=W,this.refs=b,this.updater=ie||x}y.prototype.isReactComponent={},y.prototype.setState=function(V,W){if(typeof V!="object"&&typeof V!="function"&&V!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,V,W,"setState")},y.prototype.forceUpdate=function(V){this.updater.enqueueForceUpdate(this,V,"forceUpdate")};function S(){}S.prototype=y.prototype;function N(V,W,ie){this.props=V,this.context=W,this.refs=b,this.updater=ie||x}var A=N.prototype=new S;A.constructor=N,v(A,y.prototype),A.isPureReactComponent=!0;var k=Array.isArray;function T(){}var C={H:null,A:null,T:null,S:null},j=Object.prototype.hasOwnProperty;function E(V,W,ie){var Z=ie.ref;return{$$typeof:t,type:V,key:W,ref:Z!==void 0?Z:null,props:ie}}function P(V,W){return E(V.type,W,V.props)}function U(V){return typeof V=="object"&&V!==null&&V.$$typeof===t}function L(V){var W={"=":"=0",":":"=2"};return"$"+V.replace(/[=:]/g,function(ie){return W[ie]})}var H=/\/+/g;function D(V,W){return typeof V=="object"&&V!==null&&V.key!=null?L(""+V.key):W.toString(36)}function $(V){switch(V.status){case"fulfilled":return V.value;case"rejected":throw V.reason;default:switch(typeof V.status=="string"?V.then(T,T):(V.status="pending",V.then(function(W){V.status==="pending"&&(V.status="fulfilled",V.value=W)},function(W){V.status==="pending"&&(V.status="rejected",V.reason=W)})),V.status){case"fulfilled":return V.value;case"rejected":throw V.reason}}throw V}function q(V,W,ie,Z,fe){var he=typeof V;(he==="undefined"||he==="boolean")&&(V=null);var ye=!1;if(V===null)ye=!0;else switch(he){case"bigint":case"string":case"number":ye=!0;break;case"object":switch(V.$$typeof){case t:case e:ye=!0;break;case d:return ye=V._init,q(ye(V._payload),W,ie,Z,fe)}}if(ye)return fe=fe(V),ye=Z===""?"."+D(V,0):Z,k(fe)?(ie="",ye!=null&&(ie=ye.replace(H,"$&/")+"/"),q(fe,W,ie,"",function(le){return le})):fe!=null&&(U(fe)&&(fe=P(fe,ie+(fe.key==null||V&&V.key===fe.key?"":(""+fe.key).replace(H,"$&/")+"/")+ye)),W.push(fe)),1;ye=0;var Fe=Z===""?".":Z+":";if(k(V))for(var ue=0;ue>>1,se=q[Q];if(0>>1;Qi(ie,X))Zi(fe,ie)?(q[Q]=fe,q[Z]=X,Q=Z):(q[Q]=ie,q[W]=X,Q=W);else if(Zi(fe,X))q[Q]=fe,q[Z]=X,Q=Z;else break e}}return I}function i(q,I){var X=q.sortIndex-I.sortIndex;return X!==0?X:q.id-I.id}if(t.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var s=performance;t.unstable_now=function(){return s.now()}}else{var o=Date,l=o.now();t.unstable_now=function(){return o.now()-l}}var c=[],u=[],d=1,h=null,p=3,f=!1,x=!1,v=!1,b=!1,y=typeof setTimeout=="function"?setTimeout:null,S=typeof clearTimeout=="function"?clearTimeout:null,N=typeof setImmediate<"u"?setImmediate:null;function A(q){for(var I=n(u);I!==null;){if(I.callback===null)r(u);else if(I.startTime<=q)r(u),I.sortIndex=I.expirationTime,e(c,I);else break;I=n(u)}}function k(q){if(v=!1,A(q),!x)if(n(c)!==null)x=!0,T||(T=!0,L());else{var I=n(u);I!==null&&$(k,I.startTime-q)}}var T=!1,C=-1,j=5,E=-1;function P(){return b?!0:!(t.unstable_now()-Eq&&P());){var Q=h.callback;if(typeof Q=="function"){h.callback=null,p=h.priorityLevel;var se=Q(h.expirationTime<=q);if(q=t.unstable_now(),typeof se=="function"){h.callback=se,A(q),I=!0;break t}h===n(c)&&r(c),A(q)}else r(c);h=n(c)}if(h!==null)I=!0;else{var V=n(u);V!==null&&$(k,V.startTime-q),I=!1}}break e}finally{h=null,p=X,f=!1}I=void 0}}finally{I?L():T=!1}}}var L;if(typeof N=="function")L=function(){N(U)};else if(typeof MessageChannel<"u"){var H=new MessageChannel,D=H.port2;H.port1.onmessage=U,L=function(){D.postMessage(null)}}else L=function(){y(U,0)};function $(q,I){C=y(function(){q(t.unstable_now())},I)}t.unstable_IdlePriority=5,t.unstable_ImmediatePriority=1,t.unstable_LowPriority=4,t.unstable_NormalPriority=3,t.unstable_Profiling=null,t.unstable_UserBlockingPriority=2,t.unstable_cancelCallback=function(q){q.callback=null},t.unstable_forceFrameRate=function(q){0>q||125Q?(q.sortIndex=X,e(u,q),n(c)===null&&q===n(u)&&(v?(S(C),C=-1):v=!0,$(k,X-Q))):(q.sortIndex=se,e(c,q),x||f||(x=!0,T||(T=!0,L()))),q},t.unstable_shouldYield=P,t.unstable_wrapCallback=function(q){var I=p;return function(){var X=p;p=I;try{return q.apply(this,arguments)}finally{p=X}}}})(fT)),fT}var wU;function wse(){return wU||(wU=1,mT.exports=vse()),mT.exports}var pT={exports:{}},ns={};var _U;function _se(){if(_U)return ns;_U=1;var t=Mb();function e(c){var u="https://react.dev/errors/"+c;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),pT.exports=_se(),pT.exports}var NU;function Sse(){if(NU)return Ux;NU=1;var t=wse(),e=Mb(),n=rW();function r(m){var g="https://react.dev/errors/"+m;if(1se||(m.current=Q[se],Q[se]=null,se--)}function ie(m,g){se++,Q[se]=m.current,m.current=g}var Z=V(null),fe=V(null),he=V(null),ye=V(null);function Fe(m,g){switch(ie(he,g),ie(fe,m),ie(Z,null),g.nodeType){case 9:case 11:m=(m=g.documentElement)&&(m=m.namespaceURI)?zz(m):0;break;default:if(m=g.tagName,g=g.namespaceURI)g=zz(g),m=Uz(g,m);else switch(m){case"svg":m=1;break;case"math":m=2;break;default:m=0}}W(Z),ie(Z,m)}function ue(){W(Z),W(fe),W(he)}function le(m){m.memoizedState!==null&&ie(ye,m);var g=Z.current,_=Uz(g,m.type);g!==_&&(ie(fe,m),ie(Z,_))}function Te(m){fe.current===m&&(W(Z),W(fe)),ye.current===m&&(W(ye),Fx._currentValue=X)}var re,Ie;function G(m){if(re===void 0)try{throw Error()}catch(_){var g=_.stack.trim().match(/\n( *(at )?)/);re=g&&g[1]||"",Ie=-1<_.stack.indexOf(` - at`)?" ()":-1<_.stack.indexOf("@")?"@unknown:0:0":""}return` -`+re+m+Ie}var Y=!1;function ce(m,g){if(!m||Y)return"";Y=!0;var _=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var M={DetermineComponentFrameRoot:function(){try{if(g){var kt=function(){throw Error()};if(Object.defineProperty(kt.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(kt,[])}catch(xt){var mt=xt}Reflect.construct(m,[],kt)}else{try{kt.call()}catch(xt){mt=xt}m.call(kt.prototype)}}else{try{throw Error()}catch(xt){mt=xt}(kt=m())&&typeof kt.catch=="function"&&kt.catch(function(){})}}catch(xt){if(xt&&mt&&typeof xt.stack=="string")return[xt.stack,mt.stack]}return[null,null]}};M.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var R=Object.getOwnPropertyDescriptor(M.DetermineComponentFrameRoot,"name");R&&R.configurable&&Object.defineProperty(M.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var O=M.DetermineComponentFrameRoot(),ne=O[0],Ne=O[1];if(ne&&Ne){var Be=ne.split(` -`),ut=Ne.split(` -`);for(R=M=0;MR||Be[M]!==ut[R]){var vt=` -`+Be[M].replace(" at new "," at ");return m.displayName&&vt.includes("")&&(vt=vt.replace("",m.displayName)),vt}while(1<=M&&0<=R);break}}}finally{Y=!1,Error.prepareStackTrace=_}return(_=m?m.displayName||m.name:"")?G(_):""}function B(m,g){switch(m.tag){case 26:case 27:case 5:return G(m.type);case 16:return G("Lazy");case 13:return m.child!==g&&g!==null?G("Suspense Fallback"):G("Suspense");case 19:return G("SuspenseList");case 0:case 15:return ce(m.type,!1);case 11:return ce(m.type.render,!1);case 1:return ce(m.type,!0);case 31:return G("Activity");default:return""}}function J(m){try{var g="",_=null;do g+=B(m,_),_=m,m=m.return;while(m);return g}catch(M){return` -Error generating stack: `+M.message+` -`+M.stack}}var te=Object.prototype.hasOwnProperty,de=t.unstable_scheduleCallback,me=t.unstable_cancelCallback,ee=t.unstable_shouldYield,z=t.unstable_requestPaint,F=t.unstable_now,K=t.unstable_getCurrentPriorityLevel,ve=t.unstable_ImmediatePriority,Se=t.unstable_UserBlockingPriority,Ce=t.unstable_NormalPriority,He=t.unstable_LowPriority,we=t.unstable_IdlePriority,$e=t.log,Qe=t.unstable_setDisableYieldValue,be=null,xe=null;function _e(m){if(typeof $e=="function"&&Qe(m),xe&&typeof xe.setStrictMode=="function")try{xe.setStrictMode(be,m)}catch{}}var Ue=Math.clz32?Math.clz32:Me,je=Math.log,Ve=Math.LN2;function Me(m){return m>>>=0,m===0?32:31-(je(m)/Ve|0)|0}var Ze=256,Pe=262144,Xe=4194304;function Re(m){var g=m&42;if(g!==0)return g;switch(m&-m){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return m&261888;case 262144:case 524288:case 1048576:case 2097152:return m&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return m&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return m}}function Le(m,g,_){var M=m.pendingLanes;if(M===0)return 0;var R=0,O=m.suspendedLanes,ne=m.pingedLanes;m=m.warmLanes;var Ne=M&134217727;return Ne!==0?(M=Ne&~O,M!==0?R=Re(M):(ne&=Ne,ne!==0?R=Re(ne):_||(_=Ne&~m,_!==0&&(R=Re(_))))):(Ne=M&~O,Ne!==0?R=Re(Ne):ne!==0?R=Re(ne):_||(_=M&~m,_!==0&&(R=Re(_)))),R===0?0:g!==0&&g!==R&&(g&O)===0&&(O=R&-R,_=g&-g,O>=_||O===32&&(_&4194048)!==0)?g:R}function ke(m,g){return(m.pendingLanes&~(m.suspendedLanes&~m.pingedLanes)&g)===0}function Je(m,g){switch(m){case 1:case 2:case 4:case 8:case 64:return g+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return g+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function yt(){var m=Xe;return Xe<<=1,(Xe&62914560)===0&&(Xe=4194304),m}function At(m){for(var g=[],_=0;31>_;_++)g.push(m);return g}function an(m,g){m.pendingLanes|=g,g!==268435456&&(m.suspendedLanes=0,m.pingedLanes=0,m.warmLanes=0)}function Pt(m,g,_,M,R,O){var ne=m.pendingLanes;m.pendingLanes=_,m.suspendedLanes=0,m.pingedLanes=0,m.warmLanes=0,m.expiredLanes&=_,m.entangledLanes&=_,m.errorRecoveryDisabledLanes&=_,m.shellSuspendCounter=0;var Ne=m.entanglements,Be=m.expirationTimes,ut=m.hiddenUpdates;for(_=ne&~_;0<_;){var vt=31-Ue(_),kt=1<"u")return null;try{return m.activeElement||m.body}catch{return m.body}}var Vr=/[\n"\\]/g;function Pn(m){return m.replace(Vr,function(g){return"\\"+g.charCodeAt(0).toString(16)+" "})}function _n(m,g,_,M,R,O,ne,Ne){m.name="",ne!=null&&typeof ne!="function"&&typeof ne!="symbol"&&typeof ne!="boolean"?m.type=ne:m.removeAttribute("type"),g!=null?ne==="number"?(g===0&&m.value===""||m.value!=g)&&(m.value=""+Mn(g)):m.value!==""+Mn(g)&&(m.value=""+Mn(g)):ne!=="submit"&&ne!=="reset"||m.removeAttribute("value"),g!=null?fi(m,ne,Mn(g)):_!=null?fi(m,ne,Mn(_)):M!=null&&m.removeAttribute("value"),R==null&&O!=null&&(m.defaultChecked=!!O),R!=null&&(m.checked=R&&typeof R!="function"&&typeof R!="symbol"),Ne!=null&&typeof Ne!="function"&&typeof Ne!="symbol"&&typeof Ne!="boolean"?m.name=""+Mn(Ne):m.removeAttribute("name")}function Hn(m,g,_,M,R,O,ne,Ne){if(O!=null&&typeof O!="function"&&typeof O!="symbol"&&typeof O!="boolean"&&(m.type=O),g!=null||_!=null){if(!(O!=="submit"&&O!=="reset"||g!=null)){fn(m);return}_=_!=null?""+Mn(_):"",g=g!=null?""+Mn(g):_,Ne||g===m.value||(m.value=g),m.defaultValue=g}M=M??R,M=typeof M!="function"&&typeof M!="symbol"&&!!M,m.checked=Ne?m.checked:!!M,m.defaultChecked=!!M,ne!=null&&typeof ne!="function"&&typeof ne!="symbol"&&typeof ne!="boolean"&&(m.name=ne),fn(m)}function fi(m,g,_){g==="number"&&$r(m.ownerDocument)===m||m.defaultValue===""+_||(m.defaultValue=""+_)}function hr(m,g,_,M){if(m=m.options,g){g={};for(var R=0;R<_.length;R++)g["$"+_[R]]=!0;for(_=0;_"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),kl=!1;if(Sa)try{var Cl={};Object.defineProperty(Cl,"passive",{get:function(){kl=!0}}),window.addEventListener("test",Cl,Cl),window.removeEventListener("test",Cl,Cl)}catch{kl=!1}var Ia=null,Si=null,rc=null;function Gr(){if(rc)return rc;var m,g=Si,_=g.length,M,R="value"in Ia?Ia.value:Ia.textContent,O=R.length;for(m=0;m<_&&g[m]===R[m];m++);var ne=_-m;for(M=1;M<=ne&&g[_-M]===R[O-M];M++);return rc=R.slice(m,1=nx),q4=" ",W4=!1;function K4(m,g){switch(m){case"keyup":return Oie.indexOf(g.keyCode)!==-1;case"keydown":return g.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function X4(m){return m=m.detail,typeof m=="object"&&"data"in m?m.data:null}var Zf=!1;function zie(m,g){switch(m){case"compositionend":return X4(g);case"keypress":return g.which!==32?null:(W4=!0,q4);case"textInput":return m=g.data,m===q4&&W4?null:m;default:return null}}function Uie(m,g){if(Zf)return m==="compositionend"||!gM&&K4(m,g)?(m=Gr(),rc=Si=Ia=null,Zf=!1,m):null;switch(m){case"paste":return null;case"keypress":if(!(g.ctrlKey||g.altKey||g.metaKey)||g.ctrlKey&&g.altKey){if(g.char&&1=g)return{node:_,offset:g-m};m=M}e:{for(;_;){if(_.nextSibling){_=_.nextSibling;break e}_=_.parentNode}_=void 0}_=rI(_)}}function aI(m,g){return m&&g?m===g?!0:m&&m.nodeType===3?!1:g&&g.nodeType===3?aI(m,g.parentNode):"contains"in m?m.contains(g):m.compareDocumentPosition?!!(m.compareDocumentPosition(g)&16):!1:!1}function sI(m){m=m!=null&&m.ownerDocument!=null&&m.ownerDocument.defaultView!=null?m.ownerDocument.defaultView:window;for(var g=$r(m.document);g instanceof m.HTMLIFrameElement;){try{var _=typeof g.contentWindow.location.href=="string"}catch{_=!1}if(_)m=g.contentWindow;else break;g=$r(m.document)}return g}function yM(m){var g=m&&m.nodeName&&m.nodeName.toLowerCase();return g&&(g==="input"&&(m.type==="text"||m.type==="search"||m.type==="tel"||m.type==="url"||m.type==="password")||g==="textarea"||m.contentEditable==="true")}var Kie=Sa&&"documentMode"in document&&11>=document.documentMode,Jf=null,vM=null,sx=null,wM=!1;function oI(m,g,_){var M=_.window===_?_.document:_.nodeType===9?_:_.ownerDocument;wM||Jf==null||Jf!==$r(M)||(M=Jf,"selectionStart"in M&&yM(M)?M={start:M.selectionStart,end:M.selectionEnd}:(M=(M.ownerDocument&&M.ownerDocument.defaultView||window).getSelection(),M={anchorNode:M.anchorNode,anchorOffset:M.anchorOffset,focusNode:M.focusNode,focusOffset:M.focusOffset}),sx&&ax(sx,M)||(sx=M,M=Pw(vM,"onSelect"),0>=ne,R-=ne,ac=1<<32-Ue(g)+R|_<Tn?(Qn=Yt,Yt=null):Qn=Yt.sibling;var lr=mt(tt,Yt,ct[Tn],St);if(lr===null){Yt===null&&(Yt=Qn);break}m&&Yt&&lr.alternate===null&&g(tt,Yt),qe=O(lr,qe,Tn),or===null?nn=lr:or.sibling=lr,or=lr,Yt=Qn}if(Tn===ct.length)return _(tt,Yt),er&&qc(tt,Tn),nn;if(Yt===null){for(;TnTn?(Qn=Yt,Yt=null):Qn=Yt.sibling;var Id=mt(tt,Yt,lr.value,St);if(Id===null){Yt===null&&(Yt=Qn);break}m&&Yt&&Id.alternate===null&&g(tt,Yt),qe=O(Id,qe,Tn),or===null?nn=Id:or.sibling=Id,or=Id,Yt=Qn}if(lr.done)return _(tt,Yt),er&&qc(tt,Tn),nn;if(Yt===null){for(;!lr.done;Tn++,lr=ct.next())lr=kt(tt,lr.value,St),lr!==null&&(qe=O(lr,qe,Tn),or===null?nn=lr:or.sibling=lr,or=lr);return er&&qc(tt,Tn),nn}for(Yt=M(Yt);!lr.done;Tn++,lr=ct.next())lr=xt(Yt,tt,Tn,lr.value,St),lr!==null&&(m&&lr.alternate!==null&&Yt.delete(lr.key===null?Tn:lr.key),qe=O(lr,qe,Tn),or===null?nn=lr:or.sibling=lr,or=lr);return m&&Yt.forEach(function(fse){return g(tt,fse)}),er&&qc(tt,Tn),nn}function Mr(tt,qe,ct,St){if(typeof ct=="object"&&ct!==null&&ct.type===v&&ct.key===null&&(ct=ct.props.children),typeof ct=="object"&&ct!==null){switch(ct.$$typeof){case f:e:{for(var nn=ct.key;qe!==null;){if(qe.key===nn){if(nn=ct.type,nn===v){if(qe.tag===7){_(tt,qe.sibling),St=R(qe,ct.props.children),St.return=tt,tt=St;break e}}else if(qe.elementType===nn||typeof nn=="object"&&nn!==null&&nn.$$typeof===j&&pm(nn)===qe.type){_(tt,qe.sibling),St=R(qe,ct.props),hx(St,ct),St.return=tt,tt=St;break e}_(tt,qe);break}else g(tt,qe);qe=qe.sibling}ct.type===v?(St=um(ct.props.children,tt.mode,St,ct.key),St.return=tt,tt=St):(St=Kv(ct.type,ct.key,ct.props,null,tt.mode,St),hx(St,ct),St.return=tt,tt=St)}return ne(tt);case x:e:{for(nn=ct.key;qe!==null;){if(qe.key===nn)if(qe.tag===4&&qe.stateNode.containerInfo===ct.containerInfo&&qe.stateNode.implementation===ct.implementation){_(tt,qe.sibling),St=R(qe,ct.children||[]),St.return=tt,tt=St;break e}else{_(tt,qe);break}else g(tt,qe);qe=qe.sibling}St=AM(ct,tt.mode,St),St.return=tt,tt=St}return ne(tt);case j:return ct=pm(ct),Mr(tt,qe,ct,St)}if($(ct))return Wt(tt,qe,ct,St);if(L(ct)){if(nn=L(ct),typeof nn!="function")throw Error(r(150));return ct=nn.call(ct),dn(tt,qe,ct,St)}if(typeof ct.then=="function")return Mr(tt,qe,tw(ct),St);if(ct.$$typeof===N)return Mr(tt,qe,Qv(tt,ct),St);nw(tt,ct)}return typeof ct=="string"&&ct!==""||typeof ct=="number"||typeof ct=="bigint"?(ct=""+ct,qe!==null&&qe.tag===6?(_(tt,qe.sibling),St=R(qe,ct),St.return=tt,tt=St):(_(tt,qe),St=MM(ct,tt.mode,St),St.return=tt,tt=St),ne(tt)):_(tt,qe)}return function(tt,qe,ct,St){try{dx=0;var nn=Mr(tt,qe,ct,St);return up=null,nn}catch(Yt){if(Yt===cp||Yt===Jv)throw Yt;var or=lo(29,Yt,null,tt.mode);return or.lanes=St,or.return=tt,or}finally{}}}var bm=EI(!0),PI=EI(!1),wd=!1;function UM(m){m.updateQueue={baseState:m.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function BM(m,g){m=m.updateQueue,g.updateQueue===m&&(g.updateQueue={baseState:m.baseState,firstBaseUpdate:m.firstBaseUpdate,lastBaseUpdate:m.lastBaseUpdate,shared:m.shared,callbacks:null})}function _d(m){return{lane:m,tag:0,payload:null,callback:null,next:null}}function Sd(m,g,_){var M=m.updateQueue;if(M===null)return null;if(M=M.shared,(mr&2)!==0){var R=M.pending;return R===null?g.next=g:(g.next=R.next,R.next=g),M.pending=g,g=Wv(m),fI(m,null,_),g}return qv(m,M,g,_),Wv(m)}function mx(m,g,_){if(g=g.updateQueue,g!==null&&(g=g.shared,(_&4194048)!==0)){var M=g.lanes;M&=m.pendingLanes,_|=M,g.lanes=_,gt(m,_)}}function HM(m,g){var _=m.updateQueue,M=m.alternate;if(M!==null&&(M=M.updateQueue,_===M)){var R=null,O=null;if(_=_.firstBaseUpdate,_!==null){do{var ne={lane:_.lane,tag:_.tag,payload:_.payload,callback:null,next:null};O===null?R=O=ne:O=O.next=ne,_=_.next}while(_!==null);O===null?R=O=g:O=O.next=g}else R=O=g;_={baseState:M.baseState,firstBaseUpdate:R,lastBaseUpdate:O,shared:M.shared,callbacks:M.callbacks},m.updateQueue=_;return}m=_.lastBaseUpdate,m===null?_.firstBaseUpdate=g:m.next=g,_.lastBaseUpdate=g}var $M=!1;function fx(){if($M){var m=lp;if(m!==null)throw m}}function px(m,g,_,M){$M=!1;var R=m.updateQueue;wd=!1;var O=R.firstBaseUpdate,ne=R.lastBaseUpdate,Ne=R.shared.pending;if(Ne!==null){R.shared.pending=null;var Be=Ne,ut=Be.next;Be.next=null,ne===null?O=ut:ne.next=ut,ne=Be;var vt=m.alternate;vt!==null&&(vt=vt.updateQueue,Ne=vt.lastBaseUpdate,Ne!==ne&&(Ne===null?vt.firstBaseUpdate=ut:Ne.next=ut,vt.lastBaseUpdate=Be))}if(O!==null){var kt=R.baseState;ne=0,vt=ut=Be=null,Ne=O;do{var mt=Ne.lane&-536870913,xt=mt!==Ne.lane;if(xt?(Yn&mt)===mt:(M&mt)===mt){mt!==0&&mt===op&&($M=!0),vt!==null&&(vt=vt.next={lane:0,tag:Ne.tag,payload:Ne.payload,callback:null,next:null});e:{var Wt=m,dn=Ne;mt=g;var Mr=_;switch(dn.tag){case 1:if(Wt=dn.payload,typeof Wt=="function"){kt=Wt.call(Mr,kt,mt);break e}kt=Wt;break e;case 3:Wt.flags=Wt.flags&-65537|128;case 0:if(Wt=dn.payload,mt=typeof Wt=="function"?Wt.call(Mr,kt,mt):Wt,mt==null)break e;kt=h({},kt,mt);break e;case 2:wd=!0}}mt=Ne.callback,mt!==null&&(m.flags|=64,xt&&(m.flags|=8192),xt=R.callbacks,xt===null?R.callbacks=[mt]:xt.push(mt))}else xt={lane:mt,tag:Ne.tag,payload:Ne.payload,callback:Ne.callback,next:null},vt===null?(ut=vt=xt,Be=kt):vt=vt.next=xt,ne|=mt;if(Ne=Ne.next,Ne===null){if(Ne=R.shared.pending,Ne===null)break;xt=Ne,Ne=xt.next,xt.next=null,R.lastBaseUpdate=xt,R.shared.pending=null}}while(!0);vt===null&&(Be=kt),R.baseState=Be,R.firstBaseUpdate=ut,R.lastBaseUpdate=vt,O===null&&(R.shared.lanes=0),Ad|=ne,m.lanes=ne,m.memoizedState=kt}}function jI(m,g){if(typeof m!="function")throw Error(r(191,m));m.call(g)}function DI(m,g){var _=m.callbacks;if(_!==null)for(m.callbacks=null,m=0;m<_.length;m++)jI(_[m],g)}var dp=V(null),rw=V(0);function RI(m,g){m=ru,ie(rw,m),ie(dp,g),ru=m|g.baseLanes}function VM(){ie(rw,ru),ie(dp,dp.current)}function GM(){ru=rw.current,W(dp),W(rw)}var co=V(null),$o=null;function Nd(m){var g=m.alternate;ie(Fi,Fi.current&1),ie(co,m),$o===null&&(g===null||dp.current!==null||g.memoizedState!==null)&&($o=m)}function qM(m){ie(Fi,Fi.current),ie(co,m),$o===null&&($o=m)}function FI(m){m.tag===22?(ie(Fi,Fi.current),ie(co,m),$o===null&&($o=m)):kd()}function kd(){ie(Fi,Fi.current),ie(co,co.current)}function uo(m){W(co),$o===m&&($o=null),W(Fi)}var Fi=V(0);function iw(m){for(var g=m;g!==null;){if(g.tag===13){var _=g.memoizedState;if(_!==null&&(_=_.dehydrated,_===null||ZA(_)||JA(_)))return g}else if(g.tag===19&&(g.memoizedProps.revealOrder==="forwards"||g.memoizedProps.revealOrder==="backwards"||g.memoizedProps.revealOrder==="unstable_legacy-backwards"||g.memoizedProps.revealOrder==="together")){if((g.flags&128)!==0)return g}else if(g.child!==null){g.child.return=g,g=g.child;continue}if(g===m)break;for(;g.sibling===null;){if(g.return===null||g.return===m)return null;g=g.return}g.sibling.return=g.return,g=g.sibling}return null}var Xc=0,An=null,kr=null,Vi=null,aw=!1,hp=!1,xm=!1,sw=0,gx=0,mp=null,iae=0;function ki(){throw Error(r(321))}function WM(m,g){if(g===null)return!1;for(var _=0;_O?O:8;var ne=q.T,Ne={};q.T=Ne,lA(m,!1,g,_);try{var Be=R(),ut=q.S;if(ut!==null&&ut(Ne,Be),Be!==null&&typeof Be=="object"&&typeof Be.then=="function"){var vt=rae(Be,M);xx(m,g,vt,fo(m))}else xx(m,g,M,fo(m))}catch(kt){xx(m,g,{then:function(){},status:"rejected",reason:kt},fo())}finally{I.p=O,ne!==null&&Ne.types!==null&&(ne.types=Ne.types),q.T=ne}}function cae(){}function sA(m,g,_,M){if(m.tag!==5)throw Error(r(476));var R=d5(m).queue;u5(m,R,g,X,_===null?cae:function(){return h5(m),_(M)})}function d5(m){var g=m.memoizedState;if(g!==null)return g;g={memoizedState:X,baseState:X,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Yc,lastRenderedState:X},next:null};var _={};return g.next={memoizedState:_,baseState:_,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Yc,lastRenderedState:_},next:null},m.memoizedState=g,m=m.alternate,m!==null&&(m.memoizedState=g),g}function h5(m){var g=d5(m);g.next===null&&(g=m.alternate.memoizedState),xx(m,g.next.queue,{},fo())}function oA(){return Ua(Fx)}function m5(){return Li().memoizedState}function f5(){return Li().memoizedState}function uae(m){for(var g=m.return;g!==null;){switch(g.tag){case 24:case 3:var _=fo();m=_d(_);var M=Sd(g,m,_);M!==null&&(Hs(M,g,_),mx(M,g,_)),g={cache:LM()},m.payload=g;return}g=g.return}}function dae(m,g,_){var M=fo();_={lane:M,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null},hw(m)?g5(g,_):(_=kM(m,g,_,M),_!==null&&(Hs(_,m,M),b5(_,g,M)))}function p5(m,g,_){var M=fo();xx(m,g,_,M)}function xx(m,g,_,M){var R={lane:M,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null};if(hw(m))g5(g,R);else{var O=m.alternate;if(m.lanes===0&&(O===null||O.lanes===0)&&(O=g.lastRenderedReducer,O!==null))try{var ne=g.lastRenderedState,Ne=O(ne,_);if(R.hasEagerState=!0,R.eagerState=Ne,oo(Ne,ne))return qv(m,g,R,0),Or===null&&Gv(),!1}catch{}finally{}if(_=kM(m,g,R,M),_!==null)return Hs(_,m,M),b5(_,g,M),!0}return!1}function lA(m,g,_,M){if(M={lane:2,revertLane:UA(),gesture:null,action:M,hasEagerState:!1,eagerState:null,next:null},hw(m)){if(g)throw Error(r(479))}else g=kM(m,_,M,2),g!==null&&Hs(g,m,2)}function hw(m){var g=m.alternate;return m===An||g!==null&&g===An}function g5(m,g){hp=aw=!0;var _=m.pending;_===null?g.next=g:(g.next=_.next,_.next=g),m.pending=g}function b5(m,g,_){if((_&4194048)!==0){var M=g.lanes;M&=m.pendingLanes,_|=M,g.lanes=_,gt(m,_)}}var yx={readContext:Ua,use:lw,useCallback:ki,useContext:ki,useEffect:ki,useImperativeHandle:ki,useLayoutEffect:ki,useInsertionEffect:ki,useMemo:ki,useReducer:ki,useRef:ki,useState:ki,useDebugValue:ki,useDeferredValue:ki,useTransition:ki,useSyncExternalStore:ki,useId:ki,useHostTransitionStatus:ki,useFormState:ki,useActionState:ki,useOptimistic:ki,useMemoCache:ki,useCacheRefresh:ki};yx.useEffectEvent=ki;var x5={readContext:Ua,use:lw,useCallback:function(m,g){return ws().memoizedState=[m,g===void 0?null:g],m},useContext:Ua,useEffect:t5,useImperativeHandle:function(m,g,_){_=_!=null?_.concat([m]):null,uw(4194308,4,a5.bind(null,g,m),_)},useLayoutEffect:function(m,g){return uw(4194308,4,m,g)},useInsertionEffect:function(m,g){uw(4,2,m,g)},useMemo:function(m,g){var _=ws();g=g===void 0?null:g;var M=m();if(xm){_e(!0);try{m()}finally{_e(!1)}}return _.memoizedState=[M,g],M},useReducer:function(m,g,_){var M=ws();if(_!==void 0){var R=_(g);if(xm){_e(!0);try{_(g)}finally{_e(!1)}}}else R=g;return M.memoizedState=M.baseState=R,m={pending:null,lanes:0,dispatch:null,lastRenderedReducer:m,lastRenderedState:R},M.queue=m,m=m.dispatch=dae.bind(null,An,m),[M.memoizedState,m]},useRef:function(m){var g=ws();return m={current:m},g.memoizedState=m},useState:function(m){m=tA(m);var g=m.queue,_=p5.bind(null,An,g);return g.dispatch=_,[m.memoizedState,_]},useDebugValue:iA,useDeferredValue:function(m,g){var _=ws();return aA(_,m,g)},useTransition:function(){var m=tA(!1);return m=u5.bind(null,An,m.queue,!0,!1),ws().memoizedState=m,[!1,m]},useSyncExternalStore:function(m,g,_){var M=An,R=ws();if(er){if(_===void 0)throw Error(r(407));_=_()}else{if(_=g(),Or===null)throw Error(r(349));(Yn&127)!==0||zI(M,g,_)}R.memoizedState=_;var O={value:_,getSnapshot:g};return R.queue=O,t5(BI.bind(null,M,O,m),[m]),M.flags|=2048,fp(9,{destroy:void 0},UI.bind(null,M,O,_,g),null),_},useId:function(){var m=ws(),g=Or.identifierPrefix;if(er){var _=sc,M=ac;_=(M&~(1<<32-Ue(M)-1)).toString(32)+_,g="_"+g+"R_"+_,_=sw++,0<_&&(g+="H"+_.toString(32)),g+="_"}else _=iae++,g="_"+g+"r_"+_.toString(32)+"_";return m.memoizedState=g},useHostTransitionStatus:oA,useFormState:YI,useActionState:YI,useOptimistic:function(m){var g=ws();g.memoizedState=g.baseState=m;var _={pending:null,lanes:0,dispatch:null,lastRenderedReducer:null,lastRenderedState:null};return g.queue=_,g=lA.bind(null,An,!0,_),_.dispatch=g,[m,g]},useMemoCache:ZM,useCacheRefresh:function(){return ws().memoizedState=uae.bind(null,An)},useEffectEvent:function(m){var g=ws(),_={impl:m};return g.memoizedState=_,function(){if((mr&2)!==0)throw Error(r(440));return _.impl.apply(void 0,arguments)}}},cA={readContext:Ua,use:lw,useCallback:o5,useContext:Ua,useEffect:rA,useImperativeHandle:s5,useInsertionEffect:r5,useLayoutEffect:i5,useMemo:l5,useReducer:cw,useRef:e5,useState:function(){return cw(Yc)},useDebugValue:iA,useDeferredValue:function(m,g){var _=Li();return c5(_,kr.memoizedState,m,g)},useTransition:function(){var m=cw(Yc)[0],g=Li().memoizedState;return[typeof m=="boolean"?m:bx(m),g]},useSyncExternalStore:II,useId:m5,useHostTransitionStatus:oA,useFormState:QI,useActionState:QI,useOptimistic:function(m,g){var _=Li();return VI(_,kr,m,g)},useMemoCache:ZM,useCacheRefresh:f5};cA.useEffectEvent=n5;var y5={readContext:Ua,use:lw,useCallback:o5,useContext:Ua,useEffect:rA,useImperativeHandle:s5,useInsertionEffect:r5,useLayoutEffect:i5,useMemo:l5,useReducer:eA,useRef:e5,useState:function(){return eA(Yc)},useDebugValue:iA,useDeferredValue:function(m,g){var _=Li();return kr===null?aA(_,m,g):c5(_,kr.memoizedState,m,g)},useTransition:function(){var m=eA(Yc)[0],g=Li().memoizedState;return[typeof m=="boolean"?m:bx(m),g]},useSyncExternalStore:II,useId:m5,useHostTransitionStatus:oA,useFormState:JI,useActionState:JI,useOptimistic:function(m,g){var _=Li();return kr!==null?VI(_,kr,m,g):(_.baseState=m,[m,_.queue.dispatch])},useMemoCache:ZM,useCacheRefresh:f5};y5.useEffectEvent=n5;function uA(m,g,_,M){g=m.memoizedState,_=_(M,g),_=_==null?g:h({},g,_),m.memoizedState=_,m.lanes===0&&(m.updateQueue.baseState=_)}var dA={enqueueSetState:function(m,g,_){m=m._reactInternals;var M=fo(),R=_d(M);R.payload=g,_!=null&&(R.callback=_),g=Sd(m,R,M),g!==null&&(Hs(g,m,M),mx(g,m,M))},enqueueReplaceState:function(m,g,_){m=m._reactInternals;var M=fo(),R=_d(M);R.tag=1,R.payload=g,_!=null&&(R.callback=_),g=Sd(m,R,M),g!==null&&(Hs(g,m,M),mx(g,m,M))},enqueueForceUpdate:function(m,g){m=m._reactInternals;var _=fo(),M=_d(_);M.tag=2,g!=null&&(M.callback=g),g=Sd(m,M,_),g!==null&&(Hs(g,m,_),mx(g,m,_))}};function v5(m,g,_,M,R,O,ne){return m=m.stateNode,typeof m.shouldComponentUpdate=="function"?m.shouldComponentUpdate(M,O,ne):g.prototype&&g.prototype.isPureReactComponent?!ax(_,M)||!ax(R,O):!0}function w5(m,g,_,M){m=g.state,typeof g.componentWillReceiveProps=="function"&&g.componentWillReceiveProps(_,M),typeof g.UNSAFE_componentWillReceiveProps=="function"&&g.UNSAFE_componentWillReceiveProps(_,M),g.state!==m&&dA.enqueueReplaceState(g,g.state,null)}function ym(m,g){var _=g;if("ref"in g){_={};for(var M in g)M!=="ref"&&(_[M]=g[M])}if(m=m.defaultProps){_===g&&(_=h({},_));for(var R in m)_[R]===void 0&&(_[R]=m[R])}return _}function _5(m){Vv(m)}function S5(m){console.error(m)}function N5(m){Vv(m)}function mw(m,g){try{var _=m.onUncaughtError;_(g.value,{componentStack:g.stack})}catch(M){setTimeout(function(){throw M})}}function k5(m,g,_){try{var M=m.onCaughtError;M(_.value,{componentStack:_.stack,errorBoundary:g.tag===1?g.stateNode:null})}catch(R){setTimeout(function(){throw R})}}function hA(m,g,_){return _=_d(_),_.tag=3,_.payload={element:null},_.callback=function(){mw(m,g)},_}function C5(m){return m=_d(m),m.tag=3,m}function M5(m,g,_,M){var R=_.type.getDerivedStateFromError;if(typeof R=="function"){var O=M.value;m.payload=function(){return R(O)},m.callback=function(){k5(g,_,M)}}var ne=_.stateNode;ne!==null&&typeof ne.componentDidCatch=="function"&&(m.callback=function(){k5(g,_,M),typeof R!="function"&&(Td===null?Td=new Set([this]):Td.add(this));var Ne=M.stack;this.componentDidCatch(M.value,{componentStack:Ne!==null?Ne:""})})}function hae(m,g,_,M,R){if(_.flags|=32768,M!==null&&typeof M=="object"&&typeof M.then=="function"){if(g=_.alternate,g!==null&&sp(g,_,R,!0),_=co.current,_!==null){switch(_.tag){case 31:case 13:return $o===null?kw():_.alternate===null&&Ci===0&&(Ci=3),_.flags&=-257,_.flags|=65536,_.lanes=R,M===ew?_.flags|=16384:(g=_.updateQueue,g===null?_.updateQueue=new Set([M]):g.add(M),OA(m,M,R)),!1;case 22:return _.flags|=65536,M===ew?_.flags|=16384:(g=_.updateQueue,g===null?(g={transitions:null,markerInstances:null,retryQueue:new Set([M])},_.updateQueue=g):(_=g.retryQueue,_===null?g.retryQueue=new Set([M]):_.add(M)),OA(m,M,R)),!1}throw Error(r(435,_.tag))}return OA(m,M,R),kw(),!1}if(er)return g=co.current,g!==null?((g.flags&65536)===0&&(g.flags|=256),g.flags|=65536,g.lanes=R,M!==PM&&(m=Error(r(422),{cause:M}),lx(zo(m,_)))):(M!==PM&&(g=Error(r(423),{cause:M}),lx(zo(g,_))),m=m.current.alternate,m.flags|=65536,R&=-R,m.lanes|=R,M=zo(M,_),R=hA(m.stateNode,M,R),HM(m,R),Ci!==4&&(Ci=2)),!1;var O=Error(r(520),{cause:M});if(O=zo(O,_),Mx===null?Mx=[O]:Mx.push(O),Ci!==4&&(Ci=2),g===null)return!0;M=zo(M,_),_=g;do{switch(_.tag){case 3:return _.flags|=65536,m=R&-R,_.lanes|=m,m=hA(_.stateNode,M,m),HM(_,m),!1;case 1:if(g=_.type,O=_.stateNode,(_.flags&128)===0&&(typeof g.getDerivedStateFromError=="function"||O!==null&&typeof O.componentDidCatch=="function"&&(Td===null||!Td.has(O))))return _.flags|=65536,R&=-R,_.lanes|=R,R=C5(R),M5(R,m,_,M),HM(_,R),!1}_=_.return}while(_!==null);return!1}var mA=Error(r(461)),Gi=!1;function Ba(m,g,_,M){g.child=m===null?PI(g,null,_,M):bm(g,m.child,_,M)}function A5(m,g,_,M,R){_=_.render;var O=g.ref;if("ref"in M){var ne={};for(var Ne in M)Ne!=="ref"&&(ne[Ne]=M[Ne])}else ne=M;return mm(g),M=KM(m,g,_,ne,O,R),Ne=XM(),m!==null&&!Gi?(YM(m,g,R),Qc(m,g,R)):(er&&Ne&&TM(g),g.flags|=1,Ba(m,g,M,R),g.child)}function T5(m,g,_,M,R){if(m===null){var O=_.type;return typeof O=="function"&&!CM(O)&&O.defaultProps===void 0&&_.compare===null?(g.tag=15,g.type=O,E5(m,g,O,M,R)):(m=Kv(_.type,null,M,g,g.mode,R),m.ref=g.ref,m.return=g,g.child=m)}if(O=m.child,!wA(m,R)){var ne=O.memoizedProps;if(_=_.compare,_=_!==null?_:ax,_(ne,M)&&m.ref===g.ref)return Qc(m,g,R)}return g.flags|=1,m=Gc(O,M),m.ref=g.ref,m.return=g,g.child=m}function E5(m,g,_,M,R){if(m!==null){var O=m.memoizedProps;if(ax(O,M)&&m.ref===g.ref)if(Gi=!1,g.pendingProps=M=O,wA(m,R))(m.flags&131072)!==0&&(Gi=!0);else return g.lanes=m.lanes,Qc(m,g,R)}return fA(m,g,_,M,R)}function P5(m,g,_,M){var R=M.children,O=m!==null?m.memoizedState:null;if(m===null&&g.stateNode===null&&(g.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),M.mode==="hidden"){if((g.flags&128)!==0){if(O=O!==null?O.baseLanes|_:_,m!==null){for(M=g.child=m.child,R=0;M!==null;)R=R|M.lanes|M.childLanes,M=M.sibling;M=R&~O}else M=0,g.child=null;return j5(m,g,O,_,M)}if((_&536870912)!==0)g.memoizedState={baseLanes:0,cachePool:null},m!==null&&Zv(g,O!==null?O.cachePool:null),O!==null?RI(g,O):VM(),FI(g);else return M=g.lanes=536870912,j5(m,g,O!==null?O.baseLanes|_:_,_,M)}else O!==null?(Zv(g,O.cachePool),RI(g,O),kd(),g.memoizedState=null):(m!==null&&Zv(g,null),VM(),kd());return Ba(m,g,R,_),g.child}function vx(m,g){return m!==null&&m.tag===22||g.stateNode!==null||(g.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),g.sibling}function j5(m,g,_,M,R){var O=IM();return O=O===null?null:{parent:$i._currentValue,pool:O},g.memoizedState={baseLanes:_,cachePool:O},m!==null&&Zv(g,null),VM(),FI(g),m!==null&&sp(m,g,M,!0),g.childLanes=R,null}function fw(m,g){return g=gw({mode:g.mode,children:g.children},m.mode),g.ref=m.ref,m.child=g,g.return=m,g}function D5(m,g,_){return bm(g,m.child,null,_),m=fw(g,g.pendingProps),m.flags|=2,uo(g),g.memoizedState=null,m}function mae(m,g,_){var M=g.pendingProps,R=(g.flags&128)!==0;if(g.flags&=-129,m===null){if(er){if(M.mode==="hidden")return m=fw(g,M),g.lanes=536870912,vx(null,m);if(qM(g),(m=Zr)?(m=Gz(m,Ho),m=m!==null&&m.data==="&"?m:null,m!==null&&(g.memoizedState={dehydrated:m,treeContext:bd!==null?{id:ac,overflow:sc}:null,retryLane:536870912,hydrationErrors:null},_=gI(m),_.return=g,g.child=_,za=g,Zr=null)):m=null,m===null)throw yd(g);return g.lanes=536870912,null}return fw(g,M)}var O=m.memoizedState;if(O!==null){var ne=O.dehydrated;if(qM(g),R)if(g.flags&256)g.flags&=-257,g=D5(m,g,_);else if(g.memoizedState!==null)g.child=m.child,g.flags|=128,g=null;else throw Error(r(558));else if(Gi||sp(m,g,_,!1),R=(_&m.childLanes)!==0,Gi||R){if(M=Or,M!==null&&(ne=Qt(M,_),ne!==0&&ne!==O.retryLane))throw O.retryLane=ne,cm(m,ne),Hs(M,m,ne),mA;kw(),g=D5(m,g,_)}else m=O.treeContext,Zr=Vo(ne.nextSibling),za=g,er=!0,xd=null,Ho=!1,m!==null&&yI(g,m),g=fw(g,M),g.flags|=4096;return g}return m=Gc(m.child,{mode:M.mode,children:M.children}),m.ref=g.ref,g.child=m,m.return=g,m}function pw(m,g){var _=g.ref;if(_===null)m!==null&&m.ref!==null&&(g.flags|=4194816);else{if(typeof _!="function"&&typeof _!="object")throw Error(r(284));(m===null||m.ref!==_)&&(g.flags|=4194816)}}function fA(m,g,_,M,R){return mm(g),_=KM(m,g,_,M,void 0,R),M=XM(),m!==null&&!Gi?(YM(m,g,R),Qc(m,g,R)):(er&&M&&TM(g),g.flags|=1,Ba(m,g,_,R),g.child)}function R5(m,g,_,M,R,O){return mm(g),g.updateQueue=null,_=OI(g,M,_,R),LI(m),M=XM(),m!==null&&!Gi?(YM(m,g,O),Qc(m,g,O)):(er&&M&&TM(g),g.flags|=1,Ba(m,g,_,O),g.child)}function F5(m,g,_,M,R){if(mm(g),g.stateNode===null){var O=np,ne=_.contextType;typeof ne=="object"&&ne!==null&&(O=Ua(ne)),O=new _(M,O),g.memoizedState=O.state!==null&&O.state!==void 0?O.state:null,O.updater=dA,g.stateNode=O,O._reactInternals=g,O=g.stateNode,O.props=M,O.state=g.memoizedState,O.refs={},UM(g),ne=_.contextType,O.context=typeof ne=="object"&&ne!==null?Ua(ne):np,O.state=g.memoizedState,ne=_.getDerivedStateFromProps,typeof ne=="function"&&(uA(g,_,ne,M),O.state=g.memoizedState),typeof _.getDerivedStateFromProps=="function"||typeof O.getSnapshotBeforeUpdate=="function"||typeof O.UNSAFE_componentWillMount!="function"&&typeof O.componentWillMount!="function"||(ne=O.state,typeof O.componentWillMount=="function"&&O.componentWillMount(),typeof O.UNSAFE_componentWillMount=="function"&&O.UNSAFE_componentWillMount(),ne!==O.state&&dA.enqueueReplaceState(O,O.state,null),px(g,M,O,R),fx(),O.state=g.memoizedState),typeof O.componentDidMount=="function"&&(g.flags|=4194308),M=!0}else if(m===null){O=g.stateNode;var Ne=g.memoizedProps,Be=ym(_,Ne);O.props=Be;var ut=O.context,vt=_.contextType;ne=np,typeof vt=="object"&&vt!==null&&(ne=Ua(vt));var kt=_.getDerivedStateFromProps;vt=typeof kt=="function"||typeof O.getSnapshotBeforeUpdate=="function",Ne=g.pendingProps!==Ne,vt||typeof O.UNSAFE_componentWillReceiveProps!="function"&&typeof O.componentWillReceiveProps!="function"||(Ne||ut!==ne)&&w5(g,O,M,ne),wd=!1;var mt=g.memoizedState;O.state=mt,px(g,M,O,R),fx(),ut=g.memoizedState,Ne||mt!==ut||wd?(typeof kt=="function"&&(uA(g,_,kt,M),ut=g.memoizedState),(Be=wd||v5(g,_,Be,M,mt,ut,ne))?(vt||typeof O.UNSAFE_componentWillMount!="function"&&typeof O.componentWillMount!="function"||(typeof O.componentWillMount=="function"&&O.componentWillMount(),typeof O.UNSAFE_componentWillMount=="function"&&O.UNSAFE_componentWillMount()),typeof O.componentDidMount=="function"&&(g.flags|=4194308)):(typeof O.componentDidMount=="function"&&(g.flags|=4194308),g.memoizedProps=M,g.memoizedState=ut),O.props=M,O.state=ut,O.context=ne,M=Be):(typeof O.componentDidMount=="function"&&(g.flags|=4194308),M=!1)}else{O=g.stateNode,BM(m,g),ne=g.memoizedProps,vt=ym(_,ne),O.props=vt,kt=g.pendingProps,mt=O.context,ut=_.contextType,Be=np,typeof ut=="object"&&ut!==null&&(Be=Ua(ut)),Ne=_.getDerivedStateFromProps,(ut=typeof Ne=="function"||typeof O.getSnapshotBeforeUpdate=="function")||typeof O.UNSAFE_componentWillReceiveProps!="function"&&typeof O.componentWillReceiveProps!="function"||(ne!==kt||mt!==Be)&&w5(g,O,M,Be),wd=!1,mt=g.memoizedState,O.state=mt,px(g,M,O,R),fx();var xt=g.memoizedState;ne!==kt||mt!==xt||wd||m!==null&&m.dependencies!==null&&Yv(m.dependencies)?(typeof Ne=="function"&&(uA(g,_,Ne,M),xt=g.memoizedState),(vt=wd||v5(g,_,vt,M,mt,xt,Be)||m!==null&&m.dependencies!==null&&Yv(m.dependencies))?(ut||typeof O.UNSAFE_componentWillUpdate!="function"&&typeof O.componentWillUpdate!="function"||(typeof O.componentWillUpdate=="function"&&O.componentWillUpdate(M,xt,Be),typeof O.UNSAFE_componentWillUpdate=="function"&&O.UNSAFE_componentWillUpdate(M,xt,Be)),typeof O.componentDidUpdate=="function"&&(g.flags|=4),typeof O.getSnapshotBeforeUpdate=="function"&&(g.flags|=1024)):(typeof O.componentDidUpdate!="function"||ne===m.memoizedProps&&mt===m.memoizedState||(g.flags|=4),typeof O.getSnapshotBeforeUpdate!="function"||ne===m.memoizedProps&&mt===m.memoizedState||(g.flags|=1024),g.memoizedProps=M,g.memoizedState=xt),O.props=M,O.state=xt,O.context=Be,M=vt):(typeof O.componentDidUpdate!="function"||ne===m.memoizedProps&&mt===m.memoizedState||(g.flags|=4),typeof O.getSnapshotBeforeUpdate!="function"||ne===m.memoizedProps&&mt===m.memoizedState||(g.flags|=1024),M=!1)}return O=M,pw(m,g),M=(g.flags&128)!==0,O||M?(O=g.stateNode,_=M&&typeof _.getDerivedStateFromError!="function"?null:O.render(),g.flags|=1,m!==null&&M?(g.child=bm(g,m.child,null,R),g.child=bm(g,null,_,R)):Ba(m,g,_,R),g.memoizedState=O.state,m=g.child):m=Qc(m,g,R),m}function L5(m,g,_,M){return dm(),g.flags|=256,Ba(m,g,_,M),g.child}var pA={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function gA(m){return{baseLanes:m,cachePool:kI()}}function bA(m,g,_){return m=m!==null?m.childLanes&~_:0,g&&(m|=mo),m}function O5(m,g,_){var M=g.pendingProps,R=!1,O=(g.flags&128)!==0,ne;if((ne=O)||(ne=m!==null&&m.memoizedState===null?!1:(Fi.current&2)!==0),ne&&(R=!0,g.flags&=-129),ne=(g.flags&32)!==0,g.flags&=-33,m===null){if(er){if(R?Nd(g):kd(),(m=Zr)?(m=Gz(m,Ho),m=m!==null&&m.data!=="&"?m:null,m!==null&&(g.memoizedState={dehydrated:m,treeContext:bd!==null?{id:ac,overflow:sc}:null,retryLane:536870912,hydrationErrors:null},_=gI(m),_.return=g,g.child=_,za=g,Zr=null)):m=null,m===null)throw yd(g);return JA(m)?g.lanes=32:g.lanes=536870912,null}var Ne=M.children;return M=M.fallback,R?(kd(),R=g.mode,Ne=gw({mode:"hidden",children:Ne},R),M=um(M,R,_,null),Ne.return=g,M.return=g,Ne.sibling=M,g.child=Ne,M=g.child,M.memoizedState=gA(_),M.childLanes=bA(m,ne,_),g.memoizedState=pA,vx(null,M)):(Nd(g),xA(g,Ne))}var Be=m.memoizedState;if(Be!==null&&(Ne=Be.dehydrated,Ne!==null)){if(O)g.flags&256?(Nd(g),g.flags&=-257,g=yA(m,g,_)):g.memoizedState!==null?(kd(),g.child=m.child,g.flags|=128,g=null):(kd(),Ne=M.fallback,R=g.mode,M=gw({mode:"visible",children:M.children},R),Ne=um(Ne,R,_,null),Ne.flags|=2,M.return=g,Ne.return=g,M.sibling=Ne,g.child=M,bm(g,m.child,null,_),M=g.child,M.memoizedState=gA(_),M.childLanes=bA(m,ne,_),g.memoizedState=pA,g=vx(null,M));else if(Nd(g),JA(Ne)){if(ne=Ne.nextSibling&&Ne.nextSibling.dataset,ne)var ut=ne.dgst;ne=ut,M=Error(r(419)),M.stack="",M.digest=ne,lx({value:M,source:null,stack:null}),g=yA(m,g,_)}else if(Gi||sp(m,g,_,!1),ne=(_&m.childLanes)!==0,Gi||ne){if(ne=Or,ne!==null&&(M=Qt(ne,_),M!==0&&M!==Be.retryLane))throw Be.retryLane=M,cm(m,M),Hs(ne,m,M),mA;ZA(Ne)||kw(),g=yA(m,g,_)}else ZA(Ne)?(g.flags|=192,g.child=m.child,g=null):(m=Be.treeContext,Zr=Vo(Ne.nextSibling),za=g,er=!0,xd=null,Ho=!1,m!==null&&yI(g,m),g=xA(g,M.children),g.flags|=4096);return g}return R?(kd(),Ne=M.fallback,R=g.mode,Be=m.child,ut=Be.sibling,M=Gc(Be,{mode:"hidden",children:M.children}),M.subtreeFlags=Be.subtreeFlags&65011712,ut!==null?Ne=Gc(ut,Ne):(Ne=um(Ne,R,_,null),Ne.flags|=2),Ne.return=g,M.return=g,M.sibling=Ne,g.child=M,vx(null,M),M=g.child,Ne=m.child.memoizedState,Ne===null?Ne=gA(_):(R=Ne.cachePool,R!==null?(Be=$i._currentValue,R=R.parent!==Be?{parent:Be,pool:Be}:R):R=kI(),Ne={baseLanes:Ne.baseLanes|_,cachePool:R}),M.memoizedState=Ne,M.childLanes=bA(m,ne,_),g.memoizedState=pA,vx(m.child,M)):(Nd(g),_=m.child,m=_.sibling,_=Gc(_,{mode:"visible",children:M.children}),_.return=g,_.sibling=null,m!==null&&(ne=g.deletions,ne===null?(g.deletions=[m],g.flags|=16):ne.push(m)),g.child=_,g.memoizedState=null,_)}function xA(m,g){return g=gw({mode:"visible",children:g},m.mode),g.return=m,m.child=g}function gw(m,g){return m=lo(22,m,null,g),m.lanes=0,m}function yA(m,g,_){return bm(g,m.child,null,_),m=xA(g,g.pendingProps.children),m.flags|=2,g.memoizedState=null,m}function I5(m,g,_){m.lanes|=g;var M=m.alternate;M!==null&&(M.lanes|=g),RM(m.return,g,_)}function vA(m,g,_,M,R,O){var ne=m.memoizedState;ne===null?m.memoizedState={isBackwards:g,rendering:null,renderingStartTime:0,last:M,tail:_,tailMode:R,treeForkCount:O}:(ne.isBackwards=g,ne.rendering=null,ne.renderingStartTime=0,ne.last=M,ne.tail=_,ne.tailMode=R,ne.treeForkCount=O)}function z5(m,g,_){var M=g.pendingProps,R=M.revealOrder,O=M.tail;M=M.children;var ne=Fi.current,Ne=(ne&2)!==0;if(Ne?(ne=ne&1|2,g.flags|=128):ne&=1,ie(Fi,ne),Ba(m,g,M,_),M=er?ox:0,!Ne&&m!==null&&(m.flags&128)!==0)e:for(m=g.child;m!==null;){if(m.tag===13)m.memoizedState!==null&&I5(m,_,g);else if(m.tag===19)I5(m,_,g);else if(m.child!==null){m.child.return=m,m=m.child;continue}if(m===g)break e;for(;m.sibling===null;){if(m.return===null||m.return===g)break e;m=m.return}m.sibling.return=m.return,m=m.sibling}switch(R){case"forwards":for(_=g.child,R=null;_!==null;)m=_.alternate,m!==null&&iw(m)===null&&(R=_),_=_.sibling;_=R,_===null?(R=g.child,g.child=null):(R=_.sibling,_.sibling=null),vA(g,!1,R,_,O,M);break;case"backwards":case"unstable_legacy-backwards":for(_=null,R=g.child,g.child=null;R!==null;){if(m=R.alternate,m!==null&&iw(m)===null){g.child=R;break}m=R.sibling,R.sibling=_,_=R,R=m}vA(g,!0,_,null,O,M);break;case"together":vA(g,!1,null,null,void 0,M);break;default:g.memoizedState=null}return g.child}function Qc(m,g,_){if(m!==null&&(g.dependencies=m.dependencies),Ad|=g.lanes,(_&g.childLanes)===0)if(m!==null){if(sp(m,g,_,!1),(_&g.childLanes)===0)return null}else return null;if(m!==null&&g.child!==m.child)throw Error(r(153));if(g.child!==null){for(m=g.child,_=Gc(m,m.pendingProps),g.child=_,_.return=g;m.sibling!==null;)m=m.sibling,_=_.sibling=Gc(m,m.pendingProps),_.return=g;_.sibling=null}return g.child}function wA(m,g){return(m.lanes&g)!==0?!0:(m=m.dependencies,!!(m!==null&&Yv(m)))}function fae(m,g,_){switch(g.tag){case 3:Fe(g,g.stateNode.containerInfo),vd(g,$i,m.memoizedState.cache),dm();break;case 27:case 5:le(g);break;case 4:Fe(g,g.stateNode.containerInfo);break;case 10:vd(g,g.type,g.memoizedProps.value);break;case 31:if(g.memoizedState!==null)return g.flags|=128,qM(g),null;break;case 13:var M=g.memoizedState;if(M!==null)return M.dehydrated!==null?(Nd(g),g.flags|=128,null):(_&g.child.childLanes)!==0?O5(m,g,_):(Nd(g),m=Qc(m,g,_),m!==null?m.sibling:null);Nd(g);break;case 19:var R=(m.flags&128)!==0;if(M=(_&g.childLanes)!==0,M||(sp(m,g,_,!1),M=(_&g.childLanes)!==0),R){if(M)return z5(m,g,_);g.flags|=128}if(R=g.memoizedState,R!==null&&(R.rendering=null,R.tail=null,R.lastEffect=null),ie(Fi,Fi.current),M)break;return null;case 22:return g.lanes=0,P5(m,g,_,g.pendingProps);case 24:vd(g,$i,m.memoizedState.cache)}return Qc(m,g,_)}function U5(m,g,_){if(m!==null)if(m.memoizedProps!==g.pendingProps)Gi=!0;else{if(!wA(m,_)&&(g.flags&128)===0)return Gi=!1,fae(m,g,_);Gi=(m.flags&131072)!==0}else Gi=!1,er&&(g.flags&1048576)!==0&&xI(g,ox,g.index);switch(g.lanes=0,g.tag){case 16:e:{var M=g.pendingProps;if(m=pm(g.elementType),g.type=m,typeof m=="function")CM(m)?(M=ym(m,M),g.tag=1,g=F5(null,g,m,M,_)):(g.tag=0,g=fA(null,g,m,M,_));else{if(m!=null){var R=m.$$typeof;if(R===A){g.tag=11,g=A5(null,g,m,M,_);break e}else if(R===C){g.tag=14,g=T5(null,g,m,M,_);break e}}throw g=D(m)||m,Error(r(306,g,""))}}return g;case 0:return fA(m,g,g.type,g.pendingProps,_);case 1:return M=g.type,R=ym(M,g.pendingProps),F5(m,g,M,R,_);case 3:e:{if(Fe(g,g.stateNode.containerInfo),m===null)throw Error(r(387));M=g.pendingProps;var O=g.memoizedState;R=O.element,BM(m,g),px(g,M,null,_);var ne=g.memoizedState;if(M=ne.cache,vd(g,$i,M),M!==O.cache&&FM(g,[$i],_,!0),fx(),M=ne.element,O.isDehydrated)if(O={element:M,isDehydrated:!1,cache:ne.cache},g.updateQueue.baseState=O,g.memoizedState=O,g.flags&256){g=L5(m,g,M,_);break e}else if(M!==R){R=zo(Error(r(424)),g),lx(R),g=L5(m,g,M,_);break e}else{switch(m=g.stateNode.containerInfo,m.nodeType){case 9:m=m.body;break;default:m=m.nodeName==="HTML"?m.ownerDocument.body:m}for(Zr=Vo(m.firstChild),za=g,er=!0,xd=null,Ho=!0,_=PI(g,null,M,_),g.child=_;_;)_.flags=_.flags&-3|4096,_=_.sibling}else{if(dm(),M===R){g=Qc(m,g,_);break e}Ba(m,g,M,_)}g=g.child}return g;case 26:return pw(m,g),m===null?(_=Qz(g.type,null,g.pendingProps,null))?g.memoizedState=_:er||(_=g.type,m=g.pendingProps,M=jw(he.current).createElement(_),M[Xt]=g,M[sr]=m,Ha(M,_,m),Ge(M),g.stateNode=M):g.memoizedState=Qz(g.type,m.memoizedProps,g.pendingProps,m.memoizedState),null;case 27:return le(g),m===null&&er&&(M=g.stateNode=Kz(g.type,g.pendingProps,he.current),za=g,Ho=!0,R=Zr,Dd(g.type)?(eT=R,Zr=Vo(M.firstChild)):Zr=R),Ba(m,g,g.pendingProps.children,_),pw(m,g),m===null&&(g.flags|=4194304),g.child;case 5:return m===null&&er&&((R=M=Zr)&&(M=Vae(M,g.type,g.pendingProps,Ho),M!==null?(g.stateNode=M,za=g,Zr=Vo(M.firstChild),Ho=!1,R=!0):R=!1),R||yd(g)),le(g),R=g.type,O=g.pendingProps,ne=m!==null?m.memoizedProps:null,M=O.children,XA(R,O)?M=null:ne!==null&&XA(R,ne)&&(g.flags|=32),g.memoizedState!==null&&(R=KM(m,g,aae,null,null,_),Fx._currentValue=R),pw(m,g),Ba(m,g,M,_),g.child;case 6:return m===null&&er&&((m=_=Zr)&&(_=Gae(_,g.pendingProps,Ho),_!==null?(g.stateNode=_,za=g,Zr=null,m=!0):m=!1),m||yd(g)),null;case 13:return O5(m,g,_);case 4:return Fe(g,g.stateNode.containerInfo),M=g.pendingProps,m===null?g.child=bm(g,null,M,_):Ba(m,g,M,_),g.child;case 11:return A5(m,g,g.type,g.pendingProps,_);case 7:return Ba(m,g,g.pendingProps,_),g.child;case 8:return Ba(m,g,g.pendingProps.children,_),g.child;case 12:return Ba(m,g,g.pendingProps.children,_),g.child;case 10:return M=g.pendingProps,vd(g,g.type,M.value),Ba(m,g,M.children,_),g.child;case 9:return R=g.type._context,M=g.pendingProps.children,mm(g),R=Ua(R),M=M(R),g.flags|=1,Ba(m,g,M,_),g.child;case 14:return T5(m,g,g.type,g.pendingProps,_);case 15:return E5(m,g,g.type,g.pendingProps,_);case 19:return z5(m,g,_);case 31:return mae(m,g,_);case 22:return P5(m,g,_,g.pendingProps);case 24:return mm(g),M=Ua($i),m===null?(R=IM(),R===null&&(R=Or,O=LM(),R.pooledCache=O,O.refCount++,O!==null&&(R.pooledCacheLanes|=_),R=O),g.memoizedState={parent:M,cache:R},UM(g),vd(g,$i,R)):((m.lanes&_)!==0&&(BM(m,g),px(g,null,null,_),fx()),R=m.memoizedState,O=g.memoizedState,R.parent!==M?(R={parent:M,cache:M},g.memoizedState=R,g.lanes===0&&(g.memoizedState=g.updateQueue.baseState=R),vd(g,$i,M)):(M=O.cache,vd(g,$i,M),M!==R.cache&&FM(g,[$i],_,!0))),Ba(m,g,g.pendingProps.children,_),g.child;case 29:throw g.pendingProps}throw Error(r(156,g.tag))}function Zc(m){m.flags|=4}function _A(m,g,_,M,R){if((g=(m.mode&32)!==0)&&(g=!1),g){if(m.flags|=16777216,(R&335544128)===R)if(m.stateNode.complete)m.flags|=8192;else if(mz())m.flags|=8192;else throw gm=ew,zM}else m.flags&=-16777217}function B5(m,g){if(g.type!=="stylesheet"||(g.state.loading&4)!==0)m.flags&=-16777217;else if(m.flags|=16777216,!nU(g))if(mz())m.flags|=8192;else throw gm=ew,zM}function bw(m,g){g!==null&&(m.flags|=4),m.flags&16384&&(g=m.tag!==22?yt():536870912,m.lanes|=g,xp|=g)}function wx(m,g){if(!er)switch(m.tailMode){case"hidden":g=m.tail;for(var _=null;g!==null;)g.alternate!==null&&(_=g),g=g.sibling;_===null?m.tail=null:_.sibling=null;break;case"collapsed":_=m.tail;for(var M=null;_!==null;)_.alternate!==null&&(M=_),_=_.sibling;M===null?g||m.tail===null?m.tail=null:m.tail.sibling=null:M.sibling=null}}function Jr(m){var g=m.alternate!==null&&m.alternate.child===m.child,_=0,M=0;if(g)for(var R=m.child;R!==null;)_|=R.lanes|R.childLanes,M|=R.subtreeFlags&65011712,M|=R.flags&65011712,R.return=m,R=R.sibling;else for(R=m.child;R!==null;)_|=R.lanes|R.childLanes,M|=R.subtreeFlags,M|=R.flags,R.return=m,R=R.sibling;return m.subtreeFlags|=M,m.childLanes=_,g}function pae(m,g,_){var M=g.pendingProps;switch(EM(g),g.tag){case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return Jr(g),null;case 1:return Jr(g),null;case 3:return _=g.stateNode,M=null,m!==null&&(M=m.memoizedState.cache),g.memoizedState.cache!==M&&(g.flags|=2048),Kc($i),ue(),_.pendingContext&&(_.context=_.pendingContext,_.pendingContext=null),(m===null||m.child===null)&&(ap(g)?Zc(g):m===null||m.memoizedState.isDehydrated&&(g.flags&256)===0||(g.flags|=1024,jM())),Jr(g),null;case 26:var R=g.type,O=g.memoizedState;return m===null?(Zc(g),O!==null?(Jr(g),B5(g,O)):(Jr(g),_A(g,R,null,M,_))):O?O!==m.memoizedState?(Zc(g),Jr(g),B5(g,O)):(Jr(g),g.flags&=-16777217):(m=m.memoizedProps,m!==M&&Zc(g),Jr(g),_A(g,R,m,M,_)),null;case 27:if(Te(g),_=he.current,R=g.type,m!==null&&g.stateNode!=null)m.memoizedProps!==M&&Zc(g);else{if(!M){if(g.stateNode===null)throw Error(r(166));return Jr(g),null}m=Z.current,ap(g)?vI(g):(m=Kz(R,M,_),g.stateNode=m,Zc(g))}return Jr(g),null;case 5:if(Te(g),R=g.type,m!==null&&g.stateNode!=null)m.memoizedProps!==M&&Zc(g);else{if(!M){if(g.stateNode===null)throw Error(r(166));return Jr(g),null}if(O=Z.current,ap(g))vI(g);else{var ne=jw(he.current);switch(O){case 1:O=ne.createElementNS("http://www.w3.org/2000/svg",R);break;case 2:O=ne.createElementNS("http://www.w3.org/1998/Math/MathML",R);break;default:switch(R){case"svg":O=ne.createElementNS("http://www.w3.org/2000/svg",R);break;case"math":O=ne.createElementNS("http://www.w3.org/1998/Math/MathML",R);break;case"script":O=ne.createElement("div"),O.innerHTML=" - + +