Переглянути джерело

Fix H2C printer image and resolve nozzle rack filament names (#300)

1. H2C printer card was showing the H2D image — added dedicated
   h2c.png and updated getPrinterImage() mapping.

2. Nozzle rack hover card showed raw filament IDs (e.g. "GFU99")
   instead of human-readable names. Now resolves names via 3-tier
   fallback: Bambu Cloud → local slicer profiles → raw ID.
   - Frontend: nozzle rack filament_id values included in cloud
     lookup query; NozzleSlotHoverCard displays resolved name.
   - Backend: get_filament_info endpoint refactored from cloud-only
     to cache → cloud → local profiles. Matches local presets by
     setting_id in the imported OrcaSlicer JSON blob.
maziggy 6 місяців тому
батько
коміт
f7cd173118

+ 2 - 0
CHANGELOG.md

@@ -22,6 +22,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **H2 Series — Single-Nozzle Hover Card** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — H2D/H2S printers with a single nozzle now show extended nozzle details (wear, serial, max temp) on hover over the temperature card. Backend changed from H2C-only (>2 nozzles) to all H2 series (any nozzle_info present).
 - **H2 Series — Single-Nozzle Hover Card** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — H2D/H2S printers with a single nozzle now show extended nozzle details (wear, serial, max temp) on hover over the temperature card. Backend changed from H2C-only (>2 nozzles) to all H2 series (any nozzle_info present).
 - **H2C Nozzle Rack — Translate Type Codes & Add Flow Info** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Raw nozzle type codes (e.g. "HS", "HH01") are now translated to human-readable names: material (Hardened Steel, Stainless Steel, Tungsten Carbide) and flow type (High Flow, Standard). New "Flow" row in the hover card. Translations added in all 4 locales (en, de, ja, it).
 - **H2C Nozzle Rack — Translate Type Codes & Add Flow Info** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Raw nozzle type codes (e.g. "HS", "HH01") are now translated to human-readable names: material (Hardened Steel, Stainless Steel, Tungsten Carbide) and flow type (High Flow, Standard). New "Flow" row in the hover card. Translations added in all 4 locales (en, de, ja, it).
 - **H2C Nozzle Rack — Show Filament Material in Hover Card** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle hover card now shows the loaded filament material type (e.g. "PLA", "PETG") alongside the color swatch, captured from MQTT nozzle info data.
 - **H2C Nozzle Rack — Show Filament Material in Hover Card** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle hover card now shows the loaded filament material type (e.g. "PLA", "PETG") alongside the color swatch, captured from MQTT nozzle info data.
+- **H2C Nozzle Rack — Resolve Filament Names From Cloud & Local Profiles** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle rack hover card previously showed raw filament IDs like "GFU99" instead of human-readable names. Now resolves filament names with a 3-tier fallback: Bambu Cloud preset lookup → local slicer profiles (matching by `setting_id` in the imported OrcaSlicer JSON) → raw ID fallback. Uses the same cloud lookup mechanism as AMS trays. Local profiles work without cloud authentication for users who import their slicer presets.
 - **H2C Nozzle Rack Compact Layout** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Redesigned nozzle rack from a 2×3 grid to a compact single-row layout with bottom accent bars (green = mounted, gray = docked). Temperature cards are thinner, rack card is wider (flex-[2]), and all cards vertically centered.
 - **H2C Nozzle Rack Compact Layout** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Redesigned nozzle rack from a 2×3 grid to a compact single-row layout with bottom accent bars (green = mounted, gray = docked). Temperature cards are thinner, rack card is wider (flex-[2]), and all cards vertically centered.
 - **Firmware Version Badge on Printer Card** ([#311](https://github.com/maziggy/bambuddy/issues/311)) — Printer cards now show a firmware version badge (when firmware checking is enabled). Green with checkmark when up to date, orange with download icon when an update is available. Clicking the badge opens a firmware info modal showing release notes (auto-expanded when up to date) or the existing update workflow. Badge and modal respect `firmware:read` and `firmware:update` permissions. Translations added in all 4 locales.
 - **Firmware Version Badge on Printer Card** ([#311](https://github.com/maziggy/bambuddy/issues/311)) — Printer cards now show a firmware version badge (when firmware checking is enabled). Green with checkmark when up to date, orange with download icon when an update is available. Clicking the badge opens a firmware info modal showing release notes (auto-expanded when up to date) or the existing update workflow. Badge and modal respect `firmware:read` and `firmware:update` permissions. Translations added in all 4 locales.
 - **Auto-Detect Subnet for Printer Discovery** — Docker users no longer need to manually enter a subnet in the Add Printer dialog. Bambuddy auto-detects available network subnets and pre-selects the first one. When multiple subnets are available (e.g., eth0 + wlan0), a dropdown lets users choose. Falls back to manual text input if no subnets are detected.
 - **Auto-Detect Subnet for Printer Discovery** — Docker users no longer need to manually enter a subnet in the Add Printer dialog. Bambuddy auto-detects available network subnets and pre-selects the first one. When multiple subnets are available (e.g., eth0 + wlan0), a dropdown lets users choose. Falls back to manual text input if no subnets are detected.
@@ -46,6 +47,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Support Bundle Shows 0 AMS Units** — The support info always reported `ams_unit_count: 0` because it expected `raw_data["ams"]` to be a nested dict (`{"ams": [...]}`) but the MQTT handler stores it as a flat list. Now handles both formats.
 - **Support Bundle Shows 0 AMS Units** — The support info always reported `ams_unit_count: 0` because it expected `raw_data["ams"]` to be a nested dict (`{"ams": [...]}`) but the MQTT handler stores it as a flat list. Now handles both formats.
 - **Firmware Badge Shown for Models Without API Data** ([#311](https://github.com/maziggy/bambuddy/issues/311)) — Printers whose model has no firmware data in Bambu Lab's API (e.g. H2C on public beta firmware) showed a misleading green "up to date" badge. The badge is now hidden when the API returns no `latest_version`, since there is nothing to compare against.
 - **Firmware Badge Shown for Models Without API Data** ([#311](https://github.com/maziggy/bambuddy/issues/311)) — Printers whose model has no firmware data in Bambu Lab's API (e.g. H2C on public beta firmware) showed a misleading green "up to date" badge. The badge is now hidden when the API returns no `latest_version`, since there is nothing to compare against.
 - **AMS-HT Mapping Fails for Left Nozzle on H2D Pro** ([#318](https://github.com/maziggy/bambuddy/issues/318)) — Printing with the left nozzle on dual-nozzle printers (H2D/H2D Pro) using AMS-HT failed with "Failed to get AMS mapping table." The global tray ID for AMS-HT units (ams_id >= 128) was calculated as `ams_id * 4 + tray_id` (= 512), but AMS-HT uses the raw `ams_id` (128) since it has a single tray. The backend then misidentified 512 as an external spool. Fixed in frontend tray ID calculation, backend `ams_mapping2` builder, print scheduler, and Spoolman tracking.
 - **AMS-HT Mapping Fails for Left Nozzle on H2D Pro** ([#318](https://github.com/maziggy/bambuddy/issues/318)) — Printing with the left nozzle on dual-nozzle printers (H2D/H2D Pro) using AMS-HT failed with "Failed to get AMS mapping table." The global tray ID for AMS-HT units (ams_id >= 128) was calculated as `ams_id * 4 + tray_id` (= 512), but AMS-HT uses the raw `ams_id` (128) since it has a single tray. The backend then misidentified 512 as an external spool. Fixed in frontend tray ID calculation, backend `ams_mapping2` builder, print scheduler, and Spoolman tracking.
+- **H2C Printer Card Shows H2D Image** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — The H2C printer card displayed the H2D printer image because no dedicated H2C image existed in the frontend. Added H2C image and updated `getPrinterImage()` to return it for H2C models.
 - **H2C Nozzle Rack Shows Wrong Empty Slot and Missing Filament Colors** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Empty rack slots always appeared at position 6 instead of their actual position because nozzles were mapped by array index instead of by ID. Fixed by mapping each nozzle to its correct rack position (`id - 16`). Filament colors and materials were missing because the H2C uses different MQTT field names (`color_m`, `fila_id`, `sn`, `tm`) than the H2D (`filament_colour`, `filament_id`, `serial_number`, `max_temp`). Added fallback field name resolution. Also fixed nozzle rack layout breaking on medium card size by allowing the temperature row to wrap.
 - **H2C Nozzle Rack Shows Wrong Empty Slot and Missing Filament Colors** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Empty rack slots always appeared at position 6 instead of their actual position because nozzles were mapped by array index instead of by ID. Fixed by mapping each nozzle to its correct rack position (`id - 16`). Filament colors and materials were missing because the H2C uses different MQTT field names (`color_m`, `fila_id`, `sn`, `tm`) than the H2D (`filament_colour`, `filament_id`, `serial_number`, `max_temp`). Added fallback field name resolution. Also fixed nozzle rack layout breaking on medium card size by allowing the temperature row to wrap.
 
 
 ### Documentation
 ### Documentation

+ 110 - 47
backend/app/api/routes/cloud.py

@@ -308,6 +308,68 @@ _filament_cache_time: float = 0
 FILAMENT_CACHE_TTL = 300  # 5 minutes
 FILAMENT_CACHE_TTL = 300  # 5 minutes
 
 
 
 
+async def _enrich_from_local_presets(
+    unresolved_ids: list[str],
+    result: dict,
+    db: AsyncSession,
+) -> dict:
+    """Fall back to local profiles for filament IDs not resolved by cloud.
+
+    Matches by checking the setting_id field inside the local preset's
+    resolved JSON blob (stored in the 'setting' column).
+    """
+    from sqlalchemy import text
+
+    from backend.app.models.local_preset import LocalPreset
+
+    # Build lookup: converted setting_id -> original filament_id
+    id_map: dict[str, str] = {}
+    for fid in unresolved_ids:
+        converted = _filament_id_to_setting_id(fid)
+        id_map[converted] = fid
+        # Also map the original in case the JSON uses that form
+        id_map[fid] = fid
+
+    try:
+        # Query filament presets that have a setting_id matching any of our IDs
+        # json_extract is supported in SQLite >= 3.9 and all modern Python builds
+        candidates = await db.execute(
+            select(LocalPreset).where(
+                LocalPreset.preset_type == "filament",
+                text("json_extract(setting, '$.setting_id') IS NOT NULL"),
+            )
+        )
+        for preset in candidates.scalars().all():
+            try:
+                setting_data = json.loads(preset.setting) if isinstance(preset.setting, str) else preset.setting
+                preset_setting_id = setting_data.get("setting_id", "")
+                if preset_setting_id in id_map:
+                    original_id = id_map[preset_setting_id]
+                    info = {"name": preset.name, "k": None}
+                    # Try to extract K value from the local preset
+                    pa = setting_data.get("pressure_advance")
+                    if pa is not None:
+                        try:
+                            k_val = float(pa[0]) if isinstance(pa, list) else float(pa)
+                            info["k"] = k_val
+                        except (ValueError, TypeError, IndexError):
+                            pass
+                    _filament_cache[original_id] = info
+                    result[original_id] = info
+            except Exception:
+                continue
+    except Exception as e:
+        logger.warning("Failed to search local presets for filament info: %s", e)
+
+    # Fill remaining unresolved with empty entries
+    for fid in unresolved_ids:
+        if fid not in result:
+            _filament_cache[fid] = {"name": "", "k": None}
+            result[fid] = {"name": "", "k": None}
+
+    return result
+
+
 def _filament_id_to_setting_id(filament_id: str) -> str:
 def _filament_id_to_setting_id(filament_id: str) -> str:
     """
     """
     Convert filament_id to setting_id format for Bambu Cloud API.
     Convert filament_id to setting_id format for Bambu Cloud API.
@@ -345,7 +407,8 @@ async def get_filament_info(
     """
     """
     Get filament preset info (name and K value) for multiple setting IDs.
     Get filament preset info (name and K value) for multiple setting IDs.
 
 
-    Used to enrich AMS tray tooltips with cloud preset data.
+    Used to enrich AMS tray and nozzle rack tooltips with preset data.
+    Lookup order: cache → cloud → local profiles → empty fallback.
     """
     """
     import time
     import time
 
 
@@ -358,58 +421,58 @@ async def get_filament_info(
         _filament_cache = {}
         _filament_cache = {}
         _filament_cache_time = time.time()
         _filament_cache_time = time.time()
 
 
-    token, _ = await get_stored_token(db)
-    if not token:
-        logger.info("get_filament_info: Not authenticated, returning empty")
-        # Return empty results if not authenticated (graceful degradation)
-        return {}
-
-    cloud = get_cloud_service()
-    cloud.set_token(token)
-
-    if not cloud.is_authenticated:
-        return {}
-
     result = {}
     result = {}
+    unresolved_ids: list[str] = []
+
+    # Phase 1: Check cache
     for setting_id in setting_ids:
     for setting_id in setting_ids:
         if not setting_id:
         if not setting_id:
             continue
             continue
-
-        # Check cache first
         if setting_id in _filament_cache:
         if setting_id in _filament_cache:
             result[setting_id] = _filament_cache[setting_id]
             result[setting_id] = _filament_cache[setting_id]
-            continue
-
-        try:
-            # Transform filament_id to setting_id format (GFA00 -> GFSA00)
-            api_setting_id = _filament_id_to_setting_id(setting_id)
-
-            data = await cloud.get_setting_detail(api_setting_id)
-            setting = data.get("setting", {})
-
-            # Extract name (e.g., "Bambu PLA Basic Jade White")
-            name = data.get("name", "")
-
-            # Extract K value (pressure_advance)
-            k_value = setting.get("pressure_advance")
-            if k_value is not None:
-                try:
-                    k_value = float(k_value)
-                except (ValueError, TypeError):
-                    k_value = None
-
-            info = {"name": name, "k": k_value}
-            # Cache using original ID so frontend gets expected response
-            _filament_cache[setting_id] = info
-            result[setting_id] = info
-
-        except Exception as e:
-            logger.warning(
-                f"Failed to get cloud preset {setting_id} (API ID: {_filament_id_to_setting_id(setting_id)}): {e}"
-            )
-            # Cache the failure to avoid repeated requests
-            _filament_cache[setting_id] = {"name": "", "k": None}
-            result[setting_id] = {"name": "", "k": None}
+        else:
+            unresolved_ids.append(setting_id)
+
+    # Phase 2: Try cloud for uncached IDs
+    if unresolved_ids:
+        token, _ = await get_stored_token(db)
+        if token:
+            cloud = get_cloud_service()
+            cloud.set_token(token)
+
+            if cloud.is_authenticated:
+                still_unresolved: list[str] = []
+                for setting_id in unresolved_ids:
+                    try:
+                        api_setting_id = _filament_id_to_setting_id(setting_id)
+                        data = await cloud.get_setting_detail(api_setting_id)
+                        setting = data.get("setting", {})
+                        name = data.get("name", "")
+                        k_value = setting.get("pressure_advance")
+                        if k_value is not None:
+                            try:
+                                k_value = float(k_value)
+                            except (ValueError, TypeError):
+                                k_value = None
+
+                        info = {"name": name, "k": k_value}
+                        _filament_cache[setting_id] = info
+                        result[setting_id] = info
+
+                        if not name:
+                            still_unresolved.append(setting_id)
+                    except Exception as e:
+                        logger.warning(
+                            f"Failed to get cloud preset {setting_id} "
+                            f"(API ID: {_filament_id_to_setting_id(setting_id)}): {e}"
+                        )
+                        still_unresolved.append(setting_id)
+
+                unresolved_ids = still_unresolved
+
+    # Phase 3: Try local profiles for any IDs still without a name
+    if unresolved_ids:
+        result = await _enrich_from_local_presets(unresolved_ids, result, db)
 
 
     return result
     return result
 
 

BIN
frontend/public/img/printers/h2c.png


+ 17 - 8
frontend/src/pages/PrintersPage.tsx

@@ -457,10 +457,11 @@ function nozzleFlowName(type: string, t: (key: string) => string): string {
 
 
 // Per-slot hover card for nozzle rack
 // Per-slot hover card for nozzle rack
 // activeStatus: when true, show "Active" instead of "Mounted"/"Docked" (for hotend nozzles)
 // activeStatus: when true, show "Active" instead of "Mounted"/"Docked" (for hotend nozzles)
-function NozzleSlotHoverCard({ slot, index, activeStatus, children }: {
+function NozzleSlotHoverCard({ slot, index, activeStatus, filamentName, children }: {
   slot: import('../api/client').NozzleRackSlot;
   slot: import('../api/client').NozzleRackSlot;
   index: number;
   index: number;
   activeStatus?: boolean;
   activeStatus?: boolean;
+  filamentName?: string;
   children: React.ReactNode;
   children: React.ReactNode;
 }) {
 }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -600,7 +601,7 @@ function NozzleSlotHoverCard({ slot, index, activeStatus, children }: {
                       {filamentCss && (
                       {filamentCss && (
                         <div className="w-3 h-3 rounded-sm border border-white/20" style={{ backgroundColor: filamentCss }} />
                         <div className="w-3 h-3 rounded-sm border border-white/20" style={{ backgroundColor: filamentCss }} />
                       )}
                       )}
-                      <span className="text-[10px] text-white font-semibold">{slot.filament_type || slot.filament_id || ''}</span>
+                      <span className="text-[10px] text-white font-semibold truncate max-w-[100px]">{filamentName || slot.filament_type || slot.filament_id || ''}</span>
                     </div>
                     </div>
                   </div>
                   </div>
                 )}
                 )}
@@ -775,7 +776,7 @@ function DualNozzleHoverCard({ leftSlot, rightSlot, activeNozzle, children }: {
 }
 }
 
 
 // H2C Nozzle Rack Card — compact single row showing 6-position tool-changer dock
 // H2C Nozzle Rack Card — compact single row showing 6-position tool-changer dock
-function NozzleRackCard({ slots }: { slots: import('../api/client').NozzleRackSlot[] }) {
+function NozzleRackCard({ slots, filamentInfo }: { slots: import('../api/client').NozzleRackSlot[]; filamentInfo?: Record<string, { name: string; k: number | null }> }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   // Rack nozzles only (IDs >= 2) — excludes L/R hotend nozzles (IDs 0, 1)
   // Rack nozzles only (IDs >= 2) — excludes L/R hotend nozzles (IDs 0, 1)
   // H2C rack IDs are 16-21 — map by actual ID so empty slots appear in the correct position
   // H2C rack IDs are 16-21 — map by actual ID so empty slots appear in the correct position
@@ -799,7 +800,7 @@ function NozzleRackCard({ slots }: { slots: import('../api/client').NozzleRackSl
           const filamentBg = !isEmpty ? parseFilamentColor(slot.filament_color) : null;
           const filamentBg = !isEmpty ? parseFilamentColor(slot.filament_color) : null;
 
 
           return (
           return (
-            <NozzleSlotHoverCard key={slot.id >= 0 ? slot.id : `empty-${i}`} slot={slot} index={i}>
+            <NozzleSlotHoverCard key={slot.id >= 0 ? slot.id : `empty-${i}`} slot={slot} index={i} filamentName={slot.filament_id ? filamentInfo?.[slot.filament_id]?.name : undefined}>
               <div
               <div
                 className={`w-7 h-7 rounded flex items-center justify-center cursor-default transition-colors border-b-2 ${
                 className={`w-7 h-7 rounded flex items-center justify-center cursor-default transition-colors border-b-2 ${
                   isEmpty
                   isEmpty
@@ -1106,7 +1107,8 @@ function getPrinterImage(model: string | null | undefined): string {
   if (modelLower.includes('x1c') || modelLower.includes('x1carbon')) return '/img/printers/x1c.png';
   if (modelLower.includes('x1c') || modelLower.includes('x1carbon')) return '/img/printers/x1c.png';
   if (modelLower.includes('x1')) return '/img/printers/x1c.png';
   if (modelLower.includes('x1')) return '/img/printers/x1c.png';
   if (modelLower.includes('h2d')) return '/img/printers/h2d.png';
   if (modelLower.includes('h2d')) return '/img/printers/h2d.png';
-  if (modelLower.includes('h2c') || modelLower.includes('h2s')) return '/img/printers/h2d.png';
+  if (modelLower.includes('h2c')) return '/img/printers/h2c.png';
+  if (modelLower.includes('h2s')) return '/img/printers/h2d.png';
   if (modelLower.includes('p2s')) return '/img/printers/p1s.png';
   if (modelLower.includes('p2s')) return '/img/printers/p1s.png';
   if (modelLower.includes('p1s')) return '/img/printers/p1s.png';
   if (modelLower.includes('p1s')) return '/img/printers/p1s.png';
   if (modelLower.includes('p1p')) return '/img/printers/p1p.png';
   if (modelLower.includes('p1p')) return '/img/printers/p1p.png';
@@ -1438,8 +1440,15 @@ function PrinterCard({
     if (status?.vt_tray?.tray_info_idx) {
     if (status?.vt_tray?.tray_info_idx) {
       ids.add(status.vt_tray.tray_info_idx);
       ids.add(status.vt_tray.tray_info_idx);
     }
     }
+    if (status?.nozzle_rack) {
+      for (const slot of status.nozzle_rack) {
+        if (slot.filament_id) {
+          ids.add(slot.filament_id);
+        }
+      }
+    }
     return Array.from(ids);
     return Array.from(ids);
-  }, [status?.ams, status?.vt_tray]);
+  }, [status?.ams, status?.vt_tray, status?.nozzle_rack]);
 
 
   // Fetch cloud filament info for tooltips (name includes color, also has K value)
   // Fetch cloud filament info for tooltips (name includes color, also has K value)
   const { data: filamentInfo } = useQuery({
   const { data: filamentInfo } = useQuery({
@@ -2400,7 +2409,7 @@ function PrinterCard({
                         </p>
                         </p>
                       </>
                       </>
                     ) : leftNozzleSlot ? (
                     ) : leftNozzleSlot ? (
-                      <NozzleSlotHoverCard slot={leftNozzleSlot} index={0} activeStatus>
+                      <NozzleSlotHoverCard slot={leftNozzleSlot} index={0} activeStatus filamentName={leftNozzleSlot.filament_id ? filamentInfo?.[leftNozzleSlot.filament_id]?.name : undefined}>
                         <div className="cursor-default">
                         <div className="cursor-default">
                           <p className="text-[9px] text-bambu-gray">{t('printers.temperatures.nozzle')}</p>
                           <p className="text-[9px] text-bambu-gray">{t('printers.temperatures.nozzle')}</p>
                           <p className="text-[11px] text-white">
                           <p className="text-[11px] text-white">
@@ -2452,7 +2461,7 @@ function PrinterCard({
                   )}
                   )}
                   {/* H2C nozzle rack (tool-changer dock) — only show when rack nozzles exist (IDs >= 2) */}
                   {/* H2C nozzle rack (tool-changer dock) — only show when rack nozzles exist (IDs >= 2) */}
                   {status.nozzle_rack && status.nozzle_rack.some(s => s.id >= 2) && (
                   {status.nozzle_rack && status.nozzle_rack.some(s => s.id >= 2) && (
-                    <NozzleRackCard slots={status.nozzle_rack} />
+                    <NozzleRackCard slots={status.nozzle_rack} filamentInfo={filamentInfo} />
                   )}
                   )}
                 </div>
                 </div>
               );
               );