Browse Source

Printers: the H3C card shows the hotend on the toolhead (#3442)

- after a tool swap the H3C keeps reporting the hotend that went back
  to the rack in nozzle_temper, so the card could show one cooling off
  there instead of the one printing
- on a rack printer the nozzle temperature, target and heating state
  now come from device.extruder.info, which Bambu Studio reads over
  nozzle_temper too, and nozzle_temper is ignored there
- the card names the nozzle by its rack position ("Nozzle 5"), the
  number its chip has in the nozzle rack beside it
- other printers are unchanged; the H2D's decoding moved into a helper
maziggy 2 hours ago
parent
commit
a0326afd08
71 changed files with 239 additions and 36 deletions
  1. 1 0
      CHANGELOG.md
  2. 47 31
      backend/app/services/bambu_mqtt.py
  3. 104 0
      backend/tests/unit/test_h3c_mounted_hotend_temp_3442.py
  4. 1 1
      frontend/scripts/check-i18n-parity.mjs
  5. 43 0
      frontend/src/__tests__/pages/PrintersPageAms3h.test.tsx
  6. 2 0
      frontend/src/i18n/locales/de.ts
  7. 2 0
      frontend/src/i18n/locales/en.ts
  8. 2 0
      frontend/src/i18n/locales/es.ts
  9. 2 0
      frontend/src/i18n/locales/fr.ts
  10. 2 0
      frontend/src/i18n/locales/it.ts
  11. 2 0
      frontend/src/i18n/locales/ja.ts
  12. 2 0
      frontend/src/i18n/locales/ko.ts
  13. 2 0
      frontend/src/i18n/locales/nl.ts
  14. 2 0
      frontend/src/i18n/locales/pl.ts
  15. 2 0
      frontend/src/i18n/locales/pt-BR.ts
  16. 2 0
      frontend/src/i18n/locales/ru.ts
  17. 2 0
      frontend/src/i18n/locales/sv.ts
  18. 2 0
      frontend/src/i18n/locales/tr.ts
  19. 2 0
      frontend/src/i18n/locales/uk.ts
  20. 2 0
      frontend/src/i18n/locales/zh-CN.ts
  21. 2 0
      frontend/src/i18n/locales/zh-TW.ts
  22. 8 1
      frontend/src/pages/PrintersPage.tsx
  23. 0 0
      static/assets/ArchivesPage-DfXXjwOk.js
  24. 0 0
      static/assets/CamWallPage-coOd9iIm.js
  25. 0 0
      static/assets/CameraPage-Cfx-eUBt.js
  26. 0 0
      static/assets/FileManagerPage-Crpy4N_u.js
  27. 0 0
      static/assets/InventoryPage-6y7q8RxY.js
  28. 0 0
      static/assets/Layout-_phXFaWp.js
  29. 0 0
      static/assets/Layout-mLfDLQZQ.js
  30. 0 0
      static/assets/ModelSourcesPage-D_IhteVT.js
  31. 0 0
      static/assets/PrintersPage-C9pNXMOz.js
  32. 0 0
      static/assets/SettingsPage-BgD3UMaD.js
  33. 0 0
      static/assets/SliceModal-CqgHT6mc.js
  34. 0 0
      static/assets/SpoolBuddyInventoryPage-CCEJqiPa.js
  35. 1 1
      static/assets/SpoolBuddySettingsPage-af8-N71m.js
  36. 0 0
      static/assets/SpoolBuddyWriteTagPage-CpNCcAPw.js
  37. 0 0
      static/assets/de-BDprumzN.js
  38. 0 0
      static/assets/de-BiBh27c8.js
  39. 0 0
      static/assets/es-CSQMdXdi.js
  40. 0 0
      static/assets/es-XOpwVt7J.js
  41. 0 0
      static/assets/fr-B0ADfFgN.js
  42. 0 0
      static/assets/fr-BD0Wzhbt.js
  43. 0 0
      static/assets/index-BeZpSTgc.js
  44. 0 0
      static/assets/it-B9ZkJFWC.js
  45. 0 0
      static/assets/it-Bv-VMy4u.js
  46. 0 0
      static/assets/ja-CPlr0OxL.js
  47. 0 0
      static/assets/ja-DTRormzR.js
  48. 0 0
      static/assets/ko-B7ig6c1S.js
  49. 0 0
      static/assets/ko-jStjVsyT.js
  50. 0 0
      static/assets/locationSensorDefaults-DKysHYp_.js
  51. 0 0
      static/assets/nl-ghzD5hVT.js
  52. 0 0
      static/assets/nl-ngN4nkky.js
  53. 0 0
      static/assets/pl-BFnAvv4G.js
  54. 0 0
      static/assets/pl-DeOYE0bh.js
  55. 0 0
      static/assets/pt-BR-C12e6ZV1.js
  56. 0 0
      static/assets/pt-BR-DQ1oP9O9.js
  57. 0 0
      static/assets/ru-BSIeOmGO.js
  58. 0 0
      static/assets/ru-Dt6wgnCU.js
  59. 0 0
      static/assets/spoolCost-CEcM6NH-.js
  60. 0 0
      static/assets/sv-CBil8jvY.js
  61. 0 0
      static/assets/sv-DsQtH4nt.js
  62. 0 0
      static/assets/tr-g1ZfB8Kx.js
  63. 0 0
      static/assets/tr-ncQ2n5KG.js
  64. 0 0
      static/assets/uk-CNbKKXqV.js
  65. 0 0
      static/assets/uk-DINH4-Rk.js
  66. 0 0
      static/assets/usePageFileDrop-BD3SzAPp.js
  67. 0 0
      static/assets/zh-CN-CB9BBeh7.js
  68. 0 0
      static/assets/zh-CN-CTGjf42h.js
  69. 0 0
      static/assets/zh-TW-D18wPoyv.js
  70. 0 0
      static/assets/zh-TW-DlA-bR1m.js
  71. 2 2
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -21,6 +21,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **"Any model" jobs go to the printer with the fewest print hours (#3480, sponsor vote Q4 2026)** — When several printers of a model can take a job, the queue used to pick the first one in its list, so on a farm the same printer got most of the work. It now picks the one with the fewest print hours (the Maintenance counter, including baseline hours entered there), so jobs and wear spread across the farm. Preferred colours still come first, and the queue powers on the least used printer too when it has to switch one on with an Auto Power On plug.
 
 ### Fixed
+- **The H3C's printer card shows the temperature of the hotend on the toolhead (#3442, reported by @hurr1k4ne, cause found by @daltonclift)** — After a tool swap the H3C keeps reporting the temperature of the hotend that just went back to the rack, so the card could show one cooling down there instead of the one printing. The card now takes the temperature, target and heating state of the hotend on the toolhead, as Bambu Studio does, and names it by its rack position (**Nozzle 5**), the number its chip has in the nozzle rack beside it.
 - **The SpoolBuddy kiosk works with its own device when several are registered (#3504, reported by @pleasantone)** — The kiosk's **Write** page always sent the write to the first registered device, which could be another kiosk or an old reader that had long been offline. The online state and display settings also came from that device, and every kiosk showed the weight and tags of every reader. The kiosk now works with one device: the one picked on its **Write** page, or else the device that is online. **Write** shows a choice of device when more than one is online, and the kiosk remembers it. Weight, tags and online state from other devices no longer show on it.
 - **A tag written with SpoolBuddy moves to the new spool (#3505, reported by @pleasantone)** — With Bambuddy's own inventory, writing a tag that already belonged to another spool linked it to the new spool but left it on the old one too, so scanning it could open the old spool. The tag now moves: the spool that had it gives it up, as it already did with Spoolman.
 - **SpoolBuddy checks a tag write instead of always reporting success (#3076, reported by @jinjiangz, gaps found by @pleasantone)** — The PN5180 can't see a tag's answer to a write, so **Write Tag** reported success for every page it sent. SpoolBuddy now reads the written data back and fails the write if the tag holds something else, naming the page. If the tag doesn't answer the read-back, the write still counts, reported as not verified. A tag too small for the data is refused before anything is written, and so is a tag that isn't NDEF-formatted, such as a blank MIFARE Ultralight, which looks like an NTAG to the reader. Before, a write past its end was reported as successful. When the write's result couldn't reach Bambuddy straight away, the next heartbeat brought the same write back and SpoolBuddy ran it a second time, on whatever tag was then on the reader. It now waits until the result is through.

+ 47 - 31
backend/app/services/bambu_mqtt.py

@@ -4729,6 +4729,40 @@ class BambuMQTTClient:
         except (TypeError, ValueError):
             return False
 
+    def _apply_nozzle_temp(self, temps: dict, temp_val) -> None:
+        """Set ``nozzle`` / ``nozzle_target`` / ``nozzle_heating`` from an extruder's ``temp``.
+
+        H2D protocol: temp field encoding depends on value
+        - When > 500: encoded as (target * 65536 + current) - heater is ON
+        - When < 500: direct Celsius current temp only - heater is OFF
+        """
+        # Check if we recently set the target locally (within 5 seconds)
+        # If so, don't let MQTT data overwrite it
+        local_set_time = self.state.temperatures.get("_nozzle_target_set_time", 0)
+        respect_local_target = (time.time() - local_set_time) < 5.0
+
+        if temp_val > 500:
+            # Encoded format: temp = target * 65536 + current
+            target = temp_val // 65536
+            current = temp_val % 65536
+            if 0 < target < 500 and not respect_local_target:
+                temps["nozzle_target"] = float(target)
+            if -50 < current < 500:
+                temps["nozzle"] = float(current)
+            # Heating = encoded AND we're using the MQTT target (not local override)
+            # If local target is being respected, use local target to determine heating
+            if respect_local_target:
+                local_target = self.state.temperatures.get("nozzle_target", 0)
+                temps["nozzle_heating"] = local_target > 0 and current < local_target
+            else:
+                temps["nozzle_heating"] = target > 0 and current < target
+        elif -50 < temp_val < 500:
+            # Direct Celsius = heater is OFF (or at target with heater off)
+            temps["nozzle"] = float(temp_val)
+            if not respect_local_target:
+                temps["nozzle_target"] = 0.0
+            temps["nozzle_heating"] = False  # Direct = not heating
+
     def _update_state(self, data: dict):
         """Update printer state from message data."""
         _previous_state = self.state.state
@@ -5095,15 +5129,22 @@ class BambuMQTTClient:
             and len(data["device"]["extruder"]["info"]) >= 2
         )
 
+        # A rack printer (H3C, #3442) swaps hotends on its one extruder, and
+        # nozzle_temper keeps reporting the one just put back in the rack. The
+        # mounted one's temperature is in device.extruder.info, which Bambu
+        # Studio reads over nozzle_temper too (DevExtruderSystem ParseV2_0).
+        # The layout knows an H3C by its model before any AMS data came.
+        mounted_tool_temp = self.tray_layout.rack
+
         # Standard nozzle fields: these are for the RIGHT/default nozzle on H2D
         # For H2D, we use these for nozzle_2 (RIGHT), for others use as nozzle (primary)
         # NOTE: On H2D, nozzle_temper seems to mirror left nozzle - we override with extruder_info[0] later
-        if "nozzle_temper" in data:
+        if "nozzle_temper" in data and not mounted_tool_temp:
             if has_h2d_extruder_info:
                 temps["nozzle_2"] = float(data["nozzle_temper"])  # Will be overridden by extruder_info[0]
             else:
                 temps["nozzle"] = float(data["nozzle_temper"])
-        if "nozzle_target_temper" in data:
+        if "nozzle_target_temper" in data and not mounted_tool_temp:
             if has_h2d_extruder_info:
                 temps["nozzle_2_target"] = float(data["nozzle_target_temper"])  # RIGHT target on H2D
             else:
@@ -5245,35 +5286,10 @@ class BambuMQTTClient:
                     # - When > 500: encoded as (target * 65536 + current) - heater is ON
                     # - When < 500: direct Celsius current temp only - heater is OFF
                     if len(extruder_info) >= 2 and "temp" in extruder_info[1]:
-                        ext1 = extruder_info[1]
-                        temp_val = ext1["temp"]
-
-                        # Check if we recently set the target locally (within 5 seconds)
-                        # If so, don't let MQTT data overwrite it
-                        local_set_time = self.state.temperatures.get("_nozzle_target_set_time", 0)
-                        respect_local_target = (time.time() - local_set_time) < 5.0
-
-                        if temp_val > 500:
-                            # Encoded format: temp = target * 65536 + current
-                            target = temp_val // 65536
-                            current = temp_val % 65536
-                            if 0 < target < 500 and not respect_local_target:
-                                temps["nozzle_target"] = float(target)
-                            if -50 < current < 500:
-                                temps["nozzle"] = float(current)
-                            # Heating = encoded AND we're using the MQTT target (not local override)
-                            # If local target is being respected, use local target to determine heating
-                            if respect_local_target:
-                                local_target = self.state.temperatures.get("nozzle_target", 0)
-                                temps["nozzle_heating"] = local_target > 0 and current < local_target
-                            else:
-                                temps["nozzle_heating"] = target > 0 and current < target
-                        elif -50 < temp_val < 500:
-                            # Direct Celsius = heater is OFF (or at target with heater off)
-                            temps["nozzle"] = float(temp_val)
-                            if not respect_local_target:
-                                temps["nozzle_target"] = 0.0
-                            temps["nozzle_heating"] = False  # Direct = not heating
+                        self._apply_nozzle_temp(temps, extruder_info[1]["temp"])
+                    # The H3C's one extruder: the hotend mounted on it (#3442).
+                    elif mounted_tool_temp and "temp" in extruder_info[0]:
+                        self._apply_nozzle_temp(temps, extruder_info[0]["temp"])
                     # The extruder snow is read by _parse_extruder_position, before the
                     # AMS block of the same frame (tray_now resolution needs it first).
                 # Parse bed heating state from device.bed.info.temp encoding

+ 104 - 0
backend/tests/unit/test_h3c_mounted_hotend_temp_3442.py

@@ -0,0 +1,104 @@
+"""The H3C's nozzle temperature is the mounted hotend's (#3442).
+
+The H3C swaps hotends on its one extruder. After a swap, ``nozzle_temper``
+keeps reporting the hotend just put back in the rack, while the mounted one's
+temperature is in ``device.extruder.info[0].temp`` (target << 16 | current),
+which Bambu Studio reads over ``nozzle_temper`` (DevExtruderSystem ParseV2_0).
+"""
+
+import pytest
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+from backend.tests.unit.test_h3c_ams3h import _frame
+
+pytestmark = pytest.mark.unit
+
+
+def _encoded(target: int, current: int) -> int:
+    return target * 65536 + current
+
+
+def _h3c_frame(extruder_temp: int, nozzle_temper: float = 31.0, nozzle_target: float = 0.0) -> dict:
+    frame = _frame()
+    frame["device"]["extruder"]["info"][0]["temp"] = extruder_temp
+    frame.update(nozzle_temper=nozzle_temper, nozzle_target_temper=nozzle_target)
+    return frame
+
+
+def _client(model: str) -> BambuMQTTClient:
+    return BambuMQTTClient(ip_address="10.0.0.1", serial_number="SN", access_code="c", model=model)
+
+
+class TestH3C:
+    def test_the_mounted_hotend_is_shown_not_the_racked_one(self):
+        client = _client("H3C")
+        client._process_message({"print": _h3c_frame(_encoded(220, 215))})
+
+        temps = client.state.temperatures
+        assert (temps["nozzle"], temps["nozzle_target"], temps["nozzle_heating"]) == (215.0, 220.0, True)
+
+    def test_a_delta_with_only_nozzle_temper_keeps_it(self):
+        client = _client("H3C")
+        client._process_message({"print": _h3c_frame(_encoded(220, 220))})
+        client._process_message({"print": {"nozzle_temper": 40.0, "nozzle_target_temper": 0.0}})
+
+        temps = client.state.temperatures
+        assert (temps["nozzle"], temps["nozzle_target"]) == (220.0, 220.0)
+
+    def test_a_cold_hotend_reads_direct(self):
+        client = _client("H3C")
+        client._process_message({"print": _h3c_frame(28)})
+
+        temps = client.state.temperatures
+        assert (temps["nozzle"], temps["nozzle_target"], temps["nozzle_heating"]) == (28.0, 0.0, False)
+
+    def test_a_rack_printer_of_an_unknown_model_too(self):
+        # Told apart by its AMS 3H and per-hotend inputs, like the tray ids.
+        client = _client("")
+        client._process_message({"print": _h3c_frame(_encoded(220, 215))})
+
+        assert client.state.temperatures["nozzle"] == 215.0
+
+    def test_an_h3c_without_ams_data_yet_ignores_nozzle_temper(self):
+        client = _client("N10")
+        client._process_message({"print": {"nozzle_temper": 40.0, "nozzle_target_temper": 0.0}})
+
+        assert "nozzle" not in client.state.temperatures
+
+
+class TestOtherPrinters:
+    def test_single_nozzle_keeps_nozzle_temper(self):
+        # A one-extruder report on a printer without a rack changes nothing.
+        client = _client("P2S")
+        client._process_message(
+            {
+                "print": {
+                    "nozzle_temper": 200.0,
+                    "nozzle_target_temper": 210.0,
+                    "device": {"extruder": {"state": 1, "info": [{"id": 0, "temp": _encoded(250, 245)}]}},
+                }
+            }
+        )
+
+        temps = client.state.temperatures
+        assert (temps["nozzle"], temps["nozzle_target"]) == (200.0, 210.0)
+
+    def test_h2d_unchanged(self):
+        client = _client("H2D")
+        client._process_message(
+            {
+                "print": {
+                    "nozzle_temper": 33.0,
+                    "device": {
+                        "extruder": {
+                            "state": 2,
+                            "info": [{"id": 0, "temp": _encoded(220, 210)}, {"id": 1, "temp": _encoded(250, 240)}],
+                        }
+                    },
+                }
+            }
+        )
+
+        temps = client.state.temperatures
+        assert (temps["nozzle"], temps["nozzle_target"]) == (240.0, 250.0)
+        assert (temps["nozzle_2"], temps["nozzle_2_target"]) == (210.0, 220.0)

+ 1 - 1
frontend/scripts/check-i18n-parity.mjs

@@ -480,7 +480,7 @@ const NL_COGNATES = [
   'Label...', 'LAN Developer Mode', 'Live', 'Long-Lived Access Token',
   'Ludicrous (166%)', 'MakerWorld: {{designer}}', 'Metadata', 'Min',
   'Model', '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}', '{{name}} - Timelapse', 'Nozzle',
-  'Nozzle 2', '{{n}} run', '{{n}} runs', 'Offline',
+  'Nozzle 2', 'Nozzle {{n}}', '{{n}} run', '{{n}} runs', 'Offline',
   'Online', 'Open', 'Orca Cloud', 'parallel',
   'Personal Access Token', 'Pipeline', 'Pipelines', 'Platform',
   'Pressure Advance', 'Prime tower', 'Printer', 'Printer:',

+ 43 - 0
frontend/src/__tests__/pages/PrintersPageAms3h.test.tsx

@@ -191,3 +191,46 @@ describe('PrintersPage - H3C AMS 3H', () => {
     expect(screen.queryByTestId('rack-externals-empty')).not.toBeInTheDocument();
   });
 });
+
+describe('PrintersPage - H3C mounted hotend temperature (#3442)', () => {
+  const rackNozzle = (id: number, addr_id: number) => ({
+    id, addr_id, nozzle_type: 'HS01', nozzle_diameter: '0.4', wear: 0, stat: 168, max_temp: 350,
+    serial_number: '', filament_color: 'DE4343FF', filament_id: 'GFL99', filament_type: 'PLA',
+  });
+  // Hotend of rack position 5 on the toolhead (id 0, addr_id 4); the others in the rack.
+  const nozzleRack = [rackNozzle(0, 4), rackNozzle(16, 0), rackNozzle(17, 1)];
+
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
+      http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    );
+  });
+
+  it('names the temperature after the hotend on the toolhead', async () => {
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ ...h3cStatus, nozzle_rack: nozzleRack })),
+    );
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(screen.getByTestId('nozzle-temp-label')).toHaveTextContent('Nozzle 5');
+    });
+  });
+
+  it('keeps Nozzle on a printer without a rack', async () => {
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([{ ...mockPrinter, model: 'P2S' }])),
+      http.get('/api/v1/printers/:id/status', () =>
+        HttpResponse.json({ ...h3cStatus, ams: [], vt_tray: [], nozzle_rack: nozzleRack }),
+      ),
+    );
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(screen.getByTestId('nozzle-temp-label')).toHaveTextContent(/^Nozzle$/);
+    });
+  });
+});

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

@@ -316,6 +316,8 @@ export default {
     },
     temperatures: {
       nozzle: 'Düse',
+      // The H3C's nozzle on the toolhead, by its rack position (#3442).
+      mounted: 'Düse {{n}}',
       bed: 'Druckbett',
       chamber: 'Kammer',
     },

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

@@ -318,6 +318,8 @@ export default {
     },
     temperatures: {
       nozzle: 'Nozzle',
+      // The H3C's nozzle on the toolhead, by its rack position (#3442).
+      mounted: 'Nozzle {{n}}',
       bed: 'Bed',
       chamber: 'Chamber',
     },

+ 2 - 0
frontend/src/i18n/locales/es.ts

@@ -316,6 +316,8 @@ export default {
     },
     temperatures: {
       nozzle: 'Boquilla',
+      // The H3C's nozzle on the toolhead, by its rack position (#3442).
+      mounted: 'Boquilla {{n}}',
       bed: 'Cama',
       chamber: 'Cámara',
     },

+ 2 - 0
frontend/src/i18n/locales/fr.ts

@@ -316,6 +316,8 @@ export default {
     },
     temperatures: {
       nozzle: 'Buse',
+      // The H3C's nozzle on the toolhead, by its rack position (#3442).
+      mounted: 'Buse {{n}}',
       bed: 'Plateau',
       chamber: 'Chambre',
     },

+ 2 - 0
frontend/src/i18n/locales/it.ts

@@ -316,6 +316,8 @@ export default {
     },
     temperatures: {
       nozzle: 'Ugello',
+      // The H3C's nozzle on the toolhead, by its rack position (#3442).
+      mounted: 'Ugello {{n}}',
       bed: 'Piatto',
       chamber: 'Camera',
     },

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

@@ -315,6 +315,8 @@ export default {
     },
     temperatures: {
       nozzle: 'ノズル',
+      // The H3C's nozzle on the toolhead, by its rack position (#3442).
+      mounted: 'ノズル {{n}}',
       bed: 'ベッド',
       chamber: 'チャンバー',
     },

+ 2 - 0
frontend/src/i18n/locales/ko.ts

@@ -304,6 +304,8 @@ export default {
     },
     temperatures: {
       nozzle: '노즐',
+      // The H3C's nozzle on the toolhead, by its rack position (#3442).
+      mounted: '노즐 {{n}}',
       bed: '베드',
       chamber: '챔버'
     },

+ 2 - 0
frontend/src/i18n/locales/nl.ts

@@ -316,6 +316,8 @@ export default {
     },
     temperatures: {
       nozzle: 'Nozzle',
+      // The H3C's nozzle on the toolhead, by its rack position (#3442).
+      mounted: 'Nozzle {{n}}',
       bed: 'Printbed',
       chamber: 'Kamer',
     },

+ 2 - 0
frontend/src/i18n/locales/pl.ts

@@ -318,6 +318,8 @@ export default {
     },
     temperatures: {
       nozzle: 'Dysza',
+      // The H3C's nozzle on the toolhead, by its rack position (#3442).
+      mounted: 'Dysza {{n}}',
       bed: 'Stół',
       chamber: 'Komora',
     },

+ 2 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -316,6 +316,8 @@ export default {
     },
     temperatures: {
       nozzle: 'Bico',
+      // The H3C's nozzle on the toolhead, by its rack position (#3442).
+      mounted: 'Bico {{n}}',
       bed: 'Cama',
       chamber: 'Câmara',
     },

+ 2 - 0
frontend/src/i18n/locales/ru.ts

@@ -310,6 +310,8 @@ export default {
     },
     temperatures: {
       nozzle: "Сопло",
+      // The H3C's nozzle on the toolhead, by its rack position (#3442).
+      mounted: "Сопло {{n}}",
       bed: "Стол",
       chamber: "Камера",
     },

+ 2 - 0
frontend/src/i18n/locales/sv.ts

@@ -316,6 +316,8 @@ export default {
     },
     temperatures: {
       nozzle: 'Munstycke',
+      // The H3C's nozzle on the toolhead, by its rack position (#3442).
+      mounted: 'Munstycke {{n}}',
       bed: 'Byggplatta',
       chamber: 'Kammare',
     },

+ 2 - 0
frontend/src/i18n/locales/tr.ts

@@ -316,6 +316,8 @@ export default {
     },
     temperatures: {
       nozzle: 'Nozul',
+      // The H3C's nozzle on the toolhead, by its rack position (#3442).
+      mounted: 'Nozul {{n}}',
       bed: 'Tabla',
       chamber: 'Hazne',
     },

+ 2 - 0
frontend/src/i18n/locales/uk.ts

@@ -316,6 +316,8 @@ export default {
     },
     temperatures: {
       nozzle: "Сопло",
+      // The H3C's nozzle on the toolhead, by its rack position (#3442).
+      mounted: "Сопло {{n}}",
       bed: "Стіл",
       chamber: "Камера",
     },

+ 2 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -316,6 +316,8 @@ export default {
     },
     temperatures: {
       nozzle: '喷嘴',
+      // The H3C's nozzle on the toolhead, by its rack position (#3442).
+      mounted: '喷嘴 {{n}}',
       bed: '热床',
       chamber: '腔室',
     },

+ 2 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -316,6 +316,8 @@ export default {
     },
     temperatures: {
       nozzle: '噴嘴',
+      // The H3C's nozzle on the toolhead, by its rack position (#3442).
+      mounted: '噴嘴 {{n}}',
       bed: '熱床',
       chamber: '腔室',
     },

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

@@ -4618,6 +4618,11 @@ function PrinterCard({
               const rightNozzleSlot = status.nozzle_rack?.find(s => s.id === 0);
               // Single-nozzle models (H2D, H2C): use the primary nozzle (id 0)
               const singleNozzleSlot = rightNozzleSlot || leftNozzleSlot;
+              // A rack printer (H3C) shows the nozzle on its toolhead, named by its
+              // rack position as in the rack card (#3442).
+              const mountedHotend = trayLayout.rack && singleNozzleSlot?.id === 0 && typeof singleNozzleSlot.addr_id === 'number'
+                ? singleNozzleSlot.addr_id + 1
+                : null;
               const canUseStatusControls = status.connected && hasPermission('printers:control');
               const statusControlTitle = canUseStatusControls ? undefined : t('printers.permission.noControl');
               const statusControlClass = `relative text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex-1 flex flex-col justify-center items-center transition-colors ${
@@ -4733,7 +4738,9 @@ function PrinterCard({
                       ) : singleNozzleSlot ? (
                         <NozzleSlotHoverCard slot={singleNozzleSlot} index={0} activeStatus filamentName={singleNozzleSlot.filament_id ? filamentInfo?.[singleNozzleSlot.filament_id]?.name : undefined}>
                           <div className="cursor-default">
-                            <p className="text-[length:var(--pc-t9,9px)] text-bambu-gray">{t('printers.temperatures.nozzle')}</p>
+                            <p data-testid="nozzle-temp-label" className="text-[length:var(--pc-t9,9px)] text-bambu-gray">
+                              {mountedHotend !== null ? t('printers.temperatures.mounted', { n: mountedHotend }) : t('printers.temperatures.nozzle')}
+                            </p>
                             <p className="text-[length:var(--pc-t11,11px)] text-white">
                               {Math.round(status.temperatures.nozzle || 0)}°C
                             </p>

File diff suppressed because it is too large
+ 0 - 0
static/assets/ArchivesPage-DfXXjwOk.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CamWallPage-coOd9iIm.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraPage-Cfx-eUBt.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FileManagerPage-Crpy4N_u.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/InventoryPage-6y7q8RxY.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/Layout-_phXFaWp.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/Layout-mLfDLQZQ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ModelSourcesPage-D_IhteVT.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrintersPage-C9pNXMOz.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SettingsPage-BgD3UMaD.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SliceModal-CqgHT6mc.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-CCEJqiPa.js


File diff suppressed because it is too large
+ 1 - 1
static/assets/SpoolBuddySettingsPage-af8-N71m.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-CpNCcAPw.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/de-BDprumzN.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/de-BiBh27c8.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/es-CSQMdXdi.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/es-XOpwVt7J.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/fr-B0ADfFgN.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/fr-BD0Wzhbt.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BeZpSTgc.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/it-B9ZkJFWC.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/it-Bv-VMy4u.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ja-CPlr0OxL.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ja-DTRormzR.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ko-B7ig6c1S.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ko-jStjVsyT.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/locationSensorDefaults-DKysHYp_.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/nl-ghzD5hVT.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/nl-ngN4nkky.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pl-BFnAvv4G.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pl-DeOYE0bh.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pt-BR-C12e6ZV1.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pt-BR-DQ1oP9O9.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ru-BSIeOmGO.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ru-Dt6wgnCU.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/spoolCost-CEcM6NH-.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/sv-CBil8jvY.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/sv-DsQtH4nt.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/tr-g1ZfB8Kx.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/tr-ncQ2n5KG.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/uk-CNbKKXqV.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/uk-DINH4-Rk.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/usePageFileDrop-BD3SzAPp.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/zh-CN-CB9BBeh7.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/zh-CN-CTGjf42h.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/zh-TW-D18wPoyv.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/zh-TW-DlA-bR1m.js


+ 2 - 2
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DW4zkUAo.js"></script>
+    <script type="module" crossorigin src="/assets/index-BeZpSTgc.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-Bol9xAus.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-DOwGWmSv.js">
@@ -122,7 +122,7 @@
     <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-5VMJBKdj.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-PUlx2UXc.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-CKKaaGUZ.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-mLfDLQZQ.js">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-_phXFaWp.js">
     <link rel="stylesheet" crossorigin href="/assets/index-DJkvOUma.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff