diff --git a/.gitignore b/.gitignore index d6c64ad59..adbd2bbaa 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ npm-debug.log* # Database *.db *.db-journal +*.db-wal +*.db-shm # Archive files (user data) archive/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 662701bc1..5cb08c4d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,20 @@ All notable changes to Bambuddy will be documented in this file. ### New Features - **Spool Inventory — AMS Slot Assignment** — Assign inventory spools to AMS slots for filament tracking. Hover over any non-Bambu-Lab AMS slot to assign or unassign spools. The assign modal filters out Bambu Lab spools (tracked via RFID) and spools already assigned to other slots. Bambu Lab spool slots automatically hide assign/unassign UI since they are managed by the AMS. When a Bambu Lab spool is inserted into a slot with a manual assignment, the assignment is automatically unlinked. - **Spool Inventory — Remaining Weight Editing** — Edit the remaining filament weight when adding or editing a spool. The new "Remaining Weight" field in the Additional section shows current weight (label weight minus consumed) with a max reference. Edits are stored as `weight_used` internally. -- **Spool Inventory — 3MF-Based Usage Tracking for Non-BL Spools** — Non-Bambu-Lab spools (no RFID) cannot use AMS remain% for usage tracking. Now falls back to per-filament weight estimates from the archived 3MF file (`used_g` per filament slot). For completed prints, uses the full slicer estimate. For failed or aborted prints, scales by print progress percentage. Bambu Lab spools continue using AMS remain% delta tracking as before. +- **Spool Inventory — Unified 3MF-Based Usage Tracking** ([#336](https://github.com/maziggy/bambuddy/issues/336)) — All spools (Bambu Lab and third-party) now use 3MF slicer estimates as the primary tracking source. Per-filament `used_g` data from the archived 3MF file provides precise per-spool consumption. For failed or aborted prints, per-layer G-code analysis provides accurate partial usage up to the exact failure layer, with linear progress scaling as fallback. AMS remain% delta is the final fallback for G-code-only prints without an archived 3MF. Slot-to-tray mapping uses queue `ams_mapping` for queue-initiated prints and the printer's `tray_now` state for single-filament non-queue prints, ensuring the correct physical spool is always tracked. +- **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, e.g. "PLA: 10.0g | PETG: 5.0g"), and `{progress}` (completion percentage for failed/stopped prints). 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). ### 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. +- **Model-Specific Maintenance Tasks for Carbon Rods vs Linear Rails** ([#351](https://github.com/maziggy/bambuddy/issues/351)) — Maintenance tasks "Clean Carbon Rods" and "Lubricate Linear Rails" were shown for all printers regardless of motion system. H2 and A1 series use linear rails (not carbon rods), and X1/P1/P2S series use carbon rods (not linear rails). Maintenance types are now classified by rod/rail type: "Lubricate Carbon Rods" and "Clean Carbon Rods" for X1/P1/P2S, "Lubricate Linear Rails" and "Clean Linear Rails" for A1/H2. Stale and duplicate system types are automatically cleaned up on startup. Includes model-specific wiki links and i18n keys for all 4 locales. +- **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. + +### 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. - **External Camera Not Used for Snapshot + Stream Dropping** ([#325](https://github.com/maziggy/bambuddy/issues/325)) — The snapshot endpoint (`/camera/snapshot`) always used the internal printer camera even when an external camera was configured. Now checks for external camera first, matching the existing stream endpoint behavior. Also fixed external MJPEG and RTSP streams silently dropping every ~60 seconds due to missing reconnect logic — the underlying stream generators exit on read timeout, and the caller now retries up to 3 times with a 2-second delay instead of ending the stream. - **H2C Nozzle Rack Text Unreadable on Light Filament Colors** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle rack slots use the loaded filament color as background, but white/light filaments made the white "0.4" text nearly invisible. Now uses a luminance check to switch to dark text on light backgrounds. - **File Downloads Show Generic Filenames** ([#334](https://github.com/maziggy/bambuddy/issues/334)) — Downloaded files with special characters in their names (spaces, umlauts, parentheses) were saved as generic `file_1`, `file_2` instead of the original filename. The `Content-Disposition` header parser now handles RFC 5987 percent-encoded filenames (`filename*=utf-8''...`) used by FastAPI for non-ASCII characters. Fix applied to all download endpoints (library files, archives, source files, F3D files, project exports, support bundles, printer files). diff --git a/README.md b/README.md index de9befc33..5c07cd1bd 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer - Duplicate detection & full-text search - Photo attachments & failure analysis - Timelapse editor (trim, speed, music) -- Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support) +- Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support, nozzle-aware matching for dual-nozzle H2D/H2D Pro) - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates) - Archive comparison (side-by-side diff) - Tag management (rename/delete across all archives) @@ -140,15 +140,17 @@ Perfect for remote print farms, traveling makers, or accessing your home printer - Email, Pushover, ntfy - Custom webhooks - Quiet hours & daily digest -- Customizable message templates +- Customizable message templates with per-filament usage details - Print finish photo URL in notifications +- Filament usage and progress in failed/cancelled print notifications - HMS error alerts (AMS, nozzle, etc.) - Build plate detection alerts - Queue events (waiting, skipped, failed) ### 🧵 Spool Inventory - Built-in spool inventory with AMS slot assignment, usage tracking, and remaining weight management -- Automatic filament consumption tracking: AMS RFID for Bambu Lab spools, 3MF estimates for third-party spools +- Automatic filament consumption tracking: 3MF slicer estimates for all spools (primary), AMS remain% delta as fallback +- Per-layer gcode accuracy for partial prints (failed/cancelled), with linear scaling fallback - Spool catalog, color catalog, PA profile matching, and low-stock alerts ### 🔧 Integrations @@ -200,7 +202,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer -**Plus:** Configurable slicer (Bambu Studio / OrcaSlicer) • Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE) • Auto updates • Database backup/restore • System info dashboard +**Plus:** Configurable slicer (Bambu Studio / OrcaSlicer) • Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE/JA/IT) • Auto updates • Database backup/restore • System info dashboard --- diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index aba710cd4..c38687ace 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -21,6 +21,7 @@ from backend.app.models.filament import Filament from backend.app.models.user import User from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate, ReprintRequest from backend.app.services.archive import ArchiveService +from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf logger = logging.getLogger(__name__) @@ -2669,6 +2670,12 @@ async def get_filament_requirements( # Sort by slot ID filaments.sort(key=lambda x: x["slot_id"]) + # Enrich with nozzle mapping for dual-nozzle printers + nozzle_mapping = extract_nozzle_mapping_from_3mf(zf) + if nozzle_mapping: + for filament in filaments: + filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"]) + except Exception as e: logger.warning("Failed to parse filament requirements from archive %s: %s", archive_id, e) diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index f337bd142..6b65d2514 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -56,6 +56,7 @@ from backend.app.schemas.library import ( ) from backend.app.services.archive import ArchiveService, ThreeMFParser from backend.app.services.stl_thumbnail import generate_stl_thumbnail +from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf logger = logging.getLogger(__name__) @@ -1711,6 +1712,12 @@ async def get_library_file_filament_requirements( # Sort by slot ID filaments.sort(key=lambda x: x["slot_id"]) + # Enrich with nozzle mapping for dual-nozzle printers + nozzle_mapping = extract_nozzle_mapping_from_3mf(zf) + if nozzle_mapping: + for filament in filaments: + filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"]) + except Exception as e: logger.warning("Failed to parse filament requirements from library file %s: %s", file_id, e) diff --git a/backend/app/api/routes/maintenance.py b/backend/app/api/routes/maintenance.py index ac61ab1d1..ee24ff810 100644 --- a/backend/app/api/routes/maintenance.py +++ b/backend/app/api/routes/maintenance.py @@ -26,6 +26,7 @@ from backend.app.schemas.maintenance import ( PrinterMaintenanceUpdate, ) from backend.app.services.notification_service import notification_service +from backend.app.utils.printer_models import get_rod_type logger = logging.getLogger(__name__) @@ -33,12 +34,33 @@ router = APIRouter(prefix="/maintenance", tags=["maintenance"]) # Default maintenance types DEFAULT_MAINTENANCE_TYPES = [ + # Carbon rod models only (X1/P1/P2S) { - "name": "Lubricate Linear Rails", - "description": "Apply lubricant to linear rails and rods for smooth motion", + "name": "Lubricate Carbon Rods", + "description": "Apply lubricant to carbon rods for smooth motion", "default_interval_hours": 50.0, "icon": "Droplet", }, + { + "name": "Clean Carbon Rods", + "description": "Wipe carbon rods with a dry cloth", + "default_interval_hours": 100.0, + "icon": "Sparkles", + }, + # Linear rail models only (A1/H2) + { + "name": "Lubricate Linear Rails", + "description": "Apply lubricant to linear rails for smooth motion", + "default_interval_hours": 50.0, + "icon": "Droplet", + }, + { + "name": "Clean Linear Rails", + "description": "Wipe linear rails with a dry cloth to remove dust and debris", + "default_interval_hours": 100.0, + "icon": "Sparkles", + }, + # Universal (all models) { "name": "Clean Nozzle/Hotend", "description": "Clean nozzle exterior and perform cold pull if needed", @@ -51,12 +73,6 @@ DEFAULT_MAINTENANCE_TYPES = [ "default_interval_hours": 200.0, "icon": "Ruler", }, - { - "name": "Clean Carbon Rods", - "description": "Wipe carbon rods with a dry cloth", - "default_interval_hours": 100.0, - "icon": "Sparkles", - }, { "name": "Clean Build Plate", "description": "Deep clean build plate with IPA or soap", @@ -71,6 +87,30 @@ DEFAULT_MAINTENANCE_TYPES = [ }, ] +# System types that only apply to printers with a specific rod/rail type. +# "carbon" = X1/P1/P2S series (carbon rods), "linear_rail" = A1/H2 series. +# Types not listed here apply to all printers. +_ROD_TYPE_REQUIREMENTS: dict[str, str] = { + "Lubricate Carbon Rods": "carbon", + "Clean Carbon Rods": "carbon", + "Lubricate Linear Rails": "linear_rail", + "Clean Linear Rails": "linear_rail", +} + + +def _should_apply_to_printer(type_name: str, printer_model: str | None) -> bool: + """Check if a system maintenance type should apply to a given printer model.""" + rod_requirement = _ROD_TYPE_REQUIREMENTS.get(type_name) + if rod_requirement is None: + return True # Not model-specific, applies to all + + rod_type = get_rod_type(printer_model) + if rod_type is None: + # Unknown model — default to carbon rods (legacy behavior) + return rod_requirement == "carbon" + + return rod_type == rod_requirement + async def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float: """Calculate total active hours for a printer from runtime counter plus offset. @@ -94,13 +134,27 @@ async def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float: async def ensure_default_types(db: AsyncSession) -> None: - """Ensure default maintenance types exist.""" - result = await db.execute(select(MaintenanceType).where(MaintenanceType.is_system.is_(True))) + """Ensure default maintenance types exist, remove stale/duplicate ones.""" + result = await db.execute( + select(MaintenanceType).where(MaintenanceType.is_system.is_(True)).order_by(MaintenanceType.id) + ) existing = result.scalars().all() - existing_names = {t.name for t in existing} + default_names = {t["name"] for t in DEFAULT_MAINTENANCE_TYPES} + + # Remove stale system types no longer in defaults (e.g. renamed types) + # and deduplicate: if concurrent requests created the same type twice, + # keep only the first (lowest id) and delete the rest. + seen_names: set[str] = set() + for t in existing: + if t.name not in default_names or t.name in seen_names: + await db.delete(t) + else: + seen_names.add(t.name) + + # Create any missing default types for type_def in DEFAULT_MAINTENANCE_TYPES: - if type_def["name"] not in existing_names: + if type_def["name"] not in seen_names: new_type = MaintenanceType( name=type_def["name"], description=type_def["description"], @@ -228,6 +282,11 @@ async def _get_printer_maintenance_internal( now = datetime.utcnow() for maint_type in all_types: + # Skip system types that don't apply to this printer model + # (e.g., "Clean Carbon Rods" for H2D which has steel rods) + if maint_type.is_system and not _should_apply_to_printer(maint_type.name, printer.model): + continue + item = existing_items.get(maint_type.id) default_interval_type = getattr(maint_type, "interval_type", "hours") or "hours" diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 065796e3d..89d750ff3 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1,14 +1,30 @@ +from sqlalchemy import event from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase from backend.app.core.config import settings + +def _set_sqlite_pragmas(dbapi_conn, connection_record): + """Set SQLite pragmas on each new connection for concurrency and performance.""" + cursor = dbapi_conn.cursor() + # WAL mode allows concurrent readers + one writer (vs default DELETE mode which locks entirely) + cursor.execute("PRAGMA journal_mode = WAL") + # Wait up to 5 seconds when the database is locked instead of failing immediately + cursor.execute("PRAGMA busy_timeout = 5000") + cursor.execute("PRAGMA synchronous = NORMAL") + cursor.close() + + engine = create_async_engine( settings.database_url, echo=settings.debug, ) +# Register the pragma listener on the underlying sync engine +event.listen(engine.sync_engine, "connect", _set_sqlite_pragmas) + async_session = async_sessionmaker( engine, class_=AsyncSession, @@ -29,6 +45,7 @@ async def reinitialize_database(): settings.database_url, echo=settings.debug, ) + event.listen(engine.sync_engine, "connect", _set_sqlite_pragmas) async_session = async_sessionmaker( engine, class_=AsyncSession, diff --git a/backend/app/main.py b/backend/app/main.py index d55686522..33f43bcf7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -551,13 +551,45 @@ async def on_ams_change(printer_id: int, ams_data: list): ) stale.append(assignment) # Slot empty elif _is_bambu_uuid(current_tray.get("tray_uuid", "")): - # A Bambu Lab spool was inserted — always unlink manual assignments + # A Bambu Lab spool is in this slot — check if it's the same spool + # that's currently assigned. If yes, keep the assignment (avoids + # unnecessary unlink/re-assign/ams_filament_setting cycle that clears + # the printer's filament preset on every startup). + tray_uuid = current_tray.get("tray_uuid", "") + tag_uid = current_tray.get("tag_uid", "") + spool = assignment.spool + spool_matches = False + if spool: + if (spool.tray_uuid and spool.tray_uuid.upper() == tray_uuid.upper()) or ( + spool.tag_uid + and tag_uid + and tag_uid != "0000000000000000" + and spool.tag_uid.upper() == tag_uid.upper() + ): + spool_matches = True + if spool_matches: + # Same BL spool still in slot — keep assignment, update fingerprint if needed + cur_color = current_tray.get("tray_color", "") + cur_type = current_tray.get("tray_type", "") + fp_color = assignment.fingerprint_color or "" + fp_type = assignment.fingerprint_type or "" + if cur_color.upper() != fp_color.upper() or cur_type.upper() != fp_type.upper(): + assignment.fingerprint_color = cur_color + assignment.fingerprint_type = cur_type + logger.debug( + "Auto-unlink: spool %d AMS%d-T%d — same BL spool, updated fingerprint", + assignment.spool_id, + assignment.ams_id, + assignment.tray_id, + ) + continue + # Different BL spool or unrecognized — unlink so auto-assign can match logger.info( - "Auto-unlink: spool %d AMS%d-T%d — Bambu Lab spool detected (uuid=%s)", + "Auto-unlink: spool %d AMS%d-T%d — different Bambu Lab spool detected (uuid=%s)", assignment.spool_id, assignment.ams_id, assignment.tray_id, - current_tray.get("tray_uuid", ""), + tray_uuid, ) stale.append(assignment) else: @@ -2316,6 +2348,21 @@ async def on_print_complete(printer_id: int, data: dict): "actual_filament_grams": archive.filament_used_grams, "failure_reason": archive.failure_reason, } + + # Scale filament usage for partial prints + if print_status != "completed" and archive.filament_used_grams: + progress = data.get("progress") or 0 + scale = max(0.0, min(progress / 100.0, 1.0)) + archive_data["actual_filament_grams"] = round(archive.filament_used_grams * scale, 1) + archive_data["progress"] = progress + + # Pass per-slot data from archive.extra_data + if archive.extra_data and archive.extra_data.get("filament_slots"): + slots = archive.extra_data["filament_slots"] + if print_status != "completed": + scale = max(0.0, min((data.get("progress") or 0) / 100.0, 1.0)) + slots = [{**s, "used_g": round(s["used_g"] * scale, 1)} for s in slots] + archive_data["filament_slots"] = slots # Add finish photo URL and image bytes if available if finish_photo_filename: from backend.app.api.routes.settings import get_setting diff --git a/backend/app/schemas/notification_template.py b/backend/app/schemas/notification_template.py index 754371b85..05d823975 100644 --- a/backend/app/schemas/notification_template.py +++ b/backend/app/schemas/notification_template.py @@ -31,12 +31,34 @@ EVENT_VARIABLES: dict[str, list[str]] = { "filename", "duration", "filament_grams", + "filament_details", + "finish_photo_url", + "timestamp", + "app_name", + ], + "print_failed": [ + "printer", + "filename", + "duration", + "filament_grams", + "filament_details", + "progress", + "reason", + "finish_photo_url", + "timestamp", + "app_name", + ], + "print_stopped": [ + "printer", + "filename", + "duration", + "filament_grams", + "filament_details", + "progress", "finish_photo_url", "timestamp", "app_name", ], - "print_failed": ["printer", "filename", "duration", "reason", "finish_photo_url", "timestamp", "app_name"], - "print_stopped": ["printer", "filename", "duration", "finish_photo_url", "timestamp", "app_name"], "print_progress": ["printer", "filename", "progress", "remaining_time", "timestamp", "app_name"], "printer_offline": ["printer", "timestamp", "app_name"], "printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"], @@ -72,6 +94,7 @@ SAMPLE_DATA: dict[str, dict[str, str]] = { "filename": "Benchy.3mf", "duration": "1h 18m", "filament_grams": "15.2", + "filament_details": "PLA: 15.2g", "finish_photo_url": "/api/v1/archives/123/photos/finish_20240115_154800_abc12345.jpg", "timestamp": "2024-01-15 15:48", "app_name": "Bambuddy", @@ -80,6 +103,9 @@ SAMPLE_DATA: dict[str, dict[str, str]] = { "printer": "Bambu X1C", "filename": "Benchy.3mf", "duration": "0h 45m", + "filament_grams": "7.6", + "filament_details": "PLA: 7.6g", + "progress": "50", "reason": "Filament runout", "finish_photo_url": "/api/v1/archives/123/photos/finish_20240115_151500_def67890.jpg", "timestamp": "2024-01-15 15:15", @@ -89,6 +115,9 @@ SAMPLE_DATA: dict[str, dict[str, str]] = { "printer": "Bambu X1C", "filename": "Benchy.3mf", "duration": "0h 30m", + "filament_grams": "4.6", + "filament_details": "PLA: 4.6g", + "progress": "30", "finish_photo_url": "/api/v1/archives/123/photos/finish_20240115_150000_ghi11223.jpg", "timestamp": "2024-01-15 15:00", "app_name": "Bambuddy", diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index 599041262..60317cfaa 100644 --- a/backend/app/services/archive.py +++ b/backend/app/services/archive.py @@ -151,6 +151,27 @@ class ThreeMFParser: self.metadata["_slice_filament_type"] = ", ".join(types) if colors: self.metadata["_slice_filament_color"] = ",".join(colors) + + # Collect per-slot filament usage for tracking & notifications + filament_slots = [] + for f in filaments: + slot_id = f.get("id") + used_g_str = f.get("used_g", "0") + try: + used_g = float(used_g_str) + except (ValueError, TypeError): + used_g = 0 + if used_g > 0 and slot_id: + filament_slots.append( + { + "slot_id": int(slot_id), + "used_g": round(used_g, 2), + "type": f.get("type", ""), + "color": f.get("color", ""), + } + ) + if filament_slots: + self.metadata["filament_slots"] = filament_slots except Exception: pass # Skip unparseable slice_info metadata @@ -1026,8 +1047,9 @@ class ArchiveService: if not archive: return False - # Delete files - with CRITICAL safety checks to prevent accidental deletion - # of parent directories (e.g., /opt) if file_path is empty/malformed + # Resolve the directory to delete BEFORE committing the DB change + dir_to_delete: Path | None = None + if archive.file_path and archive.file_path.strip(): file_path = settings.base_dir / archive.file_path if file_path.exists(): @@ -1041,13 +1063,11 @@ class ArchiveService: f"SECURITY: Refusing to delete archive {archive_id} - " f"path {archive_dir} is outside archive directory {settings.archive_dir}" ) - # Still delete the database record, just not the files await self.db.delete(archive) await self.db.commit() return True # Safety check 2: archive_dir must be at least 1 level deep inside archive_dir - # (should be archive_dir/uuid/file.3mf, so parent should be archive_dir/uuid) try: relative_path = archive_dir.resolve().relative_to(settings.archive_dir.resolve()) if len(relative_path.parts) < 1: @@ -1061,16 +1081,22 @@ class ArchiveService: except ValueError: pass # Already handled above - shutil.rmtree(archive_dir, ignore_errors=True) + dir_to_delete = archive_dir else: logger.error( f"SECURITY: Refusing to delete files for archive {archive_id} - " f"file_path is empty or invalid: '{archive.file_path}'" ) - # Delete database record + # Delete database record FIRST — if the commit fails (e.g. database locked + # during concurrent bulk deletes), the files stay on disk and nothing is lost. await self.db.delete(archive) await self.db.commit() + + # Only delete files AFTER the DB commit succeeds to avoid orphaned records + if dir_to_delete: + shutil.rmtree(dir_to_delete, ignore_errors=True) + return True async def attach_timelapse( diff --git a/backend/app/services/bambu_ftp.py b/backend/app/services/bambu_ftp.py index 4030a2082..5dfe559f9 100644 --- a/backend/app/services/bambu_ftp.py +++ b/backend/app/services/bambu_ftp.py @@ -181,7 +181,7 @@ class BambuFTPClient: if self._ftp: try: self._ftp.quit() - except (OSError, ftplib.Error): + except (OSError, ftplib.Error, EOFError): pass # Best-effort FTP cleanup; connection may already be closed self._ftp = None diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 0111c352e..0dd131431 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -728,6 +728,19 @@ class NotificationService: if archive_data.get("finish_photo_url"): variables["finish_photo_url"] = archive_data["finish_photo_url"] + # Build per-slot breakdown string + if archive_data.get("filament_slots"): + parts = [] + for slot in archive_data["filament_slots"]: + ftype = slot.get("type", "Unknown") or "Unknown" + used = slot.get("used_g", 0) + parts.append(f"{ftype}: {used:.1f}g") + variables["filament_details"] = " | ".join(parts) + + # Add progress for partial prints + if archive_data.get("progress") is not None: + variables["progress"] = str(archive_data["progress"]) + # Extract image data for providers that support attachments (e.g. Pushover) image_data = None if archive_data: diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index edc60c7e4..7c28ebdcb 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -23,6 +23,7 @@ from backend.app.services.notification_service import notification_service from backend.app.services.printer_manager import printer_manager from backend.app.services.smart_plug_manager import smart_plug_manager from backend.app.utils.printer_models import normalize_printer_model +from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf logger = logging.getLogger(__name__) @@ -477,6 +478,12 @@ class PrintScheduler: pass # Skip filament entry with unparseable usage data filaments.sort(key=lambda x: x["slot_id"]) + + # Enrich with nozzle mapping for dual-nozzle printers + nozzle_mapping = extract_nozzle_mapping_from_3mf(zf) + if nozzle_mapping: + for filament in filaments: + filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"]) except Exception as e: logger.warning("Failed to parse filament requirements: %s", e) return None @@ -494,6 +501,9 @@ class PrintScheduler: """ filaments = [] + # Get ams_extruder_map for dual-nozzle printers (H2D, H2D Pro) + ams_extruder_map = status.raw_data.get("ams_extruder_map", {}) + # Parse AMS units from raw_data ams_data = status.raw_data.get("ams", []) for ams_unit in ams_data: @@ -524,6 +534,7 @@ class PrintScheduler: "is_ht": is_ht, "is_external": False, "global_tray_id": global_tray_id, + "extruder_id": ams_extruder_map.get(str(ams_id)), } ) @@ -541,6 +552,7 @@ class PrintScheduler: "is_ht": False, "is_external": True, "global_tray_id": 254, + "extruder_id": 0 if ams_extruder_map else None, } ) @@ -616,6 +628,13 @@ class PrintScheduler: # Get available trays (not already used) available = [f for f in loaded if f["global_tray_id"] not in used_tray_ids] + # Nozzle-aware filtering: restrict to trays on the correct nozzle + req_nozzle_id = req.get("nozzle_id") + if req_nozzle_id is not None: + nozzle_filtered = [f for f in available if f.get("extruder_id") == req_nozzle_id] + if nozzle_filtered: + available = nozzle_filtered + # Check if tray_info_idx is unique among available trays if req_tray_info_idx: idx_matches = [f for f in available if f.get("tray_info_idx") == req_tray_info_idx] diff --git a/backend/app/services/spoolman.py b/backend/app/services/spoolman.py index b7f54a8bf..b47ce0aba 100644 --- a/backend/app/services/spoolman.py +++ b/backend/app/services/spoolman.py @@ -693,31 +693,22 @@ class SpoolmanClient: def is_bambu_lab_spool(self, tray_uuid: str, tag_uid: str = "", tray_info_idx: str = "") -> bool: """Check if a tray has a valid Bambu Lab spool. - Bambu Lab spools can be identified by: + Bambu Lab spools are identified by hardware RFID identifiers only: 1. tray_uuid: 32-character hex string (preferred, consistent across printers) 2. tag_uid: 16-character hex string (RFID tag, varies between readers) - 3. tray_info_idx: Bambu filament preset ID like "GFA00" (most reliable) - Non-Bambu Lab spools (SpoolEase, third-party) won't have these identifiers. + Note: tray_info_idx (e.g. "GFA00") is NOT a reliable indicator — third-party + spools using Bambu generic presets also have GF-prefixed tray_info_idx values. + The tray_info_idx parameter is kept for API compatibility but ignored. Args: tray_uuid: The tray UUID to check (32 hex chars) tag_uid: The RFID tag UID to check as fallback (16 hex chars) - tray_info_idx: Bambu filament preset ID like "GFA00", "GFB00" + tray_info_idx: Ignored (kept for API compatibility) Returns: - True if the spool has valid Bambu Lab identifiers, False otherwise. + True if the spool has valid Bambu Lab RFID identifiers, False otherwise. """ - # Check tray_info_idx first - Bambu filament preset IDs like "GFA00", "GFB00", etc. - # This is the most reliable indicator as it's set when the spool is recognized - if tray_info_idx: - idx = tray_info_idx.strip() - # Bambu Lab preset IDs start with "GF" followed by letter and digits - # e.g., GFA00, GFB00, GFL00, GFN00, GFG00, GFS00, GFU00 - if idx and len(idx) >= 3 and idx.startswith("GF"): - logger.debug("Identified Bambu Lab spool via tray_info_idx: %s", idx) - return True - # Check tray_uuid (preferred - consistent across printer models) if tray_uuid: uuid = tray_uuid.strip() diff --git a/backend/app/services/usage_tracker.py b/backend/app/services/usage_tracker.py index f5e36a1e7..4b670bb92 100644 --- a/backend/app/services/usage_tracker.py +++ b/backend/app/services/usage_tracker.py @@ -3,10 +3,11 @@ Captures AMS tray remain% at print start, then computes consumption deltas at print complete to update spool weight_used and last_used. -For non-BL spools (no RFID, AMS reports remain=-1), falls back to -per-filament usage estimates from the archived 3MF file. +Primary tracking uses 3MF slicer estimates (precise per-filament data). +AMS remain% delta is the fallback for trays not covered by 3MF data. """ +import json import logging from dataclasses import dataclass, field from datetime import datetime, timezone @@ -87,9 +88,9 @@ async def on_print_complete( ) -> list[dict]: """Compute consumption deltas and update spool weight_used/last_used. - Uses two tracking strategies: - 1. AMS remain% delta — for BL spools with valid RFID remain data - 2. 3MF per-filament estimates — for non-BL spools without remain data + Uses two tracking strategies in priority order: + 1. 3MF per-filament estimates (primary) — precise slicer data for all spools + 2. AMS remain% delta (fallback) — only for trays not already handled by 3MF Returns a list of dicts describing what was logged (for WebSocket broadcast). """ @@ -98,7 +99,17 @@ async def on_print_complete( results = [] handled_trays: set[tuple[int, int]] = set() - # --- Path 1: AMS remain% delta (for spools with valid RFID remain data) --- + # --- Path 1 (PRIMARY): 3MF per-filament estimates --- + if archive_id: + print_name = ( + (session.print_name if session else None) or data.get("subtask_name", "") or data.get("filename", "unknown") + ) + threemf_results = await _track_from_3mf( + printer_id, archive_id, status, print_name, handled_trays, printer_manager, db + ) + results.extend(threemf_results) + + # --- Path 2 (FALLBACK): AMS remain% delta (only for trays not handled by 3MF) --- if session and session.tray_remain_start: state = printer_manager.get_status(printer_id) if state and state.raw_data: @@ -113,6 +124,9 @@ async def on_print_complete( tray_id = int(tray.get("id", 0)) key = (ams_id, tray_id) + if key in handled_trays: + continue # Already tracked via 3MF + if key not in session.tray_remain_start: continue @@ -174,7 +188,7 @@ async def on_print_complete( ) logger.info( - "[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d AMS%d-T%d (%s)", + "[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d AMS%d-T%d (AMS fallback, %s)", spool.id, weight_grams, delta_pct, @@ -184,16 +198,6 @@ async def on_print_complete( status, ) - # --- Path 2: 3MF per-filament estimates (for non-BL spools without remain data) --- - if archive_id: - print_name = ( - (session.print_name if session else None) or data.get("subtask_name", "") or data.get("filename", "unknown") - ) - threemf_results = await _track_from_3mf( - printer_id, archive_id, status, print_name, handled_trays, printer_manager, db - ) - results.extend(threemf_results) - if results: await db.commit() @@ -209,14 +213,20 @@ async def _track_from_3mf( printer_manager, db: AsyncSession, ) -> list[dict]: - """Track usage from 3MF per-filament data for non-BL spools. + """Track usage from 3MF per-filament slicer data (primary path). - Falls back to slicer-estimated filament weight when AMS remain% is - unavailable (non-RFID spools). For partial prints (failed/aborted), - scales the estimate by print progress. + Uses slicer-estimated filament weight for all spools (BL and non-BL). + For partial prints (failed/aborted), tries per-layer gcode data first, + then falls back to linear scaling by progress. + + Slot-to-tray mapping priority: + 1. Queue item ams_mapping (for queue-initiated prints) + 2. tray_now from printer state (for single-filament non-queue prints) + 3. Default mapping: slot_id - 1 = global_tray_id (last resort) """ from backend.app.core.config import settings as app_settings from backend.app.models.archive import PrintArchive + from backend.app.models.print_queue import PrintQueueItem from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id)) @@ -232,6 +242,29 @@ async def _track_from_3mf( if not filament_usage: return [] + # --- Resolve slot-to-tray mapping --- + # 1. Try queue item ams_mapping (queue-initiated prints store the exact mapping) + slot_to_tray = None + queue_result = await db.execute( + select(PrintQueueItem) + .where(PrintQueueItem.archive_id == archive_id) + .where(PrintQueueItem.status.in_(["printing", "completed", "failed"])) + ) + queue_item = queue_result.scalar_one_or_none() + if queue_item and queue_item.ams_mapping: + try: + slot_to_tray = json.loads(queue_item.ams_mapping) + except (json.JSONDecodeError, TypeError): + pass + + # 2. For single-filament non-queue prints, use tray_now from printer state + nonzero_slots = [u for u in filament_usage if u.get("used_g", 0) > 0] + 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: + tray_now_override = state.tray_now + # Scale factor for partial prints (failed/aborted) if status == "completed": scale = 1.0 @@ -240,6 +273,34 @@ async def _track_from_3mf( progress = state.progress if state else 0 scale = max(0.0, min(progress / 100.0, 1.0)) + # Per-layer gcode accuracy for partial prints + layer_grams: dict[int, float] | None = None + if status != "completed": + state = printer_manager.get_status(printer_id) + current_layer = state.layer_num if state else 0 + if current_layer > 0: + try: + from backend.app.utils.threemf_tools import ( + extract_filament_properties_from_3mf, + extract_layer_filament_usage_from_3mf, + get_cumulative_usage_at_layer, + mm_to_grams, + ) + + layer_usage = extract_layer_filament_usage_from_3mf(file_path) + if layer_usage: + cumulative_mm = get_cumulative_usage_at_layer(layer_usage, current_layer) + filament_props = extract_filament_properties_from_3mf(file_path) + layer_grams = {} + for filament_id, mm_used in cumulative_mm.items(): + slot_id = filament_id + 1 # 0-based to 1-based + props = filament_props.get(slot_id, {}) + density = props.get("density", 1.24) + diameter = props.get("diameter", 1.75) + layer_grams[slot_id] = mm_to_grams(mm_used, diameter, density) + except Exception: + pass # Fall back to linear scaling + results = [] for usage in filament_usage: @@ -248,8 +309,18 @@ async def _track_from_3mf( if used_g <= 0: continue - # Map 3MF slot_id (1-based) to (ams_id, tray_id) - global_tray_id = slot_id - 1 + # Map 3MF slot_id to physical (ams_id, tray_id) using resolved mapping + if tray_now_override is not None: + # Single-filament non-queue print: use actual tray from printer state + global_tray_id = tray_now_override + else: + # Queue mapping or default: slot_id - 1, overridden by ams_mapping + global_tray_id = slot_id - 1 + if slot_to_tray and slot_id <= len(slot_to_tray): + mapped = slot_to_tray[slot_id - 1] + if isinstance(mapped, int) and mapped >= 0: + global_tray_id = mapped + if global_tray_id >= 128: ams_id = global_tray_id tray_id = 0 @@ -259,7 +330,7 @@ async def _track_from_3mf( key = (ams_id, tray_id) if key in handled_trays: - continue # Already tracked via AMS remain% delta + continue # Find spool assignment for this tray assign_result = await db.execute( @@ -279,11 +350,12 @@ async def _track_from_3mf( if not spool: continue - # Only use 3MF tracking for non-BL spools (BL spools use AMS remain%) - if spool.tag_uid or spool.tray_uuid: - continue + # Use per-layer grams if available, otherwise linear scale + if layer_grams and slot_id in layer_grams: + weight_grams = layer_grams[slot_id] + else: + weight_grams = used_g * scale - weight_grams = used_g * scale if weight_grams <= 0: continue @@ -304,6 +376,7 @@ async def _track_from_3mf( ) db.add(history) + handled_trays.add(key) results.append( { "spool_id": spool.id, @@ -314,11 +387,19 @@ async def _track_from_3mf( } ) + # Determine mapping source for debug logging + if tray_now_override is not None: + map_src = ", tray_now" + elif slot_to_tray: + map_src = ", queue_map" + else: + map_src = "" logger.info( - "[UsageTracker] Spool %d consumed %.1fg (3MF estimate%s) on printer %d AMS%d-T%d (%s)", + "[UsageTracker] Spool %d consumed %.1fg (3MF%s%s) on printer %d AMS%d-T%d (%s)", spool.id, weight_grams, - f" scaled to {scale:.0%}" if scale < 1 else "", + " per-layer" if (layer_grams and slot_id in layer_grams) else (f" scaled {scale:.0%}" if scale < 1 else ""), + map_src, printer_id, ams_id, tray_id, diff --git a/backend/app/utils/printer_models.py b/backend/app/utils/printer_models.py index 3b09e3f94..8fdd61be7 100644 --- a/backend/app/utils/printer_models.py +++ b/backend/app/utils/printer_models.py @@ -48,6 +48,69 @@ PRINTER_MODEL_ID_MAP = { } +# Rod/rail type classification for maintenance tasks. +# Carbon rods: X1, P1, P2S series (CoreXY with carbon fiber rods) +# Linear rails: A1, H2 series (linear rail motion system) +# Values must be uppercase with spaces stripped for normalized comparison. +CARBON_ROD_MODELS = frozenset( + [ + # Display names (uppercase, no spaces) + "X1", + "X1C", + "X1E", + "P1P", + "P1S", + "P2S", + # Internal codes + "C11", # X1C + "C12", # X1 + "C13", # X1E + "N7", # P2S + ] +) + +LINEAR_RAIL_MODELS = frozenset( + [ + # Display names (uppercase, no spaces) + "A1", + "A1MINI", + "H2D", + "H2DPRO", + "H2C", + "H2S", + # Internal codes + "N1", # A1 + "N2S", # A1 Mini + "A04", # A1 Mini (alternate) + "A11", # A1 + "A12", # A1 Mini + "O1D", # H2D + "O1E", # H2D Pro + "O2D", # H2D Pro (alternate) + "O1C", # H2C + "O1S", # H2S + ] +) + + +def get_rod_type(model: str | None) -> str | None: + """Return the rod/rail type for a printer model. + + Returns: + "carbon" for X1/P1/P2S series (carbon fiber rods), + "linear_rail" for A1/H2 series (linear rails), + None for unknown models. + """ + if not model: + return None + normalized = model.strip().upper().replace(" ", "").replace("-", "") + if normalized in CARBON_ROD_MODELS: + return "carbon" + if normalized in LINEAR_RAIL_MODELS: + return "linear_rail" + return None + + def normalize_printer_model_id(model_id: str | None) -> str | None: """Convert printer_model_id (internal code) to normalized short name. diff --git a/backend/app/utils/threemf_tools.py b/backend/app/utils/threemf_tools.py index e2c6f019b..c06c7a896 100644 --- a/backend/app/utils/threemf_tools.py +++ b/backend/app/utils/threemf_tools.py @@ -264,6 +264,62 @@ def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]: return properties +def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | None: + """Extract per-slot nozzle/extruder mapping from a 3MF file's project settings. + + On dual-nozzle printers (H2D, H2D Pro), each filament slot is assigned to a + specific nozzle. This reads the slicer's nozzle assignment from + Metadata/project_settings.config. + + Translation chain: + filament_nozzle_map[slot_id - 1] -> slicer extruder index + physical_extruder_map[slicer_ext] -> MQTT extruder ID (0=right, 1=left) + + Args: + zf: An open ZipFile of the 3MF archive + + Returns: + Dictionary mapping {slot_id: extruder_id} for dual-nozzle files, + or None if single-nozzle, missing data, or parse error. + """ + try: + if "Metadata/project_settings.config" not in zf.namelist(): + return None + + content = zf.read("Metadata/project_settings.config").decode() + data = json.loads(content) + + filament_nozzle_map = data.get("filament_nozzle_map") + physical_extruder_map = data.get("physical_extruder_map") + + if not filament_nozzle_map or not physical_extruder_map: + return None + + # Build slot_id (1-based) -> extruder_id mapping + nozzle_mapping: dict[int, int] = {} + for i, slicer_ext_str in enumerate(filament_nozzle_map): + slot_id = i + 1 + try: + slicer_ext = int(slicer_ext_str) + if slicer_ext < len(physical_extruder_map): + extruder_id = int(physical_extruder_map[slicer_ext]) + nozzle_mapping[slot_id] = extruder_id + except (ValueError, TypeError, IndexError): + pass # Skip slots with unparseable nozzle mapping + + if not nozzle_mapping: + return None + + # If all slots map to the same extruder, this is a single-nozzle printer + unique_extruders = set(nozzle_mapping.values()) + if len(unique_extruders) <= 1: + return None + + return nozzle_mapping + except Exception: + return None + + def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]: """Extract per-filament total usage from 3MF slice_info.config. diff --git a/backend/tests/unit/services/test_bambu_ftp.py b/backend/tests/unit/services/test_bambu_ftp.py index b7b448705..796b62a45 100644 --- a/backend/tests/unit/services/test_bambu_ftp.py +++ b/backend/tests/unit/services/test_bambu_ftp.py @@ -123,11 +123,10 @@ class TestDisconnectServerGone: """Test disconnect behavior when the server has stopped.""" def test_disconnect_after_server_gone(self, ftp_certs, tmp_path): - """Disconnect after server has stopped raises EOFError. + """Disconnect after server has stopped does not raise. - Note: The current disconnect() catches (OSError, ftplib.Error) but - EOFError is neither. This documents actual behavior — a future fix - could add EOFError to the except clause. + disconnect() catches OSError, ftplib.Error, and EOFError so that + best-effort cleanup never propagates exceptions to the caller. """ from backend.tests.unit.services.mock_ftp_server import ( MockBambuFTPServer, @@ -145,8 +144,9 @@ class TestDisconnectServerGone: client.connect() server.stop() - with pytest.raises(EOFError): - client.disconnect() + # Should not raise — disconnect() catches all connection errors + client.disconnect() + assert client._ftp is None # --------------------------------------------------------------------------- diff --git a/backend/tests/unit/services/test_spoolman_service.py b/backend/tests/unit/services/test_spoolman_service.py index a092d927a..8697a88c2 100644 --- a/backend/tests/unit/services/test_spoolman_service.py +++ b/backend/tests/unit/services/test_spoolman_service.py @@ -2,6 +2,7 @@ These tests specifically target the sync_ams_tray method's disable_weight_sync functionality that controls whether remaining_weight is updated. +Also includes tests for is_bambu_lab_spool RFID detection. """ from unittest.mock import AsyncMock, Mock, patch @@ -11,6 +12,71 @@ import pytest from backend.app.services.spoolman import AMSTray, SpoolmanClient +class TestIsBambuLabSpool: + """Tests for is_bambu_lab_spool — detects BL spools via RFID hardware identifiers only.""" + + @pytest.fixture + def client(self): + return SpoolmanClient("http://localhost:7912") + + def test_valid_tray_uuid_returns_true(self, client): + """A non-zero 32-char hex tray_uuid identifies a BL spool.""" + assert client.is_bambu_lab_spool("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4") is True + + def test_valid_tag_uid_returns_true(self, client): + """A non-zero 16-char hex tag_uid identifies a BL spool (fallback).""" + assert client.is_bambu_lab_spool("", tag_uid="A1B2C3D4E5F6A1B2") is True + + def test_zero_tray_uuid_returns_false(self, client): + """All-zero tray_uuid means no RFID tag read.""" + assert client.is_bambu_lab_spool("00000000000000000000000000000000") is False + + def test_zero_tag_uid_returns_false(self, client): + """All-zero tag_uid means no RFID tag read.""" + assert client.is_bambu_lab_spool("", tag_uid="0000000000000000") is False + + def test_empty_identifiers_returns_false(self, client): + """No identifiers means no BL spool.""" + assert client.is_bambu_lab_spool("") is False + assert client.is_bambu_lab_spool("", tag_uid="") is False + + def test_tray_info_idx_ignored(self, client): + """tray_info_idx is NOT a reliable BL indicator — third-party spools + using Bambu generic presets also have GF-prefixed tray_info_idx values.""" + # Third-party spool with Bambu preset but no RFID identifiers + assert client.is_bambu_lab_spool("", tray_info_idx="GFA00") is False + assert client.is_bambu_lab_spool("", tray_info_idx="GFB00") is False + assert client.is_bambu_lab_spool("", tray_info_idx="GFSA02_04") is False + + def test_tray_info_idx_with_valid_uuid_returns_true(self, client): + """BL spool with both RFID UUID and preset ID — detected by UUID.""" + assert ( + client.is_bambu_lab_spool( + "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4", + tray_info_idx="GFA00", + ) + is True + ) + + def test_tray_uuid_preferred_over_tag_uid(self, client): + """tray_uuid is checked before tag_uid (both valid).""" + assert ( + client.is_bambu_lab_spool( + "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4", + tag_uid="A1B2C3D4E5F6A1B2", + ) + is True + ) + + def test_short_tray_uuid_returns_false(self, client): + """UUID must be exactly 32 hex chars.""" + assert client.is_bambu_lab_spool("A1B2C3D4") is False + + def test_non_hex_tray_uuid_returns_false(self, client): + """UUID must be valid hex.""" + assert client.is_bambu_lab_spool("ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ") is False + + class TestSpoolmanClient: """Tests for SpoolmanClient class.""" diff --git a/backend/tests/unit/services/test_usage_tracker.py b/backend/tests/unit/services/test_usage_tracker.py index 64fb0296e..9ad9cd8aa 100644 --- a/backend/tests/unit/services/test_usage_tracker.py +++ b/backend/tests/unit/services/test_usage_tracker.py @@ -1,7 +1,7 @@ """Unit tests for the filament usage tracker. -Tests both AMS remain% delta tracking (Path 1) and 3MF per-filament -fallback tracking (Path 2) for non-BL spools. +Tests 3MF-primary tracking (Path 1) and AMS remain% delta fallback +(Path 2) for spools not covered by 3MF data. """ from datetime import datetime, timezone @@ -40,11 +40,13 @@ def _make_assignment(*, spool_id=1, printer_id=1, ams_id=0, tray_id=0): return assignment -def _make_printer_state(ams_data, progress=0): +def _make_printer_state(ams_data, progress=0, layer_num=0, tray_now=255): """Create a mock printer state with AMS data.""" state = MagicMock() state.raw_data = {"ams": ams_data} state.progress = progress + state.layer_num = layer_num + state.tray_now = tray_now return state @@ -189,16 +191,17 @@ class TestTrackFrom3MF: archive.file_path = "archives/test.3mf" db = AsyncMock() - # First execute → archive, second → assignment, third → spool + # archive, queue_item(None), assignment, spool db.execute = AsyncMock( side_effect=[ MagicMock(scalar_one_or_none=MagicMock(return_value=archive)), + MagicMock(scalar_one_or_none=MagicMock(return_value=None)), MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)), MagicMock(scalar_one_or_none=MagicMock(return_value=spool)), ] ) - pm = _make_printer_manager() + pm = _make_printer_manager(_make_printer_state([], tray_now=0)) filament_usage = [{"slot_id": 1, "used_g": 25.5, "type": "PLA", "color": "#FF0000"}] with ( @@ -234,16 +237,18 @@ class TestTrackFrom3MF: archive.file_path = "archives/test.3mf" db = AsyncMock() + # archive, queue_item(None), assignment, spool db.execute = AsyncMock( side_effect=[ MagicMock(scalar_one_or_none=MagicMock(return_value=archive)), + MagicMock(scalar_one_or_none=MagicMock(return_value=None)), MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)), MagicMock(scalar_one_or_none=MagicMock(return_value=spool)), ] ) # Print failed at 50% progress → 50g consumed from 100g estimate - pm = _make_printer_manager(_make_printer_state([], progress=50)) + pm = _make_printer_manager(_make_printer_state([], progress=50, tray_now=0)) filament_usage = [{"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": ""}] with ( @@ -269,23 +274,25 @@ class TestTrackFrom3MF: assert spool.weight_used == 50.0 @pytest.mark.asyncio - async def test_skips_bl_spools(self): - """BL spools (with tag_uid) are NOT tracked via 3MF — they use AMS remain%.""" + async def test_tracks_bl_spools_via_3mf(self): + """BL spools (with tag_uid) ARE now tracked via 3MF (unified tracking).""" spool = _make_spool(tag_uid="ABCD1234", tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4") assignment = _make_assignment() archive = MagicMock() archive.file_path = "archives/test.3mf" db = AsyncMock() + # archive, queue_item(None), assignment, spool db.execute = AsyncMock( side_effect=[ MagicMock(scalar_one_or_none=MagicMock(return_value=archive)), + MagicMock(scalar_one_or_none=MagicMock(return_value=None)), MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)), MagicMock(scalar_one_or_none=MagicMock(return_value=spool)), ] ) - pm = _make_printer_manager() + pm = _make_printer_manager(_make_printer_state([], tray_now=0)) filament_usage = [{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": ""}] with ( @@ -306,7 +313,9 @@ class TestTrackFrom3MF: db=db, ) - assert results == [] + assert len(results) == 1 + assert results[0]["spool_id"] == 1 + assert results[0]["weight_used"] == 50.0 @pytest.mark.asyncio async def test_skips_already_handled_trays(self): @@ -315,13 +324,15 @@ class TestTrackFrom3MF: archive.file_path = "archives/test.3mf" db = AsyncMock() + # archive, queue_item(None) db.execute = AsyncMock( side_effect=[ MagicMock(scalar_one_or_none=MagicMock(return_value=archive)), + MagicMock(scalar_one_or_none=MagicMock(return_value=None)), ] ) - pm = _make_printer_manager() + pm = _make_printer_manager(_make_printer_state([], tray_now=0)) filament_usage = [{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": ""}] with ( @@ -346,23 +357,25 @@ class TestTrackFrom3MF: @pytest.mark.asyncio async def test_slot_to_tray_mapping(self): - """3MF slot_id maps correctly to (ams_id, tray_id).""" - # slot 5 → global_tray_id 4 → ams_id=1, tray_id=0 + """3MF slot_id maps correctly to (ams_id, tray_id) via tray_now.""" + # tray_now=4 → ams_id=1, tray_id=0 (single filament uses tray_now) spool = _make_spool(id=9) assignment = _make_assignment(spool_id=9, ams_id=1, tray_id=0) archive = MagicMock() archive.file_path = "archives/test.3mf" db = AsyncMock() + # archive, queue_item(None), assignment, spool db.execute = AsyncMock( side_effect=[ MagicMock(scalar_one_or_none=MagicMock(return_value=archive)), + MagicMock(scalar_one_or_none=MagicMock(return_value=None)), MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)), MagicMock(scalar_one_or_none=MagicMock(return_value=spool)), ] ) - pm = _make_printer_manager() + pm = _make_printer_manager(_make_printer_state([], tray_now=4)) filament_usage = [{"slot_id": 5, "used_g": 30.0, "type": "PETG", "color": ""}] with ( diff --git a/backend/tests/unit/test_scheduler_ams_mapping.py b/backend/tests/unit/test_scheduler_ams_mapping.py index 04b7b3d7a..47fff82f7 100644 --- a/backend/tests/unit/test_scheduler_ams_mapping.py +++ b/backend/tests/unit/test_scheduler_ams_mapping.py @@ -1,8 +1,13 @@ """Tests for the AMS mapping computation in the print scheduler.""" +import io +import json +import zipfile + import pytest from backend.app.services.print_scheduler import PrintScheduler +from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf class TestSchedulerAmsMappingHelpers: @@ -467,3 +472,186 @@ class TestBuildLoadedFilamentsTrayInfoIdx: assert len(result) == 1 assert result[0]["tray_info_idx"] == "P4d64437" assert result[0]["is_external"] is True + + +def _make_3mf_zip(project_settings: dict | None = None) -> zipfile.ZipFile: + """Create an in-memory ZipFile mimicking a 3MF with project_settings.config.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + if project_settings is not None: + zf.writestr("Metadata/project_settings.config", json.dumps(project_settings)) + buf.seek(0) + return zipfile.ZipFile(buf, "r") + + +class TestExtractNozzleMappingFrom3mf: + """Test the extract_nozzle_mapping_from_3mf utility.""" + + def test_dual_nozzle_mapping(self): + """Should return slot->extruder mapping for dual-nozzle files.""" + zf = _make_3mf_zip( + { + "filament_nozzle_map": ["0", "1", "0"], + "physical_extruder_map": ["0", "1"], + } + ) + result = extract_nozzle_mapping_from_3mf(zf) + assert result == {1: 0, 2: 1, 3: 0} + zf.close() + + def test_single_nozzle_returns_none(self): + """All slots on same extruder should return None (single-nozzle).""" + zf = _make_3mf_zip( + { + "filament_nozzle_map": ["0", "0", "0"], + "physical_extruder_map": ["0"], + } + ) + result = extract_nozzle_mapping_from_3mf(zf) + assert result is None + zf.close() + + def test_missing_project_settings_returns_none(self): + """Missing project_settings.config should return None.""" + zf = _make_3mf_zip(None) + result = extract_nozzle_mapping_from_3mf(zf) + assert result is None + zf.close() + + def test_missing_fields_returns_none(self): + """Missing filament_nozzle_map or physical_extruder_map should return None.""" + zf = _make_3mf_zip({"some_other_key": "value"}) + result = extract_nozzle_mapping_from_3mf(zf) + assert result is None + zf.close() + + def test_physical_extruder_map_remapping(self): + """Should apply physical_extruder_map to remap slicer extruder to MQTT extruder.""" + # Slicer ext 0 -> MQTT ext 1, slicer ext 1 -> MQTT ext 0 + zf = _make_3mf_zip( + { + "filament_nozzle_map": ["0", "1"], + "physical_extruder_map": ["1", "0"], + } + ) + result = extract_nozzle_mapping_from_3mf(zf) + assert result == {1: 1, 2: 0} + zf.close() + + +class TestNozzleAwareMapping: + """Test nozzle-aware filament matching in the print scheduler.""" + + @pytest.fixture + def scheduler(self): + return PrintScheduler() + + def test_dual_nozzle_matching(self, scheduler): + """Filaments assigned to different nozzles should match to correct AMS units.""" + required = [ + {"slot_id": 1, "type": "PLA", "color": "#FF0000", "nozzle_id": 0}, # Right nozzle + {"slot_id": 2, "type": "PLA", "color": "#00FF00", "nozzle_id": 1}, # Left nozzle + ] + loaded = [ + {"type": "PLA", "color": "#00FF00", "global_tray_id": 0, "extruder_id": 0}, # AMS0 on right + {"type": "PLA", "color": "#FF0000", "global_tray_id": 4, "extruder_id": 1}, # AMS1 on left + ] + # Without nozzle filtering, slot 1 (red, right) would match tray 4 (red, left) by color. + # With nozzle filtering, slot 1 (right nozzle) can only use tray 0 (right extruder), + # and slot 2 (left nozzle) can only use tray 4 (left extruder). + result = scheduler._match_filaments_to_slots(required, loaded) + assert result == [0, 4] + + def test_nozzle_fallback_when_no_match(self, scheduler): + """Should fall back to unfiltered list when nozzle-filtered list is empty.""" + required = [ + {"slot_id": 1, "type": "PLA", "color": "#FF0000", "nozzle_id": 0}, # Right nozzle + ] + loaded = [ + # Only a tray on the left nozzle, none on right + {"type": "PLA", "color": "#FF0000", "global_tray_id": 4, "extruder_id": 1}, + ] + # No trays on extruder 0, so fallback to unfiltered -> should still match + result = scheduler._match_filaments_to_slots(required, loaded) + assert result == [4] + + def test_no_nozzle_id_skips_filtering(self, scheduler): + """When nozzle_id is None, no nozzle filtering should be applied.""" + required = [ + {"slot_id": 1, "type": "PLA", "color": "#FF0000"}, # No nozzle_id + ] + loaded = [ + {"type": "PLA", "color": "#FF0000", "global_tray_id": 0, "extruder_id": 0}, + {"type": "PLA", "color": "#FF0000", "global_tray_id": 4, "extruder_id": 1}, + ] + # Should match first available (tray 0) regardless of extruder + result = scheduler._match_filaments_to_slots(required, loaded) + assert result == [0] + + def test_extruder_id_in_loaded_filaments(self, scheduler): + """_build_loaded_filaments should include extruder_id from ams_extruder_map.""" + + class MockStatus: + raw_data = { + "ams": [ + {"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000"}]}, + {"id": 1, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "00FF00"}]}, + ], + "ams_extruder_map": {"0": 0, "1": 1}, + } + + result = scheduler._build_loaded_filaments(MockStatus()) + assert len(result) == 2 + assert result[0]["extruder_id"] == 0 + assert result[1]["extruder_id"] == 1 + + def test_extruder_id_none_without_map(self, scheduler): + """extruder_id should be None when ams_extruder_map is absent.""" + + class MockStatus: + raw_data = { + "ams": [ + {"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000"}]}, + ] + } + + result = scheduler._build_loaded_filaments(MockStatus()) + assert len(result) == 1 + assert result[0]["extruder_id"] is None + + def test_external_spool_extruder_id(self, scheduler): + """External spool should have extruder_id=0 when ams_extruder_map exists.""" + + class MockStatus: + raw_data = { + "vt_tray": {"tray_type": "TPU", "tray_color": "0000FF"}, + "ams_extruder_map": {"0": 0}, + } + + result = scheduler._build_loaded_filaments(MockStatus()) + assert len(result) == 1 + assert result[0]["extruder_id"] == 0 + assert result[0]["is_external"] is True + + def test_external_spool_no_extruder_map(self, scheduler): + """External spool extruder_id should be None without ams_extruder_map.""" + + class MockStatus: + raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF"}} + + result = scheduler._build_loaded_filaments(MockStatus()) + assert len(result) == 1 + assert result[0]["extruder_id"] is None + + def test_dual_nozzle_with_tray_info_idx(self, scheduler): + """Nozzle filtering should work together with tray_info_idx matching.""" + required = [ + {"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00", "nozzle_id": 0}, + {"slot_id": 2, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA01", "nozzle_id": 1}, + ] + loaded = [ + {"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": "GFA00", "extruder_id": 0}, + {"type": "PLA", "color": "#000000", "global_tray_id": 4, "tray_info_idx": "GFA01", "extruder_id": 1}, + ] + result = scheduler._match_filaments_to_slots(required, loaded) + assert result == [0, 4] diff --git a/backend/tests/unit/test_usage_tracker.py b/backend/tests/unit/test_usage_tracker.py new file mode 100644 index 000000000..a63168999 --- /dev/null +++ b/backend/tests/unit/test_usage_tracker.py @@ -0,0 +1,726 @@ +"""Unit tests for usage_tracker.py — 3MF-primary filament tracking. + +Tests the unified tracking logic: 3MF slicer estimates as primary path, +AMS remain% delta as fallback, per-layer gcode for partial prints, +slot-to-tray mapping resolution, and notification variable formatting. +""" + +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.app.services.usage_tracker import ( + PrintSession, + _active_sessions, + _track_from_3mf, + on_print_complete, + on_print_start, +) + + +def _make_spool(spool_id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uuid=None): + """Create a mock Spool object.""" + spool = MagicMock() + spool.id = spool_id + spool.label_weight = label_weight + spool.weight_used = weight_used + spool.tag_uid = tag_uid + spool.tray_uuid = tray_uuid + spool.last_used = None + return spool + + +def _make_assignment(spool_id=1, printer_id=1, ams_id=0, tray_id=0): + """Create a mock SpoolAssignment object.""" + assignment = MagicMock() + assignment.spool_id = spool_id + assignment.printer_id = printer_id + assignment.ams_id = ams_id + assignment.tray_id = tray_id + return assignment + + +def _make_archive(archive_id=1, file_path="archives/1/test.3mf", extra_data=None): + """Create a mock PrintArchive object.""" + archive = MagicMock() + archive.id = archive_id + archive.file_path = file_path + archive.extra_data = extra_data + return archive + + +def _make_queue_item(ams_mapping=None, status="printing"): + """Create a mock PrintQueueItem object.""" + item = MagicMock() + item.ams_mapping = ams_mapping + item.status = status + return item + + +def _mock_db_execute(*return_values): + """Create a mock db with execute() that returns values in sequence.""" + db = AsyncMock() + results = [] + for val in return_values: + result = MagicMock() + result.scalar_one_or_none.return_value = val + results.append(result) + db.execute = AsyncMock(side_effect=results) + return db + + +def _mock_db_sequential(responses): + """Create mock db that returns responses in order.""" + db = AsyncMock() + call_count = [0] + + async def mock_execute(*args, **kwargs): + idx = call_count[0] + call_count[0] += 1 + result = MagicMock() + if idx < len(responses): + result.scalar_one_or_none.return_value = responses[idx] + else: + result.scalar_one_or_none.return_value = None + return result + + db.execute = mock_execute + return db + + +class TestOnPrintStart: + """Tests for on_print_start().""" + + @pytest.fixture(autouse=True) + def _clear_sessions(self): + _active_sessions.clear() + yield + _active_sessions.clear() + + @pytest.mark.asyncio + async def test_captures_remain_data(self): + """Captures AMS remain% at print start.""" + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}, {"id": 1, "remain": 50}]}]} + ) + + await on_print_start(1, {"subtask_name": "Benchy"}, printer_manager) + + assert 1 in _active_sessions + session = _active_sessions[1] + assert session.print_name == "Benchy" + assert session.tray_remain_start == {(0, 0): 80, (0, 1): 50} + + @pytest.mark.asyncio + async def test_creates_session_without_remain(self): + """Creates session even without valid remain data (for 3MF tracking).""" + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": -1}]}]} + ) + + await on_print_start(1, {"subtask_name": "Test"}, printer_manager) + + assert 1 in _active_sessions + assert _active_sessions[1].tray_remain_start == {} + + +class TestOnPrintComplete: + """Tests for on_print_complete() — path ordering and interaction.""" + + @pytest.fixture(autouse=True) + def _clear_sessions(self): + _active_sessions.clear() + yield + _active_sessions.clear() + + @pytest.mark.asyncio + async def test_bl_spool_uses_3mf(self): + """BL spool (with tag_uid) is tracked via 3MF, not just AMS delta.""" + spool = _make_spool(spool_id=1, tag_uid="AABB1122", label_weight=1000) + assignment = _make_assignment(spool_id=1, printer_id=1, ams_id=0, tray_id=0) + archive = _make_archive(archive_id=10) + + # Setup: session with AMS remain data + _active_sessions[1] = PrintSession( + printer_id=1, + print_name="Benchy", + started_at=datetime.now(timezone.utc), + tray_remain_start={(0, 0): 80}, + ) + + # Mock printer state: tray_now=0 (AMS0-T0), single filament + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]}, + progress=100, + layer_num=50, + tray_now=0, + ) + + # db returns: archive, queue_item(None), assignment, spool + db = _mock_db_sequential([archive, None, assignment, spool]) + + filament_usage = [{"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": "#FF0000"}] + + with ( + patch("backend.app.core.config.settings") as mock_settings, + patch( + "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", + return_value=filament_usage, + ), + ): + mock_settings.base_dir = MagicMock() + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path) + + results = await on_print_complete( + printer_id=1, + data={"status": "completed"}, + printer_manager=printer_manager, + db=db, + archive_id=10, + ) + + # 3MF path should handle it (BL guard removed) + assert len(results) >= 1 + assert results[0]["spool_id"] == 1 + assert results[0]["weight_used"] == 15.0 + + @pytest.mark.asyncio + async def test_ams_delta_fallback_no_archive(self): + """AMS delta tracks consumption when archive_id is None.""" + spool = _make_spool(spool_id=2, label_weight=1000) + assignment = _make_assignment(spool_id=2) + + _active_sessions[1] = PrintSession( + printer_id=1, + print_name="Test", + started_at=datetime.now(timezone.utc), + tray_remain_start={(0, 0): 80}, + ) + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]}, + ) + + # db returns assignment then spool + db = _mock_db_sequential([assignment, spool]) + + results = await on_print_complete( + printer_id=1, + data={"status": "completed"}, + printer_manager=printer_manager, + db=db, + archive_id=None, + ) + + assert len(results) == 1 + assert results[0]["spool_id"] == 2 + # 10% of 1000g = 100g + assert results[0]["weight_used"] == 100.0 + assert results[0]["percent_used"] == 10 + + @pytest.mark.asyncio + async def test_no_double_tracking(self): + """When 3MF handles a tray, AMS delta skips it.""" + spool = _make_spool(spool_id=1, label_weight=1000) + assignment = _make_assignment(spool_id=1) + archive = _make_archive(archive_id=10) + + _active_sessions[1] = PrintSession( + printer_id=1, + print_name="Benchy", + started_at=datetime.now(timezone.utc), + tray_remain_start={(0, 0): 80}, + ) + + # tray_now=0 matches the single filament slot + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]}, + progress=100, + layer_num=50, + tray_now=0, + ) + + # db returns: archive, queue_item(None), assignment, spool + db = _mock_db_sequential([archive, None, assignment, spool]) + + filament_usage = [{"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": "#FF0000"}] + + with ( + patch("backend.app.core.config.settings") as mock_settings, + patch( + "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", + return_value=filament_usage, + ), + ): + mock_settings.base_dir = MagicMock() + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path) + + results = await on_print_complete( + printer_id=1, + data={"status": "completed"}, + printer_manager=printer_manager, + db=db, + archive_id=10, + ) + + # Only 1 result (3MF), NOT 2 (3MF + AMS delta) + assert len(results) == 1 + assert results[0]["weight_used"] == 15.0 + + +class TestTrackFrom3mf: + """Tests for _track_from_3mf() — per-layer, linear scaling, and slot mapping.""" + + @pytest.mark.asyncio + async def test_linear_fallback_for_partial_print(self): + """Falls back to linear scaling when gcode layer data unavailable.""" + spool = _make_spool(spool_id=1, label_weight=1000) + assignment = _make_assignment(spool_id=1) + archive = _make_archive(archive_id=10) + + # db: archive, queue_item(None), assignment, spool + db = _mock_db_sequential([archive, None, assignment, spool]) + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + progress=50, + layer_num=25, + tray_now=0, + ) + + filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": ""}] + handled_trays: set[tuple[int, int]] = set() + + with ( + patch("backend.app.core.config.settings") as mock_settings, + patch( + "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", + return_value=filament_usage, + ), + patch( + "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", + return_value=None, # No layer data available + ), + ): + mock_settings.base_dir = MagicMock() + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path) + + results = await _track_from_3mf( + printer_id=1, + archive_id=10, + status="failed", + print_name="Benchy", + handled_trays=handled_trays, + printer_manager=printer_manager, + db=db, + ) + + assert len(results) == 1 + # 50% of 20g = 10g + assert results[0]["weight_used"] == 10.0 + # Tray should be marked as handled + assert (0, 0) in handled_trays + + @pytest.mark.asyncio + async def test_per_layer_partial_print(self): + """Failed print at layer N uses gcode cumulative data.""" + spool = _make_spool(spool_id=1, label_weight=1000) + assignment = _make_assignment(spool_id=1) + archive = _make_archive(archive_id=10) + + # db: archive, queue_item(None), assignment, spool + db = _mock_db_sequential([archive, None, assignment, spool]) + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + progress=50, + layer_num=25, + tray_now=0, + ) + + filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": ""}] + # Per-layer data: at layer 25, filament 0 used 5000mm + layer_data = {10: {0: 2000.0}, 25: {0: 5000.0}, 50: {0: 10000.0}} + filament_props = {1: {"density": 1.24, "diameter": 1.75}} + handled_trays: set[tuple[int, int]] = set() + + with ( + patch("backend.app.core.config.settings") as mock_settings, + patch( + "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", + return_value=filament_usage, + ), + patch( + "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", + return_value=layer_data, + ), + patch( + "backend.app.utils.threemf_tools.get_cumulative_usage_at_layer", + return_value={0: 5000.0}, + ), + patch( + "backend.app.utils.threemf_tools.extract_filament_properties_from_3mf", + return_value=filament_props, + ), + patch( + "backend.app.utils.threemf_tools.mm_to_grams", + return_value=12.0, # 5000mm at 1.75mm/1.24g/cm3 + ), + ): + mock_settings.base_dir = MagicMock() + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path) + + results = await _track_from_3mf( + printer_id=1, + archive_id=10, + status="failed", + print_name="Benchy", + handled_trays=handled_trays, + printer_manager=printer_manager, + db=db, + ) + + assert len(results) == 1 + # Should use per-layer grams (12.0g), not linear scale (10.0g) + assert results[0]["weight_used"] == 12.0 + + @pytest.mark.asyncio + async def test_completed_print_uses_full_weight(self): + """Completed print uses full 3MF weight (scale=1.0).""" + spool = _make_spool(spool_id=1, label_weight=1000) + assignment = _make_assignment(spool_id=1) + archive = _make_archive(archive_id=10) + + # db: archive, queue_item(None), assignment, spool + db = _mock_db_sequential([archive, None, assignment, spool]) + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + progress=100, + layer_num=50, + tray_now=0, + ) + + filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": ""}] + handled_trays: set[tuple[int, int]] = set() + + with ( + patch("backend.app.core.config.settings") as mock_settings, + patch( + "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", + return_value=filament_usage, + ), + ): + mock_settings.base_dir = MagicMock() + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path) + + results = await _track_from_3mf( + printer_id=1, + archive_id=10, + status="completed", + print_name="Benchy", + handled_trays=handled_trays, + printer_manager=printer_manager, + db=db, + ) + + assert len(results) == 1 + assert results[0]["weight_used"] == 20.0 + + @pytest.mark.asyncio + async def test_tray_now_override_for_single_filament(self): + """Single-filament non-queue print uses tray_now instead of slot_id mapping.""" + # Spool 2 is at AMS1-T3 (global_tray_id=7) + spool = _make_spool(spool_id=2, label_weight=1000) + assignment = _make_assignment(spool_id=2, ams_id=1, tray_id=3) + archive = _make_archive(archive_id=10) + + # db: archive, queue_item(None), assignment, spool + db = _mock_db_sequential([archive, None, assignment, spool]) + + # tray_now=7 = (ams_id=1, tray_id=3), the ACTUAL tray used + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + progress=100, + layer_num=50, + tray_now=7, + ) + + # 3MF has slot_id=12 (would default-map to ams_id=2, tray_id=3 — WRONG) + filament_usage = [{"slot_id": 12, "used_g": 10.6, "type": "PLA", "color": "#FF0000"}] + handled_trays: set[tuple[int, int]] = set() + + with ( + patch("backend.app.core.config.settings") as mock_settings, + patch( + "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", + return_value=filament_usage, + ), + ): + mock_settings.base_dir = MagicMock() + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path) + + results = await _track_from_3mf( + printer_id=1, + archive_id=10, + status="completed", + print_name="Test", + handled_trays=handled_trays, + printer_manager=printer_manager, + db=db, + ) + + assert len(results) == 1 + assert results[0]["spool_id"] == 2 + assert results[0]["ams_id"] == 1 + assert results[0]["tray_id"] == 3 + assert results[0]["weight_used"] == 10.6 + assert (1, 3) in handled_trays + + @pytest.mark.asyncio + async def test_queue_ams_mapping_overrides_default(self): + """Queue item ams_mapping overrides default slot_id mapping.""" + # Spool at AMS1-T3 (global_tray_id=7) + spool = _make_spool(spool_id=5, label_weight=1000) + assignment = _make_assignment(spool_id=5, ams_id=1, tray_id=3) + archive = _make_archive(archive_id=20) + # Queue item maps slot 1 → global tray 7 (ams_id=1, tray_id=3) + queue_item = _make_queue_item(ams_mapping="[7, -1, -1, -1]") + + # db: archive, queue_item, assignment, spool + db = _mock_db_sequential([archive, queue_item, assignment, spool]) + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + progress=100, + layer_num=50, + tray_now=7, + ) + + filament_usage = [{"slot_id": 1, "used_g": 25.0, "type": "PETG", "color": ""}] + handled_trays: set[tuple[int, int]] = set() + + with ( + patch("backend.app.core.config.settings") as mock_settings, + patch( + "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", + return_value=filament_usage, + ), + ): + mock_settings.base_dir = MagicMock() + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path) + + results = await _track_from_3mf( + printer_id=1, + archive_id=20, + status="completed", + print_name="Queue Print", + handled_trays=handled_trays, + printer_manager=printer_manager, + db=db, + ) + + assert len(results) == 1 + assert results[0]["spool_id"] == 5 + assert results[0]["ams_id"] == 1 + assert results[0]["tray_id"] == 3 + assert results[0]["weight_used"] == 25.0 + + @pytest.mark.asyncio + async def test_multi_filament_uses_queue_mapping(self): + """Multi-filament queue prints use ams_mapping for each slot.""" + spool_a = _make_spool(spool_id=1, label_weight=1000) + spool_b = _make_spool(spool_id=2, label_weight=1000) + assign_a = _make_assignment(spool_id=1, ams_id=0, tray_id=0) + assign_b = _make_assignment(spool_id=2, ams_id=1, tray_id=2) + archive = _make_archive(archive_id=30) + # slot 1 → tray 0 (AMS0-T0), slot 2 → tray 6 (AMS1-T2) + queue_item = _make_queue_item(ams_mapping="[0, 6]") + + # db: archive, queue_item, assign_a, spool_a, assign_b, spool_b + db = _mock_db_sequential([archive, queue_item, assign_a, spool_a, assign_b, spool_b]) + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + progress=100, + layer_num=50, + tray_now=6, + ) + + filament_usage = [ + {"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}, + {"slot_id": 2, "used_g": 5.0, "type": "PETG", "color": ""}, + ] + handled_trays: set[tuple[int, int]] = set() + + with ( + patch("backend.app.core.config.settings") as mock_settings, + patch( + "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", + return_value=filament_usage, + ), + ): + mock_settings.base_dir = MagicMock() + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path) + + results = await _track_from_3mf( + printer_id=1, + archive_id=30, + status="completed", + print_name="Multi", + handled_trays=handled_trays, + printer_manager=printer_manager, + db=db, + ) + + assert len(results) == 2 + assert results[0]["spool_id"] == 1 + assert results[0]["ams_id"] == 0 + assert results[0]["tray_id"] == 0 + assert results[0]["weight_used"] == 10.0 + assert results[1]["spool_id"] == 2 + assert results[1]["ams_id"] == 1 + assert results[1]["tray_id"] == 2 + assert results[1]["weight_used"] == 5.0 + + @pytest.mark.asyncio + async def test_no_tray_now_override_for_multi_filament(self): + """Multi-filament non-queue prints fall back to default mapping, not tray_now.""" + spool = _make_spool(spool_id=1, label_weight=1000) + assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=0) + archive = _make_archive(archive_id=10) + + # db: archive, queue_item(None), assignment, spool (2nd slot has no assignment) + db = _mock_db_sequential([archive, None, assignment, spool, None]) + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + progress=100, + layer_num=50, + tray_now=4, # tray_now won't be used + ) + + # Two filament slots with usage + filament_usage = [ + {"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}, + {"slot_id": 2, "used_g": 5.0, "type": "PETG", "color": ""}, + ] + handled_trays: set[tuple[int, int]] = set() + + with ( + patch("backend.app.core.config.settings") as mock_settings, + patch( + "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", + return_value=filament_usage, + ), + ): + mock_settings.base_dir = MagicMock() + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path) + + results = await _track_from_3mf( + printer_id=1, + archive_id=10, + status="completed", + print_name="Test", + handled_trays=handled_trays, + printer_manager=printer_manager, + db=db, + ) + + # Should use default mapping (slot 1 → tray 0, slot 2 → tray 1) + assert len(results) == 1 # Only slot 1 has assignment + assert results[0]["ams_id"] == 0 + assert results[0]["tray_id"] == 0 + + +class TestNotificationVariables: + """Tests for filament_details formatting in notifications.""" + + def test_filament_details_single_slot(self): + """Single slot produces 'PLA: 15.2g' format.""" + slots = [{"type": "PLA", "used_g": 15.2, "slot_id": 1, "color": "#FF0000"}] + parts = [] + for slot in slots: + ftype = slot.get("type", "Unknown") or "Unknown" + used = slot.get("used_g", 0) + parts.append(f"{ftype}: {used:.1f}g") + result = " | ".join(parts) + assert result == "PLA: 15.2g" + + def test_filament_details_multi_slot(self): + """Multiple slots produce 'PLA: 10.0g | PETG: 5.0g' format.""" + slots = [ + {"type": "PLA", "used_g": 10.0, "slot_id": 1, "color": ""}, + {"type": "PETG", "used_g": 5.0, "slot_id": 2, "color": ""}, + ] + parts = [] + for slot in slots: + ftype = slot.get("type", "Unknown") or "Unknown" + used = slot.get("used_g", 0) + parts.append(f"{ftype}: {used:.1f}g") + result = " | ".join(parts) + assert result == "PLA: 10.0g | PETG: 5.0g" + + def test_filament_details_empty_type(self): + """Empty type defaults to 'Unknown'.""" + slots = [{"type": "", "used_g": 5.0, "slot_id": 1, "color": ""}] + parts = [] + for slot in slots: + ftype = slot.get("type", "Unknown") or "Unknown" + used = slot.get("used_g", 0) + parts.append(f"{ftype}: {used:.1f}g") + result = " | ".join(parts) + assert result == "Unknown: 5.0g" + + def test_filament_grams_scaled_for_partial(self): + """filament_grams is scaled by progress for partial prints.""" + filament_used_grams = 20.0 + progress = 50 + scale = max(0.0, min(progress / 100.0, 1.0)) + scaled = round(filament_used_grams * scale, 1) + assert scaled == 10.0 + + def test_filament_grams_zero_progress(self): + """Progress=0 at cancellation gives 0.0g.""" + filament_used_grams = 20.0 + progress = 0 + scale = max(0.0, min(progress / 100.0, 1.0)) + scaled = round(filament_used_grams * scale, 1) + assert scaled == 0.0 + + def test_slot_scaling_for_partial(self): + """Per-slot usage is scaled linearly for partial prints.""" + slots = [ + {"type": "PLA", "used_g": 20.0, "slot_id": 1, "color": ""}, + {"type": "PETG", "used_g": 10.0, "slot_id": 2, "color": ""}, + ] + progress = 30 + scale = max(0.0, min(progress / 100.0, 1.0)) + scaled_slots = [{**s, "used_g": round(s["used_g"] * scale, 1)} for s in slots] + assert scaled_slots[0]["used_g"] == 6.0 + assert scaled_slots[1]["used_g"] == 3.0 diff --git a/frontend/src/__tests__/hooks/useFilamentMapping.test.ts b/frontend/src/__tests__/hooks/useFilamentMapping.test.ts index 06c2a847a..68e997593 100644 --- a/frontend/src/__tests__/hooks/useFilamentMapping.test.ts +++ b/frontend/src/__tests__/hooks/useFilamentMapping.test.ts @@ -347,3 +347,182 @@ describe('computeAmsMapping', () => { expect(result).toEqual([254]); // External spool global ID }); }); + +describe('buildLoadedFilaments - nozzle awareness', () => { + it('sets extruderId from ams_extruder_map', () => { + const status = createPrinterStatus([ + { + id: 0, + tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }], + }, + { + id: 1, + tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }], + }, + ]); + (status as any).ams_extruder_map = { '0': 1, '1': 0 }; + + const result = buildLoadedFilaments(status); + + expect(result[0].extruderId).toBe(1); // AMS 0 → left nozzle + expect(result[1].extruderId).toBe(0); // AMS 1 → right nozzle + }); + + it('leaves extruderId undefined when no ams_extruder_map', () => { + const status = createPrinterStatus([ + { + id: 0, + tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }], + }, + ]); + + const result = buildLoadedFilaments(status); + + expect(result[0].extruderId).toBeUndefined(); + }); +}); + +describe('computeAmsMapping - nozzle filtering', () => { + it('filters candidates by nozzle_id when set', () => { + // Filament requires left nozzle (extruder 1), only AMS 0 is on left + const reqs = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 }, + ], + }; + const status = createPrinterStatus([ + { + id: 0, // Left nozzle + tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }], + }, + { + id: 1, // Right nozzle + tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }], + }, + ]); + (status as any).ams_extruder_map = { '0': 1, '1': 0 }; + + const result = computeAmsMapping(reqs, status); + + expect(result).toEqual([0]); // AMS 0, tray 0 (on left nozzle) + }); + + it('filters to right nozzle when nozzle_id=0', () => { + const reqs = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 0 }, + ], + }; + const status = createPrinterStatus([ + { + id: 0, // Left nozzle + tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }], + }, + { + id: 1, // Right nozzle + tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }], + }, + ]); + (status as any).ams_extruder_map = { '0': 1, '1': 0 }; + + const result = computeAmsMapping(reqs, status); + + expect(result).toEqual([4]); // AMS 1, tray 0 (global ID = 1*4+0 = 4, on right nozzle) + }); + + it('falls back to all trays when target nozzle has no trays at all', () => { + // Requires nozzle_id=1 (left), but no AMS units are on left nozzle + const reqs = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 }, + ], + }; + const status = createPrinterStatus([ + { + id: 0, // Right nozzle only + tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }], + }, + ]); + (status as any).ams_extruder_map = { '0': 0 }; // AMS 0 → right nozzle, none on left + + const result = computeAmsMapping(reqs, status); + + expect(result).toEqual([0]); // Falls back to unfiltered (right nozzle PLA) + }); + + it('stays restricted when target nozzle has trays but wrong type', () => { + // Left nozzle has PETG, right has PLA — but requires PLA on left + const reqs = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 }, + ], + }; + const status = createPrinterStatus([ + { + id: 0, // Left nozzle - only PETG + tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }], + }, + { + id: 1, // Right nozzle - has PLA + tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }], + }, + ]); + (status as any).ams_extruder_map = { '0': 1, '1': 0 }; + + const result = computeAmsMapping(reqs, status); + + expect(result).toEqual([-1]); // No PLA on left nozzle, stays restricted + }); + + it('skips nozzle filtering when nozzle_id is undefined', () => { + const reqs = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }, // No nozzle_id + ], + }; + const status = createPrinterStatus([ + { + id: 0, + tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }], + }, + { + id: 1, + tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }], + }, + ]); + (status as any).ams_extruder_map = { '0': 1, '1': 0 }; + + const result = computeAmsMapping(reqs, status); + + expect(result).toEqual([4]); // Picks best match regardless of nozzle + }); + + it('handles dual-nozzle multi-slot mapping', () => { + // Two filaments: one for left, one for right + const reqs = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 }, // Left + { slot_id: 2, type: 'PETG', color: '#00FF00', used_grams: 10, nozzle_id: 0 }, // Right + ], + }; + const status = createPrinterStatus([ + { + id: 0, // Left nozzle + tray: [ + { id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, + ], + }, + { + id: 1, // Right nozzle + tray: [ + { id: 0, tray_type: 'PETG', tray_color: '00FF00' }, + ], + }, + ]); + (status as any).ams_extruder_map = { '0': 1, '1': 0 }; + + const result = computeAmsMapping(reqs, status); + + expect(result).toEqual([0, 4]); // Left gets AMS0-T0, Right gets AMS1-T0 + }); +}); diff --git a/frontend/src/components/PrintModal/FilamentMapping.tsx b/frontend/src/components/PrintModal/FilamentMapping.tsx index 7ae5ce97c..99085f502 100644 --- a/frontend/src/components/PrintModal/FilamentMapping.tsx +++ b/frontend/src/components/PrintModal/FilamentMapping.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react'; import { api } from '../../api/client'; @@ -17,6 +18,7 @@ export function FilamentMapping({ onManualMappingChange, defaultExpanded = false, }: FilamentMappingProps & { defaultExpanded?: boolean }) { + const { t } = useTranslation(); const queryClient = useQueryClient(); const [isRefreshing, setIsRefreshing] = useState(false); const [isExpanded, setIsExpanded] = useState(defaultExpanded); @@ -32,6 +34,7 @@ export function FilamentMapping({ useFilamentMapping(filamentReqs, printerStatus, manualMappings); const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0; + const isDualNozzle = filamentReqs?.filaments?.some((f) => f.nozzle_id != null) ?? false; // Don't render if no filament requirements if (!hasFilamentReqs) { @@ -126,8 +129,16 @@ export function FilamentMapping({ - {/* Required type + grams */} - + {/* Required type + grams + nozzle badge */} + + {isDualNozzle && item.nozzle_id != null && ( + + {item.nozzle_id === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')} + + )} {item.type} ({item.used_grams}g) {/* Arrow */} diff --git a/frontend/src/components/PrintModal/types.ts b/frontend/src/components/PrintModal/types.ts index daa3f3a99..38241cab7 100644 --- a/frontend/src/components/PrintModal/types.ts +++ b/frontend/src/components/PrintModal/types.ts @@ -158,6 +158,7 @@ export interface FilamentReqsData { color: string; used_grams: number; used_meters: number; + nozzle_id?: number; }>; } diff --git a/frontend/src/hooks/useFilamentMapping.ts b/frontend/src/hooks/useFilamentMapping.ts index e430149ba..bb79c80c3 100644 --- a/frontend/src/hooks/useFilamentMapping.ts +++ b/frontend/src/hooks/useFilamentMapping.ts @@ -15,6 +15,8 @@ import type { PrinterStatus } from '../api/client'; */ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined): LoadedFilament[] { const filaments: LoadedFilament[] = []; + const amsExtruderMap = printerStatus?.ams_extruder_map; + const hasDualNozzle = amsExtruderMap && Object.keys(amsExtruderMap).length > 0; // Add filaments from all AMS units (regular and HT) printerStatus?.ams?.forEach((amsUnit) => { @@ -33,6 +35,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined): label: formatSlotLabel(amsUnit.id, tray.id, isHt, false), globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false), trayInfoIdx: tray.tray_info_idx || '', + extruderId: amsExtruderMap?.[String(amsUnit.id)], }); } }); @@ -52,6 +55,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined): label: 'External', globalTrayId: 254, trayInfoIdx: printerStatus.vt_tray.tray_info_idx || '', + extruderId: hasDualNozzle ? 0 : undefined, }); } @@ -90,7 +94,15 @@ export function computeAmsMapping( const reqTrayInfoIdx = req.tray_info_idx || ''; // Get available trays (not already used) - const available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId)); + let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId)); + + // Nozzle-aware filtering: restrict to trays on the correct nozzle + if (req.nozzle_id != null) { + const nozzleFiltered = available.filter((f) => f.extruderId === req.nozzle_id); + if (nozzleFiltered.length > 0) { + available = nozzleFiltered; + } + } let idxMatch: LoadedFilament | undefined; let exactMatch: LoadedFilament | undefined; @@ -191,6 +203,8 @@ export interface LoadedFilament { globalTrayId: number; /** Unique spool identifier (e.g., "GFA00", "P4d64437") */ trayInfoIdx?: string; + /** Extruder ID for dual-nozzle printers (0=right, 1=left) */ + extruderId?: number; } /** @@ -203,6 +217,8 @@ export interface FilamentRequirement { used_grams: number; /** Unique spool identifier from slicing (e.g., "GFA00", "P4d64437") */ tray_info_idx?: string; + /** Target nozzle for dual-nozzle printers (0=right, 1=left) */ + nozzle_id?: number; } /** @@ -247,48 +263,7 @@ export function useLoadedFilaments( printerStatus: PrinterStatus | undefined ): LoadedFilament[] { return useMemo(() => { - const filaments: LoadedFilament[] = []; - - // Add filaments from all AMS units (regular and HT) - printerStatus?.ams?.forEach((amsUnit) => { - const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray - amsUnit.tray.forEach((tray) => { - if (tray.tray_type) { - const color = normalizeColor(tray.tray_color); - filaments.push({ - type: tray.tray_type, - color, - colorName: getColorName(color), - amsId: amsUnit.id, - trayId: tray.id, - isHt, - isExternal: false, - label: formatSlotLabel(amsUnit.id, tray.id, isHt, false), - globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false), - trayInfoIdx: tray.tray_info_idx || '', - }); - } - }); - }); - - // 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 || '', - }); - } - - return filaments; + return buildLoadedFilaments(printerStatus); }, [printerStatus]); } @@ -355,7 +330,15 @@ export function useFilamentMapping( const reqTrayInfoIdx = req.tray_info_idx || ''; // Get available trays (not already used) - const available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId)); + let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId)); + + // Nozzle-aware filtering: restrict to trays on the correct nozzle + if (req.nozzle_id != null) { + const nozzleFiltered = available.filter((f) => f.extruderId === req.nozzle_id); + if (nozzleFiltered.length > 0) { + available = nozzleFiltered; + } + } let idxMatch: LoadedFilament | undefined; let exactMatch: LoadedFilament | undefined; diff --git a/frontend/src/hooks/useMultiPrinterFilamentMapping.ts b/frontend/src/hooks/useMultiPrinterFilamentMapping.ts index b8e62d45b..0a9e30a50 100644 --- a/frontend/src/hooks/useMultiPrinterFilamentMapping.ts +++ b/frontend/src/hooks/useMultiPrinterFilamentMapping.ts @@ -124,26 +124,32 @@ function computeMatchDetails( } } - // Auto-match - const exactMatch = loadedFilaments.find( + // Auto-match with nozzle-aware filtering + let candidates = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId)); + if (req.nozzle_id != null) { + const nozzleFiltered = candidates.filter((f) => f.extruderId === req.nozzle_id); + if (nozzleFiltered.length > 0) { + candidates = nozzleFiltered; + } + } + + const exactMatch = candidates.find( (f) => - !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase() && normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color) ); const similarMatch = exactMatch ? undefined - : loadedFilaments.find( + : candidates.find( (f) => - !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase() && colorsAreSimilar(f.color, req.color) ); const typeOnlyMatch = exactMatch || similarMatch ? undefined - : loadedFilaments.find( - (f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase() + : candidates.find( + (f) => f.type?.toUpperCase() === req.type?.toUpperCase() ); const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch; @@ -196,26 +202,32 @@ function computeMappingWithOverrides( continue; } - // Auto-match - const exactMatch = loadedFilaments.find( + // Auto-match with nozzle-aware filtering + let candidates = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId)); + if (req.nozzle_id != null) { + const nozzleFiltered = candidates.filter((f) => f.extruderId === req.nozzle_id); + if (nozzleFiltered.length > 0) { + candidates = nozzleFiltered; + } + } + + const exactMatch = candidates.find( (f) => - !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase() && normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color) ); const similarMatch = exactMatch ? undefined - : loadedFilaments.find( + : candidates.find( (f) => - !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase() && colorsAreSimilar(f.color, req.color) ); const typeOnlyMatch = exactMatch || similarMatch ? undefined - : loadedFilaments.find( - (f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase() + : candidates.find( + (f) => f.type?.toUpperCase() === req.type?.toUpperCase() ); const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch; diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 8dfc1e160..470934b84 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -124,6 +124,7 @@ export default { nozzleCount: 'Düsenanzahl', autoArchive: 'Automatische Archivierung', status: { + available: 'Verfügbar', idle: 'Bereit', printing: 'Druckt', paused: 'Pausiert', @@ -163,6 +164,7 @@ export default { }, // Controls hideOffline: 'Offline ausblenden', + nextAvailable: 'Nächster verfügbar', powerOn: 'Einschalten', offlinePrintersWithPlugs: 'Offline-Drucker mit Smart-Plugs', noPrintersConfigured: 'Noch keine Drucker konfiguriert', @@ -980,6 +982,7 @@ export default { removeFromPrinter: 'Von diesem Drucker entfernen', // Types types: { + lubricateCarbonRods: 'Karbonstäbe schmieren', lubricateRails: 'Linearschienen schmieren', cleanNozzle: 'Düse/Hotend reinigen', checkBelts: 'Riemenspannung prüfen', @@ -988,6 +991,7 @@ export default { checkCooling: 'Kühlungslüfter prüfen', generalInspection: 'Allgemeine Inspektion', cleanCarbonRods: 'Kohlenstoffstangen reinigen', + cleanLinearRails: 'Linearschienen reinigen', checkPtfeTube: 'PTFE-Schlauch prüfen', replaceHepaFilter: 'HEPA-Filter ersetzen', replaceCarbonFilter: 'Aktivkohlefilter ersetzen', @@ -2632,6 +2636,10 @@ export default { sameTypeDifferentColor: 'Gleicher Typ, andere Farbe', filamentTypeNotLoaded: 'Filamenttyp nicht geladen', openCalendar: 'Kalender öffnen', + leftNozzle: 'L', + rightNozzle: 'R', + leftNozzleTooltip: 'Linke Düse', + rightNozzleTooltip: 'Rechte Düse', }, // Backup @@ -2973,6 +2981,7 @@ export default { // Maintenance type descriptions (built-in) maintenanceDescriptions: { + lubricateCarbonRods: 'Schmiermittel auf Karbonstäbe für sanfte Bewegung auftragen', lubricateRails: 'Schmiermittel auf Linearschienen für sanfte Bewegung auftragen', cleanNozzle: 'Hotend und Düse reinigen, um Verstopfungen zu verhindern', checkBelts: 'Riemenspannung für präzise Drucke überprüfen', @@ -2981,6 +2990,7 @@ export default { checkCooling: 'Sicherstellen, dass Lüfter ordnungsgemäß funktionieren', generalInspection: 'Allgemeine Druckerinspektion', cleanCarbonRods: 'Karbonstäbe reinigen, um Reibung zu reduzieren', + cleanLinearRails: 'Linearschienen abwischen, um Staub und Schmutz zu entfernen', checkPtfeTube: 'PTFE-Schlauch auf Verschleiß oder Beschädigung prüfen', replaceHepaFilter: 'HEPA-Filter für Luftqualität ersetzen', replaceCarbonFilter: 'Aktivkohlefilter ersetzen', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index df586256f..2075fad81 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -124,6 +124,7 @@ export default { nozzleCount: 'Nozzle Count', autoArchive: 'Auto Archive', status: { + available: 'Available', idle: 'Idle', printing: 'Printing', paused: 'Paused', @@ -163,6 +164,7 @@ export default { }, // Controls hideOffline: 'Hide offline', + nextAvailable: 'Next available', powerOn: 'Power On', offlinePrintersWithPlugs: 'Offline printers with smart plugs', noPrintersConfigured: 'No printers configured yet', @@ -980,6 +982,7 @@ export default { removeFromPrinter: 'Remove from this printer', // Types types: { + lubricateCarbonRods: 'Lubricate Carbon Rods', lubricateRails: 'Lubricate Linear Rails', cleanNozzle: 'Clean Nozzle/Hotend', checkBelts: 'Check Belt Tension', @@ -988,6 +991,7 @@ export default { checkCooling: 'Check Cooling Fans', generalInspection: 'General Inspection', cleanCarbonRods: 'Clean Carbon Rods', + cleanLinearRails: 'Clean Linear Rails', checkPtfeTube: 'Check PTFE Tube', replaceHepaFilter: 'Replace HEPA Filter', replaceCarbonFilter: 'Replace Carbon Filter', @@ -2636,6 +2640,10 @@ export default { sameTypeDifferentColor: 'Same type, different color', filamentTypeNotLoaded: 'Filament type not loaded', openCalendar: 'Open calendar', + leftNozzle: 'L', + rightNozzle: 'R', + leftNozzleTooltip: 'Left nozzle', + rightNozzleTooltip: 'Right nozzle', }, // Backup @@ -2978,6 +2986,7 @@ export default { // Maintenance type descriptions (built-in) maintenanceDescriptions: { + lubricateCarbonRods: 'Apply lubricant to carbon rods for smooth motion', lubricateRails: 'Apply lubricant to linear rails for smooth motion', cleanNozzle: 'Clean hotend and nozzle to prevent clogs', checkBelts: 'Verify belt tension for accurate prints', @@ -2986,6 +2995,7 @@ export default { checkCooling: 'Ensure cooling fans are working properly', generalInspection: 'General printer inspection', cleanCarbonRods: 'Clean carbon rods to reduce friction', + cleanLinearRails: 'Wipe linear rails to remove dust and debris', checkPtfeTube: 'Inspect PTFE tube for wear or damage', replaceHepaFilter: 'Replace HEPA filter for air quality', replaceCarbonFilter: 'Replace activated carbon filter', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 7078d974e..32edbcae8 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -121,6 +121,7 @@ export default { nozzleCount: 'Numero Ugelli', autoArchive: 'Auto Archiviazione', status: { + available: 'Disponibile', idle: 'Inattiva', printing: 'In stampa', paused: 'In pausa', @@ -160,6 +161,7 @@ export default { }, // Controls hideOffline: 'Nascondi offline', + nextAvailable: 'Prossima disponibile', powerOn: 'Accendi', offlinePrintersWithPlugs: 'Stampanti offline con smart plug', noPrintersConfigured: 'Nessuna stampante configurata', @@ -967,6 +969,7 @@ export default { removeFromPrinter: 'Rimuovi da questa stampante', // Types types: { + lubricateCarbonRods: 'Lubrifica aste in carbonio', lubricateRails: 'Lubrifica guide lineari', cleanNozzle: 'Pulisci ugello/Hotend', checkBelts: 'Controlla tensione cinghie', @@ -975,6 +978,7 @@ export default { checkCooling: 'Controlla ventole raffreddamento', generalInspection: 'Ispezione generale', cleanCarbonRods: 'Pulisci aste in carbonio', + cleanLinearRails: 'Pulisci guide lineari', checkPtfeTube: 'Controlla tubo PTFE', replaceHepaFilter: 'Sostituisci filtro HEPA', replaceCarbonFilter: 'Sostituisci filtro carbone', @@ -2306,6 +2310,10 @@ export default { noPrintersAvailable: 'Nessuna stampante disponibile', printerBusy: 'Stampante occupata', printerOffline: 'Stampante offline', + leftNozzle: 'L', + rightNozzle: 'R', + leftNozzleTooltip: 'Ugello sinistro', + rightNozzleTooltip: 'Ugello destro', }, // Backup @@ -2647,6 +2655,7 @@ export default { // Maintenance type descriptions (built-in) maintenanceDescriptions: { + lubricateCarbonRods: 'Applica lubrificante alle aste in carbonio per un movimento fluido', lubricateRails: 'Applica lubrificante alle guide lineari per un movimento fluido', cleanNozzle: 'Pulisci hotend e ugello per prevenire intasamenti', checkBelts: 'Verifica tensione cinghie per stampe accurate', @@ -2655,6 +2664,7 @@ export default { checkCooling: 'Assicurati che le ventole di raffreddamento funzionino', generalInspection: 'Ispezione generale stampante', cleanCarbonRods: 'Pulisci le aste in carbonio per ridurre attrito', + cleanLinearRails: 'Pulisci le guide lineari per rimuovere polvere e detriti', checkPtfeTube: 'Ispeziona il tubo PTFE per usura o danni', replaceHepaFilter: 'Sostituisci filtro HEPA per qualità aria', replaceCarbonFilter: 'Sostituisci filtro a carbone attivo', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 656427701..602ddc13d 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -133,6 +133,7 @@ export default { nozzleCount: 'ノズル数', autoArchive: '自動アーカイブ', status: { + available: '利用可能', idle: '待機中', printing: '印刷中', paused: '一時停止', @@ -167,6 +168,7 @@ export default { extraLarge: '特大', }, hideOffline: 'オフラインを非表示', + nextAvailable: '次に完了', powerOn: '電源オン', noPrintersConfigured: 'プリンターが設定されていません', readyToPrint: '印刷可能', @@ -1015,6 +1017,7 @@ export default { noPrintersAssigned: 'プリンター未割り当て', removeFromPrinter: 'このプリンターから削除', types: { + lubricateCarbonRods: 'カーボンロッドの潤滑', lubricateRails: 'リニアレールの潤滑', cleanNozzle: 'ノズル/ホットエンドの清掃', checkBelts: 'ベルト張力の確認', @@ -1023,6 +1026,7 @@ export default { checkCooling: '冷却ファンの確認', generalInspection: '総合点検', cleanCarbonRods: 'カーボンロッドの清掃', + cleanLinearRails: 'リニアレールの清掃', checkPtfeTube: 'PTFEチューブの確認', replaceHepaFilter: 'HEPAフィルター交換', replaceCarbonFilter: 'カーボンフィルター交換', @@ -2552,6 +2556,10 @@ export default { printerBusy: 'プリンターは使用中です', printerOffline: 'プリンターはオフラインです', cancel: 'キャンセル', + leftNozzle: 'L', + rightNozzle: 'R', + leftNozzleTooltip: '左ノズル', + rightNozzleTooltip: '右ノズル', }, backup: { restoreBackup: 'バックアップの復元', @@ -2856,6 +2864,7 @@ export default { }, }, maintenanceDescriptions: { + lubricateCarbonRods: 'カーボンロッドに潤滑剤を塗布してスムーズな動きを確保', lubricateRails: 'リニアレールの潤滑', cleanNozzle: 'ノズル/ホットエンドの清掃', checkBelts: 'ベルト張力の確認', @@ -2864,6 +2873,7 @@ export default { checkCooling: '冷却ファンの確認', generalInspection: '総合点検', cleanCarbonRods: 'カーボンロッドの清掃', + cleanLinearRails: 'リニアレールを拭いてほこりや汚れを除去', checkPtfeTube: 'PTFEチューブの確認', replaceHepaFilter: 'HEPAフィルター交換', replaceCarbonFilter: 'カーボンフィルター交換', diff --git a/frontend/src/pages/MaintenancePage.tsx b/frontend/src/pages/MaintenancePage.tsx index 027fc3191..eef6add12 100644 --- a/frontend/src/pages/MaintenancePage.tsx +++ b/frontend/src/pages/MaintenancePage.tsx @@ -140,14 +140,19 @@ function getMaintenanceWikiUrl(typeName: string, printerModel: string | null): s const isP2S = model.includes('P2S'); switch (typeName) { - case 'Lubricate Linear Rails': + case 'Lubricate Carbon Rods': + // X1, P1, P2S series have carbon rods if (isX1) return 'https://wiki.bambulab.com/en/x1/maintenance/basic-maintenance'; if (isP1) return 'https://wiki.bambulab.com/en/p1/maintenance/p1p-maintenance'; + if (isP2S) return 'https://wiki.bambulab.com/en/p2s/maintenance/belt-tension'; + return null; + + case 'Lubricate Linear Rails': + // A1 and H2 series have linear rails if (isA1Mini) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/lubricate-y-axis'; if (isA1) return 'https://wiki.bambulab.com/en/a1/maintenance/lubricate-y-axis'; if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication'; - if (isP2S) return 'https://wiki.bambulab.com/en/p2s/maintenance/belt-tension'; // P2S maintenance page - return 'https://wiki.bambulab.com/en/general/lead-screws-lubrication'; + return null; case 'Clean Nozzle/Hotend': if (isX1 || isP1) return 'https://wiki.bambulab.com/en/x1/troubleshooting/nozzle-clog'; @@ -168,11 +173,16 @@ function getMaintenanceWikiUrl(typeName: string, printerModel: string | null): s return 'https://wiki.bambulab.com/en/x1/maintenance/belt-tension'; case 'Clean Carbon Rods': - // Only X1 and P1 series have carbon rods - if (isX1 || isP1) return 'https://wiki.bambulab.com/en/general/carbon-rods-clearance'; - // A1, H2, P2S don't have carbon rods - return null - if (isA1Mini || isA1 || isH2 || isP2S) return null; - return 'https://wiki.bambulab.com/en/general/carbon-rods-clearance'; + // X1, P1, P2S series have carbon rods + if (isX1 || isP1 || isP2S) return 'https://wiki.bambulab.com/en/general/carbon-rods-clearance'; + return null; + + case 'Clean Linear Rails': + // A1 and H2 series have linear rails + if (isA1Mini) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/lubricate-y-axis'; + if (isA1) return 'https://wiki.bambulab.com/en/a1/maintenance/lubricate-y-axis'; + if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication'; + return null; case 'Clean Build Plate': // Same for all printers @@ -381,7 +391,7 @@ function PrinterSection({ hasPermission: (permission: Permission) => boolean; t: TFunction; }) { - const [expanded, setExpanded] = useState(true); + const [expanded, setExpanded] = useState(false); const [editingHours, setEditingHours] = useState(false); const [hoursInput, setHoursInput] = useState(overview.total_print_hours.toFixed(1)); diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 92ea82746..ee1ea69ac 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -1137,21 +1137,17 @@ function getWifiStrength(rssi: number): { labelKey: string; color: string; bars: } /** - * Check if a tray contains a Bambu Lab spool. - * Uses same logic as backend: tray_info_idx (GF*), tray_uuid, or tag_uid. + * Check if a tray contains a Bambu Lab spool (RFID-tagged). + * Only checks hardware identifiers (tray_uuid, tag_uid) — NOT tray_info_idx, + * which is a filament profile/preset ID that third-party spools also get when + * the user selects a generic Bambu preset (e.g. "GFA00" for Generic PLA). */ function isBambuLabSpool(tray: { tray_uuid?: string | null; tag_uid?: string | null; - tray_info_idx?: string | null; } | null | undefined): boolean { if (!tray) return false; - // Check tray_info_idx first (most reliable - Bambu preset IDs start with "GF") - if (tray.tray_info_idx && tray.tray_info_idx.startsWith('GF')) { - return true; - } - // Check tray_uuid (32 hex chars, non-zero) if (tray.tray_uuid && tray.tray_uuid !== '00000000000000000000000000000000') { return true; @@ -1240,39 +1236,16 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) { const { t } = useTranslation(); const queryClient = useQueryClient(); - const counts = useMemo(() => { - let printing = 0; - let idle = 0; - let offline = 0; - let loading = 0; - - printers?.forEach((printer) => { - const status = queryClient.getQueryData<{ connected: boolean; state: string | null }>(['printerStatus', printer.id]); - if (status === undefined) { - // Status not yet loaded - don't count as offline yet - loading++; - } else if (!status.connected) { - offline++; - } else if (status.state === 'RUNNING') { - printing++; - } else { - idle++; - } - }); - - return { printing, idle, offline, loading, total: (printers?.length || 0) }; - }, [printers, queryClient]); - // Subscribe to query cache changes to re-render when status updates // Throttled to prevent rapid re-renders from causing tab crashes - const [, setTick] = useState(0); + const [cacheTick, setCacheTick] = useState(0); useEffect(() => { let pending = false; const unsubscribe = queryClient.getQueryCache().subscribe(() => { if (!pending) { pending = true; requestAnimationFrame(() => { - setTick(t => t + 1); + setCacheTick(t => t + 1); pending = false; }); } @@ -1280,10 +1253,53 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) { return () => unsubscribe(); }, [queryClient]); + const { counts, nextFinish } = useMemo(() => { + let printing = 0; + let idle = 0; + let offline = 0; + let loading = 0; + let nextPrinterName: string | null = null; + let nextRemainingMin: number | null = null; + let nextProgress: number = 0; + + printers?.forEach((printer) => { + const status = queryClient.getQueryData<{ connected: boolean; state: string | null; remaining_time: number | null; progress: number | null }>(['printerStatus', printer.id]); + if (status === undefined) { + // Status not yet loaded - don't count as offline yet + loading++; + } else if (!status.connected) { + offline++; + } else if (status.state === 'RUNNING') { + printing++; + if (status.remaining_time != null && status.remaining_time > 0) { + if (nextRemainingMin === null || status.remaining_time < nextRemainingMin) { + nextRemainingMin = status.remaining_time; + nextPrinterName = printer.name; + nextProgress = status.progress || 0; + } + } + } else { + idle++; + } + }); + + return { + counts: { printing, idle, offline, loading, total: (printers?.length || 0) }, + nextFinish: nextPrinterName && nextRemainingMin ? { name: nextPrinterName, remainingMin: nextRemainingMin, progress: nextProgress } : null, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [printers, queryClient, cacheTick]); + if (!printers?.length) return null; return (
+
+
0 ? 'bg-bambu-green' : 'bg-gray-500'}`} /> + + {counts.idle} {t('printers.status.available').toLowerCase()} + +
{counts.printing > 0 && (
@@ -1292,14 +1308,6 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
)} - {counts.idle > 0 && ( -
-
- - {counts.idle} {t('printers.status.idle').toLowerCase()} - -
- )} {counts.offline > 0 && (
@@ -1308,6 +1316,23 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
)} + {nextFinish && ( + <> +
+
+ {t('printers.nextAvailable')}: + {nextFinish.name} +
+
+
+ {Math.round(nextFinish.progress)}% + ({formatTime(nextFinish.remainingMin * 60)}) +
+ + )}
); } diff --git a/static/assets/index-CYXpThrD.js b/static/assets/index-DIy2OgxD.js similarity index 71% rename from static/assets/index-CYXpThrD.js rename to static/assets/index-DIy2OgxD.js index b3ed329cb..2d5c46ec8 100644 --- a/static/assets/index-CYXpThrD.js +++ b/static/assets/index-DIy2OgxD.js @@ -1,18 +1,18 @@ -function gse(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 cE={exports:{}},Ux={};var gU;function bse(){if(gU)return Ux;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 Ux.Fragment=e,Ux.jsx=n,Ux.jsxs=n,Ux}var bU;function xse(){return bU||(bU=1,cE.exports=bse()),cE.exports}var a=xse(),uE={exports:{}},Nn={};var xU;function yse(){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 m(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(){}},y=Object.assign,b={};function v(V,W,ie){this.props=V,this.context=W,this.refs=b,this.updater=ie||x}v.prototype.isReactComponent={},v.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")},v.prototype.forceUpdate=function(V){this.updater.enqueueForceUpdate(this,V,"forceUpdate")};function S(){}S.prototype=v.prototype;function N(V,W,ie){this.props=V,this.context=W,this.refs=b,this.updater=ie||x}var E=N.prototype=new S;E.constructor=N,y(E,v.prototype),E.isPureReactComponent=!0;var k=Array.isArray;function A(){}var M={H:null,A:null,T:null,S:null},P=Object.prototype.hasOwnProperty;function T(V,W,ie){var J=ie.ref;return{$$typeof:t,type:V,key:W,ref:J!==void 0?J:null,props:ie}}function j(V,W){return T(V.type,W,V.props)}function z(V){return typeof V=="object"&&V!==null&&V.$$typeof===t}function F(V){var W={"=":"=0",":":"=2"};return"$"+V.replace(/[=:]/g,function(ie){return W[ie]})}var $=/\/+/g;function D(V,W){return typeof V=="object"&&V!==null&&V.key!=null?F(""+V.key):W.toString(36)}function H(V){switch(V.status){case"fulfilled":return V.value;case"rejected":throw V.reason;default:switch(typeof V.status=="string"?V.then(A,A):(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,J,pe){var fe=typeof V;(fe==="undefined"||fe==="boolean")&&(V=null);var ve=!1;if(V===null)ve=!0;else switch(fe){case"bigint":case"string":case"number":ve=!0;break;case"object":switch(V.$$typeof){case t:case e:ve=!0;break;case d:return ve=V._init,q(ve(V._payload),W,ie,J,pe)}}if(ve)return pe=pe(V),ve=J===""?"."+D(V,0):J,k(pe)?(ie="",ve!=null&&(ie=ve.replace($,"$&/")+"/"),q(pe,W,ie,"",function(ce){return ce})):pe!=null&&(z(pe)&&(pe=j(pe,ie+(pe.key==null||V&&V.key===pe.key?"":(""+pe.key).replace($,"$&/")+"/")+ve)),W.push(pe)),1;ve=0;var Le=J===""?".":J+":";if(k(V))for(var de=0;de>>1,se=q[Z];if(0>>1;Zi(ie,K))Ji(pe,ie)?(q[Z]=pe,q[J]=K,Z=J):(q[Z]=ie,q[W]=K,Z=W);else if(Ji(pe,K))q[Z]=pe,q[J]=K,Z=J;else break e}}return I}function i(q,I){var K=q.sortIndex-I.sortIndex;return K!==0?K: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,m=!1,x=!1,y=!1,b=!1,v=typeof setTimeout=="function"?setTimeout:null,S=typeof clearTimeout=="function"?clearTimeout:null,N=typeof setImmediate<"u"?setImmediate:null;function E(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(y=!1,E(q),!x)if(n(c)!==null)x=!0,A||(A=!0,F());else{var I=n(u);I!==null&&H(k,I.startTime-q)}}var A=!1,M=-1,P=5,T=-1;function j(){return b?!0:!(t.unstable_now()-Tq&&j());){var Z=h.callback;if(typeof Z=="function"){h.callback=null,p=h.priorityLevel;var se=Z(h.expirationTime<=q);if(q=t.unstable_now(),typeof se=="function"){h.callback=se,E(q),I=!0;break t}h===n(c)&&r(c),E(q)}else r(c);h=n(c)}if(h!==null)I=!0;else{var V=n(u);V!==null&&H(k,V.startTime-q),I=!1}}break e}finally{h=null,p=K,m=!1}I=void 0}}finally{I?F():A=!1}}}var F;if(typeof N=="function")F=function(){N(z)};else if(typeof MessageChannel<"u"){var $=new MessageChannel,D=$.port2;$.port1.onmessage=z,F=function(){D.postMessage(null)}}else F=function(){v(z,0)};function H(q,I){M=v(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||125Z?(q.sortIndex=K,e(u,q),n(c)===null&&q===n(u)&&(y?(S(M),M=-1):y=!0,H(k,K-Z))):(q.sortIndex=se,e(c,q),x||m||(x=!0,A||(A=!0,F()))),q},t.unstable_shouldYield=j,t.unstable_wrapCallback=function(q){var I=p;return function(){var K=p;p=I;try{return q.apply(this,arguments)}finally{p=K}}}})(fE)),fE}var wU;function _se(){return wU||(wU=1,hE.exports=wse()),hE.exports}var mE={exports:{}},ns={};var _U;function Sse(){if(_U)return ns;_U=1;var t=Eb();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(),mE.exports=Sse(),mE.exports}var NU;function Nse(){if(NU)return Bx;NU=1;var t=_se(),e=Eb(),n=nW();function r(f){var g="https://react.dev/errors/"+f;if(1se||(f.current=Z[se],Z[se]=null,se--)}function ie(f,g){se++,Z[se]=f.current,f.current=g}var J=V(null),pe=V(null),fe=V(null),ve=V(null);function Le(f,g){switch(ie(fe,g),ie(pe,f),ie(J,null),g.nodeType){case 9:case 11:f=(f=g.documentElement)&&(f=f.namespaceURI)?zz(f):0;break;default:if(f=g.tagName,g=g.namespaceURI)g=zz(g),f=Uz(g,f);else switch(f){case"svg":f=1;break;case"math":f=2;break;default:f=0}}W(J),ie(J,f)}function de(){W(J),W(pe),W(fe)}function ce(f){f.memoizedState!==null&&ie(ve,f);var g=J.current,_=Uz(g,f.type);g!==_&&(ie(pe,f),ie(J,_))}function Ae(f){pe.current===f&&(W(J),W(pe)),ve.current===f&&(W(ve),Ox._currentValue=K)}var re,Ie;function G(f){if(re===void 0)try{throw Error()}catch(_){var g=_.stack.trim().match(/\n( *(at )?)/);re=g&&g[1]||"",Ie=-1<_.stack.indexOf(` +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 Gw=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 cA={exports:{}},zx={};var pU;function gse(){if(pU)return zx;pU=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 gU;function bse(){return gU||(gU=1,cA.exports=gse()),cA.exports}var a=bse(),uA={exports:{}},Nn={};var bU;function xse(){if(bU)return Nn;bU=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 m(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(){}},y=Object.assign,b={};function v(V,W,ie){this.props=V,this.context=W,this.refs=b,this.updater=ie||x}v.prototype.isReactComponent={},v.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")},v.prototype.forceUpdate=function(V){this.updater.enqueueForceUpdate(this,V,"forceUpdate")};function S(){}S.prototype=v.prototype;function k(V,W,ie){this.props=V,this.context=W,this.refs=b,this.updater=ie||x}var A=k.prototype=new S;A.constructor=k,y(A,v.prototype),A.isPureReactComponent=!0;var N=Array.isArray;function E(){}var M={H:null,A:null,T:null,S:null},P=Object.prototype.hasOwnProperty;function T(V,W,ie){var J=ie.ref;return{$$typeof:t,type:V,key:W,ref:J!==void 0?J:null,props:ie}}function j(V,W){return T(V.type,W,V.props)}function z(V){return typeof V=="object"&&V!==null&&V.$$typeof===t}function F(V){var W={"=":"=0",":":"=2"};return"$"+V.replace(/[=:]/g,function(ie){return W[ie]})}var $=/\/+/g;function D(V,W){return typeof V=="object"&&V!==null&&V.key!=null?F(""+V.key):W.toString(36)}function H(V){switch(V.status){case"fulfilled":return V.value;case"rejected":throw V.reason;default:switch(typeof V.status=="string"?V.then(E,E):(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,J,pe){var fe=typeof V;(fe==="undefined"||fe==="boolean")&&(V=null);var ve=!1;if(V===null)ve=!0;else switch(fe){case"bigint":case"string":case"number":ve=!0;break;case"object":switch(V.$$typeof){case t:case e:ve=!0;break;case d:return ve=V._init,q(ve(V._payload),W,ie,J,pe)}}if(ve)return pe=pe(V),ve=J===""?"."+D(V,0):J,N(pe)?(ie="",ve!=null&&(ie=ve.replace($,"$&/")+"/"),q(pe,W,ie,"",function(ce){return ce})):pe!=null&&(z(pe)&&(pe=j(pe,ie+(pe.key==null||V&&V.key===pe.key?"":(""+pe.key).replace($,"$&/")+"/")+ve)),W.push(pe)),1;ve=0;var Le=J===""?".":J+":";if(N(V))for(var de=0;de>>1,se=q[Z];if(0>>1;Zi(ie,K))Ji(pe,ie)?(q[Z]=pe,q[J]=K,Z=J):(q[Z]=ie,q[W]=K,Z=W);else if(Ji(pe,K))q[Z]=pe,q[J]=K,Z=J;else break e}}return I}function i(q,I){var K=q.sortIndex-I.sortIndex;return K!==0?K: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,m=!1,x=!1,y=!1,b=!1,v=typeof setTimeout=="function"?setTimeout:null,S=typeof clearTimeout=="function"?clearTimeout:null,k=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 N(q){if(y=!1,A(q),!x)if(n(c)!==null)x=!0,E||(E=!0,F());else{var I=n(u);I!==null&&H(N,I.startTime-q)}}var E=!1,M=-1,P=5,T=-1;function j(){return b?!0:!(t.unstable_now()-Tq&&j());){var Z=h.callback;if(typeof Z=="function"){h.callback=null,p=h.priorityLevel;var se=Z(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&&H(N,V.startTime-q),I=!1}}break e}finally{h=null,p=K,m=!1}I=void 0}}finally{I?F():E=!1}}}var F;if(typeof k=="function")F=function(){k(z)};else if(typeof MessageChannel<"u"){var $=new MessageChannel,D=$.port2;$.port1.onmessage=z,F=function(){D.postMessage(null)}}else F=function(){v(z,0)};function H(q,I){M=v(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||125Z?(q.sortIndex=K,e(u,q),n(c)===null&&q===n(u)&&(y?(S(M),M=-1):y=!0,H(N,K-Z))):(q.sortIndex=se,e(c,q),x||m||(x=!0,E||(E=!0,F()))),q},t.unstable_shouldYield=j,t.unstable_wrapCallback=function(q){var I=p;return function(){var K=p;p=I;try{return q.apply(this,arguments)}finally{p=K}}}})(fA)),fA}var vU;function wse(){return vU||(vU=1,hA.exports=vse()),hA.exports}var mA={exports:{}},ns={};var wU;function _se(){if(wU)return ns;wU=1;var t=Cb();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(),mA.exports=_se(),mA.exports}var SU;function Sse(){if(SU)return Ux;SU=1;var t=wse(),e=Cb(),n=nW();function r(f){var g="https://react.dev/errors/"+f;if(1se||(f.current=Z[se],Z[se]=null,se--)}function ie(f,g){se++,Z[se]=f.current,f.current=g}var J=V(null),pe=V(null),fe=V(null),ve=V(null);function Le(f,g){switch(ie(fe,g),ie(pe,f),ie(J,null),g.nodeType){case 9:case 11:f=(f=g.documentElement)&&(f=f.namespaceURI)?Iz(f):0;break;default:if(f=g.tagName,g=g.namespaceURI)g=Iz(g),f=zz(g,f);else switch(f){case"svg":f=1;break;case"math":f=2;break;default:f=0}}W(J),ie(J,f)}function de(){W(J),W(pe),W(fe)}function ce(f){f.memoizedState!==null&&ie(ve,f);var g=J.current,_=zz(g,f.type);g!==_&&(ie(pe,f),ie(J,_))}function Ee(f){pe.current===f&&(W(J),W(pe)),ve.current===f&&(W(ve),Lx._currentValue=K)}var re,Ie;function G(f){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+f+Ie}var Y=!1;function ue(f,g){if(!f||Y)return"";Y=!0;var _=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var C={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 ft=xt}Reflect.construct(f,[],kt)}else{try{kt.call()}catch(xt){ft=xt}f.call(kt.prototype)}}else{try{throw Error()}catch(xt){ft=xt}(kt=f())&&typeof kt.catch=="function"&&kt.catch(function(){})}}catch(xt){if(xt&&ft&&typeof xt.stack=="string")return[xt.stack,ft.stack]}return[null,null]}};C.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var R=Object.getOwnPropertyDescriptor(C.DetermineComponentFrameRoot,"name");R&&R.configurable&&Object.defineProperty(C.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var O=C.DetermineComponentFrameRoot(),ne=O[0],Se=O[1];if(ne&&Se){var Be=ne.split(` `),ut=Se.split(` `);for(R=C=0;CR||Be[C]!==ut[R]){var vt=` `+Be[C].replace(" at new "," at ");return f.displayName&&vt.includes("")&&(vt=vt.replace("",f.displayName)),vt}while(1<=C&&0<=R);break}}}finally{Y=!1,Error.prepareStackTrace=_}return(_=f?f.displayName||f.name:"")?G(_):""}function B(f,g){switch(f.tag){case 26:case 27:case 5:return G(f.type);case 16:return G("Lazy");case 13:return f.child!==g&&g!==null?G("Suspense Fallback"):G("Suspense");case 19:return G("SuspenseList");case 0:case 15:return ue(f.type,!1);case 11:return ue(f.type.render,!1);case 1:return ue(f.type,!0);case 31:return G("Activity");default:return""}}function ee(f){try{var g="",_=null;do g+=B(f,_),_=f,f=f.return;while(f);return g}catch(C){return` Error generating stack: `+C.message+` -`+C.stack}}var Q=Object.prototype.hasOwnProperty,le=t.unstable_scheduleCallback,he=t.unstable_cancelCallback,te=t.unstable_shouldYield,U=t.unstable_requestPaint,L=t.unstable_now,X=t.unstable_getCurrentPriorityLevel,ge=t.unstable_ImmediatePriority,_e=t.unstable_UserBlockingPriority,Ee=t.unstable_NormalPriority,$e=t.unstable_LowPriority,Ce=t.unstable_IdlePriority,He=t.log,Qe=t.unstable_setDisableYieldValue,xe=null,ye=null;function we(f){if(typeof He=="function"&&Qe(f),ye&&typeof ye.setStrictMode=="function")try{ye.setStrictMode(xe,f)}catch{}}var Ue=Math.clz32?Math.clz32:ke,Pe=Math.log,Ve=Math.LN2;function ke(f){return f>>>=0,f===0?32:31-(Pe(f)/Ve|0)|0}var Ze=256,je=262144,Xe=4194304;function Re(f){var g=f&42;if(g!==0)return g;switch(f&-f){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 f&261888;case 262144:case 524288:case 1048576:case 2097152:return f&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return f&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return f}}function Oe(f,g,_){var C=f.pendingLanes;if(C===0)return 0;var R=0,O=f.suspendedLanes,ne=f.pingedLanes;f=f.warmLanes;var Se=C&134217727;return Se!==0?(C=Se&~O,C!==0?R=Re(C):(ne&=Se,ne!==0?R=Re(ne):_||(_=Se&~f,_!==0&&(R=Re(_))))):(Se=C&~O,Se!==0?R=Re(Se):ne!==0?R=Re(ne):_||(_=C&~f,_!==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 Ne(f,g){return(f.pendingLanes&~(f.suspendedLanes&~f.pingedLanes)&g)===0}function Je(f,g){switch(f){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 f=Xe;return Xe<<=1,(Xe&62914560)===0&&(Xe=4194304),f}function Et(f){for(var g=[],_=0;31>_;_++)g.push(f);return g}function an(f,g){f.pendingLanes|=g,g!==268435456&&(f.suspendedLanes=0,f.pingedLanes=0,f.warmLanes=0)}function jt(f,g,_,C,R,O){var ne=f.pendingLanes;f.pendingLanes=_,f.suspendedLanes=0,f.pingedLanes=0,f.warmLanes=0,f.expiredLanes&=_,f.entangledLanes&=_,f.errorRecoveryDisabledLanes&=_,f.shellSuspendCounter=0;var Se=f.entanglements,Be=f.expirationTimes,ut=f.hiddenUpdates;for(_=ne&~_;0<_;){var vt=31-Ue(_),kt=1<"u")return null;try{return f.activeElement||f.body}catch{return f.body}}var Vr=/[\n"\\]/g;function Tn(f){return f.replace(Vr,function(g){return"\\"+g.charCodeAt(0).toString(16)+" "})}function _n(f,g,_,C,R,O,ne,Se){f.name="",ne!=null&&typeof ne!="function"&&typeof ne!="symbol"&&typeof ne!="boolean"?f.type=ne:f.removeAttribute("type"),g!=null?ne==="number"?(g===0&&f.value===""||f.value!=g)&&(f.value=""+Cn(g)):f.value!==""+Cn(g)&&(f.value=""+Cn(g)):ne!=="submit"&&ne!=="reset"||f.removeAttribute("value"),g!=null?mi(f,ne,Cn(g)):_!=null?mi(f,ne,Cn(_)):C!=null&&f.removeAttribute("value"),R==null&&O!=null&&(f.defaultChecked=!!O),R!=null&&(f.checked=R&&typeof R!="function"&&typeof R!="symbol"),Se!=null&&typeof Se!="function"&&typeof Se!="symbol"&&typeof Se!="boolean"?f.name=""+Cn(Se):f.removeAttribute("name")}function $n(f,g,_,C,R,O,ne,Se){if(O!=null&&typeof O!="function"&&typeof O!="symbol"&&typeof O!="boolean"&&(f.type=O),g!=null||_!=null){if(!(O!=="submit"&&O!=="reset"||g!=null)){mn(f);return}_=_!=null?""+Cn(_):"",g=g!=null?""+Cn(g):_,Se||g===f.value||(f.value=g),f.defaultValue=g}C=C??R,C=typeof C!="function"&&typeof C!="symbol"&&!!C,f.checked=Se?f.checked:!!C,f.defaultChecked=!!C,ne!=null&&typeof ne!="function"&&typeof ne!="symbol"&&typeof ne!="boolean"&&(f.name=ne),mn(f)}function mi(f,g,_){g==="number"&&Hr(f.ownerDocument)===f||f.defaultValue===""+_||(f.defaultValue=""+_)}function hr(f,g,_,C){if(f=f.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 Ml={};Object.defineProperty(Ml,"passive",{get:function(){kl=!0}}),window.addEventListener("test",Ml,Ml),window.removeEventListener("test",Ml,Ml)}catch{kl=!1}var Ia=null,Si=null,rc=null;function Gr(){if(rc)return rc;var f,g=Si,_=g.length,C,R="value"in Ia?Ia.value:Ia.textContent,O=R.length;for(f=0;f<_&&g[f]===R[f];f++);var ne=_-f;for(C=1;C<=ne&&g[_-C]===R[O-C];C++);return rc=R.slice(f,1=rx),qF=" ",WF=!1;function KF(f,g){switch(f){case"keyup":return Iie.indexOf(g.keyCode)!==-1;case"keydown":return g.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function XF(f){return f=f.detail,typeof f=="object"&&"data"in f?f.data:null}var Zm=!1;function Uie(f,g){switch(f){case"compositionend":return XF(g);case"keypress":return g.which!==32?null:(WF=!0,qF);case"textInput":return f=g.data,f===qF&&WF?null:f;default:return null}}function Bie(f,g){if(Zm)return f==="compositionend"||!pC&&KF(f,g)?(f=Gr(),rc=Si=Ia=null,Zm=!1,f):null;switch(f){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-f};f=C}e:{for(;_;){if(_.nextSibling){_=_.nextSibling;break e}_=_.parentNode}_=void 0}_=rI(_)}}function aI(f,g){return f&&g?f===g?!0:f&&f.nodeType===3?!1:g&&g.nodeType===3?aI(f,g.parentNode):"contains"in f?f.contains(g):f.compareDocumentPosition?!!(f.compareDocumentPosition(g)&16):!1:!1}function sI(f){f=f!=null&&f.ownerDocument!=null&&f.ownerDocument.defaultView!=null?f.ownerDocument.defaultView:window;for(var g=Hr(f.document);g instanceof f.HTMLIFrameElement;){try{var _=typeof g.contentWindow.location.href=="string"}catch{_=!1}if(_)f=g.contentWindow;else break;g=Hr(f.document)}return g}function xC(f){var g=f&&f.nodeName&&f.nodeName.toLowerCase();return g&&(g==="input"&&(f.type==="text"||f.type==="search"||f.type==="tel"||f.type==="url"||f.type==="password")||g==="textarea"||f.contentEditable==="true")}var Xie=Sa&&"documentMode"in document&&11>=document.documentMode,Jm=null,yC=null,ox=null,vC=!1;function oI(f,g,_){var C=_.window===_?_.document:_.nodeType===9?_:_.ownerDocument;vC||Jm==null||Jm!==Hr(C)||(C=Jm,"selectionStart"in C&&xC(C)?C={start:C.selectionStart,end:C.selectionEnd}:(C=(C.ownerDocument&&C.ownerDocument.defaultView||window).getSelection(),C={anchorNode:C.anchorNode,anchorOffset:C.anchorOffset,focusNode:C.focusNode,focusOffset:C.focusOffset}),ox&&sx(ox,C)||(ox=C,C=jw(yC,"onSelect"),0>=ne,R-=ne,ac=1<<32-Ue(g)+R|_<An?(Qn=Yt,Yt=null):Qn=Yt.sibling;var lr=ft(tt,Yt,ct[An],St);if(lr===null){Yt===null&&(Yt=Qn);break}f&&Yt&&lr.alternate===null&&g(tt,Yt),qe=O(lr,qe,An),or===null?nn=lr:or.sibling=lr,or=lr,Yt=Qn}if(An===ct.length)return _(tt,Yt),Jn&&qc(tt,An),nn;if(Yt===null){for(;AnAn?(Qn=Yt,Yt=null):Qn=Yt.sibling;var Id=ft(tt,Yt,lr.value,St);if(Id===null){Yt===null&&(Yt=Qn);break}f&&Yt&&Id.alternate===null&&g(tt,Yt),qe=O(Id,qe,An),or===null?nn=Id:or.sibling=Id,or=Id,Yt=Qn}if(lr.done)return _(tt,Yt),Jn&&qc(tt,An),nn;if(Yt===null){for(;!lr.done;An++,lr=ct.next())lr=kt(tt,lr.value,St),lr!==null&&(qe=O(lr,qe,An),or===null?nn=lr:or.sibling=lr,or=lr);return Jn&&qc(tt,An),nn}for(Yt=C(Yt);!lr.done;An++,lr=ct.next())lr=xt(Yt,tt,An,lr.value,St),lr!==null&&(f&&lr.alternate!==null&&Yt.delete(lr.key===null?An:lr.key),qe=O(lr,qe,An),or===null?nn=lr:or.sibling=lr,or=lr);return f&&Yt.forEach(function(pse){return g(tt,pse)}),Jn&&qc(tt,An),nn}function Cr(tt,qe,ct,St){if(typeof ct=="object"&&ct!==null&&ct.type===y&&ct.key===null&&(ct=ct.props.children),typeof ct=="object"&&ct!==null){switch(ct.$$typeof){case m:e:{for(var nn=ct.key;qe!==null;){if(qe.key===nn){if(nn=ct.type,nn===y){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===P&&gf(nn)===qe.type){_(tt,qe.sibling),St=R(qe,ct.props),fx(St,ct),St.return=tt,tt=St;break e}_(tt,qe);break}else g(tt,qe);qe=qe.sibling}ct.type===y?(St=df(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),fx(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=CC(ct,tt.mode,St),St.return=tt,tt=St}return ne(tt);case P:return ct=gf(ct),Cr(tt,qe,ct,St)}if(H(ct))return Wt(tt,qe,ct,St);if(F(ct)){if(nn=F(ct),typeof nn!="function")throw Error(r(150));return ct=nn.call(ct),dn(tt,qe,ct,St)}if(typeof ct.then=="function")return Cr(tt,qe,tw(ct),St);if(ct.$$typeof===N)return Cr(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=MC(ct,tt.mode,St),St.return=tt,tt=St),ne(tt)):_(tt,qe)}return function(tt,qe,ct,St){try{hx=0;var nn=Cr(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 xf=TI(!0),jI=TI(!1),wd=!1;function zC(f){f.updateQueue={baseState:f.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function UC(f,g){f=f.updateQueue,g.updateQueue===f&&(g.updateQueue={baseState:f.baseState,firstBaseUpdate:f.firstBaseUpdate,lastBaseUpdate:f.lastBaseUpdate,shared:f.shared,callbacks:null})}function _d(f){return{lane:f,tag:0,payload:null,callback:null,next:null}}function Sd(f,g,_){var C=f.updateQueue;if(C===null)return null;if(C=C.shared,(fr&2)!==0){var R=C.pending;return R===null?g.next=g:(g.next=R.next,R.next=g),C.pending=g,g=Wv(f),mI(f,null,_),g}return qv(f,C,g,_),Wv(f)}function mx(f,g,_){if(g=g.updateQueue,g!==null&&(g=g.shared,(_&4194048)!==0)){var C=g.lanes;C&=f.pendingLanes,_|=C,g.lanes=_,gt(f,_)}}function BC(f,g){var _=f.updateQueue,C=f.alternate;if(C!==null&&(C=C.updateQueue,_===C)){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:C.baseState,firstBaseUpdate:R,lastBaseUpdate:O,shared:C.shared,callbacks:C.callbacks},f.updateQueue=_;return}f=_.lastBaseUpdate,f===null?_.firstBaseUpdate=g:f.next=g,_.lastBaseUpdate=g}var $C=!1;function px(){if($C){var f=lp;if(f!==null)throw f}}function gx(f,g,_,C){$C=!1;var R=f.updateQueue;wd=!1;var O=R.firstBaseUpdate,ne=R.lastBaseUpdate,Se=R.shared.pending;if(Se!==null){R.shared.pending=null;var Be=Se,ut=Be.next;Be.next=null,ne===null?O=ut:ne.next=ut,ne=Be;var vt=f.alternate;vt!==null&&(vt=vt.updateQueue,Se=vt.lastBaseUpdate,Se!==ne&&(Se===null?vt.firstBaseUpdate=ut:Se.next=ut,vt.lastBaseUpdate=Be))}if(O!==null){var kt=R.baseState;ne=0,vt=ut=Be=null,Se=O;do{var ft=Se.lane&-536870913,xt=ft!==Se.lane;if(xt?(Yn&ft)===ft:(C&ft)===ft){ft!==0&&ft===op&&($C=!0),vt!==null&&(vt=vt.next={lane:0,tag:Se.tag,payload:Se.payload,callback:null,next:null});e:{var Wt=f,dn=Se;ft=g;var Cr=_;switch(dn.tag){case 1:if(Wt=dn.payload,typeof Wt=="function"){kt=Wt.call(Cr,kt,ft);break e}kt=Wt;break e;case 3:Wt.flags=Wt.flags&-65537|128;case 0:if(Wt=dn.payload,ft=typeof Wt=="function"?Wt.call(Cr,kt,ft):Wt,ft==null)break e;kt=h({},kt,ft);break e;case 2:wd=!0}}ft=Se.callback,ft!==null&&(f.flags|=64,xt&&(f.flags|=8192),xt=R.callbacks,xt===null?R.callbacks=[ft]:xt.push(ft))}else xt={lane:ft,tag:Se.tag,payload:Se.payload,callback:Se.callback,next:null},vt===null?(ut=vt=xt,Be=kt):vt=vt.next=xt,ne|=ft;if(Se=Se.next,Se===null){if(Se=R.shared.pending,Se===null)break;xt=Se,Se=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),Ed|=ne,f.lanes=ne,f.memoizedState=kt}}function PI(f,g){if(typeof f!="function")throw Error(r(191,f));f.call(g)}function DI(f,g){var _=f.callbacks;if(_!==null)for(f.callbacks=null,f=0;f<_.length;f++)PI(_[f],g)}var dp=V(null),rw=V(0);function RI(f,g){f=ru,ie(rw,f),ie(dp,g),ru=f|g.baseLanes}function HC(){ie(rw,ru),ie(dp,dp.current)}function VC(){ru=rw.current,W(dp),W(rw)}var co=V(null),Ho=null;function Nd(f){var g=f.alternate;ie(Li,Li.current&1),ie(co,f),Ho===null&&(g===null||dp.current!==null||g.memoizedState!==null)&&(Ho=f)}function GC(f){ie(Li,Li.current),ie(co,f),Ho===null&&(Ho=f)}function LI(f){f.tag===22?(ie(Li,Li.current),ie(co,f),Ho===null&&(Ho=f)):kd()}function kd(){ie(Li,Li.current),ie(co,co.current)}function uo(f){W(co),Ho===f&&(Ho=null),W(Li)}var Li=V(0);function iw(f){for(var g=f;g!==null;){if(g.tag===13){var _=g.memoizedState;if(_!==null&&(_=_.dehydrated,_===null||Q2(_)||Z2(_)))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===f)break;for(;g.sibling===null;){if(g.return===null||g.return===f)return null;g=g.return}g.sibling.return=g.return,g=g.sibling}return null}var Xc=0,En=null,kr=null,Vi=null,aw=!1,hp=!1,yf=!1,sw=0,bx=0,fp=null,aae=0;function ki(){throw Error(r(321))}function qC(f,g){if(g===null)return!1;for(var _=0;_O?O:8;var ne=q.T,Se={};q.T=Se,o2(f,!1,g,_);try{var Be=R(),ut=q.S;if(ut!==null&&ut(Se,Be),Be!==null&&typeof Be=="object"&&typeof Be.then=="function"){var vt=iae(Be,C);yx(f,g,vt,mo(f))}else yx(f,g,C,mo(f))}catch(kt){yx(f,g,{then:function(){},status:"rejected",reason:kt},mo())}finally{I.p=O,ne!==null&&Se.types!==null&&(ne.types=Se.types),q.T=ne}}function uae(){}function a2(f,g,_,C){if(f.tag!==5)throw Error(r(476));var R=d5(f).queue;u5(f,R,g,K,_===null?uae:function(){return h5(f),_(C)})}function d5(f){var g=f.memoizedState;if(g!==null)return g;g={memoizedState:K,baseState:K,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Yc,lastRenderedState:K},next:null};var _={};return g.next={memoizedState:_,baseState:_,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Yc,lastRenderedState:_},next:null},f.memoizedState=g,f=f.alternate,f!==null&&(f.memoizedState=g),g}function h5(f){var g=d5(f);g.next===null&&(g=f.alternate.memoizedState),yx(f,g.next.queue,{},mo())}function s2(){return Ua(Ox)}function f5(){return Oi().memoizedState}function m5(){return Oi().memoizedState}function dae(f){for(var g=f.return;g!==null;){switch(g.tag){case 24:case 3:var _=mo();f=_d(_);var C=Sd(g,f,_);C!==null&&($s(C,g,_),mx(C,g,_)),g={cache:LC()},f.payload=g;return}g=g.return}}function hae(f,g,_){var C=mo();_={lane:C,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null},hw(f)?g5(g,_):(_=NC(f,g,_,C),_!==null&&($s(_,f,C),b5(_,g,C)))}function p5(f,g,_){var C=mo();yx(f,g,_,C)}function yx(f,g,_,C){var R={lane:C,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null};if(hw(f))g5(g,R);else{var O=f.alternate;if(f.lanes===0&&(O===null||O.lanes===0)&&(O=g.lastRenderedReducer,O!==null))try{var ne=g.lastRenderedState,Se=O(ne,_);if(R.hasEagerState=!0,R.eagerState=Se,oo(Se,ne))return qv(f,g,R,0),Fr===null&&Gv(),!1}catch{}finally{}if(_=NC(f,g,R,C),_!==null)return $s(_,f,C),b5(_,g,C),!0}return!1}function o2(f,g,_,C){if(C={lane:2,revertLane:z2(),gesture:null,action:C,hasEagerState:!1,eagerState:null,next:null},hw(f)){if(g)throw Error(r(479))}else g=NC(f,_,C,2),g!==null&&$s(g,f,2)}function hw(f){var g=f.alternate;return f===En||g!==null&&g===En}function g5(f,g){hp=aw=!0;var _=f.pending;_===null?g.next=g:(g.next=_.next,_.next=g),f.pending=g}function b5(f,g,_){if((_&4194048)!==0){var C=g.lanes;C&=f.pendingLanes,_|=C,g.lanes=_,gt(f,_)}}var vx={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};vx.useEffectEvent=ki;var x5={readContext:Ua,use:lw,useCallback:function(f,g){return ws().memoizedState=[f,g===void 0?null:g],f},useContext:Ua,useEffect:t5,useImperativeHandle:function(f,g,_){_=_!=null?_.concat([f]):null,uw(4194308,4,a5.bind(null,g,f),_)},useLayoutEffect:function(f,g){return uw(4194308,4,f,g)},useInsertionEffect:function(f,g){uw(4,2,f,g)},useMemo:function(f,g){var _=ws();g=g===void 0?null:g;var C=f();if(yf){we(!0);try{f()}finally{we(!1)}}return _.memoizedState=[C,g],C},useReducer:function(f,g,_){var C=ws();if(_!==void 0){var R=_(g);if(yf){we(!0);try{_(g)}finally{we(!1)}}}else R=g;return C.memoizedState=C.baseState=R,f={pending:null,lanes:0,dispatch:null,lastRenderedReducer:f,lastRenderedState:R},C.queue=f,f=f.dispatch=hae.bind(null,En,f),[C.memoizedState,f]},useRef:function(f){var g=ws();return f={current:f},g.memoizedState=f},useState:function(f){f=e2(f);var g=f.queue,_=p5.bind(null,En,g);return g.dispatch=_,[f.memoizedState,_]},useDebugValue:r2,useDeferredValue:function(f,g){var _=ws();return i2(_,f,g)},useTransition:function(){var f=e2(!1);return f=u5.bind(null,En,f.queue,!0,!1),ws().memoizedState=f,[!1,f]},useSyncExternalStore:function(f,g,_){var C=En,R=ws();if(Jn){if(_===void 0)throw Error(r(407));_=_()}else{if(_=g(),Fr===null)throw Error(r(349));(Yn&127)!==0||zI(C,g,_)}R.memoizedState=_;var O={value:_,getSnapshot:g};return R.queue=O,t5(BI.bind(null,C,O,f),[f]),C.flags|=2048,mp(9,{destroy:void 0},UI.bind(null,C,O,_,g),null),_},useId:function(){var f=ws(),g=Fr.identifierPrefix;if(Jn){var _=sc,C=ac;_=(C&~(1<<32-Ue(C)-1)).toString(32)+_,g="_"+g+"R_"+_,_=sw++,0<_&&(g+="H"+_.toString(32)),g+="_"}else _=aae++,g="_"+g+"r_"+_.toString(32)+"_";return f.memoizedState=g},useHostTransitionStatus:s2,useFormState:YI,useActionState:YI,useOptimistic:function(f){var g=ws();g.memoizedState=g.baseState=f;var _={pending:null,lanes:0,dispatch:null,lastRenderedReducer:null,lastRenderedState:null};return g.queue=_,g=o2.bind(null,En,!0,_),_.dispatch=g,[f,g]},useMemoCache:QC,useCacheRefresh:function(){return ws().memoizedState=dae.bind(null,En)},useEffectEvent:function(f){var g=ws(),_={impl:f};return g.memoizedState=_,function(){if((fr&2)!==0)throw Error(r(440));return _.impl.apply(void 0,arguments)}}},l2={readContext:Ua,use:lw,useCallback:o5,useContext:Ua,useEffect:n2,useImperativeHandle:s5,useInsertionEffect:r5,useLayoutEffect:i5,useMemo:l5,useReducer:cw,useRef:e5,useState:function(){return cw(Yc)},useDebugValue:r2,useDeferredValue:function(f,g){var _=Oi();return c5(_,kr.memoizedState,f,g)},useTransition:function(){var f=cw(Yc)[0],g=Oi().memoizedState;return[typeof f=="boolean"?f:xx(f),g]},useSyncExternalStore:II,useId:f5,useHostTransitionStatus:s2,useFormState:QI,useActionState:QI,useOptimistic:function(f,g){var _=Oi();return VI(_,kr,f,g)},useMemoCache:QC,useCacheRefresh:m5};l2.useEffectEvent=n5;var y5={readContext:Ua,use:lw,useCallback:o5,useContext:Ua,useEffect:n2,useImperativeHandle:s5,useInsertionEffect:r5,useLayoutEffect:i5,useMemo:l5,useReducer:JC,useRef:e5,useState:function(){return JC(Yc)},useDebugValue:r2,useDeferredValue:function(f,g){var _=Oi();return kr===null?i2(_,f,g):c5(_,kr.memoizedState,f,g)},useTransition:function(){var f=JC(Yc)[0],g=Oi().memoizedState;return[typeof f=="boolean"?f:xx(f),g]},useSyncExternalStore:II,useId:f5,useHostTransitionStatus:s2,useFormState:JI,useActionState:JI,useOptimistic:function(f,g){var _=Oi();return kr!==null?VI(_,kr,f,g):(_.baseState=f,[f,_.queue.dispatch])},useMemoCache:QC,useCacheRefresh:m5};y5.useEffectEvent=n5;function c2(f,g,_,C){g=f.memoizedState,_=_(C,g),_=_==null?g:h({},g,_),f.memoizedState=_,f.lanes===0&&(f.updateQueue.baseState=_)}var u2={enqueueSetState:function(f,g,_){f=f._reactInternals;var C=mo(),R=_d(C);R.payload=g,_!=null&&(R.callback=_),g=Sd(f,R,C),g!==null&&($s(g,f,C),mx(g,f,C))},enqueueReplaceState:function(f,g,_){f=f._reactInternals;var C=mo(),R=_d(C);R.tag=1,R.payload=g,_!=null&&(R.callback=_),g=Sd(f,R,C),g!==null&&($s(g,f,C),mx(g,f,C))},enqueueForceUpdate:function(f,g){f=f._reactInternals;var _=mo(),C=_d(_);C.tag=2,g!=null&&(C.callback=g),g=Sd(f,C,_),g!==null&&($s(g,f,_),mx(g,f,_))}};function v5(f,g,_,C,R,O,ne){return f=f.stateNode,typeof f.shouldComponentUpdate=="function"?f.shouldComponentUpdate(C,O,ne):g.prototype&&g.prototype.isPureReactComponent?!sx(_,C)||!sx(R,O):!0}function w5(f,g,_,C){f=g.state,typeof g.componentWillReceiveProps=="function"&&g.componentWillReceiveProps(_,C),typeof g.UNSAFE_componentWillReceiveProps=="function"&&g.UNSAFE_componentWillReceiveProps(_,C),g.state!==f&&u2.enqueueReplaceState(g,g.state,null)}function vf(f,g){var _=g;if("ref"in g){_={};for(var C in g)C!=="ref"&&(_[C]=g[C])}if(f=f.defaultProps){_===g&&(_=h({},_));for(var R in f)_[R]===void 0&&(_[R]=f[R])}return _}function _5(f){Vv(f)}function S5(f){console.error(f)}function N5(f){Vv(f)}function fw(f,g){try{var _=f.onUncaughtError;_(g.value,{componentStack:g.stack})}catch(C){setTimeout(function(){throw C})}}function k5(f,g,_){try{var C=f.onCaughtError;C(_.value,{componentStack:_.stack,errorBoundary:g.tag===1?g.stateNode:null})}catch(R){setTimeout(function(){throw R})}}function d2(f,g,_){return _=_d(_),_.tag=3,_.payload={element:null},_.callback=function(){fw(f,g)},_}function M5(f){return f=_d(f),f.tag=3,f}function C5(f,g,_,C){var R=_.type.getDerivedStateFromError;if(typeof R=="function"){var O=C.value;f.payload=function(){return R(O)},f.callback=function(){k5(g,_,C)}}var ne=_.stateNode;ne!==null&&typeof ne.componentDidCatch=="function"&&(f.callback=function(){k5(g,_,C),typeof R!="function"&&(Ad===null?Ad=new Set([this]):Ad.add(this));var Se=C.stack;this.componentDidCatch(C.value,{componentStack:Se!==null?Se:""})})}function fae(f,g,_,C,R){if(_.flags|=32768,C!==null&&typeof C=="object"&&typeof C.then=="function"){if(g=_.alternate,g!==null&&sp(g,_,R,!0),_=co.current,_!==null){switch(_.tag){case 31:case 13:return Ho===null?kw():_.alternate===null&&Mi===0&&(Mi=3),_.flags&=-257,_.flags|=65536,_.lanes=R,C===ew?_.flags|=16384:(g=_.updateQueue,g===null?_.updateQueue=new Set([C]):g.add(C),O2(f,C,R)),!1;case 22:return _.flags|=65536,C===ew?_.flags|=16384:(g=_.updateQueue,g===null?(g={transitions:null,markerInstances:null,retryQueue:new Set([C])},_.updateQueue=g):(_=g.retryQueue,_===null?g.retryQueue=new Set([C]):_.add(C)),O2(f,C,R)),!1}throw Error(r(435,_.tag))}return O2(f,C,R),kw(),!1}if(Jn)return g=co.current,g!==null?((g.flags&65536)===0&&(g.flags|=256),g.flags|=65536,g.lanes=R,C!==TC&&(f=Error(r(422),{cause:C}),cx(zo(f,_)))):(C!==TC&&(g=Error(r(423),{cause:C}),cx(zo(g,_))),f=f.current.alternate,f.flags|=65536,R&=-R,f.lanes|=R,C=zo(C,_),R=d2(f.stateNode,C,R),BC(f,R),Mi!==4&&(Mi=2)),!1;var O=Error(r(520),{cause:C});if(O=zo(O,_),Ex===null?Ex=[O]:Ex.push(O),Mi!==4&&(Mi=2),g===null)return!0;C=zo(C,_),_=g;do{switch(_.tag){case 3:return _.flags|=65536,f=R&-R,_.lanes|=f,f=d2(_.stateNode,C,f),BC(_,f),!1;case 1:if(g=_.type,O=_.stateNode,(_.flags&128)===0&&(typeof g.getDerivedStateFromError=="function"||O!==null&&typeof O.componentDidCatch=="function"&&(Ad===null||!Ad.has(O))))return _.flags|=65536,R&=-R,_.lanes|=R,R=M5(R),C5(R,f,_,C),BC(_,R),!1}_=_.return}while(_!==null);return!1}var h2=Error(r(461)),Gi=!1;function Ba(f,g,_,C){g.child=f===null?jI(g,null,_,C):xf(g,f.child,_,C)}function E5(f,g,_,C,R){_=_.render;var O=g.ref;if("ref"in C){var ne={};for(var Se in C)Se!=="ref"&&(ne[Se]=C[Se])}else ne=C;return mf(g),C=WC(f,g,_,ne,O,R),Se=KC(),f!==null&&!Gi?(XC(f,g,R),Qc(f,g,R)):(Jn&&Se&&EC(g),g.flags|=1,Ba(f,g,C,R),g.child)}function A5(f,g,_,C,R){if(f===null){var O=_.type;return typeof O=="function"&&!kC(O)&&O.defaultProps===void 0&&_.compare===null?(g.tag=15,g.type=O,T5(f,g,O,C,R)):(f=Kv(_.type,null,C,g,g.mode,R),f.ref=g.ref,f.return=g,g.child=f)}if(O=f.child,!v2(f,R)){var ne=O.memoizedProps;if(_=_.compare,_=_!==null?_:sx,_(ne,C)&&f.ref===g.ref)return Qc(f,g,R)}return g.flags|=1,f=Gc(O,C),f.ref=g.ref,f.return=g,g.child=f}function T5(f,g,_,C,R){if(f!==null){var O=f.memoizedProps;if(sx(O,C)&&f.ref===g.ref)if(Gi=!1,g.pendingProps=C=O,v2(f,R))(f.flags&131072)!==0&&(Gi=!0);else return g.lanes=f.lanes,Qc(f,g,R)}return f2(f,g,_,C,R)}function j5(f,g,_,C){var R=C.children,O=f!==null?f.memoizedState:null;if(f===null&&g.stateNode===null&&(g.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),C.mode==="hidden"){if((g.flags&128)!==0){if(O=O!==null?O.baseLanes|_:_,f!==null){for(C=g.child=f.child,R=0;C!==null;)R=R|C.lanes|C.childLanes,C=C.sibling;C=R&~O}else C=0,g.child=null;return P5(f,g,O,_,C)}if((_&536870912)!==0)g.memoizedState={baseLanes:0,cachePool:null},f!==null&&Zv(g,O!==null?O.cachePool:null),O!==null?RI(g,O):HC(),LI(g);else return C=g.lanes=536870912,P5(f,g,O!==null?O.baseLanes|_:_,_,C)}else O!==null?(Zv(g,O.cachePool),RI(g,O),kd(),g.memoizedState=null):(f!==null&&Zv(g,null),HC(),kd());return Ba(f,g,R,_),g.child}function wx(f,g){return f!==null&&f.tag===22||g.stateNode!==null||(g.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),g.sibling}function P5(f,g,_,C,R){var O=FC();return O=O===null?null:{parent:Hi._currentValue,pool:O},g.memoizedState={baseLanes:_,cachePool:O},f!==null&&Zv(g,null),HC(),LI(g),f!==null&&sp(f,g,C,!0),g.childLanes=R,null}function mw(f,g){return g=gw({mode:g.mode,children:g.children},f.mode),g.ref=f.ref,f.child=g,g.return=f,g}function D5(f,g,_){return xf(g,f.child,null,_),f=mw(g,g.pendingProps),f.flags|=2,uo(g),g.memoizedState=null,f}function mae(f,g,_){var C=g.pendingProps,R=(g.flags&128)!==0;if(g.flags&=-129,f===null){if(Jn){if(C.mode==="hidden")return f=mw(g,C),g.lanes=536870912,wx(null,f);if(GC(g),(f=Zr)?(f=Gz(f,$o),f=f!==null&&f.data==="&"?f:null,f!==null&&(g.memoizedState={dehydrated:f,treeContext:bd!==null?{id:ac,overflow:sc}:null,retryLane:536870912,hydrationErrors:null},_=gI(f),_.return=g,g.child=_,za=g,Zr=null)):f=null,f===null)throw yd(g);return g.lanes=536870912,null}return mw(g,C)}var O=f.memoizedState;if(O!==null){var ne=O.dehydrated;if(GC(g),R)if(g.flags&256)g.flags&=-257,g=D5(f,g,_);else if(g.memoizedState!==null)g.child=f.child,g.flags|=128,g=null;else throw Error(r(558));else if(Gi||sp(f,g,_,!1),R=(_&f.childLanes)!==0,Gi||R){if(C=Fr,C!==null&&(ne=Qt(C,_),ne!==0&&ne!==O.retryLane))throw O.retryLane=ne,uf(f,ne),$s(C,f,ne),h2;kw(),g=D5(f,g,_)}else f=O.treeContext,Zr=Vo(ne.nextSibling),za=g,Jn=!0,xd=null,$o=!1,f!==null&&yI(g,f),g=mw(g,C),g.flags|=4096;return g}return f=Gc(f.child,{mode:C.mode,children:C.children}),f.ref=g.ref,g.child=f,f.return=g,f}function pw(f,g){var _=g.ref;if(_===null)f!==null&&f.ref!==null&&(g.flags|=4194816);else{if(typeof _!="function"&&typeof _!="object")throw Error(r(284));(f===null||f.ref!==_)&&(g.flags|=4194816)}}function f2(f,g,_,C,R){return mf(g),_=WC(f,g,_,C,void 0,R),C=KC(),f!==null&&!Gi?(XC(f,g,R),Qc(f,g,R)):(Jn&&C&&EC(g),g.flags|=1,Ba(f,g,_,R),g.child)}function R5(f,g,_,C,R,O){return mf(g),g.updateQueue=null,_=FI(g,C,_,R),OI(f),C=KC(),f!==null&&!Gi?(XC(f,g,O),Qc(f,g,O)):(Jn&&C&&EC(g),g.flags|=1,Ba(f,g,_,O),g.child)}function L5(f,g,_,C,R){if(mf(g),g.stateNode===null){var O=np,ne=_.contextType;typeof ne=="object"&&ne!==null&&(O=Ua(ne)),O=new _(C,O),g.memoizedState=O.state!==null&&O.state!==void 0?O.state:null,O.updater=u2,g.stateNode=O,O._reactInternals=g,O=g.stateNode,O.props=C,O.state=g.memoizedState,O.refs={},zC(g),ne=_.contextType,O.context=typeof ne=="object"&&ne!==null?Ua(ne):np,O.state=g.memoizedState,ne=_.getDerivedStateFromProps,typeof ne=="function"&&(c2(g,_,ne,C),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&&u2.enqueueReplaceState(O,O.state,null),gx(g,C,O,R),px(),O.state=g.memoizedState),typeof O.componentDidMount=="function"&&(g.flags|=4194308),C=!0}else if(f===null){O=g.stateNode;var Se=g.memoizedProps,Be=vf(_,Se);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",Se=g.pendingProps!==Se,vt||typeof O.UNSAFE_componentWillReceiveProps!="function"&&typeof O.componentWillReceiveProps!="function"||(Se||ut!==ne)&&w5(g,O,C,ne),wd=!1;var ft=g.memoizedState;O.state=ft,gx(g,C,O,R),px(),ut=g.memoizedState,Se||ft!==ut||wd?(typeof kt=="function"&&(c2(g,_,kt,C),ut=g.memoizedState),(Be=wd||v5(g,_,Be,C,ft,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=C,g.memoizedState=ut),O.props=C,O.state=ut,O.context=ne,C=Be):(typeof O.componentDidMount=="function"&&(g.flags|=4194308),C=!1)}else{O=g.stateNode,UC(f,g),ne=g.memoizedProps,vt=vf(_,ne),O.props=vt,kt=g.pendingProps,ft=O.context,ut=_.contextType,Be=np,typeof ut=="object"&&ut!==null&&(Be=Ua(ut)),Se=_.getDerivedStateFromProps,(ut=typeof Se=="function"||typeof O.getSnapshotBeforeUpdate=="function")||typeof O.UNSAFE_componentWillReceiveProps!="function"&&typeof O.componentWillReceiveProps!="function"||(ne!==kt||ft!==Be)&&w5(g,O,C,Be),wd=!1,ft=g.memoizedState,O.state=ft,gx(g,C,O,R),px();var xt=g.memoizedState;ne!==kt||ft!==xt||wd||f!==null&&f.dependencies!==null&&Yv(f.dependencies)?(typeof Se=="function"&&(c2(g,_,Se,C),xt=g.memoizedState),(vt=wd||v5(g,_,vt,C,ft,xt,Be)||f!==null&&f.dependencies!==null&&Yv(f.dependencies))?(ut||typeof O.UNSAFE_componentWillUpdate!="function"&&typeof O.componentWillUpdate!="function"||(typeof O.componentWillUpdate=="function"&&O.componentWillUpdate(C,xt,Be),typeof O.UNSAFE_componentWillUpdate=="function"&&O.UNSAFE_componentWillUpdate(C,xt,Be)),typeof O.componentDidUpdate=="function"&&(g.flags|=4),typeof O.getSnapshotBeforeUpdate=="function"&&(g.flags|=1024)):(typeof O.componentDidUpdate!="function"||ne===f.memoizedProps&&ft===f.memoizedState||(g.flags|=4),typeof O.getSnapshotBeforeUpdate!="function"||ne===f.memoizedProps&&ft===f.memoizedState||(g.flags|=1024),g.memoizedProps=C,g.memoizedState=xt),O.props=C,O.state=xt,O.context=Be,C=vt):(typeof O.componentDidUpdate!="function"||ne===f.memoizedProps&&ft===f.memoizedState||(g.flags|=4),typeof O.getSnapshotBeforeUpdate!="function"||ne===f.memoizedProps&&ft===f.memoizedState||(g.flags|=1024),C=!1)}return O=C,pw(f,g),C=(g.flags&128)!==0,O||C?(O=g.stateNode,_=C&&typeof _.getDerivedStateFromError!="function"?null:O.render(),g.flags|=1,f!==null&&C?(g.child=xf(g,f.child,null,R),g.child=xf(g,null,_,R)):Ba(f,g,_,R),g.memoizedState=O.state,f=g.child):f=Qc(f,g,R),f}function O5(f,g,_,C){return hf(),g.flags|=256,Ba(f,g,_,C),g.child}var m2={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function p2(f){return{baseLanes:f,cachePool:kI()}}function g2(f,g,_){return f=f!==null?f.childLanes&~_:0,g&&(f|=fo),f}function F5(f,g,_){var C=g.pendingProps,R=!1,O=(g.flags&128)!==0,ne;if((ne=O)||(ne=f!==null&&f.memoizedState===null?!1:(Li.current&2)!==0),ne&&(R=!0,g.flags&=-129),ne=(g.flags&32)!==0,g.flags&=-33,f===null){if(Jn){if(R?Nd(g):kd(),(f=Zr)?(f=Gz(f,$o),f=f!==null&&f.data!=="&"?f:null,f!==null&&(g.memoizedState={dehydrated:f,treeContext:bd!==null?{id:ac,overflow:sc}:null,retryLane:536870912,hydrationErrors:null},_=gI(f),_.return=g,g.child=_,za=g,Zr=null)):f=null,f===null)throw yd(g);return Z2(f)?g.lanes=32:g.lanes=536870912,null}var Se=C.children;return C=C.fallback,R?(kd(),R=g.mode,Se=gw({mode:"hidden",children:Se},R),C=df(C,R,_,null),Se.return=g,C.return=g,Se.sibling=C,g.child=Se,C=g.child,C.memoizedState=p2(_),C.childLanes=g2(f,ne,_),g.memoizedState=m2,wx(null,C)):(Nd(g),b2(g,Se))}var Be=f.memoizedState;if(Be!==null&&(Se=Be.dehydrated,Se!==null)){if(O)g.flags&256?(Nd(g),g.flags&=-257,g=x2(f,g,_)):g.memoizedState!==null?(kd(),g.child=f.child,g.flags|=128,g=null):(kd(),Se=C.fallback,R=g.mode,C=gw({mode:"visible",children:C.children},R),Se=df(Se,R,_,null),Se.flags|=2,C.return=g,Se.return=g,C.sibling=Se,g.child=C,xf(g,f.child,null,_),C=g.child,C.memoizedState=p2(_),C.childLanes=g2(f,ne,_),g.memoizedState=m2,g=wx(null,C));else if(Nd(g),Z2(Se)){if(ne=Se.nextSibling&&Se.nextSibling.dataset,ne)var ut=ne.dgst;ne=ut,C=Error(r(419)),C.stack="",C.digest=ne,cx({value:C,source:null,stack:null}),g=x2(f,g,_)}else if(Gi||sp(f,g,_,!1),ne=(_&f.childLanes)!==0,Gi||ne){if(ne=Fr,ne!==null&&(C=Qt(ne,_),C!==0&&C!==Be.retryLane))throw Be.retryLane=C,uf(f,C),$s(ne,f,C),h2;Q2(Se)||kw(),g=x2(f,g,_)}else Q2(Se)?(g.flags|=192,g.child=f.child,g=null):(f=Be.treeContext,Zr=Vo(Se.nextSibling),za=g,Jn=!0,xd=null,$o=!1,f!==null&&yI(g,f),g=b2(g,C.children),g.flags|=4096);return g}return R?(kd(),Se=C.fallback,R=g.mode,Be=f.child,ut=Be.sibling,C=Gc(Be,{mode:"hidden",children:C.children}),C.subtreeFlags=Be.subtreeFlags&65011712,ut!==null?Se=Gc(ut,Se):(Se=df(Se,R,_,null),Se.flags|=2),Se.return=g,C.return=g,C.sibling=Se,g.child=C,wx(null,C),C=g.child,Se=f.child.memoizedState,Se===null?Se=p2(_):(R=Se.cachePool,R!==null?(Be=Hi._currentValue,R=R.parent!==Be?{parent:Be,pool:Be}:R):R=kI(),Se={baseLanes:Se.baseLanes|_,cachePool:R}),C.memoizedState=Se,C.childLanes=g2(f,ne,_),g.memoizedState=m2,wx(f.child,C)):(Nd(g),_=f.child,f=_.sibling,_=Gc(_,{mode:"visible",children:C.children}),_.return=g,_.sibling=null,f!==null&&(ne=g.deletions,ne===null?(g.deletions=[f],g.flags|=16):ne.push(f)),g.child=_,g.memoizedState=null,_)}function b2(f,g){return g=gw({mode:"visible",children:g},f.mode),g.return=f,f.child=g}function gw(f,g){return f=lo(22,f,null,g),f.lanes=0,f}function x2(f,g,_){return xf(g,f.child,null,_),f=b2(g,g.pendingProps.children),f.flags|=2,g.memoizedState=null,f}function I5(f,g,_){f.lanes|=g;var C=f.alternate;C!==null&&(C.lanes|=g),DC(f.return,g,_)}function y2(f,g,_,C,R,O){var ne=f.memoizedState;ne===null?f.memoizedState={isBackwards:g,rendering:null,renderingStartTime:0,last:C,tail:_,tailMode:R,treeForkCount:O}:(ne.isBackwards=g,ne.rendering=null,ne.renderingStartTime=0,ne.last=C,ne.tail=_,ne.tailMode=R,ne.treeForkCount=O)}function z5(f,g,_){var C=g.pendingProps,R=C.revealOrder,O=C.tail;C=C.children;var ne=Li.current,Se=(ne&2)!==0;if(Se?(ne=ne&1|2,g.flags|=128):ne&=1,ie(Li,ne),Ba(f,g,C,_),C=Jn?lx:0,!Se&&f!==null&&(f.flags&128)!==0)e:for(f=g.child;f!==null;){if(f.tag===13)f.memoizedState!==null&&I5(f,_,g);else if(f.tag===19)I5(f,_,g);else if(f.child!==null){f.child.return=f,f=f.child;continue}if(f===g)break e;for(;f.sibling===null;){if(f.return===null||f.return===g)break e;f=f.return}f.sibling.return=f.return,f=f.sibling}switch(R){case"forwards":for(_=g.child,R=null;_!==null;)f=_.alternate,f!==null&&iw(f)===null&&(R=_),_=_.sibling;_=R,_===null?(R=g.child,g.child=null):(R=_.sibling,_.sibling=null),y2(g,!1,R,_,O,C);break;case"backwards":case"unstable_legacy-backwards":for(_=null,R=g.child,g.child=null;R!==null;){if(f=R.alternate,f!==null&&iw(f)===null){g.child=R;break}f=R.sibling,R.sibling=_,_=R,R=f}y2(g,!0,_,null,O,C);break;case"together":y2(g,!1,null,null,void 0,C);break;default:g.memoizedState=null}return g.child}function Qc(f,g,_){if(f!==null&&(g.dependencies=f.dependencies),Ed|=g.lanes,(_&g.childLanes)===0)if(f!==null){if(sp(f,g,_,!1),(_&g.childLanes)===0)return null}else return null;if(f!==null&&g.child!==f.child)throw Error(r(153));if(g.child!==null){for(f=g.child,_=Gc(f,f.pendingProps),g.child=_,_.return=g;f.sibling!==null;)f=f.sibling,_=_.sibling=Gc(f,f.pendingProps),_.return=g;_.sibling=null}return g.child}function v2(f,g){return(f.lanes&g)!==0?!0:(f=f.dependencies,!!(f!==null&&Yv(f)))}function pae(f,g,_){switch(g.tag){case 3:Le(g,g.stateNode.containerInfo),vd(g,Hi,f.memoizedState.cache),hf();break;case 27:case 5:ce(g);break;case 4:Le(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,GC(g),null;break;case 13:var C=g.memoizedState;if(C!==null)return C.dehydrated!==null?(Nd(g),g.flags|=128,null):(_&g.child.childLanes)!==0?F5(f,g,_):(Nd(g),f=Qc(f,g,_),f!==null?f.sibling:null);Nd(g);break;case 19:var R=(f.flags&128)!==0;if(C=(_&g.childLanes)!==0,C||(sp(f,g,_,!1),C=(_&g.childLanes)!==0),R){if(C)return z5(f,g,_);g.flags|=128}if(R=g.memoizedState,R!==null&&(R.rendering=null,R.tail=null,R.lastEffect=null),ie(Li,Li.current),C)break;return null;case 22:return g.lanes=0,j5(f,g,_,g.pendingProps);case 24:vd(g,Hi,f.memoizedState.cache)}return Qc(f,g,_)}function U5(f,g,_){if(f!==null)if(f.memoizedProps!==g.pendingProps)Gi=!0;else{if(!v2(f,_)&&(g.flags&128)===0)return Gi=!1,pae(f,g,_);Gi=(f.flags&131072)!==0}else Gi=!1,Jn&&(g.flags&1048576)!==0&&xI(g,lx,g.index);switch(g.lanes=0,g.tag){case 16:e:{var C=g.pendingProps;if(f=gf(g.elementType),g.type=f,typeof f=="function")kC(f)?(C=vf(f,C),g.tag=1,g=L5(null,g,f,C,_)):(g.tag=0,g=f2(null,g,f,C,_));else{if(f!=null){var R=f.$$typeof;if(R===E){g.tag=11,g=E5(null,g,f,C,_);break e}else if(R===M){g.tag=14,g=A5(null,g,f,C,_);break e}}throw g=D(f)||f,Error(r(306,g,""))}}return g;case 0:return f2(f,g,g.type,g.pendingProps,_);case 1:return C=g.type,R=vf(C,g.pendingProps),L5(f,g,C,R,_);case 3:e:{if(Le(g,g.stateNode.containerInfo),f===null)throw Error(r(387));C=g.pendingProps;var O=g.memoizedState;R=O.element,UC(f,g),gx(g,C,null,_);var ne=g.memoizedState;if(C=ne.cache,vd(g,Hi,C),C!==O.cache&&RC(g,[Hi],_,!0),px(),C=ne.element,O.isDehydrated)if(O={element:C,isDehydrated:!1,cache:ne.cache},g.updateQueue.baseState=O,g.memoizedState=O,g.flags&256){g=O5(f,g,C,_);break e}else if(C!==R){R=zo(Error(r(424)),g),cx(R),g=O5(f,g,C,_);break e}else{switch(f=g.stateNode.containerInfo,f.nodeType){case 9:f=f.body;break;default:f=f.nodeName==="HTML"?f.ownerDocument.body:f}for(Zr=Vo(f.firstChild),za=g,Jn=!0,xd=null,$o=!0,_=jI(g,null,C,_),g.child=_;_;)_.flags=_.flags&-3|4096,_=_.sibling}else{if(hf(),C===R){g=Qc(f,g,_);break e}Ba(f,g,C,_)}g=g.child}return g;case 26:return pw(f,g),f===null?(_=Qz(g.type,null,g.pendingProps,null))?g.memoizedState=_:Jn||(_=g.type,f=g.pendingProps,C=Pw(fe.current).createElement(_),C[Xt]=g,C[sr]=f,$a(C,_,f),Ge(C),g.stateNode=C):g.memoizedState=Qz(g.type,f.memoizedProps,g.pendingProps,f.memoizedState),null;case 27:return ce(g),f===null&&Jn&&(C=g.stateNode=Kz(g.type,g.pendingProps,fe.current),za=g,$o=!0,R=Zr,Dd(g.type)?(J2=R,Zr=Vo(C.firstChild)):Zr=R),Ba(f,g,g.pendingProps.children,_),pw(f,g),f===null&&(g.flags|=4194304),g.child;case 5:return f===null&&Jn&&((R=C=Zr)&&(C=Gae(C,g.type,g.pendingProps,$o),C!==null?(g.stateNode=C,za=g,Zr=Vo(C.firstChild),$o=!1,R=!0):R=!1),R||yd(g)),ce(g),R=g.type,O=g.pendingProps,ne=f!==null?f.memoizedProps:null,C=O.children,K2(R,O)?C=null:ne!==null&&K2(R,ne)&&(g.flags|=32),g.memoizedState!==null&&(R=WC(f,g,sae,null,null,_),Ox._currentValue=R),pw(f,g),Ba(f,g,C,_),g.child;case 6:return f===null&&Jn&&((f=_=Zr)&&(_=qae(_,g.pendingProps,$o),_!==null?(g.stateNode=_,za=g,Zr=null,f=!0):f=!1),f||yd(g)),null;case 13:return F5(f,g,_);case 4:return Le(g,g.stateNode.containerInfo),C=g.pendingProps,f===null?g.child=xf(g,null,C,_):Ba(f,g,C,_),g.child;case 11:return E5(f,g,g.type,g.pendingProps,_);case 7:return Ba(f,g,g.pendingProps,_),g.child;case 8:return Ba(f,g,g.pendingProps.children,_),g.child;case 12:return Ba(f,g,g.pendingProps.children,_),g.child;case 10:return C=g.pendingProps,vd(g,g.type,C.value),Ba(f,g,C.children,_),g.child;case 9:return R=g.type._context,C=g.pendingProps.children,mf(g),R=Ua(R),C=C(R),g.flags|=1,Ba(f,g,C,_),g.child;case 14:return A5(f,g,g.type,g.pendingProps,_);case 15:return T5(f,g,g.type,g.pendingProps,_);case 19:return z5(f,g,_);case 31:return mae(f,g,_);case 22:return j5(f,g,_,g.pendingProps);case 24:return mf(g),C=Ua(Hi),f===null?(R=FC(),R===null&&(R=Fr,O=LC(),R.pooledCache=O,O.refCount++,O!==null&&(R.pooledCacheLanes|=_),R=O),g.memoizedState={parent:C,cache:R},zC(g),vd(g,Hi,R)):((f.lanes&_)!==0&&(UC(f,g),gx(g,null,null,_),px()),R=f.memoizedState,O=g.memoizedState,R.parent!==C?(R={parent:C,cache:C},g.memoizedState=R,g.lanes===0&&(g.memoizedState=g.updateQueue.baseState=R),vd(g,Hi,C)):(C=O.cache,vd(g,Hi,C),C!==R.cache&&RC(g,[Hi],_,!0))),Ba(f,g,g.pendingProps.children,_),g.child;case 29:throw g.pendingProps}throw Error(r(156,g.tag))}function Zc(f){f.flags|=4}function w2(f,g,_,C,R){if((g=(f.mode&32)!==0)&&(g=!1),g){if(f.flags|=16777216,(R&335544128)===R)if(f.stateNode.complete)f.flags|=8192;else if(fz())f.flags|=8192;else throw bf=ew,IC}else f.flags&=-16777217}function B5(f,g){if(g.type!=="stylesheet"||(g.state.loading&4)!==0)f.flags&=-16777217;else if(f.flags|=16777216,!nU(g))if(fz())f.flags|=8192;else throw bf=ew,IC}function bw(f,g){g!==null&&(f.flags|=4),f.flags&16384&&(g=f.tag!==22?yt():536870912,f.lanes|=g,xp|=g)}function _x(f,g){if(!Jn)switch(f.tailMode){case"hidden":g=f.tail;for(var _=null;g!==null;)g.alternate!==null&&(_=g),g=g.sibling;_===null?f.tail=null:_.sibling=null;break;case"collapsed":_=f.tail;for(var C=null;_!==null;)_.alternate!==null&&(C=_),_=_.sibling;C===null?g||f.tail===null?f.tail=null:f.tail.sibling=null:C.sibling=null}}function Jr(f){var g=f.alternate!==null&&f.alternate.child===f.child,_=0,C=0;if(g)for(var R=f.child;R!==null;)_|=R.lanes|R.childLanes,C|=R.subtreeFlags&65011712,C|=R.flags&65011712,R.return=f,R=R.sibling;else for(R=f.child;R!==null;)_|=R.lanes|R.childLanes,C|=R.subtreeFlags,C|=R.flags,R.return=f,R=R.sibling;return f.subtreeFlags|=C,f.childLanes=_,g}function gae(f,g,_){var C=g.pendingProps;switch(AC(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,C=null,f!==null&&(C=f.memoizedState.cache),g.memoizedState.cache!==C&&(g.flags|=2048),Kc(Hi),de(),_.pendingContext&&(_.context=_.pendingContext,_.pendingContext=null),(f===null||f.child===null)&&(ap(g)?Zc(g):f===null||f.memoizedState.isDehydrated&&(g.flags&256)===0||(g.flags|=1024,jC())),Jr(g),null;case 26:var R=g.type,O=g.memoizedState;return f===null?(Zc(g),O!==null?(Jr(g),B5(g,O)):(Jr(g),w2(g,R,null,C,_))):O?O!==f.memoizedState?(Zc(g),Jr(g),B5(g,O)):(Jr(g),g.flags&=-16777217):(f=f.memoizedProps,f!==C&&Zc(g),Jr(g),w2(g,R,f,C,_)),null;case 27:if(Ae(g),_=fe.current,R=g.type,f!==null&&g.stateNode!=null)f.memoizedProps!==C&&Zc(g);else{if(!C){if(g.stateNode===null)throw Error(r(166));return Jr(g),null}f=J.current,ap(g)?vI(g):(f=Kz(R,C,_),g.stateNode=f,Zc(g))}return Jr(g),null;case 5:if(Ae(g),R=g.type,f!==null&&g.stateNode!=null)f.memoizedProps!==C&&Zc(g);else{if(!C){if(g.stateNode===null)throw Error(r(166));return Jr(g),null}if(O=J.current,ap(g))vI(g);else{var ne=Pw(fe.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=" + diff --git a/test_backend.sh b/test_backend.sh index adc766981..76821aa98 100755 --- a/test_backend.sh +++ b/test_backend.sh @@ -3,9 +3,9 @@ cd backend ruff check && ruff format --check -if [ "$1" = "--full" ]; then - ../venv/bin/python3 -m pytest tests/ -v -n 14 -else - ../venv/bin/python3 -m pytest tests/ -v -n 14 --ignore=tests/unit/services/test_bambu_ftp.py -fi -cd .. +#if [ "$1" = "--full" ]; then +# ../venv/bin/python3 -m pytest tests/ -v -n 14 +#else +../venv/bin/python3 -m pytest tests/ -v -n 14 --ignore=tests/unit/services/test_bambu_ftp.py +#fi +#cd ..