Pārlūkot izejas kodu

Fix H2C nozzle rack display and add L/R hover cards (#300)

Rack card now shows 6 rack-only slots (IDs 16-21) with empty placeholders,
excluding L/R hotend nozzles. New DualNozzleHoverCard shows both nozzle
details side-by-side with Active/Idle status. Single-nozzle H2 printers
get extended hover details. Backend stores nozzle_info for all H2 series.
Added i18n keys (en/de/ja) and 10 tests (7 backend + 3 frontend).
maziggy 3 mēneši atpakaļ
vecāks
revīzija
94ba65e3bc

+ 5 - 2
CHANGELOG.md

@@ -17,7 +17,9 @@ 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.
 - **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
 ### Improved
-- **H2C Nozzle Rack — Show All 6 Nozzles Including Mounted** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — The nozzle rack now shows all 6 nozzles, including the one currently mounted on the hotend. Backend includes all nozzle_info entries (hotend + rack) instead of filtering to rack-only IDs. Frontend filters to non-empty nozzles so mounted nozzles appear with the existing green border/text indicator.
+- **H2C Nozzle Rack — 6-Slot Display With Empty Placeholders** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — The nozzle rack card now always shows 6 rack positions (IDs 16–21), with filled slots showing diameter and empty slots showing placeholder dashes. L/R hotend nozzles (IDs 0, 1) are excluded from the rack card and shown in the dedicated L/R indicator instead.
+- **H2 Series — L/R Nozzle Hover Card** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — New dual-nozzle hover card shows L and R nozzle details side by side (diameter, type, flow, status, wear, max temp, serial). Active nozzle highlighted in amber with Active/Idle status based on `active_extruder`, replacing the misleading "Docked" label.
+- **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 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.
@@ -27,7 +29,7 @@ All notable changes to Bambuddy will be documented in this file.
 
 
 ### Fixed
 ### Fixed
 - **Nozzle Rack Hides 0% Wear** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — New nozzles with 0% wear showed no wear info in the hover card because the condition treated 0 the same as "not available." Now displays "Wear: 0%" correctly. The field is still hidden when the printer doesn't report wear data.
 - **Nozzle Rack Hides 0% Wear** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — New nozzles with 0% wear showed no wear info in the hover card because the condition treated 0 the same as "not available." Now displays "Wear: 0%" correctly. The field is still hidden when the printer doesn't report wear data.
-- **H2C Nozzle Rack Shows Wrong Nozzle Count** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — The nozzle rack only showed 5 of 6 nozzles because the mounted nozzle was excluded by the id >= 2 filter. Backend now includes all nozzle_info entries sorted by ID; frontend filters to non-empty entries so all 6 nozzles are visible with proper mounted/docked indicators.
+- **Nozzle Rack Shows L/R Hotend Nozzles in Rack** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — The nozzle rack card incorrectly included L/R hotend nozzles (IDs 0, 1) alongside the 6 rack slots. Now filters to IDs >= 2 (rack only) and always pads to 6 positions with empty placeholders.
 - **H2C Firmware Update Downloads Wrong Firmware** ([#311](https://github.com/maziggy/bambuddy/issues/311)) — H2C printers were mapped to the H2D firmware API key (`h2d`), causing firmware checks to offer H2D firmware instead of H2C firmware. H2C has its own firmware track (01.01.x.x vs H2D's 01.02.x.x). Added separate `h2c` API key mapping. Also added missing H2C/H2S entries to printer model ID and 3MF model maps.
 - **H2C Firmware Update Downloads Wrong Firmware** ([#311](https://github.com/maziggy/bambuddy/issues/311)) — H2C printers were mapped to the H2D firmware API key (`h2d`), causing firmware checks to offer H2D firmware instead of H2C firmware. H2C has its own firmware track (01.01.x.x vs H2D's 01.02.x.x). Added separate `h2c` API key mapping. Also added missing H2C/H2S entries to printer model ID and 3MF model maps.
 - **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.
 - **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.
 - **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.
@@ -62,6 +64,7 @@ All notable changes to Bambuddy will be documented in this file.
   - Async wrapper tests: upload/download/list/delete with A1 fallback and multi-path download
   - Async wrapper tests: upload/download/list/delete with A1 fallback and multi-path download
   - Failure injection tests: regressions for `error_perm` hierarchy, `diagnose_storage` CWD propagation, injection count decrement
   - Failure injection tests: regressions for `error_perm` hierarchy, `diagnose_storage` CWD propagation, injection count decrement
   - Added `pyOpenSSL` to `requirements-dev.txt` for Docker test image compatibility
   - Added `pyOpenSSL` to `requirements-dev.txt` for Docker test image compatibility
+- **Nozzle Rack Tests** — Backend: 7 tests for MQTT nozzle_info parsing (H2C 8-entry, H2D 2-entry, H2S single, empty, sorting, field mapping, nozzle state updates). Frontend: 3 tests for rack card rendering (H2C shows 6 slots, empty placeholders, hidden when no rack IDs).
 
 
 ## [0.1.8.1] - 2026-02-07
 ## [0.1.8.1] - 2026-02-07
 
 

+ 5 - 5
backend/app/services/bambu_mqtt.py

@@ -1748,11 +1748,11 @@ class BambuMQTTClient:
             nozzle_data = device.get("nozzle", {})
             nozzle_data = device.get("nozzle", {})
             nozzle_info = nozzle_data.get("info", [])
             nozzle_info = nozzle_data.get("info", [])
             if isinstance(nozzle_info, list):
             if isinstance(nozzle_info, list):
-                # H2C tool-changer: >2 entries means nozzle rack
-                # nozzle_info contains L/R nozzle heads (id 0,1) AND rack slots.
-                # Include ALL entries so mounted nozzles (on hotend) appear in the rack
-                # display with their full data (wear, max_temp, serial, etc.).
-                if len(nozzle_info) > 2:
+                # H2 series: nozzle_info contains extended nozzle data (wear, serial,
+                # max_temp, etc.) for all nozzles: L/R hotend (IDs 0,1) and rack slots
+                # (IDs 16-21 on H2C). Store ALL entries so the frontend can use them
+                # for hover cards on both the L/R indicator and the nozzle rack card.
+                if nozzle_info:
                     self.state.nozzle_rack = sorted(
                     self.state.nozzle_rack = sorted(
                         [
                         [
                             {
                             {

+ 255 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -603,3 +603,258 @@ class TestAMSDataMerging:
         # Verify other slots are preserved
         # Verify other slots are preserved
         assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "A1 should still have PLA"
         assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "A1 should still have PLA"
         assert ams_data[1]["tray"][0]["tray_type"] == "PLA", "B1 should still have PLA"
         assert ams_data[1]["tray"][0]["tray_type"] == "PLA", "B1 should still have PLA"
+
+
+class TestNozzleRackData:
+    """Tests for nozzle rack data parsing from H2 series device.nozzle.info."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        """Create a BambuMQTTClient instance for testing."""
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        return client
+
+    def test_h2c_nozzle_rack_populated_with_8_entries(self, mqtt_client):
+        """H2C provides 8 nozzle entries: IDs 0,1 (L/R hotend) + 16-21 (rack)."""
+        payload = {
+            "print": {
+                "device": {
+                    "nozzle": {
+                        "info": [
+                            {
+                                "id": 0,
+                                "type": "HS",
+                                "diameter": "0.4",
+                                "wear": 5,
+                                "stat": 1,
+                                "max_temp": 300,
+                                "serial_number": "SN-L",
+                            },
+                            {
+                                "id": 1,
+                                "type": "HS",
+                                "diameter": "0.4",
+                                "wear": 3,
+                                "stat": 0,
+                                "max_temp": 300,
+                                "serial_number": "SN-R",
+                            },
+                            {
+                                "id": 16,
+                                "type": "HS",
+                                "diameter": "0.4",
+                                "wear": 10,
+                                "stat": 0,
+                                "max_temp": 300,
+                                "serial_number": "SN-16",
+                            },
+                            {
+                                "id": 17,
+                                "type": "HH01",
+                                "diameter": "0.6",
+                                "wear": 0,
+                                "stat": 0,
+                                "max_temp": 300,
+                                "serial_number": "SN-17",
+                            },
+                            {
+                                "id": 18,
+                                "type": "HS",
+                                "diameter": "0.4",
+                                "wear": 2,
+                                "stat": 0,
+                                "max_temp": 300,
+                                "serial_number": "SN-18",
+                            },
+                            {
+                                "id": 19,
+                                "type": "",
+                                "diameter": "",
+                                "wear": None,
+                                "stat": None,
+                                "max_temp": 0,
+                                "serial_number": "",
+                            },
+                            {
+                                "id": 20,
+                                "type": "",
+                                "diameter": "",
+                                "wear": None,
+                                "stat": None,
+                                "max_temp": 0,
+                                "serial_number": "",
+                            },
+                            {
+                                "id": 21,
+                                "type": "",
+                                "diameter": "",
+                                "wear": None,
+                                "stat": None,
+                                "max_temp": 0,
+                                "serial_number": "",
+                            },
+                        ]
+                    }
+                }
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        assert len(mqtt_client.state.nozzle_rack) == 8
+        ids = [n["id"] for n in mqtt_client.state.nozzle_rack]
+        assert ids == [0, 1, 16, 17, 18, 19, 20, 21]
+
+    def test_h2d_nozzle_rack_populated_with_2_entries(self, mqtt_client):
+        """H2D provides 2 nozzle entries: IDs 0,1 (L/R hotend) — no rack slots."""
+        payload = {
+            "print": {
+                "device": {
+                    "nozzle": {
+                        "info": [
+                            {
+                                "id": 0,
+                                "type": "HS",
+                                "diameter": "0.4",
+                                "wear": 5,
+                                "stat": 1,
+                                "max_temp": 300,
+                                "serial_number": "SN-L",
+                            },
+                            {
+                                "id": 1,
+                                "type": "HS",
+                                "diameter": "0.4",
+                                "wear": 3,
+                                "stat": 1,
+                                "max_temp": 300,
+                                "serial_number": "SN-R",
+                            },
+                        ]
+                    }
+                }
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        assert len(mqtt_client.state.nozzle_rack) == 2
+        ids = [n["id"] for n in mqtt_client.state.nozzle_rack]
+        assert ids == [0, 1]
+
+    def test_single_nozzle_h2s_populated(self, mqtt_client):
+        """H2S provides 1 nozzle entry: ID 0 only — single nozzle printer."""
+        payload = {
+            "print": {
+                "device": {
+                    "nozzle": {
+                        "info": [
+                            {
+                                "id": 0,
+                                "type": "HS",
+                                "diameter": "0.4",
+                                "wear": 2,
+                                "stat": 1,
+                                "max_temp": 300,
+                                "serial_number": "SN-0",
+                            },
+                        ]
+                    }
+                }
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        assert len(mqtt_client.state.nozzle_rack) == 1
+        assert mqtt_client.state.nozzle_rack[0]["id"] == 0
+
+    def test_empty_nozzle_info_does_not_populate_rack(self, mqtt_client):
+        """Empty nozzle info list should not populate nozzle_rack."""
+        payload = {"print": {"device": {"nozzle": {"info": []}}}}
+        mqtt_client._process_message(payload)
+
+        assert mqtt_client.state.nozzle_rack == []
+
+    def test_nozzle_rack_sorted_by_id(self, mqtt_client):
+        """Nozzle rack entries should be sorted by ID regardless of input order."""
+        payload = {
+            "print": {
+                "device": {
+                    "nozzle": {
+                        "info": [
+                            {"id": 17, "type": "HS", "diameter": "0.6"},
+                            {"id": 0, "type": "HS", "diameter": "0.4"},
+                            {"id": 16, "type": "HS", "diameter": "0.4"},
+                            {"id": 1, "type": "HS", "diameter": "0.4"},
+                        ]
+                    }
+                }
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        ids = [n["id"] for n in mqtt_client.state.nozzle_rack]
+        assert ids == [0, 1, 16, 17]
+
+    def test_nozzle_rack_field_mapping(self, mqtt_client):
+        """Verify field mapping from MQTT nozzle_info to nozzle_rack dict keys."""
+        payload = {
+            "print": {
+                "device": {
+                    "nozzle": {
+                        "info": [
+                            {
+                                "id": 16,
+                                "type": "HH01",
+                                "diameter": "0.6",
+                                "wear": 15,
+                                "stat": 0,
+                                "max_temp": 320,
+                                "serial_number": "SN-ABC123",
+                                "filament_colour": "FF8800",
+                                "filament_id": "F42",
+                                "tray_type": "ABS",
+                            }
+                        ]
+                    }
+                }
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        slot = mqtt_client.state.nozzle_rack[0]
+        assert slot["id"] == 16
+        assert slot["type"] == "HH01"
+        assert slot["diameter"] == "0.6"
+        assert slot["wear"] == 15
+        assert slot["stat"] == 0
+        assert slot["max_temp"] == 320
+        assert slot["serial_number"] == "SN-ABC123"
+        assert slot["filament_color"] == "FF8800"
+        assert slot["filament_id"] == "F42"
+        assert slot["filament_type"] == "ABS"
+
+    def test_nozzle_info_updates_nozzle_state(self, mqtt_client):
+        """Nozzle info for IDs 0,1 should also update nozzle state (type/diameter)."""
+        payload = {
+            "print": {
+                "device": {
+                    "nozzle": {
+                        "info": [
+                            {"id": 0, "type": "HS", "diameter": "0.4"},
+                            {"id": 1, "type": "HH01", "diameter": "0.6"},
+                        ]
+                    }
+                }
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        assert mqtt_client.state.nozzles[0].nozzle_type == "HS"
+        assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
+        assert mqtt_client.state.nozzles[1].nozzle_type == "HH01"
+        assert mqtt_client.state.nozzles[1].nozzle_diameter == "0.6"

+ 72 - 0
frontend/src/__tests__/pages/PrintersPage.test.tsx

@@ -184,6 +184,78 @@ describe('PrintersPage', () => {
     });
     });
   });
   });
 
 
+  describe('nozzle rack card', () => {
+    const h2cStatus = {
+      ...mockPrinterStatus,
+      nozzle_rack: [
+        { id: 0, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 5, stat: 1, max_temp: 300, serial_number: 'SN-L', filament_color: '', filament_id: '', filament_type: '' },
+        { id: 1, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 3, stat: 0, max_temp: 300, serial_number: 'SN-R', filament_color: '', filament_id: '', filament_type: '' },
+        { id: 16, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 10, stat: 0, max_temp: 300, serial_number: 'SN-16', filament_color: '', filament_id: '', filament_type: '' },
+        { id: 17, nozzle_type: 'HH01', nozzle_diameter: '0.6', wear: 0, stat: 0, max_temp: 300, serial_number: 'SN-17', filament_color: '', filament_id: '', filament_type: '' },
+        { id: 18, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 2, stat: 0, max_temp: 300, serial_number: 'SN-18', filament_color: '', filament_id: '', filament_type: '' },
+        { id: 19, nozzle_type: '', nozzle_diameter: '', wear: null, stat: null, max_temp: 0, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },
+        { id: 20, nozzle_type: '', nozzle_diameter: '', wear: null, stat: null, max_temp: 0, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },
+        { id: 21, nozzle_type: '', nozzle_diameter: '', wear: null, stat: null, max_temp: 0, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },
+      ],
+    };
+
+    it('shows nozzle rack when H2C rack slots present', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(h2cStatus);
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('Nozzle Rack').length).toBeGreaterThan(0);
+      });
+    });
+
+    it('shows 6 rack slot elements for H2C', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(h2cStatus);
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('Nozzle Rack').length).toBeGreaterThan(0);
+      });
+
+      // Rack shows diameters for occupied slots and dashes for empty ones
+      const dashes = screen.getAllByText('—');
+      expect(dashes.length).toBeGreaterThanOrEqual(3); // 3 empty rack positions (IDs 19,20,21)
+    });
+
+    it('hides nozzle rack when only L/R nozzles present (H2D)', async () => {
+      const h2dStatus = {
+        ...mockPrinterStatus,
+        nozzle_rack: [
+          { id: 0, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 5, stat: 1, max_temp: 300, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },
+          { id: 1, nozzle_type: 'HS', nozzle_diameter: '0.4', wear: 3, stat: 1, max_temp: 300, serial_number: '', filament_color: '', filament_id: '', filament_type: '' },
+        ],
+      };
+
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(h2dStatus);
+        })
+      );
+
+      render(<PrintersPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+
+      expect(screen.queryByText('Nozzle Rack')).not.toBeInTheDocument();
+    });
+  });
+
   describe('firmware version badge', () => {
   describe('firmware version badge', () => {
     const firmwareUpToDate = {
     const firmwareUpToDate = {
       printer_id: 1,
       printer_id: 1,

+ 6 - 0
frontend/src/i18n/locales/de.ts

@@ -210,6 +210,12 @@ export default {
     nozzleRack: 'Düsenhalter',
     nozzleRack: 'Düsenhalter',
     nozzleDocked: 'Angedockt',
     nozzleDocked: 'Angedockt',
     nozzleMounted: 'Montiert',
     nozzleMounted: 'Montiert',
+    nozzleActive: 'Aktiv',
+    nozzleIdle: 'Inaktiv',
+    nozzleDiameter: 'Durchmesser',
+    nozzleType: 'Typ',
+    nozzleStatus: 'Status',
+    nozzleFilament: 'Filament',
     nozzleWear: 'Verschleiß',
     nozzleWear: 'Verschleiß',
     nozzleMaxTemp: 'Max Temp',
     nozzleMaxTemp: 'Max Temp',
     nozzleSerial: 'Seriennr.',
     nozzleSerial: 'Seriennr.',

+ 6 - 0
frontend/src/i18n/locales/en.ts

@@ -210,6 +210,12 @@ export default {
     nozzleRack: 'Nozzle Rack',
     nozzleRack: 'Nozzle Rack',
     nozzleDocked: 'Docked',
     nozzleDocked: 'Docked',
     nozzleMounted: 'Mounted',
     nozzleMounted: 'Mounted',
+    nozzleActive: 'Active',
+    nozzleIdle: 'Idle',
+    nozzleDiameter: 'Diameter',
+    nozzleType: 'Type',
+    nozzleStatus: 'Status',
+    nozzleFilament: 'Filament',
     nozzleWear: 'Wear',
     nozzleWear: 'Wear',
     nozzleMaxTemp: 'Max Temp',
     nozzleMaxTemp: 'Max Temp',
     nozzleSerial: 'Serial',
     nozzleSerial: 'Serial',

+ 6 - 0
frontend/src/i18n/locales/ja.ts

@@ -194,6 +194,12 @@ export default {
     nozzleRack: 'ノズルラック',
     nozzleRack: 'ノズルラック',
     nozzleDocked: 'ドッキング中',
     nozzleDocked: 'ドッキング中',
     nozzleMounted: 'マウント中',
     nozzleMounted: 'マウント中',
+    nozzleActive: 'アクティブ',
+    nozzleIdle: 'アイドル',
+    nozzleDiameter: '直径',
+    nozzleType: 'タイプ',
+    nozzleStatus: 'ステータス',
+    nozzleFilament: 'フィラメント',
     nozzleWear: '摩耗',
     nozzleWear: '摩耗',
     nozzleMaxTemp: '最高温度',
     nozzleMaxTemp: '最高温度',
     nozzleSerial: 'シリアル',
     nozzleSerial: 'シリアル',

+ 205 - 25
frontend/src/pages/PrintersPage.tsx

@@ -456,9 +456,11 @@ function nozzleFlowName(type: string, t: (key: string) => string): string {
 }
 }
 
 
 // Per-slot hover card for nozzle rack
 // Per-slot hover card for nozzle rack
-function NozzleSlotHoverCard({ slot, index, children }: {
+// activeStatus: when true, show "Active" instead of "Mounted"/"Docked" (for hotend nozzles)
+function NozzleSlotHoverCard({ slot, index, activeStatus, children }: {
   slot: import('../api/client').NozzleRackSlot;
   slot: import('../api/client').NozzleRackSlot;
   index: number;
   index: number;
+  activeStatus?: boolean;
   children: React.ReactNode;
   children: React.ReactNode;
 }) {
 }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -534,14 +536,14 @@ function NozzleSlotHoverCard({ slot, index, children }: {
               <div className="p-2.5 space-y-1.5">
               <div className="p-2.5 space-y-1.5">
                 {/* Diameter */}
                 {/* Diameter */}
                 <div className="flex items-center justify-between">
                 <div className="flex items-center justify-between">
-                  <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">Diameter</span>
+                  <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">{t('printers.nozzleDiameter')}</span>
                   <span className="text-xs text-white font-semibold">{slot.nozzle_diameter} mm</span>
                   <span className="text-xs text-white font-semibold">{slot.nozzle_diameter} mm</span>
                 </div>
                 </div>
 
 
                 {/* Type */}
                 {/* Type */}
                 {typeFull && (
                 {typeFull && (
                   <div className="flex items-center justify-between">
                   <div className="flex items-center justify-between">
-                    <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">Type</span>
+                    <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">{t('printers.nozzleType')}</span>
                     <span className="text-xs text-white font-semibold truncate max-w-[100px]">{typeFull}</span>
                     <span className="text-xs text-white font-semibold truncate max-w-[100px]">{typeFull}</span>
                   </div>
                   </div>
                 )}
                 )}
@@ -556,13 +558,13 @@ function NozzleSlotHoverCard({ slot, index, children }: {
 
 
                 {/* Status badge */}
                 {/* Status badge */}
                 <div className="flex items-center justify-between">
                 <div className="flex items-center justify-between">
-                  <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">Status</span>
+                  <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">{t('printers.nozzleStatus')}</span>
                   <span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${
                   <span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${
-                    isMounted
+                    activeStatus || isMounted
                       ? 'bg-green-900/50 text-green-400'
                       ? 'bg-green-900/50 text-green-400'
                       : 'bg-bambu-dark-tertiary text-bambu-gray'
                       : 'bg-bambu-dark-tertiary text-bambu-gray'
                   }`}>
                   }`}>
-                    {isMounted ? t('printers.nozzleMounted') : t('printers.nozzleDocked')}
+                    {activeStatus ? t('printers.nozzleActive') : isMounted ? t('printers.nozzleMounted') : t('printers.nozzleDocked')}
                   </span>
                   </span>
                 </div>
                 </div>
 
 
@@ -593,7 +595,7 @@ function NozzleSlotHoverCard({ slot, index, children }: {
                 {/* Filament: material type + color swatch (hide if no color) */}
                 {/* Filament: material type + color swatch (hide if no color) */}
                 {(filamentCss || slot.filament_type) && (
                 {(filamentCss || slot.filament_type) && (
                   <div className="flex items-center justify-between">
                   <div className="flex items-center justify-between">
-                    <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">Filament</span>
+                    <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">{t('printers.nozzleFilament')}</span>
                     <div className="flex items-center gap-1">
                     <div className="flex items-center gap-1">
                       {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 }} />
@@ -623,35 +625,192 @@ function NozzleSlotHoverCard({ slot, index, children }: {
   );
   );
 }
 }
 
 
+// Dual-nozzle hover card showing L and R nozzle details side by side
+function DualNozzleHoverCard({ leftSlot, rightSlot, activeNozzle, children }: {
+  leftSlot?: import('../api/client').NozzleRackSlot;
+  rightSlot?: import('../api/client').NozzleRackSlot;
+  activeNozzle: 'L' | 'R';
+  children: React.ReactNode;
+}) {
+  const { t } = useTranslation();
+  const [isVisible, setIsVisible] = useState(false);
+  const [position, setPosition] = useState<'top' | 'bottom'>('top');
+  const triggerRef = useRef<HTMLDivElement>(null);
+  const cardRef = useRef<HTMLDivElement>(null);
+  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  useEffect(() => {
+    if (isVisible && triggerRef.current && cardRef.current) {
+      const triggerRect = triggerRef.current.getBoundingClientRect();
+      const cardHeight = cardRef.current.offsetHeight;
+      const headerHeight = 56;
+      const spaceAbove = triggerRect.top - headerHeight;
+      const spaceBelow = window.innerHeight - triggerRect.bottom;
+      if (spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove) {
+        setPosition('bottom');
+      } else {
+        setPosition('top');
+      }
+    }
+  }, [isVisible]);
+
+  const handleMouseEnter = () => {
+    if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    timeoutRef.current = setTimeout(() => setIsVisible(true), 80);
+  };
+
+  const handleMouseLeave = () => {
+    if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    timeoutRef.current = setTimeout(() => setIsVisible(false), 100);
+  };
+
+  useEffect(() => {
+    return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); };
+  }, []);
+
+  if (!leftSlot && !rightSlot) return <>{children}</>;
+
+  const renderColumn = (slot: import('../api/client').NozzleRackSlot, side: 'L' | 'R') => {
+    const isActive = activeNozzle === side;
+    const typeFull = nozzleTypeName(slot.nozzle_type, t);
+    const flowFull = nozzleFlowName(slot.nozzle_type, t);
+
+    return (
+      <div className="flex-1 space-y-1.5">
+        <div className={`text-[10px] font-bold pb-1 border-b border-bambu-dark-tertiary/50 ${isActive ? 'text-amber-400' : 'text-bambu-gray'}`}>
+          {side === 'L' ? t('common.left') : t('common.right')}
+        </div>
+        {slot.nozzle_diameter && (
+          <div className="flex items-center justify-between">
+            <span className="text-[10px] text-bambu-gray">{t('printers.nozzleDiameter')}</span>
+            <span className="text-xs text-white font-semibold">{slot.nozzle_diameter} mm</span>
+          </div>
+        )}
+        {typeFull && (
+          <div className="flex items-center justify-between">
+            <span className="text-[10px] text-bambu-gray">{t('printers.nozzleType')}</span>
+            <span className="text-[10px] text-white font-semibold">{typeFull}</span>
+          </div>
+        )}
+        {flowFull && (
+          <div className="flex items-center justify-between">
+            <span className="text-[10px] text-bambu-gray">{t('printers.nozzleFlow')}</span>
+            <span className="text-[10px] text-white font-semibold">{flowFull}</span>
+          </div>
+        )}
+        <div className="flex items-center justify-between">
+          <span className="text-[10px] text-bambu-gray">{t('printers.nozzleStatus')}</span>
+          <span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${
+            isActive
+              ? 'bg-green-900/50 text-green-400'
+              : 'bg-bambu-dark-tertiary text-bambu-gray'
+          }`}>
+            {isActive ? t('printers.nozzleActive') : t('printers.nozzleIdle')}
+          </span>
+        </div>
+        {slot.wear != null && (
+          <div className="flex items-center justify-between">
+            <span className="text-[10px] text-bambu-gray">{t('printers.nozzleWear')}</span>
+            <span className="text-xs text-white font-semibold">{slot.wear}%</span>
+          </div>
+        )}
+        {slot.max_temp > 0 && (
+          <div className="flex items-center justify-between">
+            <span className="text-[10px] text-bambu-gray">{t('printers.nozzleMaxTemp')}</span>
+            <span className="text-xs text-white font-semibold">{slot.max_temp}°C</span>
+          </div>
+        )}
+        {slot.serial_number && (
+          <div className="flex items-center justify-between">
+            <span className="text-[10px] text-bambu-gray">{t('printers.nozzleSerial')}</span>
+            <span className="text-[10px] text-white font-mono">{slot.serial_number}</span>
+          </div>
+        )}
+      </div>
+    );
+  };
+
+  return (
+    <div
+      ref={triggerRef}
+      className="relative flex-1"
+      onMouseEnter={handleMouseEnter}
+      onMouseLeave={handleMouseLeave}
+    >
+      {children}
+
+      {isVisible && (
+        <div
+          ref={cardRef}
+          className={`
+            absolute left-1/2 -translate-x-1/2 z-50
+            ${position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'}
+            animate-in fade-in-0 zoom-in-95 duration-150
+          `}
+          style={{ maxWidth: 'calc(100vw - 24px)' }}
+        >
+          <div className="w-96 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl overflow-hidden backdrop-blur-sm">
+            <div className="p-2.5 flex gap-3">
+              {leftSlot && renderColumn(leftSlot, 'L')}
+              {leftSlot && rightSlot && <div className="w-px bg-bambu-dark-tertiary/50" />}
+              {rightSlot && renderColumn(rightSlot, 'R')}
+            </div>
+          </div>
+
+          {/* Arrow pointer */}
+          <div
+            className={`
+              absolute left-1/2 -translate-x-1/2 w-0 h-0
+              border-l-[6px] border-l-transparent
+              border-r-[6px] border-r-transparent
+              ${position === 'top'
+                ? 'top-full border-t-[6px] border-t-bambu-dark-tertiary'
+                : 'bottom-full border-b-[6px] border-b-bambu-dark-tertiary'}
+            `}
+          />
+        </div>
+      )}
+    </div>
+  );
+}
+
 // 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 }: { slots: import('../api/client').NozzleRackSlot[] }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  // Backend sends all nozzle_info entries (hotend + rack).
-  // Filter to non-empty nozzles — these are the actual nozzles in the system.
-  const allNozzles = slots.filter(s => s.nozzle_diameter || s.nozzle_type);
+  // Rack nozzles only (IDs >= 2) — excludes L/R hotend nozzles (IDs 0, 1)
+  const rackNozzles = slots.filter(s => s.id >= 2);
+  // Always show 6 rack positions — pad with empty placeholders for unoccupied slots
+  const RACK_SIZE = 6;
+  const rackSlots: (import('../api/client').NozzleRackSlot)[] = Array.from(
+    { length: RACK_SIZE },
+    (_, i) => rackNozzles[i] ?? {
+      id: -(i + 1), nozzle_type: '', nozzle_diameter: '', wear: null, stat: null,
+      max_temp: 0, serial_number: '', filament_color: '', filament_id: '', filament_type: '',
+    },
+  );
 
 
   return (
   return (
     <div className="text-center px-2.5 py-1.5 bg-bambu-dark rounded-lg flex-[2] flex flex-col justify-center">
     <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>
       <p className="text-[9px] text-bambu-gray mb-1">{t('printers.nozzleRack')}</p>
       <div className="flex gap-[3px] justify-center">
       <div className="flex gap-[3px] justify-center">
-        {allNozzles.map((slot, i) => {
-          const isMounted = slot.stat === 1;
-          const filamentBg = parseFilamentColor(slot.filament_color);
+        {rackSlots.map((slot, i) => {
+          const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type;
+          const filamentBg = !isEmpty ? parseFilamentColor(slot.filament_color) : null;
 
 
           return (
           return (
-            <NozzleSlotHoverCard key={slot.id ?? i} slot={slot} index={i}>
+            <NozzleSlotHoverCard key={slot.id >= 0 ? slot.id : `empty-${i}`} slot={slot} index={i}>
               <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 ${
-                  isMounted
-                    ? 'bg-green-950/35 border-green-400'
+                  isEmpty
+                    ? 'bg-bambu-dark-tertiary/20 border-bambu-dark-tertiary/20'
                     : 'bg-bambu-dark-tertiary/40 border-bambu-dark-tertiary/40'
                     : 'bg-bambu-dark-tertiary/40 border-bambu-dark-tertiary/40'
                 }`}
                 }`}
                 style={filamentBg ? { backgroundColor: filamentBg } : undefined}
                 style={filamentBg ? { backgroundColor: filamentBg } : undefined}
               >
               >
-                <span className={`text-[10px] font-semibold ${isMounted ? 'text-green-400' : 'text-white'}`}
+                <span className={`text-[10px] font-semibold ${isEmpty ? 'text-bambu-gray/30' : 'text-white'}`}
                       style={filamentBg ? { textShadow: '0 1px 3px rgba(0,0,0,0.9)' } : undefined}
                       style={filamentBg ? { textShadow: '0 1px 3px rgba(0,0,0,0.9)' } : undefined}
                 >
                 >
-                  {slot.nozzle_diameter || '?'}
+                  {isEmpty ? '—' : (slot.nozzle_diameter || '?')}
                 </span>
                 </span>
               </div>
               </div>
             </NozzleSlotHoverCard>
             </NozzleSlotHoverCard>
@@ -2223,6 +2382,9 @@ function PrinterCard({
               const isDualNozzle = printer.nozzle_count === 2 || status.temperatures.nozzle_2 !== undefined;
               const isDualNozzle = printer.nozzle_count === 2 || status.temperatures.nozzle_2 !== undefined;
               // active_extruder: 0=right, 1=left
               // active_extruder: 0=right, 1=left
               const activeNozzle = status.active_extruder === 1 ? 'L' : 'R';
               const activeNozzle = status.active_extruder === 1 ? 'L' : 'R';
+              // Extended nozzle data from nozzle_rack (H2 series: wear, serial, max_temp, etc.)
+              const leftNozzleSlot = status.nozzle_rack?.find(s => s.id === 0);
+              const rightNozzleSlot = status.nozzle_rack?.find(s => s.id === 1);
 
 
               return (
               return (
                 <div className="flex items-stretch gap-1.5">
                 <div className="flex items-stretch gap-1.5">
@@ -2236,6 +2398,15 @@ function PrinterCard({
                           {Math.round(status.temperatures.nozzle || 0)}° / {Math.round(status.temperatures.nozzle_2 || 0)}°
                           {Math.round(status.temperatures.nozzle || 0)}° / {Math.round(status.temperatures.nozzle_2 || 0)}°
                         </p>
                         </p>
                       </>
                       </>
+                    ) : leftNozzleSlot ? (
+                      <NozzleSlotHoverCard slot={leftNozzleSlot} index={0} activeStatus>
+                        <div className="cursor-default">
+                          <p className="text-[9px] text-bambu-gray">{t('printers.temperatures.nozzle')}</p>
+                          <p className="text-[11px] text-white">
+                            {Math.round(status.temperatures.nozzle || 0)}°C
+                          </p>
+                        </div>
+                      </NozzleSlotHoverCard>
                     ) : (
                     ) : (
                       <>
                       <>
                         <p className="text-[9px] text-bambu-gray">{t('printers.temperatures.nozzle')}</p>
                         <p className="text-[9px] text-bambu-gray">{t('printers.temperatures.nozzle')}</p>
@@ -2263,14 +2434,23 @@ function PrinterCard({
                   )}
                   )}
                   {/* Active nozzle indicator for dual-nozzle printers */}
                   {/* Active nozzle indicator for dual-nozzle printers */}
                   {isDualNozzle && (
                   {isDualNozzle && (
-                    <div className="text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex flex-col justify-center items-center" title={t('printers.activeNozzle', { nozzle: activeNozzle === 'L' ? t('common.left') : t('common.right') })}>
-                      <p className={`text-[11px] font-bold ${activeNozzle === 'L' ? 'text-amber-400' : 'text-gray-500'}`}>L</p>
-                      <p className="text-[9px] text-bambu-gray">{t('printers.temperatures.nozzle')}</p>
-                      <p className={`text-[11px] font-bold ${activeNozzle === 'R' ? 'text-amber-400' : 'text-gray-500'}`}>R</p>
-                    </div>
+                    <DualNozzleHoverCard leftSlot={leftNozzleSlot} rightSlot={rightNozzleSlot} activeNozzle={activeNozzle}>
+                      <div className="text-center px-3 py-1.5 bg-bambu-dark rounded-lg h-full flex flex-col justify-center items-center cursor-default" title={t('printers.activeNozzle', { nozzle: activeNozzle === 'L' ? t('common.left') : t('common.right') })}>
+                        <div className="flex items-center gap-2 mb-1">
+                          <span className={`text-[11px] font-bold ${activeNozzle === 'L' ? 'text-amber-400' : 'text-gray-500'}`}>
+                            L{leftNozzleSlot?.nozzle_diameter ? ` ${leftNozzleSlot.nozzle_diameter}` : ''}
+                          </span>
+                          <span className="text-[9px] text-bambu-gray/40">·</span>
+                          <span className={`text-[11px] font-bold ${activeNozzle === 'R' ? 'text-amber-400' : 'text-gray-500'}`}>
+                            R{rightNozzleSlot?.nozzle_diameter ? ` ${rightNozzleSlot.nozzle_diameter}` : ''}
+                          </span>
+                        </div>
+                        <p className="text-[9px] text-bambu-gray">{t('printers.temperatures.nozzle')}</p>
+                      </div>
+                    </DualNozzleHoverCard>
                   )}
                   )}
-                  {/* H2C nozzle rack (tool-changer dock) */}
-                  {status.nozzle_rack && status.nozzle_rack.length > 0 && (
+                  {/* 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) && (
                     <NozzleRackCard slots={status.nozzle_rack} />
                     <NozzleRackCard slots={status.nozzle_rack} />
                   )}
                   )}
                 </div>
                 </div>

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
static/assets/index-Az-fQOvm.css


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
static/assets/index-DCzsNoVv.css


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
static/assets/index-tNOV_9of.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CbyBA630.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Az-fQOvm.css">
+    <script type="module" crossorigin src="/assets/index-tNOV_9of.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DCzsNoVv.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels