This commit is contained in:
maziggy
2026-02-12 11:54:50 +01:00
parent ec82092bc7
commit 1f0931e00c
19 changed files with 968 additions and 448 deletions
+8 -2
View File
@@ -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()
+317
View File
@@ -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
View File
@@ -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):
+5
View File
@@ -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
+1
View File
@@ -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
+3 -1
View File
@@ -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."""
+75 -11
View File
@@ -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.
+42 -5
View File
@@ -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,
+1
View File
@@ -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 */}
+2 -2
View File
@@ -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':
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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: 'ダウンロード',
+10 -5
View File
@@ -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 */}
+41 -9
View File
@@ -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 = (
+21
View File
@@ -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
View File
@@ -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>