Browse Source

fix(printers): H2D Pro and X1E get their chamber heater (issue #3335)

- A printer stored as "H2D Pro" (what discovery and the add-printer
  list store) was checked as "H2D PRO", space kept, against model sets
  that hold "H2DPRO": no chamber temperature in status or the API, the
  chamber target refused with 400, no queue preheat or soak, and the
  airduct never switched. One _normalize_model in printer_manager
  (upper case, no spaces; dashes kept for BL-P001 / A1-MINI) is used
  by all seven model checks; spaced set entries that can no longer
  match are gone, and a test rejects new ones.
- The printer card's airduct toggle list lacked "H2D Pro".
- The X1E has a chamber heater (to 60 C) but was listed as sensor
  only: X1E and C13 join CHAMBER_HEATER_MODELS. It has no airduct
  flap, so none is switched.
- Tests for every stored spelling through the checks, the status
  payload, the chamber route, the queue preheat and the card toggle.
maziggy 22 hours ago
parent
commit
86875af4ad

+ 1 - 0
CHANGELOG.md

@@ -174,6 +174,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **An H2D Pro reports its chamber and can heat it, and so can an X1E (#3335, reported by @Spegeli)** — A printer stored as "H2D Pro", which is what discovery and the add-printer list store, was checked against the model lists as "H2D PRO", with the space kept, while the lists spell it "H2DPRO". So an H2D Pro got no chamber temperature on the printer card and in the API (Home Assistant included), setting its chamber temperature was refused, and queued prints got no chamber preheat or soak and never switched the airduct to heating, or back to cooling for a PLA print after ABS; the printer card did not offer its airduct toggle either. Every model check now compares the name without spaces, and the card lists the H2D Pro. Separately, the X1E was listed as having only a chamber sensor although it has a chamber heater (up to 60 °C): setting its chamber temperature was refused and the queue never preheated its chamber. It now does both; the X1E has no airduct flap, so none is switched.
 - **A print started over LAN is linked to its archive, and its print time is no longer short (#3327, reported by @Andrewdlec)** — A print sent from Bambu Studio over LAN, or started from Handy or the printer's screen, reports no job id, and printer status found a running print's archive only by that id, so `current_archive_id` stayed empty for the whole print and the printer card could not name the plate of a multi-plate file. Status now also finds the running print's archive as soon as it exists, also after a Bambuddy restart mid-print, and never names an archive that was deleted. The printer card's cover image and a reloaded skip-objects list of such a print are now read from the archived 3MF instead of being fetched from the busy printer again over FTPS (skip-objects only while Bambuddy knows which archive is the print's, never by name). Separately, an archive's `started_at` was the moment it was written, after the 3MF download, which can take a minute at the start of a print; the print time taken from it at completion was short by that much. It is now the moment the print was seen to start, also for an archive written without a 3MF.
 - **A spool replaced during a print is charged only for its part (#3325, reported by @HarryCoff2)** — When a spool ran out and a new one was loaded in the same slot (an external spool, or an AMS slot without a backup) and assigned in Bambuddy while the print ran, the whole print was charged to the new spool. Bambuddy now notes the layer of the new assignment and, when the print ends, splits the filament between the two spools by what the G-code feeds before and after it, for completed and failed prints, with the built-in inventory and with Spoolman, also after a restart mid-print. A slot that had no spool when the print started is still charged to the spool assigned to it, for the whole print.
 - **A write is saved before its response is sent (#3320, reported by @MorganMLGman)** — Many API routes left the final save to the end of the request, and that ran only after the response had gone out. A client reading straight after a successful write could miss it (a new project came back as 404, an edit still showed the old value), and a save that failed at that point was undone behind a success the client already had. Every route now saves before it answers, and a failed save is reported as an error.

+ 3 - 3
backend/app/api/routes/printers.py

@@ -3753,9 +3753,9 @@ async def set_chamber_temperature(
     """Set the chamber target temperature.
 
     Gated on `supports_chamber_heater(model)`: only H2C, H2D, H2D Pro, H2S,
-    and X2D have an active chamber heater. Sensor-only models (X1C, X1E,
-    P2S) report chamber temp but silently swallow M141, so we 400 here
-    rather than send a no-op.
+    X1E and X2D have an active chamber heater. Sensor-only models (X1C, P2S)
+    report chamber temp but silently swallow M141, so we 400 here rather
+    than send a no-op.
     """
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()

+ 26 - 19
backend/app/services/printer_manager.py

@@ -59,7 +59,6 @@ A1_MODELS = frozenset(
     [
         # Display names
         "A1",
-        "A1 MINI",
         "A1-MINI",
         "A1MINI",
         # Internal codes (from MQTT/SSDP)
@@ -82,6 +81,16 @@ STG_CUR_IDLE_BUG_MODELS = A1_MODELS | frozenset(
 )
 
 
+def _normalize_model(model: str) -> str:
+    """Upper case without spaces, the form the model sets below hold (#3335).
+
+    A printer is stored as "H2D Pro" or "A1 Mini"; upper-casing alone kept the
+    space, and "H2D PRO" matched no set. Dashes stay: the sets name the X1C's
+    code as "BL-P001".
+    """
+    return model.strip().upper().replace(" ", "")
+
+
 def supports_chamber_temp(model: str | None) -> bool:
     """Check if a printer model has a real chamber temperature sensor.
 
@@ -90,15 +99,13 @@ def supports_chamber_temp(model: str | None) -> bool:
     """
     if not model:
         return False
-    # Normalize model name (uppercase, strip whitespace)
-    model_upper = model.strip().upper()
-    return model_upper in CHAMBER_TEMP_SUPPORTED_MODELS
+    return _normalize_model(model) in CHAMBER_TEMP_SUPPORTED_MODELS
 
 
 # Models with an ACTIVE chamber heater (M141 has an effect).
 # Many printers in CHAMBER_TEMP_SUPPORTED_MODELS only have a passive sensor —
-# X1C, X1E, P2S report chamber temperature but cannot actively heat it. Only
-# the models below ship a PTC heater that responds to M141.
+# X1C and P2S report chamber temperature but cannot actively heat it. Only
+# the models below ship a heater that responds to M141; the X1E heats to 60 °C.
 CHAMBER_HEATER_MODELS = frozenset(
     [
         # Display names
@@ -106,8 +113,10 @@ CHAMBER_HEATER_MODELS = frozenset(
         "H2D",
         "H2DPRO",
         "H2S",
+        "X1E",
         "X2D",
         # Internal codes (from MQTT/SSDP)
+        "C13",  # X1E
         "O1C",  # H2C
         "O1C2",  # H2C dual-nozzle variant
         "O1D",  # H2D
@@ -123,17 +132,17 @@ def supports_chamber_heater(model: str | None) -> bool:
     """Check if a printer model has an active chamber heater (responds to M141).
 
     The chamber temperature SENSOR is more widely deployed than the chamber
-    HEATER — X1C/X1E/P2S report chamber temp but ignore M141. Only H2C, H2D,
-    H2D Pro, H2S, X2D actually heat. Sensor-only models silently swallow the
+    HEATER — X1C/P2S report chamber temp but ignore M141. Only H2C, H2D,
+    H2D Pro, H2S, X1E, X2D actually heat. Sensor-only models silently swallow the
     command at the firmware level, so we 400 at the route to surface that.
     """
     if not model:
         return False
-    return model.strip().upper() in CHAMBER_HEATER_MODELS
+    return _normalize_model(model) in CHAMBER_HEATER_MODELS
 
 
 # Models with a cooling / heating airduct flap. Same set as the frontend
-# PrintersPage airduct-toggle whitelist (P2S, X2D, H2D, H2C, H2S, H2D Pro).
+# PrintersPage airduct-toggle whitelist (P2S, X2D, H2D, H2D Pro, H2C, H2S).
 # X1E has a chamber heater but NO airduct flap — the warm-air recirculation
 # happens via the fixed front-door inlet, so no `set_airduct` command is
 # needed (and the firmware ignores it). P2S has an airduct but no heater —
@@ -174,7 +183,7 @@ def supports_airduct(model: str | None) -> bool:
     """
     if not model:
         return False
-    return model.strip().upper() in CHAMBER_AIRDUCT_MODELS
+    return _normalize_model(model) in CHAMBER_AIRDUCT_MODELS
 
 
 def has_stg_cur_idle_bug(model: str | None) -> bool:
@@ -186,12 +195,11 @@ def has_stg_cur_idle_bug(model: str | None) -> bool:
     """
     if not model:
         return False
-    model_upper = model.strip().upper()
-    return model_upper in STG_CUR_IDLE_BUG_MODELS
+    return _normalize_model(model) in STG_CUR_IDLE_BUG_MODELS
 
 
 # Minimum firmware versions for AMS drying support (confirmed via capture testing)
-# Keys are exact model names (upper-cased). Do NOT use substring matching — it would
+# Keys are exact model names (upper-cased, no spaces). Do NOT use substring matching — it would
 # incorrectly gate X1E (matched by "X1") and H2D Pro (matched by "H2D").
 _DRYING_MIN_FIRMWARE: dict[str, str] = {
     "H2D": "01.02.30.00",
@@ -205,7 +213,7 @@ _DRYING_MIN_FIRMWARE: dict[str, str] = {
     "N7": "01.02.00.00",  # P2S internal model code
 }
 # Models that definitely don't support AMS drying (no AMS 2 Pro / AMS-HT compatibility)
-_DRYING_UNSUPPORTED_MODELS = frozenset({"A1", "A1MINI", "A1-MINI", "A1 MINI", "O1S", "N1", "N2S"})
+_DRYING_UNSUPPORTED_MODELS = frozenset({"A1", "A1MINI", "A1-MINI", "O1S", "N1", "N2S"})
 
 # Models whose AMS can dry, but only from the printer's own touchscreen. Bambu's P1
 # manual is explicit: "P1S connected AMS drying functions may only be controlled from
@@ -227,7 +235,7 @@ def drying_screen_only(model: str | None) -> bool:
     """
     if not model:
         return False
-    return model.strip().upper() in _DRYING_SCREEN_ONLY_MODELS
+    return _normalize_model(model) in _DRYING_SCREEN_ONLY_MODELS
 
 
 # Temperature keys the UI actually draws. `state.temperatures` is also working
@@ -321,7 +329,7 @@ def supports_drying(model: str | None, firmware: str | None) -> bool:
     """
     if not model:
         return False
-    model_upper = model.strip().upper()
+    model_upper = _normalize_model(model)
     if model_upper in _DRYING_UNSUPPORTED_MODELS or model_upper in _DRYING_SCREEN_ONLY_MODELS:
         return False
     if model_upper in _DRYING_MIN_FIRMWARE:
@@ -338,7 +346,6 @@ def supports_drying(model: str | None, firmware: str | None) -> bool:
 # will reject the command in those cases anyway via dry_sf_reason=[0] (TaskOccupied).
 _DRY_WHILE_PRINTING_MIN_FIRMWARE: dict[str, str] = {
     "H2D": "01.03.00.00",
-    "H2D PRO": "01.02.00.00",
     "H2DPRO": "01.02.00.00",
     "O1E": "01.02.00.00",  # H2D Pro SSDP code
     "O2D": "01.02.00.00",  # H2D Pro alternate code
@@ -369,7 +376,7 @@ def supports_drying_while_printing(model: str | None, firmware: str | None) -> b
     """
     if not model:
         return False
-    model_upper = model.strip().upper()
+    model_upper = _normalize_model(model)
     if model_upper not in _DRY_WHILE_PRINTING_MIN_FIRMWARE:
         return False
     return bool(firmware and firmware >= _DRY_WHILE_PRINTING_MIN_FIRMWARE[model_upper])

+ 33 - 0
backend/tests/integration/test_h2d_pro_status_3335.py

@@ -0,0 +1,33 @@
+"""An H2D Pro reports its chamber in status (#3335).
+
+The status route drops the chamber readings of models without a chamber
+sensor and reports whether the chamber heater can be set. An H2D Pro, stored
+as "H2D Pro", was treated as having neither.
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from backend.app.services.bambu_mqtt import PrinterState
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+@pytest.mark.parametrize("model", ["H2D Pro", "X1E"])
+async def test_the_chamber_is_reported_and_can_be_heated(async_client, printer_factory, model):
+    printer = await printer_factory(model=model)
+    state = PrinterState()
+    state.connected = True
+    state.state = "IDLE"
+    state.temperatures = {"nozzle": 25.0, "bed": 25.0, "chamber": 31.0, "chamber_target": 0.0}
+
+    with patch("backend.app.api.routes.printers.printer_manager") as manager:
+        manager.get_status = MagicMock(return_value=state)
+        manager.is_awaiting_plate_clear = MagicMock(return_value=False)
+        response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
+
+    assert response.status_code == 200
+    body = response.json()
+    assert body["temperatures"]["chamber"] == 31.0
+    assert body["supports_chamber_heater"] is True

+ 5 - 4
backend/tests/integration/test_printers_api.py

@@ -4441,7 +4441,7 @@ class TestSetChamberTemperatureAPI:
     """Integration tests for POST /printers/{id}/temperature/chamber.
 
     Gated on supports_chamber_heater(model). Sensor-only models that report
-    chamber temp but have no heater (X1C, X1E, P2S) get a 400 at the route
+    chamber temp but have no heater (X1C, P2S) get a 400 at the route
     level rather than a silent no-op at the firmware level.
     """
 
@@ -4453,7 +4453,7 @@ class TestSetChamberTemperatureAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    @pytest.mark.parametrize("sensor_only_model", ["X1C", "X1E", "P2S"])
+    @pytest.mark.parametrize("sensor_only_model", ["X1C", "P2S"])
     async def test_sensor_only_model_rejected(self, async_client: AsyncClient, printer_factory, sensor_only_model):
         """Models with sensor but no heater must 400 before any client call."""
         printer = await printer_factory(name="P", model=sensor_only_model)
@@ -4477,9 +4477,10 @@ class TestSetChamberTemperatureAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    @pytest.mark.parametrize("heater_model", ["H2C", "H2D", "H2DPRO", "H2S", "X2D"])
+    @pytest.mark.parametrize("heater_model", ["H2C", "H2D", "H2DPRO", "H2D Pro", "H2S", "X1E", "X2D"])
     async def test_success_per_heater_model(self, async_client: AsyncClient, printer_factory, heater_model):
-        """All five heater-equipped models accept the command."""
+        """Every heater-equipped model accepts the command, under the name it is
+        stored with: an H2D Pro is "H2D Pro" (#3335)."""
         printer = await printer_factory(name="P", model=heater_model)
         mock_client = MagicMock()
         mock_client.set_chamber_temperature.return_value = True

+ 84 - 0
backend/tests/unit/test_model_names_with_spaces_3335.py

@@ -0,0 +1,84 @@
+"""A model stored with a space in its name is found in the model sets (#3335).
+
+Discovery and the add-printer dropdown store an H2D Pro as "H2D Pro". The
+checks upper-cased it to "H2D PRO", which no set holds, so an H2D Pro had no
+chamber temperature, no chamber heater and no airduct. Every check in
+printer_manager now compares the name without spaces.
+"""
+
+import pytest
+
+from backend.app.services import printer_manager as pm
+from backend.app.services.printer_manager import (
+    drying_screen_only,
+    has_stg_cur_idle_bug,
+    supports_airduct,
+    supports_chamber_heater,
+    supports_chamber_temp,
+    supports_drying,
+    supports_drying_while_printing,
+)
+
+H2D_PRO = ["H2D Pro", "h2d pro", " H2D Pro ", "H2DPRO", "O1E", "O2D"]
+
+
+@pytest.mark.parametrize("model", H2D_PRO)
+def test_an_h2d_pro_has_its_chamber_and_airduct(model):
+    assert supports_chamber_temp(model)
+    assert supports_chamber_heater(model)
+    assert supports_airduct(model)
+
+
+@pytest.mark.parametrize("model", H2D_PRO)
+def test_an_h2d_pro_dries_while_printing(model):
+    assert supports_drying_while_printing(model, "01.02.00.00")
+    assert not supports_drying_while_printing(model, "01.01.09.00")
+
+
+@pytest.mark.parametrize("model", ["A1 Mini", "a1 mini", "A1MINI", "A1-MINI", "N1"])
+def test_an_a1_mini_is_still_an_a1_mini(model):
+    assert has_stg_cur_idle_bug(model)
+    assert not supports_drying(model, "99.99.99.99")
+    assert not supports_chamber_temp(model)
+
+
+def test_the_x1c_code_keeps_its_dash():
+    assert supports_chamber_temp("BL-P001")
+    assert supports_drying_while_printing("BL-P001", "01.11.02.00")
+
+
+def test_a_p1s_still_dries_only_from_its_screen():
+    assert drying_screen_only("p1s")
+    assert not supports_drying("P1S", "99.99.99.99")
+
+
+@pytest.mark.parametrize("model", ["X1E", "x1e", "C13"])
+def test_the_x1e_heats_its_chamber_but_has_no_airduct(model):
+    assert supports_chamber_heater(model)
+    assert supports_chamber_temp(model)
+    assert not supports_airduct(model)
+
+
+@pytest.mark.parametrize("model", ["X1C", "BL-P001", "P2S", "N7"])
+def test_sensor_only_chambers_have_no_heater(model):
+    assert supports_chamber_temp(model)
+    assert not supports_chamber_heater(model)
+
+
+def _model_tables():
+    for name in dir(pm):
+        value = getattr(pm, name)
+        if "MODEL" in name or "FIRMWARE" in name:
+            if isinstance(value, (frozenset, set, dict)):
+                yield name, value
+
+
+def test_no_set_holds_a_name_the_checks_cannot_match():
+    tables = dict(_model_tables())
+    assert len(tables) >= 8  # the scan finds the sets at all
+
+    unmatchable = {
+        name: sorted(key for key in table if key != key.strip().upper().replace(" ", ""))
+        for name, table in tables.items()
+    }
+    assert {name: keys for name, keys in unmatchable.items() if keys} == {}

+ 29 - 4
backend/tests/unit/test_scheduler_preheat.py

@@ -531,7 +531,8 @@ async def test_lost_client_skips_silently(scheduler, item, archive):
 
 
 @pytest.mark.asyncio
-async def test_h2d_flips_airduct_to_heating_before_m141(scheduler, item, archive):
+@pytest.mark.parametrize("model", ["H2D", "H2D Pro"])  # an H2D Pro is stored as "H2D Pro" (#3335)
+async def test_h2d_flips_airduct_to_heating_before_m141(scheduler, item, archive, model):
     """H-series + X2D have a cooling/heating airduct flap that DEFAULTS to
     cooling. If we energise M141 without first flipping to heating, the
     chamber fan actively extracts the heat we're trying to put in and the
@@ -552,7 +553,7 @@ async def test_h2d_flips_airduct_to_heating_before_m141(scheduler, item, archive
     ):
         pm.get_client.return_value = client
         pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
-        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+        await scheduler._preheat_and_soak(db, item, _make_printer(model), archive)
 
     client.set_airduct_mode.assert_called_once_with("heating")
     client.set_chamber_temperature.assert_called_once_with(45)
@@ -561,6 +562,29 @@ async def test_h2d_flips_airduct_to_heating_before_m141(scheduler, item, archive
     assert call_order == [("airduct", "heating"), ("chamber", 45)]
 
 
+@pytest.mark.asyncio
+@pytest.mark.parametrize("model", ["X1E", "C13"])
+async def test_x1e_heats_the_chamber_without_an_airduct(scheduler, item, archive, model):
+    """The X1E has a chamber heater (to 60 °C) but no airduct flap: M141 fires,
+    set_airduct does not."""
+    db = AsyncMock()
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
+        await scheduler._preheat_and_soak(db, item, _make_printer(model), archive)
+
+    client.set_chamber_temperature.assert_called_once_with(45)
+    client.set_airduct_mode.assert_not_called()
+
+
 @pytest.mark.asyncio
 async def test_x1c_skips_airduct_no_heater_no_call(scheduler, item, archive):
     """X1C has neither an active chamber heater nor an airduct flap (the
@@ -668,7 +692,8 @@ async def test_h2d_chamber_heat_switches_airduct_to_heating(scheduler, item, arc
 
 
 @pytest.mark.asyncio
-async def test_h2d_chamber_zero_switches_airduct_to_cooling(scheduler, item, archive):
+@pytest.mark.parametrize("model", ["H2D", "H2D Pro"])
+async def test_h2d_chamber_zero_switches_airduct_to_cooling(scheduler, item, archive, model):
     """H2D running a PLA print (chamber_target derives 0) on an airduct
     previously left in heating mode (from a prior ABS run) must switch
     back to cooling. Otherwise PLA prints inherit ABS's closed-flap recirc
@@ -686,7 +711,7 @@ async def test_h2d_chamber_zero_switches_airduct_to_cooling(scheduler, item, arc
         pm.get_client.return_value = client
         # Currently in heating (mode 1). PLA loaded → derived target 0.
         pm.get_status.return_value = _make_state(60.0, 30.0, trays=["PLA"], airduct_mode=1)
-        await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
+        await scheduler._preheat_and_soak(db, item, _make_printer(model), archive)
 
     client.set_airduct_mode.assert_called_once_with("cooling")
     client.set_chamber_temperature.assert_not_called()

+ 17 - 0
frontend/src/__tests__/pages/PrintersPageMachineControlsWiring.test.tsx

@@ -236,6 +236,23 @@ describe('PrintersPage machine controls: light and airduct', () => {
     await user.click(await screen.findByRole('button', { name: label }));
     await waitFor(() => expect(sent).toEqual([{ path: 'airduct-mode', params: { mode } }]));
   });
+
+  it('an H2D Pro, stored as "H2D Pro", has the airduct menu too (#3335)', async () => {
+    const user = userEvent.setup();
+    const sent = setup('H2D Pro', { airduct_mode: 0 });
+
+    await user.click(await screen.findByTitle(/^Airduct Mode: /));
+    await user.click(await screen.findByRole('button', { name: 'Heating' }));
+    await waitFor(() => expect(sent).toEqual([{ path: 'airduct-mode', params: { mode: 'heating' } }]));
+  });
+
+  it('an X1E has no airduct menu', async () => {
+    setup('X1E', { airduct_mode: 0 });
+
+    await screen.findByText('Test X1E');
+    await waitFor(() => expect(screen.getByText('Bed')).toBeInTheDocument());
+    expect(screen.queryByTitle(/^Airduct Mode: /)).toBeNull();
+  });
 });
 
 describe('PrintersPage machine controls: movement', () => {

+ 3 - 3
frontend/src/pages/PrintersPage.tsx

@@ -4729,7 +4729,7 @@ function PrinterCard({
                       )}
                     </div>
                     {status.temperatures.chamber !== undefined && (() => {
-                      // Sensor-only models (X1C, X1E, P2S) show the chamber reading
+                      // Sensor-only models (X1C, P2S) show the chamber reading
                       // but can't act on M141, so we keep the card read-only there.
                       const hasChamberHeater = status.supports_chamber_heater === true;
                       return (
@@ -4897,8 +4897,8 @@ function PrinterCard({
                         <ChamberLight on={status.chamber_light ?? false} className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
                       </button>
 
-                      {/* Airduct Mode (P2S / X2D / H2*) */}
-                      {(['P2S', 'X2D', 'H2D', 'H2C', 'H2S'].includes(printer.model ?? '')) && (() => {
+                      {/* Airduct Mode (P2S / X2D / H2*, an H2D Pro stored as "H2D Pro", #3335) */}
+                      {(['P2S', 'X2D', 'H2D', 'H2D Pro', 'H2C', 'H2S'].includes(printer.model ?? '')) && (() => {
                         const isHeating = status.airduct_mode === 1;
                         const Icon = isHeating ? Flame : Snowflake;
                         const color = isHeating ? 'text-orange-600 dark:text-orange-400' : 'text-sky-600 dark:text-sky-400';