mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 03:31:25 +02:00
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:
co-authored by
MartinNYHC
parent
5ee2ef8dbf
commit
7ba67012f6
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 ""),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'ファームウェア情報',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 || '—'}
|
||||
|
||||
Reference in New Issue
Block a user