diff --git a/backend/app/api/routes/inventory.py b/backend/app/api/routes/inventory.py index b5cc7d815..9ba30dadc 100644 --- a/backend/app/api/routes/inventory.py +++ b/backend/app/api/routes/inventory.py @@ -13,6 +13,7 @@ from backend.app.core.auth import RequirePermissionIfAuthEnabled from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG from backend.app.core.database import get_db from backend.app.core.permissions import Permission +from backend.app.models.ams_label import AmsLabel from backend.app.models.color_catalog import ColorCatalogEntry from backend.app.models.spool import Spool from backend.app.models.spool_assignment import SpoolAssignment @@ -638,6 +639,8 @@ async def list_assignments( _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), ): """List spool assignments, optionally filtered by printer.""" + from backend.app.services.printer_manager import printer_manager + query = select(SpoolAssignment).options( selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles), selectinload(SpoolAssignment.printer), @@ -645,7 +648,55 @@ async def list_assignments( if printer_id is not None: query = query.where(SpoolAssignment.printer_id == printer_id) result = await db.execute(query) - return list(result.scalars().all()) + assignments = list(result.scalars().all()) + + # Build (printer_id, ams_id) -> ams_serial map from live printer states. + # Fetch all statuses in one call rather than one get_status() call per printer. + serial_map: dict[tuple[int, int], str] = {} + seen_printer_ids: set[int] = {a.printer_id for a in assignments} + all_statuses = printer_manager.get_all_statuses() + for pid in seen_printer_ids: + state = all_statuses.get(pid) + if state and state.raw_data: + for ams_unit in state.raw_data.get("ams", []): + sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "") + if sn: + try: + serial_map[(pid, int(ams_unit.get("id", 0)))] = sn + except (ValueError, TypeError): + continue + + # Fetch all relevant AMS labels keyed by serial number + all_serials = set(serial_map.values()) + # Also include synthetic fallback keys for assignments without a known serial + synthetic_keys: dict[str, tuple[int, int]] = {} + for a in assignments: + if (a.printer_id, a.ams_id) not in serial_map: + synthetic = f"p{a.printer_id}a{a.ams_id}" + synthetic_keys[synthetic] = (a.printer_id, a.ams_id) + all_serials.add(synthetic) + + label_by_serial: dict[str, str] = {} + if all_serials: + lbl_result = await db.execute( + select(AmsLabel).where(AmsLabel.ams_serial_number.in_(all_serials)) + ) + for lbl in lbl_result.scalars().all(): + label_by_serial[lbl.ams_serial_number] = lbl.label + + # Build response objects, attaching ams_label where available + responses: list[SpoolAssignmentResponse] = [] + for a in assignments: + resp = SpoolAssignmentResponse.model_validate(a) + sn = serial_map.get((a.printer_id, a.ams_id)) + if sn and sn in label_by_serial: + resp.ams_label = label_by_serial[sn] + elif not sn: + synthetic = f"p{a.printer_id}a{a.ams_id}" + resp.ams_label = label_by_serial.get(synthetic) + responses.append(resp) + + return responses @router.post("/assignments", response_model=SpoolAssignmentResponse) diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 412cc3a3a..418df1648 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -12,9 +12,11 @@ from backend.app.core.auth import RequirePermissionIfAuthEnabled from backend.app.core.config import settings from backend.app.core.database import get_db from backend.app.core.permissions import Permission +from backend.app.models.ams_label import AmsLabel from backend.app.models.printer import Printer from backend.app.models.slot_preset import SlotPresetMapping from backend.app.schemas.printer import ( + AmsLabelBody, AMSTray, AMSUnit, HMSErrorResponse, @@ -433,6 +435,10 @@ async def get_printer_status( temp=ams_data.get("temp"), is_ams_ht=is_ams_ht, tray=trays, + # Serial number: Bambu MQTT uses "sn" key on AMS unit objects + serial_number=str(ams_data.get("sn") or ams_data.get("serial_number") or ""), + # Firmware version: populated by _handle_version_info from info.module ams/* entries + sw_ver=str(ams_data.get("sw_ver") or ""), ) ) @@ -1956,6 +1962,124 @@ async def reset_ams_slot( } +@router.get("/{printer_id}/ams-labels") +async def get_ams_labels( + printer_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), + db: AsyncSession = Depends(get_db), +): + """Get all user-defined AMS labels for a printer, keyed by AMS unit ID. + + Labels are stored by AMS serial number. This endpoint resolves the current + serial-to-ams_id mapping from the live printer state so the response is still + keyed by ams_id for UI compatibility. + """ + # Build serial -> ams_id map from live printer state + serial_to_ams_id: dict[str, int] = {} + state = printer_manager.get_status(printer_id) + if state and state.raw_data: + for ams_unit in state.raw_data.get("ams", []): + sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "") + if sn: + serial_to_ams_id[sn] = int(ams_unit.get("id", 0)) + + # Collect all known serials for this printer (live + synthetic fallback keys) + serials_to_query = set(serial_to_ams_id.keys()) + + # Fetch labels for all known serials + labels: dict[int, str] = {} + if serials_to_query: + result = await db.execute( + select(AmsLabel).where(AmsLabel.ams_serial_number.in_(serials_to_query)) + ) + for lbl in result.scalars().all(): + aid = serial_to_ams_id.get(lbl.ams_serial_number) + if aid is not None: + labels[aid] = lbl.label + + # Also fetch labels stored under synthetic keys for this printer (backward compat) + # Collect all synthetic keys first, then query with a single IN clause. + if state and state.raw_data: + synthetic_key_to_aid: dict[str, int] = { + f"p{printer_id}a{int(ams_unit.get('id', 0))}": int(ams_unit.get("id", 0)) + for ams_unit in state.raw_data.get("ams", []) + if int(ams_unit.get("id", 0)) not in labels + } + if synthetic_key_to_aid: + result = await db.execute( + select(AmsLabel).where(AmsLabel.ams_serial_number.in_(synthetic_key_to_aid.keys())) + ) + for lbl in result.scalars().all(): + aid = synthetic_key_to_aid.get(lbl.ams_serial_number) + if aid is not None: + labels[aid] = lbl.label + + return labels + + +@router.put("/{printer_id}/ams-labels/{ams_id}") +async def save_ams_label( + printer_id: int, + ams_id: int, + body: AmsLabelBody, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE), + db: AsyncSession = Depends(get_db), +): + """Create or update the friendly name for a specific AMS unit. + + When ``ams_serial`` is provided the label is stored under that serial number so + it survives the AMS being moved to a different printer. When it is absent (e.g. + older firmware that does not report a serial) a synthetic key based on the + printer_id and ams_id is used as a fallback. + """ + # Verify printer exists + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + if not result.scalar_one_or_none(): + raise HTTPException(404, "Printer not found") + + # Determine the serial key to store under + stripped = body.ams_serial.strip() if body.ams_serial else "" + serial_key = stripped if stripped else f"p{printer_id}a{ams_id}" + + result = await db.execute( + select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key) + ) + existing = result.scalar_one_or_none() + + if existing: + existing.label = body.label + existing.ams_id = ams_id + else: + db.add(AmsLabel(ams_serial_number=serial_key, ams_id=ams_id, label=body.label)) + + await db.commit() + return {"ams_id": ams_id, "label": body.label} + + +@router.delete("/{printer_id}/ams-labels/{ams_id}") +async def delete_ams_label( + printer_id: int, + ams_id: int, + ams_serial: str = Query(default="", max_length=50), + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE), + db: AsyncSession = Depends(get_db), +): + """Delete the friendly name for a specific AMS unit, reverting to the auto label.""" + stripped = ams_serial.strip() if ams_serial else "" + serial_key = stripped if stripped else f"p{printer_id}a{ams_id}" + + result = await db.execute( + select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key) + ) + existing = result.scalar_one_or_none() + + if existing: + await db.delete(existing) + await db.commit() + + return {"success": True} + + @router.post("/{printer_id}/debug/simulate-print-complete") async def debug_simulate_print_complete( printer_id: int, diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 51b337abf..3ee93ae69 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -411,7 +411,7 @@ async def restore_backup( from fastapi import HTTPException - from backend.app.core.database import close_all_connections + from backend.app.core.database import close_all_connections, init_db, reinitialize_database from backend.app.services.virtual_printer import virtual_printer_manager base_dir = app_settings.base_dir @@ -498,8 +498,11 @@ async def restore_backup( logger.warning("Could not restore %s directory: %s", name, e) skipped_dirs.append(name) - # 7. Note: Virtual printer and database will be reinitialized on restart - # Do NOT try to restart services here - the database session is closed + # 7. Reinitialize the database engine and apply schema migrations so that + # tables added after the backup was created (e.g. ams_labels) exist + # immediately, without requiring a manual restart. + await reinitialize_database() + await init_db() logger.info("Restore complete - restart required") message = "Backup restored successfully. Please restart Bambuddy for changes to take effect." diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 08ce35d0a..f0f30e77e 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -74,6 +74,7 @@ async def init_db(): from backend.app.models import ( # noqa: F401 active_print_spoolman, ams_history, + ams_label, api_key, archive, bug_report, @@ -1361,6 +1362,45 @@ async def run_migrations(conn): except OperationalError: pass # Already applied + # Migration: Convert ams_labels table from (printer_id, ams_id) key to ams_serial_number key + # Labels are now keyed by AMS serial number so they persist when the AMS is moved to another printer. + try: + await conn.execute(text("DROP TABLE IF EXISTS ams_labels_new")) + result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='ams_labels'")) + row = result.fetchone() + if row and "printer_id" in (row[0] or ""): + # Old schema: rebuild the table with ams_serial_number as the unique key. + # Existing rows get a synthetic serial "p{printer_id}a{ams_id}" so data is preserved. + await conn.execute( + text(""" + CREATE TABLE ams_labels_new ( + id INTEGER PRIMARY KEY, + ams_serial_number VARCHAR(50) NOT NULL, + ams_id INTEGER, + label VARCHAR(100) NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_ams_label_serial UNIQUE (ams_serial_number) + ) + """) + ) + await conn.execute( + text(""" + INSERT INTO ams_labels_new (id, ams_serial_number, ams_id, label, created_at, updated_at) + SELECT id, + 'p' || CAST(printer_id AS TEXT) || 'a' || CAST(ams_id AS TEXT), + ams_id, + label, + created_at, + updated_at + FROM ams_labels + """) + ) + await conn.execute(text("DROP TABLE ams_labels")) + await conn.execute(text("ALTER TABLE ams_labels_new RENAME TO ams_labels")) + except OperationalError: + pass # Already migrated or table does not exist yet + # Cleanup: Remove obsolete settings keys that are no longer used obsolete_keys = ["slicer_binary_path"] for key in obsolete_keys: diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 485ec844e..b3bbbbb12 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,4 +1,5 @@ from backend.app.models.ams_history import AMSSensorHistory +from backend.app.models.ams_label import AmsLabel from backend.app.models.api_key import APIKey from backend.app.models.archive import PrintArchive from backend.app.models.color_catalog import ColorCatalogEntry @@ -40,6 +41,7 @@ __all__ = [ "Project", "APIKey", "AMSSensorHistory", + "AmsLabel", "PendingUpload", "LibraryFolder", "LibraryFile", diff --git a/backend/app/models/ams_label.py b/backend/app/models/ams_label.py new file mode 100644 index 000000000..8f31acb3c --- /dev/null +++ b/backend/app/models/ams_label.py @@ -0,0 +1,30 @@ +"""Model for storing user-defined friendly names for AMS units. + +Users can assign a custom label to each AMS (e.g. "Workshop AMS", "Silk Colours") +that is displayed in place of or alongside the auto-generated label (AMS-A, HT-A, …). + +Labels are keyed by AMS serial number so they persist when the AMS is moved to a +different printer. A fallback (printer_id + ams_id) is retained for units whose +serial number is not yet known. +""" + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from backend.app.core.database import Base + + +class AmsLabel(Base): + """Maps an AMS unit serial number to a user-defined friendly name.""" + + __tablename__ = "ams_labels" + __table_args__ = (UniqueConstraint("ams_serial_number", name="uq_ams_label_serial"),) + + id: Mapped[int] = mapped_column(primary_key=True) + ams_serial_number: Mapped[str] = mapped_column(String(50)) # AMS unit serial number (sn from MQTT) + ams_id: Mapped[int | None] = mapped_column(Integer, nullable=True) # AMS unit ID hint (0, 1, 2, 3, 128…) + label: Mapped[str] = mapped_column(String(100)) # User-defined friendly name + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/schemas/printer.py b/backend/app/schemas/printer.py index fa00278b5..cf1ce1b4c 100644 --- a/backend/app/schemas/printer.py +++ b/backend/app/schemas/printer.py @@ -138,6 +138,8 @@ class AMSUnit(BaseModel): temp: float | None = None is_ams_ht: bool = False # True for AMS-HT (single spool), False for regular AMS (4 spools) tray: list[AMSTray] = [] + serial_number: str = "" # AMS unit serial number (sn from MQTT) + sw_ver: str = "" # AMS firmware version (from get_version info.module) class NozzleInfoResponse(BaseModel): @@ -160,6 +162,11 @@ class NozzleRackSlot(BaseModel): filament_type: str = "" # Material type (e.g. "PLA", "PETG") +class AmsLabelBody(BaseModel): + label: str = Field(..., min_length=1, max_length=100) + ams_serial: str = Field(default="", max_length=50) + + class PrintOptionsResponse(BaseModel): """AI detection and print options from xcam data.""" diff --git a/backend/app/schemas/spool.py b/backend/app/schemas/spool.py index 0a2394b8f..f226a5e7c 100644 --- a/backend/app/schemas/spool.py +++ b/backend/app/schemas/spool.py @@ -117,6 +117,7 @@ class SpoolAssignmentResponse(BaseModel): created_at: datetime spool: SpoolResponse | None = None configured: bool = False + ams_label: str | None = None # User-defined friendly name for the AMS unit class Config: from_attributes = True diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index ca7329605..fa6dfa9f9 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -22,6 +22,13 @@ import paho.mqtt.client as mqtt logger = logging.getLogger(__name__) +# AMS module name prefixes used in get_version responses. +# The numeric suffix after '/' is the AMS unit ID as reported in push_status. +# "ams/" – original AMS (X1C, X1E, P1S, …) +# "n3f/" – AMS 2 Pro (H2D Pro and similar) +# "n3s/" – AMS HT (H2D Pro and similar; IDs typically start at 128) +_AMS_MODULE_PREFIXES = ("ams/", "n3f/", "n3s/") + @dataclass class MQTTLogEntry: @@ -297,6 +304,14 @@ class BambuMQTTClient: self._disconnection_event: threading.Event | None = None self._previous_ams_hash: str | None = None # Track AMS changes + # Cache AMS firmware/SN from get_version in case it arrives before AMS status + # Key: ams_id (int). Value: {'sw_ver': str, 'sn': str} + self._ams_version_cache: dict[int, dict[str, str]] = {} + + # Track which (ams_id, field) warnings have already been emitted this connection + # so that missing-serial / missing-firmware warnings fire only once per connection. + self._ams_version_warned: set[tuple[int | str, str]] = set() + # K-profile command tracking self._sequence_id: int = 0 self._pending_kprofile_response: asyncio.Event | None = None @@ -356,6 +371,8 @@ class BambuMQTTClient: def _on_connect(self, client, userdata, flags, rc, properties=None): if rc == 0: self.state.connected = True + # Reset per-connection warning state so warnings fire once per (re)connection + self._ams_version_warned = set() client.subscribe(self.topic_subscribe) # Subscribe to request topic for ams_mapping capture (if supported by broker) if self._request_topic_supported: @@ -664,12 +681,24 @@ class BambuMQTTClient: """Handle version info response from get_version command. Parses firmware version from the 'ota' module in the module list. + Also extracts AMS unit firmware versions from AMS modules and stores + them on the corresponding AMS unit in raw_data so the status route can + expose them to the frontend. + + AMS module naming conventions (numeric suffix is the AMS unit ID): + - ``ams/`` – original AMS + - ``n3f/`` – AMS 2 Pro (H2D Pro and similar) + - ``n3s/`` – AMS HT (H2D Pro and similar) + Message format: { "command": "get_version", "module": [ {"name": "ota", "sw_ver": "01.08.05.00"}, {"name": "rv1126", "sw_ver": "00.00.14.74"}, + {"name": "ams/0", "sw_ver": "00.00.06.96", "sn": "ABC123"}, + {"name": "n3f/0", "sw_ver": "03.00.21.29", "sn": "19C06A552504488"}, + {"name": "n3s/128", "sw_ver": "03.00.21.29", "sn": "19F06A561801096"}, ... ] } @@ -678,6 +707,7 @@ class BambuMQTTClient: if not isinstance(modules, list): return + state_changed = False for module in modules: if not isinstance(module, dict): continue @@ -688,11 +718,115 @@ class BambuMQTTClient: self.state.firmware_version = version if old_version != version: logger.info("[%s] Firmware version: %s", self.serial_number, version) - # Trigger state change callback - if self.on_state_change: - self.on_state_change(self.state) + state_changed = True break + # Extract AMS unit firmware versions from AMS modules. + # See module-level _AMS_MODULE_PREFIXES for supported naming conventions. + # Always cache regardless of whether AMS data has arrived yet — get_version + # often arrives before the first push_status, so caching must be unconditional. + ams_raw = self.state.raw_data.get("ams") + for module in modules: + if not isinstance(module, dict): + continue + name = module.get("name", "") + if not any(name.startswith(prefix) for prefix in _AMS_MODULE_PREFIXES): + continue + try: + ams_id = int(name.split("/", 1)[1]) + except (ValueError, IndexError): + continue + sw_ver = module.get("sw_ver", "") + sn = module.get("sn", "") + + # Always cache so _apply_ams_version_cache can apply it when AMS data arrives + if sw_ver or sn: + self._ams_version_cache[ams_id] = {"sw_ver": sw_ver, "sn": sn} + state_changed = True + + # Also directly update any AMS unit already present in raw_data + if ams_raw and isinstance(ams_raw, list): + for ams_unit in ams_raw: + if not isinstance(ams_unit, dict): + continue + try: + unit_id = int(ams_unit.get("id")) if ams_unit.get("id") is not None else None + except (ValueError, TypeError): + unit_id = None + if unit_id == ams_id: + if sw_ver: + ams_unit["sw_ver"] = sw_ver + logger.debug("[%s] AMS %s firmware: %s", self.serial_number, ams_id, sw_ver) + # Only set sn from version info if not already present in AMS data + if sn and not ams_unit.get("sn"): + ams_unit["sn"] = sn + break + + # Trigger state change callback AFTER both loops so AMS sn/sw_ver are + # included in the broadcast (not just the printer firmware version). + if state_changed and self.on_state_change: + self.on_state_change(self.state) + + # Warn if any AMS unit is still missing serial number or firmware version + # after processing the version info response. Warn only once per connection + # to avoid repeated noise on older firmware that doesn't report these fields. + if ams_raw and isinstance(ams_raw, list): + for ams_unit in ams_raw: + if not isinstance(ams_unit, dict): + continue + ams_id = ams_unit.get("id", "?") + if not ams_unit.get("sn") and not ams_unit.get("serial_number"): + key = (ams_id, "sn") + if key not in self._ams_version_warned: + self._ams_version_warned.add(key) + logger.warning( + "[%s] AMS unit %s: serial number not available in version info", + self.serial_number, + ams_id, + ) + if not ams_unit.get("sw_ver"): + key = (ams_id, "sw_ver") + if key not in self._ams_version_warned: + self._ams_version_warned.add(key) + logger.warning( + "[%s] AMS unit %s: firmware version not available in version info", + self.serial_number, + ams_id, + ) + + def _apply_ams_version_cache(self, ams_list: list) -> None: + """Apply cached AMS firmware/SN (from get_version) onto an AMS list in-place. + + get_version may arrive before pushall/AMS status, and AMS unit IDs may be + strings in MQTT payloads. This helper normalizes IDs and fills missing + sw_ver/sn fields without overwriting values already present. + """ + if not ams_list or not isinstance(ams_list, list): + return + cache = self._ams_version_cache + if not cache: + return + for unit in ams_list: + if not isinstance(unit, dict): + continue + raw_id = unit.get("id") + try: + unit_id = int(raw_id) if raw_id is not None else None + except (ValueError, TypeError): + unit_id = None + if unit_id is None: + continue + cached = cache.get(unit_id) + if not cached: + continue + sw_ver = cached.get("sw_ver") or "" + sn = cached.get("sn") or "" + if sw_ver and not unit.get("sw_ver"): + unit["sw_ver"] = sw_ver + # Only set sn if not already present in AMS data + if sn and not unit.get("sn") and not unit.get("serial_number"): + unit["sn"] = sn + def _parse_xcam_data(self, xcam_data): """Parse xcam data for camera settings and AI detection options.""" if not isinstance(xcam_data, dict): @@ -1239,6 +1373,10 @@ class BambuMQTTClient: merged_trays.append(new_tray) # Update ams_unit with merged trays ams_unit = {**ams_unit, "tray": merged_trays} + elif existing_unit: + # Partial update without tray data: merge new fields into existing + # unit to preserve tray, sn, sw_ver, and other accumulated data. + ams_unit = {**existing_unit, **ams_unit} existing_by_id[ams_id] = ams_unit # Convert back to list, sorted by ID for consistent ordering @@ -1287,6 +1425,8 @@ class BambuMQTTClient: self.state.raw_data["ams"] = merged_ams + # Apply cached AMS firmware/SN from get_version (handles ordering and id type mismatches) + self._apply_ams_version_cache(merged_ams) # Update timestamp for RFID refresh detection (frontend can detect "new data arrived") self.state.last_ams_update = time.time() logger.debug("[%s] Merged AMS data: %s new units, %s total", self.serial_number, len(ams_list), len(merged_ams)) diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index 17b49070a..cb1ccf425 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -589,6 +589,10 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo "temp": ams_data.get("temp"), "is_ams_ht": is_ams_ht, "tray": trays, + # Serial number: Bambu MQTT uses "sn" key on AMS unit objects + "serial_number": str(ams_data.get("sn") or ams_data.get("serial_number") or ""), + # Firmware version: populated by _handle_version_info from get_version + "sw_ver": str(ams_data.get("sw_ver") or ""), } ) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 390205aaa..70ed85344 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -134,6 +134,8 @@ export interface AMSUnit { temp: number | null; is_ams_ht: boolean; // True for AMS-HT (single spool), False for regular AMS (4 spools) tray: AMSTray[]; + serial_number: string; // AMS unit serial number (from MQTT sn field) + sw_ver: string; // AMS firmware version (from get_version info.module ams/* entry) } export interface NozzleInfo { @@ -1878,6 +1880,7 @@ export interface SpoolAssignment { spool?: InventorySpool | null; configured: boolean; created_at: string; + ams_label?: string | null; // User-defined friendly name for the AMS unit } // Update types @@ -3345,6 +3348,23 @@ export const api = { request<{ success: boolean }>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`, { method: 'DELETE', }), + + // AMS Labels (user-defined friendly names) + getAmsLabels: (printerId: number) => + request>(`/printers/${printerId}/ams-labels`), + saveAmsLabel: (printerId: number, amsId: number, label: string, amsSerial = '') => + request<{ ams_id: number; label: string }>( + `/printers/${printerId}/ams-labels/${amsId}`, + { + method: 'PUT', + body: JSON.stringify({ label, ams_serial: amsSerial }), + } + ), + deleteAmsLabel: (printerId: number, amsId: number, amsSerial = '') => + request<{ success: boolean }>(`/printers/${printerId}/ams-labels/${amsId}?ams_serial=${encodeURIComponent(amsSerial)}`, { + method: 'DELETE', + }), + configureAmsSlot: ( printerId: number, amsId: number, diff --git a/frontend/src/components/FilamentSlotCircle.tsx b/frontend/src/components/FilamentSlotCircle.tsx new file mode 100644 index 000000000..4bcd7e611 --- /dev/null +++ b/frontend/src/components/FilamentSlotCircle.tsx @@ -0,0 +1,47 @@ +/** + * FilamentSlotCircle renders a small color circle with the 1-based slot + * number centered inside, matching the style used on AMS cards in PrintersPage. + * + * Props: + * trayColor - 6-char hex color string WITHOUT leading '#' (e.g. "FF0000"). + * Pass undefined / empty string when the slot is empty. + * trayType - Filament material string (e.g. "PLA"). Used to decide the + * fallback background when there is no color but a type is known. + * isEmpty - Whether the slot contains no filament. + * slotNumber - 1-based slot number to display inside the circle. + */ + +interface FilamentSlotCircleProps { + trayColor?: string | null; + trayType?: string | null; + isEmpty: boolean; + slotNumber: number; +} + +function isLightFilamentColor(hex: string): boolean { + if (!hex || hex.length < 6) return false; + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6; +} + +export function FilamentSlotCircle({ trayColor, trayType, isEmpty, slotNumber }: FilamentSlotCircleProps) { + return ( +
+ + {slotNumber} + +
+ ); +} diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 016026057..00027c07e 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -445,6 +445,16 @@ export default { clickToViewHmsErrors: 'Klicken, um HMS-Fehler anzuzeigen', estimatedCompletion: 'Geschätzte Fertigstellungszeit', slotOptions: 'Slot-Optionen', + // AMS hover popup + amsPopup: { + friendlyName: 'AMS-Name', + friendlyNamePlaceholder: 'z. B. AMS-Anzeigename', + serialNumber: 'Seriennummer', + firmwareVersion: 'Firmware', + save: 'Speichern', + clear: 'Löschen', + noEditPermission: 'Sie haben keine Berechtigung, AMS-Einheiten umzubenennen', + }, // Firmware modal firmwareModal: { title: 'Firmware-Update', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 21516026f..0a639a8af 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -445,6 +445,16 @@ export default { clickToViewHmsErrors: 'Click to view HMS errors', estimatedCompletion: 'Estimated completion time', slotOptions: 'Slot options', + // AMS hover popup + amsPopup: { + friendlyName: 'AMS Name', + friendlyNamePlaceholder: 'e.g. AMS Friendly Name', + serialNumber: 'Serial Number', + firmwareVersion: 'Firmware', + save: 'Save', + clear: 'Clear', + noEditPermission: 'You do not have permission to rename AMS units', + }, // Firmware modal firmwareModal: { title: 'Firmware Update', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index c0c478d56..ab281fa69 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -445,6 +445,16 @@ export default { clickToViewHmsErrors: 'Cliquez pour voir les erreurs HMS', estimatedCompletion: 'Fin estimée', slotOptions: 'Options du slot', + // AMS hover popup + amsPopup: { + friendlyName: 'Nom AMS', + friendlyNamePlaceholder: 'ex. Nom convivial AMS', + serialNumber: 'Numéro de série', + firmwareVersion: 'Firmware', + save: 'Enregistrer', + clear: 'Effacer', + noEditPermission: "Vous n'avez pas la permission de renommer les unités AMS", + }, // Firmware modal firmwareModal: { title: 'Mise à jour Firmware', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 9e84e2c34..ca8a49427 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -436,6 +436,16 @@ export default { clickToViewHmsErrors: 'Clicca per vedere errori HMS', estimatedCompletion: 'Tempo completamento stimato', slotOptions: 'Opzioni slot', + // AMS hover popup + amsPopup: { + friendlyName: 'Nome AMS', + friendlyNamePlaceholder: 'es. Nome AMS amichevole', + serialNumber: 'Numero di serie', + firmwareVersion: 'Firmware', + save: 'Salva', + clear: 'Cancella', + noEditPermission: 'Non hai il permesso di rinominare le unità AMS', + }, // Firmware modal firmwareModal: { title: 'Aggiornamento Firmware', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 3cb12dc73..970971486 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -408,6 +408,16 @@ export default { }, estimatedCompletion: '完了予定時刻', slotOptions: 'スロットオプション', + // AMS hover popup + amsPopup: { + friendlyName: 'AMS名', + friendlyNamePlaceholder: '例: AMS フレンドリー名', + serialNumber: 'シリアル番号', + firmwareVersion: 'ファームウェア', + save: '保存', + clear: 'クリア', + noEditPermission: 'AMS ユニットの名前を変更する権限がありません', + }, firmwareModal: { title: 'ファームウェアアップデート', titleUpToDate: 'ファームウェア情報', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 065703eb1..5621e2820 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -445,6 +445,16 @@ export default { clickToViewHmsErrors: 'Clique para ver erros do HMS', estimatedCompletion: 'Tempo estimado de conclusão', slotOptions: 'Opções de slot', + // AMS hover popup + amsPopup: { + friendlyName: 'Nome do AMS', + friendlyNamePlaceholder: 'ex.: Nome amigável do AMS', + serialNumber: 'Número de série', + firmwareVersion: 'Firmware', + save: 'Salvar', + clear: 'Limpar', + noEditPermission: 'Você não tem permissão para renomear unidades AMS', + }, // Firmware modal firmwareModal: { title: 'Atualização de Firmware', diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index fb8b32447..171fa3864 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -210,7 +210,7 @@ const columnCells: Record ReactNode> = { const slotLabel = formatSlotLabel(assignment.ams_id, assignment.tray_id, isHt, isExternal); return ( - {printerLabel} {slotLabel} + {printerLabel} {slotLabel}{assignment.ams_label ? ` (${assignment.ams_label})` : ''} ); }, @@ -359,7 +359,8 @@ const columnSortValues: Record= 128; - return `${a.printer_name || ''} ${formatSlotLabel(a.ams_id, a.tray_id, isHt, isExt)}`; + const label = a.ams_label ? ` (${a.ams_label})` : ''; + return `${a.printer_name || ''} ${formatSlotLabel(a.ams_id, a.tray_id, isHt, isExt)}${label}`; }, label_weight: (s) => s.label_weight, net: (s) => Math.max(0, s.label_weight - s.weight_used), diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 04059fe6c..d477cc50d 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -72,6 +72,7 @@ import { PrintModal } from '../components/PrintModal'; import { PrinterInfoModal } from '../components/PrinterInfoModal'; import { getGlobalTrayId } from '../utils/amsHelpers'; import { getPrinterImage, getWifiStrength } from '../utils/printer'; +import { FilamentSlotCircle } from '../components/FilamentSlotCircle'; import { hexToColorName, parseFilamentColor, isLightColor } from '../utils/colors'; // Complete Bambu Lab filament color mapping by tray_id_name @@ -1289,6 +1290,201 @@ function mapModelCode(ssdpModel: string | null): string { return modelMap[ssdpModel] || ssdpModel; } +// ─── AMS Name Hover Card ────────────────────────────────────────────────────── +// Wraps the AMS label (e.g. "AMS-A") and shows a popup with: +// • User-defined friendly name (editable, protected by printers:update) +// • AMS serial number +// • AMS firmware version +export function AmsNameHoverCard({ + ams, + printerId, + label, + amsLabels, + canEdit, + onSaved, + children, +}: { + ams: import('../api/client').AMSUnit; + printerId: number; + label: string; // auto-generated label, e.g. "AMS-A" + amsLabels?: Record; + canEdit: boolean; + onSaved: () => void; + children: React.ReactNode; +}) { + const { t } = useTranslation(); + const [isVisible, setIsVisible] = useState(false); + const [position, setPosition] = useState<'top' | 'bottom'>('top'); + const [editValue, setEditValue] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [isInputFocused, setIsInputFocused] = useState(false); + const triggerRef = useRef(null); + const cardRef = useRef(null); + const timeoutRef = useRef | null>(null); + + useEffect(() => { + if (isVisible) { + setEditValue(amsLabels?.[ams.id] ?? ''); + setSaveError(null); + requestAnimationFrame(() => { + if (triggerRef.current && cardRef.current) { + const rect = triggerRef.current.getBoundingClientRect(); + const spaceAbove = rect.top - 56; + const spaceBelow = window.innerHeight - rect.bottom; + setPosition(spaceAbove < cardRef.current.offsetHeight + 12 && spaceBelow > spaceAbove ? 'bottom' : 'top'); + } + }); + } + }, [isVisible, amsLabels, ams.id]); + + const handleMouseEnter = () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setIsVisible(true), 80); + }; + const handleMouseLeave = () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + if (!isInputFocused) { + timeoutRef.current = setTimeout(() => setIsVisible(false), 200); + } + }; + useEffect(() => () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }, []); + + const handleSave = async () => { + if (!canEdit) return; + setIsSaving(true); + setSaveError(null); + try { + const trimmed = editValue.trim(); + if (trimmed) { + await api.saveAmsLabel(printerId, ams.id, trimmed, ams.serial_number); + } else { + await api.deleteAmsLabel(printerId, ams.id, ams.serial_number); + } + onSaved(); + setIsVisible(false); + } catch (err) { + setSaveError(err instanceof Error ? err.message : String(err)); + } finally { + setIsSaving(false); + } + }; + + const handleClear = async () => { + if (!canEdit) return; + setIsSaving(true); + setSaveError(null); + try { + await api.deleteAmsLabel(printerId, ams.id, ams.serial_number); + onSaved(); + setIsVisible(false); + } catch (err) { + setSaveError(err instanceof Error ? err.message : String(err)); + } finally { + setIsSaving(false); + } + }; + + return ( +
+ {children} + + {isVisible && ( +
+
+ {/* AMS auto-label */} +
{label}
+ + {/* Serial number */} +
+ + {t('printers.amsPopup.serialNumber')} + + {ams.serial_number || '—'} +
+ + {/* Firmware version */} +
+ + {t('printers.amsPopup.firmwareVersion')} + + {ams.sw_ver || '—'} +
+ + {/* Divider */} +
+ + {/* Friendly name editor */} +
+ + {t('printers.amsPopup.friendlyName')} + + canEdit && setEditValue(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSave()} + onFocus={() => setIsInputFocused(true)} + onBlur={() => { + setIsInputFocused(false); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setIsVisible(false), 200); + }} + placeholder={canEdit ? t('printers.amsPopup.friendlyNamePlaceholder') : (amsLabels?.[ams.id] || '—')} + disabled={!canEdit} + title={!canEdit ? t('printers.amsPopup.noEditPermission') : undefined} + className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1 text-xs text-white placeholder-bambu-gray/60 focus:outline-none focus:border-bambu-green disabled:opacity-50 disabled:cursor-not-allowed" + maxLength={100} + /> + {canEdit && ( +
+ {saveError && ( +

{saveError}

+ )} +
+ + {amsLabels?.[ams.id] && ( + + )} +
+
+ )} +
+
+
+ )} +
+ ); +} + + function PrinterCard({ printer, hideIfDisconnected, @@ -1498,6 +1694,13 @@ function PrinterCard({ staleTime: 2 * 60 * 1000, // 2 minutes }); + // Fetch user-defined AMS friendly names from the database + const { data: amsLabels, refetch: refetchAmsLabels } = useQuery({ + queryKey: ['amsLabels', printer.id], + queryFn: () => api.getAmsLabels(printer.id), + staleTime: 5 * 60 * 1000, // 5 minutes + }); + // Cache WiFi signal to prevent it disappearing on updates const [cachedWifiSignal, setCachedWifiSignal] = useState(null); useEffect(() => { @@ -2789,9 +2992,19 @@ function PrinterCard({ {/* Header: Label + Stats (no icon) */}
- - {getAmsLabel(ams.id, ams.tray.length)} - + {/* AMS name — hover to see serial, firmware, and edit friendly name */} + + + {amsLabels?.[ams.id] || getAmsLabel(ams.id, ams.tray.length)} + + {isDualNozzle && (isLeftNozzle || isRightNozzle) && ( )} @@ -2884,13 +3097,12 @@ function PrinterCard({
-
{tray?.tray_type || '—'} @@ -3107,13 +3319,12 @@ function PrinterCard({
-
{tray?.tray_type || '—'} @@ -3137,9 +3348,19 @@ function PrinterCard({
{/* Row 1: Label + Nozzle */}
- - {getAmsLabel(ams.id, ams.tray.length)} - + {/* AMS name — hover to see serial, firmware, and edit friendly name */} + + + {amsLabels?.[ams.id] || getAmsLabel(ams.id, ams.tray.length)} + + {isDualNozzle && (isLeftNozzle || isRightNozzle) && ( )} @@ -3361,13 +3582,12 @@ function PrinterCard({ const isEmpty = !extTray.tray_type; const extSlotContent = (
-
{extTray.tray_type || '—'}