mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 23:21:58 +02:00
Sync
This commit is contained in:
@@ -613,7 +613,10 @@ async def list_assignments(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
|
||||
):
|
||||
"""List spool assignments, optionally filtered by printer."""
|
||||
query = select(SpoolAssignment).options(selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles))
|
||||
query = select(SpoolAssignment).options(
|
||||
selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
|
||||
selectinload(SpoolAssignment.printer),
|
||||
)
|
||||
if printer_id is not None:
|
||||
query = query.where(SpoolAssignment.printer_id == printer_id)
|
||||
result = await db.execute(query)
|
||||
@@ -745,7 +748,10 @@ async def assign_spool(
|
||||
# Return assignment with spool data
|
||||
result = await db.execute(
|
||||
select(SpoolAssignment)
|
||||
.options(selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles))
|
||||
.options(
|
||||
selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
|
||||
selectinload(SpoolAssignment.printer),
|
||||
)
|
||||
.where(SpoolAssignment.id == assignment.id)
|
||||
)
|
||||
resp = result.scalar_one()
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Bambu Lab filament color code to color name mapping.
|
||||
|
||||
Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
|
||||
|
||||
Maps tray_id_name codes (e.g. "A06-D0") to human-readable color names (e.g. "Titan Gray").
|
||||
"""
|
||||
|
||||
# Full color code → name mapping by material prefix
|
||||
BAMBU_FILAMENT_COLORS: dict[str, str] = {
|
||||
# PLA Basic (A00)
|
||||
"A00-W1": "Jade White",
|
||||
"A00-P0": "Beige",
|
||||
"A00-D2": "Light Gray",
|
||||
"A00-Y0": "Yellow",
|
||||
"A00-Y2": "Sunflower Yellow",
|
||||
"A00-A1": "Pumpkin Orange",
|
||||
"A00-A0": "Orange",
|
||||
"A00-Y4": "Gold",
|
||||
"A00-G3": "Bright Green",
|
||||
"A00-G1": "Bambu Green",
|
||||
"A00-G2": "Mistletoe Green",
|
||||
"A00-R3": "Hot Pink",
|
||||
"A00-P6": "Magenta",
|
||||
"A00-R0": "Red",
|
||||
"A00-R2": "Maroon Red",
|
||||
"A00-P5": "Purple",
|
||||
"A00-P2": "Indigo Purple",
|
||||
"A00-B5": "Turquoise",
|
||||
"A00-B8": "Cyan",
|
||||
"A00-B3": "Cobalt Blue",
|
||||
"A00-N0": "Brown",
|
||||
"A00-N1": "Cocoa Brown",
|
||||
"A00-Y3": "Bronze",
|
||||
"A00-D0": "Gray",
|
||||
"A00-D1": "Silver",
|
||||
"A00-B1": "Blue Grey",
|
||||
"A00-D3": "Dark Gray",
|
||||
"A00-K0": "Black",
|
||||
# PLA Basic Gradient (A00-M*)
|
||||
"A00-M3": "Pink Citrus",
|
||||
"A00-M6": "Dusk Glare",
|
||||
"A00-M0": "Arctic Whisper",
|
||||
"A00-M1": "Solar Breeze",
|
||||
"A00-M5": "Blueberry Bubblegum",
|
||||
"A00-M4": "Mint Lime",
|
||||
"A00-M2": "Ocean to Meadow",
|
||||
"A00-M7": "Cotton Candy Cloud",
|
||||
# PLA Lite (A18)
|
||||
"A18-K0": "Black",
|
||||
"A18-D0": "Gray",
|
||||
"A18-W0": "White",
|
||||
"A18-R0": "Red",
|
||||
"A18-Y0": "Yellow",
|
||||
"A18-B0": "Cyan",
|
||||
"A18-B1": "Blue",
|
||||
"A18-P0": "Matte Beige",
|
||||
# PLA Matte (A01)
|
||||
"A01-W2": "Ivory White",
|
||||
"A01-W3": "Bone White",
|
||||
"A01-Y2": "Lemon Yellow",
|
||||
"A01-A2": "Mandarin Orange",
|
||||
"A01-P3": "Sakura Pink",
|
||||
"A01-P4": "Lilac Purple",
|
||||
"A01-R3": "Plum",
|
||||
"A01-R1": "Scarlet Red",
|
||||
"A01-R4": "Dark Red",
|
||||
"A01-G0": "Apple Green",
|
||||
"A01-G1": "Grass Green",
|
||||
"A01-G7": "Dark Green",
|
||||
"A01-B4": "Ice Blue",
|
||||
"A01-B0": "Sky Blue",
|
||||
"A01-B3": "Marine Blue",
|
||||
"A01-B6": "Dark Blue",
|
||||
"A01-Y3": "Desert Tan",
|
||||
"A01-N1": "Latte Brown",
|
||||
"A01-N3": "Caramel",
|
||||
"A01-R2": "Terracotta",
|
||||
"A01-N2": "Dark Brown",
|
||||
"A01-N0": "Dark Chocolate",
|
||||
"A01-D3": "Ash Gray",
|
||||
"A01-D0": "Nardo Gray",
|
||||
"A01-K1": "Charcoal",
|
||||
# PLA Glow (A12)
|
||||
"A12-G0": "Green",
|
||||
"A12-R0": "Pink",
|
||||
"A12-A0": "Orange",
|
||||
"A12-Y0": "Yellow",
|
||||
"A12-B0": "Blue",
|
||||
# PLA Marble (A07)
|
||||
"A07-R5": "Red Granite",
|
||||
"A07-D4": "White Marble",
|
||||
# PLA Aero (A11)
|
||||
"A11-W0": "White",
|
||||
"A11-K0": "Black",
|
||||
# PLA Sparkle (A08)
|
||||
"A08-G3": "Alpine Green Sparkle",
|
||||
"A08-D5": "Slate Gray Sparkle",
|
||||
"A08-B7": "Royal Purple Sparkle",
|
||||
"A08-R2": "Crimson Red Sparkle",
|
||||
"A08-K2": "Onyx Black Sparkle",
|
||||
"A08-Y1": "Classic Gold Sparkle",
|
||||
# PLA Metal (A02)
|
||||
"A02-B2": "Cobalt Blue Metallic",
|
||||
"A02-G2": "Oxide Green Metallic",
|
||||
"A02-Y1": "Iridium Gold Metallic",
|
||||
"A02-D2": "Iron Gray Metallic",
|
||||
# PLA Translucent (A17)
|
||||
"A17-B1": "Blue",
|
||||
"A17-A0": "Orange",
|
||||
"A17-P0": "Purple",
|
||||
# PLA Silk+ (A06)
|
||||
"A06-Y1": "Gold",
|
||||
"A06-D0": "Titan Gray",
|
||||
"A06-D1": "Silver",
|
||||
"A06-W0": "White",
|
||||
"A06-R0": "Candy Red",
|
||||
"A06-G0": "Candy Green",
|
||||
"A06-G1": "Mint",
|
||||
"A06-B1": "Blue",
|
||||
"A06-B0": "Baby Blue",
|
||||
"A06-P0": "Purple",
|
||||
"A06-R1": "Rose Gold",
|
||||
"A06-R2": "Pink",
|
||||
"A06-Y0": "Champagne",
|
||||
# PLA Silk Multi-Color (A05)
|
||||
"A05-M8": "Dawn Radiance",
|
||||
"A05-M4": "Aurora Purple",
|
||||
"A05-M1": "South Beach",
|
||||
"A05-T3": "Neon City",
|
||||
"A05-T2": "Midnight Blaze",
|
||||
"A05-T1": "Gilded Rose",
|
||||
"A05-T4": "Blue Hawaii",
|
||||
"A05-T5": "Velvet Eclipse",
|
||||
# PLA Galaxy (A15)
|
||||
"A15-B0": "Purple",
|
||||
"A15-G0": "Green",
|
||||
"A15-G1": "Nebulae",
|
||||
"A15-R0": "Brown",
|
||||
# PLA Wood (A16)
|
||||
"A16-K0": "Black Walnut",
|
||||
"A16-R0": "Rosewood",
|
||||
"A16-N0": "Clay Brown",
|
||||
"A16-G0": "Classic Birch",
|
||||
"A16-W0": "White Oak",
|
||||
"A16-Y0": "Ochre Yellow",
|
||||
# PLA-CF (A50)
|
||||
"A50-D6": "Lava Gray",
|
||||
"A50-K0": "Black",
|
||||
"A50-B6": "Royal Blue",
|
||||
# PLA Tough+ (A10)
|
||||
"A10-W0": "White",
|
||||
"A10-D0": "Gray",
|
||||
# PLA Tough (A09)
|
||||
"A09-B5": "Lavender Blue",
|
||||
"A09-B4": "Light Blue",
|
||||
"A09-A0": "Orange",
|
||||
"A09-D1": "Silver",
|
||||
"A09-R3": "Vermilion Red",
|
||||
"A09-Y0": "Yellow",
|
||||
# PETG HF (G02)
|
||||
"G02-K0": "Black",
|
||||
"G02-W0": "White",
|
||||
"G02-R0": "Red",
|
||||
"G02-D0": "Gray",
|
||||
"G02-D1": "Dark Gray",
|
||||
"G02-Y1": "Cream",
|
||||
"G02-Y0": "Yellow",
|
||||
"G02-A0": "Orange",
|
||||
"G02-N1": "Peanut Brown",
|
||||
"G02-G1": "Lime Green",
|
||||
"G02-G0": "Green",
|
||||
"G02-G2": "Forest Green",
|
||||
"G02-B1": "Lake Blue",
|
||||
"G02-B0": "Blue",
|
||||
# PETG Translucent (G01)
|
||||
"G01-G1": "Translucent Teal",
|
||||
"G01-B0": "Translucent Light Blue",
|
||||
"G01-C0": "Clear",
|
||||
"G01-D0": "Translucent Gray",
|
||||
"G01-G0": "Translucent Olive",
|
||||
"G01-N0": "Translucent Brown",
|
||||
"G01-A0": "Translucent Orange",
|
||||
"G01-P1": "Translucent Pink",
|
||||
"G01-P0": "Translucent Purple",
|
||||
# PETG-CF (G50)
|
||||
"G50-P7": "Violet Purple",
|
||||
"G50-K0": "Black",
|
||||
# ABS (B00)
|
||||
"B00-D1": "Silver",
|
||||
"B00-K0": "Black",
|
||||
"B00-W0": "White",
|
||||
"B00-G6": "Bambu Green",
|
||||
"B00-G7": "Olive",
|
||||
"B00-Y1": "Tangerine Yellow",
|
||||
"B00-A0": "Orange",
|
||||
"B00-R0": "Red",
|
||||
"B00-B4": "Azure",
|
||||
"B00-B0": "Blue",
|
||||
"B00-B6": "Navy Blue",
|
||||
# ABS-GF (B50)
|
||||
"B50-A0": "Orange",
|
||||
"B50-K0": "Black",
|
||||
# ASA (B01)
|
||||
"B01-W0": "White",
|
||||
"B01-K0": "Black",
|
||||
"B01-D0": "Gray",
|
||||
# ASA Aero (B02)
|
||||
"B02-W0": "White",
|
||||
# PC (C00)
|
||||
"C00-C1": "Transparent",
|
||||
"C00-C0": "Clear Black",
|
||||
"C00-K0": "Black",
|
||||
"C00-W0": "White",
|
||||
# PC FR (C01)
|
||||
"C01-K0": "Black",
|
||||
# TPU for AMS (U02)
|
||||
"U02-B0": "Blue",
|
||||
"U02-D0": "Gray",
|
||||
"U02-K0": "Black",
|
||||
# PAHT-CF (N04)
|
||||
"N04-K0": "Black",
|
||||
# PA6-GF (N08)
|
||||
"N08-K0": "Black",
|
||||
# Support for PLA/PETG (S02, S05)
|
||||
"S02-W0": "Nature",
|
||||
"S02-W1": "White",
|
||||
"S05-C0": "Black",
|
||||
# Support for ABS (S06)
|
||||
"S06-W0": "White",
|
||||
# Support for PA/PET (S03)
|
||||
"S03-G1": "Green",
|
||||
# PVA (S04)
|
||||
"S04-Y0": "Clear",
|
||||
}
|
||||
|
||||
# Fallback: color code suffix → name (for unknown material prefixes)
|
||||
BAMBU_COLOR_CODE_FALLBACK: dict[str, str] = {
|
||||
"W0": "White",
|
||||
"W1": "Jade White",
|
||||
"W2": "Ivory White",
|
||||
"W3": "Bone White",
|
||||
"Y0": "Yellow",
|
||||
"Y1": "Gold",
|
||||
"Y2": "Sunflower Yellow",
|
||||
"Y3": "Bronze",
|
||||
"Y4": "Gold",
|
||||
"A0": "Orange",
|
||||
"A1": "Pumpkin Orange",
|
||||
"A2": "Mandarin Orange",
|
||||
"R0": "Red",
|
||||
"R1": "Scarlet Red",
|
||||
"R2": "Maroon Red",
|
||||
"R3": "Hot Pink",
|
||||
"R4": "Dark Red",
|
||||
"R5": "Red Granite",
|
||||
"P0": "Beige",
|
||||
"P1": "Pink",
|
||||
"P2": "Indigo Purple",
|
||||
"P3": "Sakura Pink",
|
||||
"P4": "Lilac Purple",
|
||||
"P5": "Purple",
|
||||
"P6": "Magenta",
|
||||
"P7": "Violet Purple",
|
||||
"B0": "Blue",
|
||||
"B1": "Blue Grey",
|
||||
"B2": "Cobalt Blue",
|
||||
"B3": "Cobalt Blue",
|
||||
"B4": "Ice Blue",
|
||||
"B5": "Turquoise",
|
||||
"B6": "Navy Blue",
|
||||
"B7": "Royal Purple",
|
||||
"B8": "Cyan",
|
||||
"G0": "Green",
|
||||
"G1": "Grass Green",
|
||||
"G2": "Mistletoe Green",
|
||||
"G3": "Bright Green",
|
||||
"G6": "Bambu Green",
|
||||
"G7": "Dark Green",
|
||||
"N0": "Brown",
|
||||
"N1": "Peanut Brown",
|
||||
"N2": "Dark Brown",
|
||||
"N3": "Caramel",
|
||||
"D0": "Gray",
|
||||
"D1": "Silver",
|
||||
"D2": "Light Gray",
|
||||
"D3": "Dark Gray",
|
||||
"D4": "White Marble",
|
||||
"D5": "Slate Gray",
|
||||
"D6": "Lava Gray",
|
||||
"K0": "Black",
|
||||
"K1": "Charcoal",
|
||||
"K2": "Onyx Black",
|
||||
"C0": "Clear Black",
|
||||
"C1": "Transparent",
|
||||
}
|
||||
|
||||
|
||||
def resolve_bambu_color_name(tray_id_name: str) -> str | None:
|
||||
"""Resolve a Bambu Lab tray_id_name code to a human-readable color name.
|
||||
|
||||
Tries exact match first, then falls back to color code suffix lookup.
|
||||
Returns None if the code cannot be resolved.
|
||||
"""
|
||||
if not tray_id_name:
|
||||
return None
|
||||
|
||||
# Exact match
|
||||
name = BAMBU_FILAMENT_COLORS.get(tray_id_name)
|
||||
if name:
|
||||
return name
|
||||
|
||||
# Fallback: use color code suffix (e.g. "D0" from "A06-D0")
|
||||
parts = tray_id_name.split("-")
|
||||
if len(parts) >= 2:
|
||||
return BAMBU_COLOR_CODE_FALLBACK.get(parts[1])
|
||||
|
||||
return None
|
||||
+26
-3
@@ -596,11 +596,34 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
||||
tray_info_idx = tray.get("tray_info_idx", "")
|
||||
if not tray.get("tray_type"):
|
||||
continue # Empty slot
|
||||
# Skip if assignment already exists for this slot
|
||||
# Check if assignment already exists for this slot
|
||||
existing = await db.execute(
|
||||
select(SA).where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == tray_id)
|
||||
select(SA)
|
||||
.options(selectinload(SA.spool))
|
||||
.where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == tray_id)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
existing_assignment = existing.scalar_one_or_none()
|
||||
if existing_assignment:
|
||||
# Sync spool weight_used from AMS remain if valid
|
||||
remain_raw = tray.get("remain")
|
||||
if remain_raw is not None and existing_assignment.spool:
|
||||
try:
|
||||
remain_val = int(remain_raw)
|
||||
except (TypeError, ValueError):
|
||||
remain_val = -1
|
||||
if 0 <= remain_val <= 100:
|
||||
lw = existing_assignment.spool.label_weight or 1000
|
||||
new_used = round(lw * (100 - remain_val) / 100.0, 1)
|
||||
if abs((existing_assignment.spool.weight_used or 0) - new_used) > 1:
|
||||
logger.info(
|
||||
"Weight sync: spool %d weight_used %s -> %s (remain=%d)",
|
||||
existing_assignment.spool_id,
|
||||
existing_assignment.spool.weight_used,
|
||||
new_used,
|
||||
remain_val,
|
||||
)
|
||||
existing_assignment.spool.weight_used = new_used
|
||||
await db.commit()
|
||||
continue
|
||||
|
||||
if is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
|
||||
|
||||
@@ -25,6 +25,11 @@ class SpoolAssignment(Base):
|
||||
|
||||
__table_args__ = (UniqueConstraint("printer_id", "ams_id", "tray_id"),)
|
||||
|
||||
@property
|
||||
def printer_name(self) -> str | None:
|
||||
"""Get printer name from loaded relationship."""
|
||||
return self.printer.name if self.printer else None
|
||||
|
||||
|
||||
from backend.app.models.printer import Printer # noqa: E402, F401
|
||||
from backend.app.models.spool import Spool # noqa: E402, F401
|
||||
|
||||
@@ -96,6 +96,7 @@ class SpoolAssignmentResponse(BaseModel):
|
||||
id: int
|
||||
spool_id: int
|
||||
printer_id: int
|
||||
printer_name: str | None = None
|
||||
ams_id: int
|
||||
tray_id: int
|
||||
fingerprint_color: str | None = None
|
||||
|
||||
@@ -1062,7 +1062,9 @@ class BambuMQTTClient:
|
||||
self._previous_ams_hash = ams_hash
|
||||
if self.on_ams_change:
|
||||
logger.info("[%s] AMS data changed, triggering sync callback", self.serial_number)
|
||||
self.on_ams_change(ams_list)
|
||||
# Pass merged AMS data (not raw ams_list) — partial MQTT updates
|
||||
# may lack fields like 'remain' that the merged state preserves
|
||||
self.on_ams_change(merged_ams)
|
||||
|
||||
def _update_state(self, data: dict):
|
||||
"""Update printer state from message data."""
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
Firmware Check Service
|
||||
|
||||
Checks for firmware updates by fetching from Bambu Lab's official firmware download page.
|
||||
Also provides firmware download functionality for offline updates.
|
||||
Checks for firmware updates by fetching from Bambu Lab's official wiki and firmware
|
||||
download page. The wiki is used as the primary version source (always up-to-date),
|
||||
while the download page provides firmware file URLs for offline updates.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -18,10 +19,13 @@ from backend.app.core.config import _data_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Bambu Lab firmware download page
|
||||
# Bambu Lab firmware download page (for download URLs)
|
||||
BAMBU_FIRMWARE_BASE = "https://bambulab.com"
|
||||
FIRMWARE_PAGE = "/en/support/firmware-download/all"
|
||||
|
||||
# Bambu Lab wiki (primary source for latest version detection)
|
||||
BAMBU_WIKI_BASE = "https://wiki.bambulab.com"
|
||||
|
||||
# Cache TTL in seconds (1 hour)
|
||||
CACHE_TTL = 3600
|
||||
|
||||
@@ -61,6 +65,20 @@ API_KEY_TO_DEV_MODEL = {
|
||||
"h2d-pro": "O1E",
|
||||
}
|
||||
|
||||
# Wiki firmware release history pages (primary version source)
|
||||
API_KEY_TO_WIKI_PATH = {
|
||||
"x1": "/en/x1/manual/X1-X1C-firmware-release-history",
|
||||
"x1e": "/en/x1/manual/X1E-firmware-release-history",
|
||||
"p1": "/en/p1/manual/p1p-firmware-release-history",
|
||||
"a1": "/en/a1/manual/a1-firmware-release-history",
|
||||
"a1-mini": "/en/a1-mini/manual/a1-mini-firmware-release-history",
|
||||
"h2d": "/en/h2d/manual/h2d-firmware-release-history",
|
||||
"h2c": "/en/h2c/manual/h2c-firmware-release-history",
|
||||
"h2s": "/en/h2s/manual/h2s-firmware-release-history",
|
||||
"p2s": "/en/p2s/manual/p2s-firmware-release-history",
|
||||
"h2d-pro": "/en/h2d-pro/manual/firmware-release-history",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FirmwareVersion:
|
||||
@@ -109,11 +127,34 @@ class FirmwareCheckService:
|
||||
|
||||
return self._build_id # Return cached value if available
|
||||
|
||||
async def _fetch_firmware_versions(self, api_key: str) -> FirmwareVersion | None:
|
||||
"""Fetch firmware versions for a specific printer from Bambu Lab API."""
|
||||
async def _fetch_version_from_wiki(self, api_key: str) -> str | None:
|
||||
"""Fetch the latest firmware version from Bambu Lab's wiki release history page."""
|
||||
wiki_path = API_KEY_TO_WIKI_PATH.get(api_key)
|
||||
if not wiki_path:
|
||||
return None
|
||||
|
||||
try:
|
||||
url = f"{BAMBU_WIKI_BASE}{wiki_path}"
|
||||
response = await self._client.get(url, follow_redirects=True)
|
||||
|
||||
if response.status_code == 200:
|
||||
# Extract version strings (format: XX.XX.XX.XX), first match is the latest
|
||||
versions = re.findall(r"(\d{2}\.\d{2}\.\d{2}\.\d{2})", response.text)
|
||||
if versions:
|
||||
logger.debug("Wiki firmware for %s: %s", api_key, versions[0])
|
||||
return versions[0]
|
||||
else:
|
||||
logger.debug("Wiki firmware page for %s returned %s", api_key, response.status_code)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Error fetching wiki firmware for %s: %s", api_key, e)
|
||||
|
||||
return None
|
||||
|
||||
async def _fetch_from_download_page(self, api_key: str) -> FirmwareVersion | None:
|
||||
"""Fetch firmware info from Bambu Lab's download page (has download URLs)."""
|
||||
build_id = await self._get_build_id()
|
||||
if not build_id:
|
||||
logger.warning("No build ID available, cannot fetch firmware versions")
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -135,16 +176,39 @@ class FirmwareCheckService:
|
||||
release_notes=latest.get("release_notes_en"),
|
||||
release_time=latest.get("release_time"),
|
||||
)
|
||||
else:
|
||||
# api_key is a printer model identifier (e.g. "x1", "p1"), not a secret
|
||||
logger.warning("Failed to fetch firmware for %s: %s", api_key, response.status_code)
|
||||
|
||||
except Exception as e:
|
||||
# api_key is a printer model identifier (e.g. "x1", "p1"), not a secret
|
||||
logger.error("Error fetching firmware for %s: %s", api_key, e)
|
||||
logger.debug("Error fetching download page firmware for %s: %s", api_key, e)
|
||||
|
||||
return None
|
||||
|
||||
async def _fetch_firmware_versions(self, api_key: str) -> FirmwareVersion | None:
|
||||
"""Fetch firmware version info, using wiki as primary source and download page as fallback."""
|
||||
# Try wiki first (always has the latest version)
|
||||
wiki_version = await self._fetch_version_from_wiki(api_key)
|
||||
|
||||
# Try download page (has download URLs, may lag behind wiki)
|
||||
download_info = await self._fetch_from_download_page(api_key)
|
||||
|
||||
if wiki_version:
|
||||
# Wiki has the latest version — use it, attach download URL if available
|
||||
download_url = ""
|
||||
release_notes = None
|
||||
if download_info and download_info.version == wiki_version:
|
||||
download_url = download_info.download_url
|
||||
release_notes = download_info.release_notes
|
||||
return FirmwareVersion(
|
||||
version=wiki_version,
|
||||
download_url=download_url,
|
||||
release_notes=release_notes,
|
||||
)
|
||||
|
||||
if download_info:
|
||||
return download_info
|
||||
|
||||
logger.warning("Could not fetch firmware info for %s from wiki or download page", api_key)
|
||||
return None
|
||||
|
||||
async def get_latest_version(self, model: str) -> FirmwareVersion | None:
|
||||
"""
|
||||
Get the latest firmware version for a printer model.
|
||||
|
||||
@@ -63,11 +63,23 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
|
||||
elif tray_sub_brands and tray_sub_brands.upper() != material.upper():
|
||||
material = tray_sub_brands
|
||||
|
||||
# Try to find color name from color catalog
|
||||
color_name = tray_id_name or None
|
||||
rgba = tray_color if tray_color else None
|
||||
# Resolve color name from tray_id_name code, hex catalog, or raw tray_id_name
|
||||
from backend.app.core.bambu_colors import resolve_bambu_color_name
|
||||
|
||||
# Look up color catalog for a better color name if we only have hex
|
||||
rgba = tray_color if tray_color else None
|
||||
color_name = None
|
||||
|
||||
# 1. Try Bambu color code mapping (e.g. "A06-D0" → "Titan Gray")
|
||||
if tray_id_name:
|
||||
color_name = resolve_bambu_color_name(tray_id_name)
|
||||
logger.info("Color resolve: tray_id_name=%r → resolved=%r", tray_id_name, color_name)
|
||||
# If not a known code, use tray_id_name directly (it may be a readable name)
|
||||
if not color_name and "-" not in tray_id_name:
|
||||
color_name = tray_id_name
|
||||
else:
|
||||
logger.info("Color resolve: tray_id_name is empty, rgba=%r", rgba)
|
||||
|
||||
# 2. Try color catalog lookup by hex color
|
||||
if not color_name and rgba and len(rgba) >= 6:
|
||||
hex_prefix = f"#{rgba[:6].upper()}"
|
||||
cat_result = await db.execute(
|
||||
@@ -88,6 +100,30 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
|
||||
core_weight = entry.weight
|
||||
break
|
||||
|
||||
# Resolve slicer filament name from builtin table
|
||||
slicer_filament_name = None
|
||||
if tray_info_idx:
|
||||
try:
|
||||
from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
|
||||
|
||||
slicer_filament_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx)
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback: use tray_sub_brands as the display name
|
||||
if not slicer_filament_name and tray_sub_brands:
|
||||
slicer_filament_name = tray_sub_brands
|
||||
|
||||
# Calculate initial weight_used from AMS remain percentage
|
||||
remain_raw = tray_data.get("remain")
|
||||
try:
|
||||
remain_pct = int(remain_raw) if remain_raw is not None else 100
|
||||
except (TypeError, ValueError):
|
||||
remain_pct = 100
|
||||
# Clamp to valid range: negative means unknown, >100 is invalid
|
||||
if remain_pct < 0 or remain_pct > 100:
|
||||
remain_pct = 100 # Unknown → assume full
|
||||
weight_used = round(label_weight * (100 - remain_pct) / 100.0, 1)
|
||||
|
||||
spool = Spool(
|
||||
material=material,
|
||||
subtype=subtype,
|
||||
@@ -96,8 +132,9 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
|
||||
brand="Bambu Lab",
|
||||
label_weight=label_weight,
|
||||
core_weight=core_weight,
|
||||
weight_used=0,
|
||||
weight_used=weight_used,
|
||||
slicer_filament=tray_info_idx or None,
|
||||
slicer_filament_name=slicer_filament_name,
|
||||
nozzle_temp_min=int(nozzle_min) if nozzle_min else None,
|
||||
nozzle_temp_max=int(nozzle_max) if nozzle_max else None,
|
||||
tag_uid=tag_uid if tag_uid and tag_uid != ZERO_TAG_UID else None,
|
||||
|
||||
@@ -1784,6 +1784,7 @@ export interface SpoolAssignment {
|
||||
id: number;
|
||||
spool_id: number;
|
||||
printer_id: number;
|
||||
printer_name: string | null;
|
||||
ams_id: number;
|
||||
tray_id: number;
|
||||
fingerprint_color: string | null;
|
||||
|
||||
@@ -10,7 +10,7 @@ interface FilamentData {
|
||||
kFactor: string;
|
||||
fillLevel: number | null; // null = unknown
|
||||
trayUuid?: string | null; // Bambu Lab spool UUID for Spoolman linking
|
||||
fillSource?: 'ams' | 'spoolman'; // Source of fill level data
|
||||
fillSource?: 'ams' | 'spoolman' | 'inventory'; // Source of fill level data
|
||||
}
|
||||
|
||||
interface SpoolmanConfig {
|
||||
@@ -242,6 +242,9 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
|
||||
{data.fillSource === 'spoolman' && data.fillLevel !== null && (
|
||||
<span className="text-[9px] text-bambu-gray font-normal">{t('spoolman.fillSourceLabel')}</span>
|
||||
)}
|
||||
{data.fillSource === 'inventory' && data.fillLevel !== null && (
|
||||
<span className="text-[9px] text-bambu-gray font-normal">{t('inventory.fillSourceLabel')}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Fill bar */}
|
||||
|
||||
@@ -228,9 +228,9 @@ export function useWebSocket() {
|
||||
break;
|
||||
|
||||
case 'spool_auto_assigned':
|
||||
// RFID tag matched - refresh inventory data
|
||||
// RFID tag matched - refresh inventory and assignment data
|
||||
debouncedInvalidate('inventory-spools');
|
||||
debouncedInvalidate('inventory-assignments');
|
||||
debouncedInvalidate('spool-assignments');
|
||||
break;
|
||||
|
||||
case 'spool_usage_logged':
|
||||
|
||||
@@ -2447,7 +2447,7 @@ export default {
|
||||
coreWeight: 'Leergewicht der Spule',
|
||||
searchSpoolWeight: 'Spulengewicht suchen...',
|
||||
weightUsed: 'Verbraucht',
|
||||
slicerFilament: 'Slicer-Filament-ID',
|
||||
slicerFilament: 'Slicer-Filament',
|
||||
slicerFilamentName: 'Slicer-Preset-Name',
|
||||
slicerPreset: 'Slicer-Preset',
|
||||
searchPresets: 'Filament-Presets suchen...',
|
||||
@@ -2565,6 +2565,7 @@ export default {
|
||||
weightConsumed: 'Verbrauchtes Gewicht',
|
||||
clearHistory: 'Löschen',
|
||||
historyCleared: 'Verbrauchshistorie gelöscht',
|
||||
fillSourceLabel: '(Inv)',
|
||||
},
|
||||
|
||||
// Timelapse
|
||||
|
||||
@@ -2447,7 +2447,7 @@ export default {
|
||||
coreWeight: 'Empty Spool Weight',
|
||||
searchSpoolWeight: 'Search spool weight...',
|
||||
weightUsed: 'Used',
|
||||
slicerFilament: 'Slicer Filament ID',
|
||||
slicerFilament: 'Slicer Filament',
|
||||
slicerFilamentName: 'Slicer Preset Name',
|
||||
slicerPreset: 'Slicer Preset',
|
||||
searchPresets: 'Search filament presets...',
|
||||
@@ -2569,6 +2569,7 @@ export default {
|
||||
weightConsumed: 'Weight Consumed',
|
||||
clearHistory: 'Clear',
|
||||
historyCleared: 'Usage history cleared',
|
||||
fillSourceLabel: '(Inv)',
|
||||
},
|
||||
|
||||
// Timelapse
|
||||
|
||||
@@ -2378,7 +2378,7 @@ export default {
|
||||
coreWeight: '空スプール重量',
|
||||
searchSpoolWeight: 'スプール重量を検索...',
|
||||
weightUsed: '使用量',
|
||||
slicerFilament: 'スライサーフィラメントID',
|
||||
slicerFilament: 'スライサーフィラメント',
|
||||
slicerFilamentName: 'スライサープリセット名',
|
||||
slicerPreset: 'スライサープリセット',
|
||||
searchPresets: 'フィラメントプリセットを検索...',
|
||||
@@ -2494,6 +2494,7 @@ export default {
|
||||
weightConsumed: '消費重量',
|
||||
clearHistory: 'クリア',
|
||||
historyCleared: '使用履歴がクリアされました',
|
||||
fillSourceLabel: '(Inv)',
|
||||
},
|
||||
timelapse: {
|
||||
download: 'ダウンロード',
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Button } from '../components/Button';
|
||||
import { SpoolFormModal } from '../components/SpoolFormModal';
|
||||
import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { resolveSpoolColorName } from '../utils/colors';
|
||||
|
||||
type ArchiveFilter = 'active' | 'archived';
|
||||
type UsageFilter = 'all' | 'used' | 'new';
|
||||
@@ -149,12 +150,12 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
|
||||
<span className="text-sm text-bambu-gray">{spool.last_used ? formatDate(spool.last_used) : 'Never'}</span>
|
||||
),
|
||||
rgba: ({ spool }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center">
|
||||
<span
|
||||
className="w-5 h-5 rounded-full border border-white/20 flex-shrink-0"
|
||||
style={{ backgroundColor: spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080' }}
|
||||
title={spool.rgba ? `#${spool.rgba.substring(0, 6)}` : undefined}
|
||||
/>
|
||||
<span className="text-sm text-white">{spool.color_name || '-'}</span>
|
||||
</div>
|
||||
),
|
||||
material: ({ spool }) => (
|
||||
@@ -164,7 +165,7 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
|
||||
<span className="text-sm text-bambu-gray">{spool.subtype || '-'}</span>
|
||||
),
|
||||
color_name: ({ spool }) => (
|
||||
<span className="text-sm text-bambu-gray">{spool.color_name || '-'}</span>
|
||||
<span className="text-sm text-bambu-gray">{resolveSpoolColorName(spool.color_name, spool.rgba) || '-'}</span>
|
||||
),
|
||||
brand: ({ spool }) => (
|
||||
<span className="text-sm text-bambu-gray">{spool.brand || '-'}</span>
|
||||
@@ -177,9 +178,13 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
|
||||
location: ({ spool, assignmentMap }) => {
|
||||
const assignment = assignmentMap[spool.id];
|
||||
if (!assignment) return <span className="text-sm text-bambu-gray">-</span>;
|
||||
const printerLabel = assignment.printer_name || `Printer ${assignment.printer_id}`;
|
||||
// Bambu slot notation: AMS 0=A, 1=B, 2=C, 3=D; tray 0-based → 1-based
|
||||
const slotLetter = String.fromCharCode(65 + assignment.ams_id);
|
||||
const slotNumber = assignment.tray_id + 1;
|
||||
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">
|
||||
AMS {assignment.ams_id} T{assignment.tray_id}
|
||||
{printerLabel} {slotLetter}{slotNumber}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
@@ -701,7 +706,7 @@ export default function InventoryPage() {
|
||||
{/* Color header */}
|
||||
<div className="h-14 flex items-center justify-center" style={{ backgroundColor: colorStyle }}>
|
||||
<span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
|
||||
{spool.color_name || '-'}
|
||||
{resolveSpoolColorName(spool.color_name, spool.rgba) || '-'}
|
||||
</span>
|
||||
</div>
|
||||
{/* Content */}
|
||||
|
||||
@@ -2688,14 +2688,24 @@ function PrinterCard({
|
||||
// Get saved slot preset mapping (for user-configured slots)
|
||||
const slotPreset = slotPresets?.[globalTrayId];
|
||||
|
||||
// Spoolman fill level fallback (when AMS reports 0%)
|
||||
// Fill level fallback chain: AMS remain → Spoolman → Inventory spool
|
||||
const trayTag = tray?.tray_uuid?.toUpperCase();
|
||||
const linkedSpool = trayTag ? linkedSpools?.[trayTag] : undefined;
|
||||
const spoolmanFill = getSpoolmanFillLevel(linkedSpool);
|
||||
const inventoryAssignment = onGetAssignment?.(printer.id, ams.id, slotIdx);
|
||||
const inventoryFill = (() => {
|
||||
const sp = inventoryAssignment?.spool;
|
||||
if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
|
||||
return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const effectiveFill = hasFillLevel && tray.remain > 0
|
||||
? tray.remain
|
||||
: (spoolmanFill ?? (hasFillLevel ? tray.remain : null));
|
||||
const fillSource = (hasFillLevel && tray.remain === 0 && spoolmanFill !== null) ? 'spoolman' as const : 'ams' as const;
|
||||
: (spoolmanFill ?? inventoryFill ?? (hasFillLevel ? tray.remain : null));
|
||||
const fillSource = (hasFillLevel && tray.remain === 0 && (spoolmanFill !== null || inventoryFill !== null))
|
||||
? (spoolmanFill !== null ? 'spoolman' as const : 'inventory' as const)
|
||||
: 'ams' as const;
|
||||
|
||||
// Build filament data for hover card
|
||||
const filamentData = tray?.tray_type ? {
|
||||
@@ -2898,14 +2908,25 @@ function PrinterCard({
|
||||
// Get saved slot preset mapping (for user-configured slots)
|
||||
const slotPreset = slotPresets?.[globalTrayId];
|
||||
|
||||
// Spoolman fill level fallback (when AMS reports 0%)
|
||||
// Fill level fallback chain: AMS remain → Spoolman → Inventory spool
|
||||
const htTrayTag = tray?.tray_uuid?.toUpperCase();
|
||||
const htLinkedSpool = htTrayTag ? linkedSpools?.[htTrayTag] : undefined;
|
||||
const htSpoolmanFill = getSpoolmanFillLevel(htLinkedSpool);
|
||||
const htTraySlotId = tray?.id ?? 0;
|
||||
const htInventoryAssignment = onGetAssignment?.(printer.id, ams.id, htTraySlotId);
|
||||
const htInventoryFill = (() => {
|
||||
const sp = htInventoryAssignment?.spool;
|
||||
if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
|
||||
return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const htEffectiveFill = hasFillLevel && tray.remain > 0
|
||||
? tray.remain
|
||||
: (htSpoolmanFill ?? (hasFillLevel ? tray.remain : null));
|
||||
const htFillSource = (hasFillLevel && tray.remain === 0 && htSpoolmanFill !== null) ? 'spoolman' as const : 'ams' as const;
|
||||
: (htSpoolmanFill ?? htInventoryFill ?? (hasFillLevel ? tray.remain : null));
|
||||
const htFillSource = (hasFillLevel && tray.remain === 0 && (htSpoolmanFill !== null || htInventoryFill !== null))
|
||||
? (htSpoolmanFill !== null ? 'spoolman' as const : 'inventory' as const)
|
||||
: 'ams' as const;
|
||||
|
||||
// Build filament data for hover card
|
||||
const filamentData = tray?.tray_type ? {
|
||||
@@ -3135,10 +3156,19 @@ function PrinterCard({
|
||||
// Get saved slot preset mapping (external spool uses amsId=255, trayId=0)
|
||||
const extSlotPreset = slotPresets?.[255 * 4 + 0];
|
||||
|
||||
// Spoolman fill level for external spool
|
||||
// Fill level fallback chain: Spoolman → Inventory spool
|
||||
const extTrayTag = extTray.tray_uuid?.toUpperCase();
|
||||
const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined;
|
||||
const extSpoolmanFill = getSpoolmanFillLevel(extLinkedSpool);
|
||||
const extInventoryAssignment = onGetAssignment?.(printer.id, 255, 0);
|
||||
const extInventoryFill = (() => {
|
||||
const sp = extInventoryAssignment?.spool;
|
||||
if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
|
||||
return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const extEffectiveFill = extSpoolmanFill ?? extInventoryFill ?? null;
|
||||
|
||||
// Build filament data for hover card
|
||||
const extFilamentData = {
|
||||
@@ -3147,10 +3177,12 @@ function PrinterCard({
|
||||
colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
|
||||
colorHex: extTray.tray_color || null,
|
||||
kFactor: formatKValue(extTray.k),
|
||||
fillLevel: extSpoolmanFill, // Use Spoolman data if available
|
||||
fillLevel: extEffectiveFill,
|
||||
trayUuid: extTray.tray_uuid || null,
|
||||
tagUid: extTray.tag_uid || null,
|
||||
fillSource: extSpoolmanFill !== null ? 'spoolman' as const : undefined,
|
||||
fillSource: extSpoolmanFill !== null ? 'spoolman' as const
|
||||
: extInventoryFill !== null ? 'inventory' as const
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const extSlotContent = (
|
||||
|
||||
@@ -104,3 +104,24 @@ export function getColorName(hexColor: string): string {
|
||||
}
|
||||
return hexToColorName(hexColor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a spool's display color name.
|
||||
* Tries: stored color_name (if it's a readable name) → hex color database → HSL fallback.
|
||||
* Detects Bambu internal codes (e.g. "A06-D0") and resolves them to names ("Titan Gray").
|
||||
*/
|
||||
export function resolveSpoolColorName(colorName: string | null, rgba: string | null): string | null {
|
||||
// If color_name looks like a readable name (no pattern like "X00-Y0"), use it directly
|
||||
if (colorName && !/^[A-Z]\d+-[A-Z]\d+$/.test(colorName)) {
|
||||
return colorName;
|
||||
}
|
||||
// Try hex color lookup from rgba
|
||||
if (rgba && rgba.length >= 6) {
|
||||
const hex = rgba.substring(0, 6).toLowerCase();
|
||||
if (BAMBU_HEX_COLORS[hex]) {
|
||||
return BAMBU_HEX_COLORS[hex];
|
||||
}
|
||||
}
|
||||
// Return null (displayed as "-") — better than showing a code
|
||||
return null;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -23,7 +23,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-B4os6TlG.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-C-jcYP-o.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C8xaQF5N.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user