Sfoglia il codice sorgente

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 12 ore fa
parent
commit
f71e29f879

+ 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>