Feature AMS Info Card (#570)

* AMS Labels addition to PrintersPage

* Added database reinitialization for schema migrations on database restore

* Bug fixes and AMS Label persistence updates

* Update database.py to resolve PR conflicts

* PR Comment Resolution

* Resolve conflicts in database.py for PR#570

* Updates to address PR#570 additional comments

* Optimize visibility handling for popup component

* Improve serial key handling in printers.py

Refactor serial key assignment to handle empty AMS serial gracefully.

* Implement error handling in serial number mapping

Add error handling for serial number mapping.

* Add migration to drop old ams_labels table

---------

Co-authored-by: MartinNYHC <mz@v8w.de>
This commit is contained in:
Thomas Rambach
2026-03-05 08:36:39 +01:00
committed by GitHub
co-authored by MartinNYHC
parent 5ee2ef8dbf
commit 7ba67012f6
20 changed files with 786 additions and 36 deletions
+52 -1
View File
@@ -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)
+124
View File
@@ -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,
+6 -3
View File
@@ -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."
+40
View File
@@ -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:
+2
View File
@@ -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",
+30
View File
@@ -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())
+7
View File
@@ -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."""
+1
View File
@@ -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
+143 -3
View File
@@ -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/<id>" – original AMS (X1C, X1E, P1S, …)
# "n3f/<id>" – AMS 2 Pro (H2D Pro and similar)
# "n3s/<id>" – 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/<id>`` – original AMS
- ``n3f/<id>`` – AMS 2 Pro (H2D Pro and similar)
- ``n3s/<id>`` – 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))
+4
View File
@@ -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 ""),
}
)
+20
View File
@@ -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<Record<number, string>>(`/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,
@@ -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 (
<div
className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2 flex items-center justify-center"
style={{
backgroundColor: trayColor ? `#${trayColor}` : (trayType ? '#333' : 'transparent'),
borderColor: isEmpty ? '#666' : 'rgba(255,255,255,0.1)',
borderStyle: isEmpty ? 'dashed' : 'solid',
}}
>
<span
className="text-[6px] font-bold leading-none select-none"
style={{ color: trayColor && isLightFilamentColor(trayColor) ? '#000' : '#fff' }}
>
{slotNumber}
</span>
</div>
);
}
+10
View File
@@ -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',
+10
View File
@@ -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',
+10
View File
@@ -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',
+10
View File
@@ -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',
+10
View File
@@ -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: 'ファームウェア情報',
+10
View File
@@ -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',
+3 -2
View File
@@ -210,7 +210,7 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
const slotLabel = formatSlotLabel(assignment.ams_id, assignment.tray_id, isHt, isExternal);
return (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-500/20 text-purple-400">
{printerLabel} {slotLabel}
{printerLabel} {slotLabel}{assignment.ams_label ? ` (${assignment.ams_label})` : ''}
</span>
);
},
@@ -359,7 +359,8 @@ const columnSortValues: Record<string, (spool: InventorySpool, assignmentMap: Re
if (!a) return '';
const isExt = a.ams_id === 254 || a.ams_id === 255;
const isHt = !isExt && a.ams_id >= 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),
+247 -27
View File
@@ -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<number, string>;
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<string | null>(null);
const [isInputFocused, setIsInputFocused] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div
ref={triggerRef}
className="relative inline-block"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
{isVisible && (
<div
ref={cardRef}
className={`
absolute left-0 z-50
${position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'}
animate-in fade-in-0 zoom-in-95 duration-150
`}
style={{ maxWidth: 'calc(100vw - 24px)' }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="w-52 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl overflow-hidden backdrop-blur-sm p-2.5 space-y-2">
{/* AMS auto-label */}
<div className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">{label}</div>
{/* Serial number */}
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] tracking-wide text-bambu-gray font-medium shrink-0">
{t('printers.amsPopup.serialNumber')}
</span>
<span className="text-[10px] text-white font-mono truncate">{ams.serial_number || '—'}</span>
</div>
{/* Firmware version */}
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] tracking-wide text-bambu-gray font-medium shrink-0">
{t('printers.amsPopup.firmwareVersion')}
</span>
<span className="text-[10px] text-white font-mono truncate">{ams.sw_ver || '—'}</span>
</div>
{/* Divider */}
<div className="h-px bg-bambu-dark-tertiary/50" />
{/* Friendly name editor */}
<div className="space-y-1">
<span className="text-[10px] text-bambu-gray font-medium block">
{t('printers.amsPopup.friendlyName')}
</span>
<input
type="text"
value={editValue}
onChange={(e) => 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 && (
<div className="space-y-1">
{saveError && (
<p className="text-[10px] text-red-400 break-words">{saveError}</p>
)}
<div className="flex gap-1 justify-end">
<button
onClick={handleSave}
disabled={isSaving}
className="px-2 py-0.5 text-[10px] bg-bambu-green text-white rounded hover:bg-bambu-green/80 disabled:opacity-50"
>
{t('printers.amsPopup.save')}
</button>
{amsLabels?.[ams.id] && (
<button
onClick={handleClear}
disabled={isSaving}
className="px-2 py-0.5 text-[10px] bg-bambu-dark-tertiary text-bambu-gray rounded hover:bg-bambu-dark-tertiary/70 disabled:opacity-50"
>
{t('printers.amsPopup.clear')}
</button>
)}
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
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<number | null>(null);
useEffect(() => {
@@ -2789,9 +2992,19 @@ function PrinterCard({
{/* Header: Label + Stats (no icon) */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-white font-medium">
{getAmsLabel(ams.id, ams.tray.length)}
</span>
{/* AMS name — hover to see serial, firmware, and edit friendly name */}
<AmsNameHoverCard
ams={ams}
printerId={printer.id}
label={getAmsLabel(ams.id, ams.tray.length)}
amsLabels={amsLabels}
canEdit={hasPermission('printers:update')}
onSaved={refetchAmsLabels}
>
<span className="text-[10px] text-white font-medium cursor-default select-none">
{amsLabels?.[ams.id] || getAmsLabel(ams.id, ams.tray.length)}
</span>
</AmsNameHoverCard>
{isDualNozzle && (isLeftNozzle || isRightNozzle) && (
<NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
)}
@@ -2884,13 +3097,12 @@ function PrinterCard({
<div
className={`bg-bambu-dark-tertiary rounded p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
>
<div
className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
style={{
backgroundColor: tray?.tray_color ? `#${tray.tray_color}` : (tray?.tray_type ? '#333' : 'transparent'),
borderColor: isEmpty ? '#666' : 'rgba(255,255,255,0.1)',
borderStyle: isEmpty ? 'dashed' : 'solid',
}}
{/* Filament color circle with 1-based slot number centered inside */}
<FilamentSlotCircle
trayColor={tray?.tray_color}
trayType={tray?.tray_type}
isEmpty={isEmpty}
slotNumber={slotIdx + 1}
/>
<div className="text-[9px] text-white font-bold truncate">
{tray?.tray_type || '—'}
@@ -3107,13 +3319,12 @@ function PrinterCard({
<div
className={`bg-bambu-dark-tertiary rounded p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
>
<div
className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
style={{
backgroundColor: tray?.tray_color ? `#${tray.tray_color}` : (tray?.tray_type ? '#333' : 'transparent'),
borderColor: isEmpty ? '#666' : 'rgba(255,255,255,0.1)',
borderStyle: isEmpty ? 'dashed' : 'solid',
}}
{/* Filament color circle with 1-based slot number centered inside */}
<FilamentSlotCircle
trayColor={tray?.tray_color}
trayType={tray?.tray_type}
isEmpty={isEmpty}
slotNumber={1}
/>
<div className="text-[9px] text-white font-bold truncate">
{tray?.tray_type || '—'}
@@ -3137,9 +3348,19 @@ function PrinterCard({
<div key={ams.id} className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30">
{/* Row 1: Label + Nozzle */}
<div className="flex items-center gap-1 mb-2">
<span className="text-[10px] text-white font-medium">
{getAmsLabel(ams.id, ams.tray.length)}
</span>
{/* AMS name — hover to see serial, firmware, and edit friendly name */}
<AmsNameHoverCard
ams={ams}
printerId={printer.id}
label={getAmsLabel(ams.id, ams.tray.length)}
amsLabels={amsLabels}
canEdit={hasPermission('printers:update')}
onSaved={refetchAmsLabels}
>
<span className="text-[10px] text-white font-medium cursor-default select-none">
{amsLabels?.[ams.id] || getAmsLabel(ams.id, ams.tray.length)}
</span>
</AmsNameHoverCard>
{isDualNozzle && (isLeftNozzle || isRightNozzle) && (
<NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
)}
@@ -3361,13 +3582,12 @@ function PrinterCard({
const isEmpty = !extTray.tray_type;
const extSlotContent = (
<div className={`bg-bambu-dark-tertiary rounded p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isExtActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}>
<div
className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
style={{
backgroundColor: extTray.tray_color ? `#${extTray.tray_color}` : (extTray.tray_type ? '#333' : 'transparent'),
borderColor: isEmpty ? '#666' : 'rgba(255,255,255,0.1)',
borderStyle: isEmpty ? 'dashed' : 'solid',
}}
{/* Filament color circle with 1-based slot number centered inside */}
<FilamentSlotCircle
trayColor={extTray.tray_color}
trayType={extTray.tray_type}
isEmpty={isEmpty}
slotNumber={slotTrayId + 1}
/>
<div className={`text-[9px] font-bold truncate ${isEmpty ? 'text-white/40' : 'text-white'}`}>
{extTray.tray_type || '—'}