Kaynağa Gözat

Fix H2C nozzle rack showing wrong slots and redesign to compact layout (#300)

  The nozzle_info MQTT array contains L/R nozzle heads (IDs 0, 1) and
  rack slots (IDs 16-21). Backend was dumping all entries into nozzle_rack,
  and frontend slice(0,6) grabbed L, R, plus only 4 rack slots — cutting
  off the last position (e.g. the 0.6mm nozzle) and showing the mounted
  nozzle as docked.

  Backend: filter nozzle_rack to id >= 2 (rack-only), sort by ID.
  Frontend: compact single-row layout with bottom accent bars for
  mounted/docked status, wider rack card (flex-[2]), vertically
  centered temp cards.
maziggy 6 ay önce
ebeveyn
işleme
53e13ecbd2

+ 2 - 0
CHANGELOG.md

@@ -16,10 +16,12 @@ All notable changes to Bambuddy will be documented in this file.
 - **Extended Support Bundle Diagnostics** — Support bundle now collects comprehensive diagnostic data for faster issue resolution: printer connectivity and firmware versions, integration status (Spoolman, MQTT, Home Assistant), network interfaces (subnets only), Python package versions, database health checks, Docker environment details, WebSocket connections, and log file info. All data properly anonymized — no IPs, names, or serials included. Privacy disclosure updated on System Info page.
 
 ### Improved
+- **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.
 - **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.
 - **Japanese Locale Complete Overhaul** — Restructured `ja.ts` from a divergent format (different key structure, 12 structural conflicts, 1,366 missing translations) to match the English/German locale structure exactly. Translated all 2,083 keys into Japanese, achieving full parity with EN/DE. Zero structural divergences, zero missing keys.
 
 ### Fixed
+- **H2C Nozzle Rack Shows Wrong Nozzles** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — The nozzle rack included L/R nozzle heads (IDs 0, 1) alongside the actual rack slots (IDs 16–21), causing the mounted nozzle to appear docked and the last rack position (e.g., 0.6mm) to be cut off. Backend now filters to rack-only entries (id >= 2) and sorts by ID for consistent ordering.
 - **Sidebar Links Custom Icons Have Inverted Colors** ([#308](https://github.com/maziggy/bambuddy/issues/308)) — Custom uploaded icons in sidebar links had their colors inverted in dark mode due to a CSS `invert()` filter. The filter was intended for monochrome preset icons but was incorrectly applied to user-uploaded images (e.g., full-color logos). Removed the invert filter from custom icon rendering in the sidebar and the add/edit link modal.
 - **Virtual Printer FTP Transfer Fails With Connection Reset** ([#58](https://github.com/maziggy/bambuddy/issues/58)) — Large 3MF uploads to the virtual printer intermittently failed with `[Errno 104] Connection reset by peer` while the small verify_job always succeeded. The `_handle_data_connection` callback returned immediately, allowing the asyncio server-handler task to complete while the data connection was still in active use. The passive port listener also stayed open during transfers, risking duplicate data connections. Fixed by keeping the callback alive until the transfer completes (`_transfer_done` event), closing the passive listener after accepting the connection, and rejecting duplicate data connections. Also added a 5-second drain timeout to MQTT status pushes to prevent blocking when the slicer is busy uploading.
 - **Camera Stop 401 When Auth Enabled** — Camera stop requests (`sendBeacon`) failed with 401 Unauthorized when authentication was enabled because `sendBeacon` cannot send auth headers. Replaced with `fetch` + `keepalive: true` which supports Authorization headers while remaining reliable during page unload.

+ 25 - 17
backend/app/services/bambu_mqtt.py

@@ -1748,28 +1748,36 @@ class BambuMQTTClient:
             nozzle_data = device.get("nozzle", {})
             nozzle_info = nozzle_data.get("info", [])
             if isinstance(nozzle_info, list):
-                # H2C tool-changer: >2 entries means nozzle rack (6 dock + 1 mounted = 7)
+                # H2C tool-changer: >2 entries means nozzle rack
+                # nozzle_info contains L/R nozzle heads (id 0,1) AND rack slots (id >= 16).
+                # Filter out L/R heads — they're already tracked in self.state.nozzles.
                 if len(nozzle_info) > 2:
-                    self.state.nozzle_rack = [
-                        {
-                            "id": n.get("id", i),
-                            "type": str(n.get("type", "")),
-                            "diameter": str(n.get("diameter", "")),
-                            "wear": n.get("wear"),
-                            "stat": n.get("stat"),
-                            "max_temp": n.get("max_temp", 0),
-                            "serial_number": str(n.get("serial_number", "")),
-                            "filament_color": str(n.get("filament_colour", "")),
-                            "filament_id": str(n.get("filament_id", "")),
-                        }
-                        for i, n in enumerate(nozzle_info)
-                    ]
+                    rack_entries = [n for n in nozzle_info if n.get("id", 0) >= 2]
+                    self.state.nozzle_rack = sorted(
+                        [
+                            {
+                                "id": n.get("id", i),
+                                "type": str(n.get("type", "")),
+                                "diameter": str(n.get("diameter", "")),
+                                "wear": n.get("wear"),
+                                "stat": n.get("stat"),
+                                "max_temp": n.get("max_temp", 0),
+                                "serial_number": str(n.get("serial_number", "")),
+                                "filament_color": str(n.get("filament_colour", "")),
+                                "filament_id": str(n.get("filament_id", "")),
+                            }
+                            for i, n in enumerate(rack_entries)
+                        ],
+                        key=lambda x: x["id"],
+                    )
                     if not hasattr(self, "_nozzle_rack_logged") and nozzle_info:
                         self._nozzle_rack_logged = True
                         logger.info(
-                            "[%s] Nozzle rack raw keys: %s",
+                            "[%s] Nozzle info: %d entries, IDs: %s, rack IDs: %s",
                             self.serial_number,
-                            [list(n.keys()) for n in nozzle_info[:2]],
+                            len(nozzle_info),
+                            [n.get("id") for n in nozzle_info],
+                            [n.get("id") for n in rack_entries],
                         )
                 for nozzle in nozzle_info:
                     idx = nozzle.get("id", 0)

+ 14 - 24
frontend/src/pages/PrintersPage.tsx

@@ -587,51 +587,41 @@ function NozzleSlotHoverCard({ slot, index, children }: {
   );
 }
 
-// H2C Nozzle Rack Card — 2×3 grid 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[] }) {
   const { t } = useTranslation();
-  // Filter to dock slots only (exclude the mounted/active entry which is typically id=0 or stat indicates mounted)
-  // Show up to 6 dock positions
-  const dockSlots = slots.slice(0, 6);
+  // Backend now filters to rack-only slots (excludes L/R nozzle heads)
+  const dockSlots = slots;
 
   return (
     <div className="text-center px-2.5 py-1.5 bg-bambu-dark rounded-lg flex-[2] flex flex-col justify-center">
       <p className="text-[9px] text-bambu-gray mb-1">{t('printers.nozzleRack')}</p>
-      <div className="grid grid-cols-3 gap-1">
+      <div className="flex gap-[3px] justify-center">
         {dockSlots.map((slot, i) => {
           const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type;
           const isMounted = slot.stat === 1;
-          // Type abbreviation: S=stainless, H=hardened
-          const typeAbbr = slot.nozzle_type?.includes('hardened') ? 'H' : slot.nozzle_type?.includes('stainless') ? 'S' : '';
           const filamentBg = parseFilamentColor(slot.filament_color);
 
           return (
             <NozzleSlotHoverCard key={slot.id ?? i} slot={slot} index={i}>
               <div
-                className={`rounded-md py-1 px-1 text-center cursor-default transition-colors ${
+                className={`w-7 h-7 rounded flex items-center justify-center cursor-default transition-colors border-b-2 ${
                   isEmpty
-                    ? 'bg-bambu-dark-tertiary/20 opacity-30'
+                    ? 'bg-bambu-dark-tertiary/20 opacity-20 border-transparent'
                     : isMounted
-                      ? 'ring-1 ring-green-500/70 bg-green-950/40'
-                      : 'bg-bambu-dark-tertiary/40'
+                      ? 'bg-green-950/35 border-green-400'
+                      : 'bg-bambu-dark-tertiary/40 border-bambu-dark-tertiary/40'
                 }`}
                 style={filamentBg && !isEmpty ? { backgroundColor: filamentBg } : undefined}
               >
                 {isEmpty ? (
-                  <p className="text-[9px] text-bambu-gray/50">—</p>
+                  <span className="text-[9px] text-bambu-gray/50">—</span>
                 ) : (
-                  <>
-                    <p className={`text-[11px] font-semibold leading-tight ${isMounted ? 'text-green-400' : 'text-white'}`}
-                       style={filamentBg ? { textShadow: '0 1px 3px rgba(0,0,0,0.9)' } : undefined}
-                    >
-                      {slot.nozzle_diameter || '?'}
-                    </p>
-                    {typeAbbr && (
-                      <p className="text-[8px] text-bambu-gray/70 leading-none mt-0.5"
-                         style={filamentBg ? { textShadow: '0 1px 2px rgba(0,0,0,0.8)' } : undefined}
-                      >{typeAbbr}</p>
-                    )}
-                  </>
+                  <span className={`text-[10px] font-semibold ${isMounted ? 'text-green-400' : 'text-white'}`}
+                        style={filamentBg ? { textShadow: '0 1px 3px rgba(0,0,0,0.9)' } : undefined}
+                  >
+                    {slot.nozzle_diameter || '?'}
+                  </span>
                 )}
               </div>
             </NozzleSlotHoverCard>