Browse Source

fix(printers): X2D aux fans named the right way round (issue #3373)

- On the X2D airduct part 2 is the left aux fan and part 10 the
  right one (Bambu Studio N6.json); the P2S numbers them the other
  way round, and its names were used for both
- X2D card: "Left Auxiliary Fan" (aux) then "Right Auxiliary Fan"
  (aux2), still left to right; P2S unchanged
- fan-speed route names the side per model (aux_fan_sides), and its
  description and error text say which side part 10 is
- API field left_aux_fan_speed and keys aux/aux2 kept for clients
- New i18n key printers.fans.rightAuxiliary in all 15 locales
- Reported by @Spegeli.
maziggy 3 days ago
parent
commit
2e18689226

+ 1 - 0
CHANGELOG.md

@@ -185,6 +185,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
+- **The X2D's auxiliary fans are named the right way round (#3373, reported by @Spegeli)** — On the X2D, the printer card's **Left Auxiliary Fan** drove the fan on the right of the chamber, and **Auxiliary Fan** the one on the left. The names came from the P2S, which numbers these two fans the other way round. On an X2D the card now shows **Left Auxiliary Fan** then **Right Auxiliary Fan**, still left to right, and the confirmation after setting a speed names the same side. The P2S is unchanged. The API keeps its field and fan names (`left_aux_fan_speed`, `aux`, `aux2`): on an X2D, `aux2` and `left_aux_fan_speed` are the right fan. Wiki updated.
 - **The full-page G-code preview draws on wide screens (#3371, reported by @Underscan007)** — On a window 1024 px wide or more, with classic (non-overlay) scrollbars, **3D Preview** stayed black while the page scrollbar flickered on and off. The canvas was briefly wider than its pane after each resize and pushed the page into scrolling, which narrowed the pane again, without end. The preview is now clipped to its pane, so it settles at once and draws.
 - **A print that finishes while Bambuddy restarts is archived as completed (#3310, reported by @Thomansky)** — When a print ended while Bambuddy was down (a container update, a host reboot), the printer's FINISH arrived as the first status of a new connection, so it never counted as the print ending. The reconnect then closed the archive as aborted with "No status update", logged it with no print time, cancelled its queue job and sent "print stopped". Now, when the printer still reports the archive's own job (the same job ID) as finished, the archive is closed as completed, with the library print count, batch run, finish notification and smart-plug auto-off that go with it. Its end time is the start plus the slicer's estimate for the plate that printed, never later than the reconnect, since the printer doesn't say when it finished. That time counts in the print-time totals but not in time accuracy, on the archive card or in Statistics. A failed job is closed as failed, with the printer's error as the reason when it has one. When the printer is idle, shows another job or reports no job ID, the archive is closed as aborted, as before.
 - **Home Assistant's connection test names a certificate problem (#3300)** — After Home Assistant moved to https, **Test Connection** said "Could not connect to Home Assistant" when Home Assistant was up and answering, because a certificate Bambuddy does not trust fails like an address nothing answers on. It now says the certificate is not trusted, why (self-signed or from your own authority, issued for a hostname while the URL uses the IP address, or expired) and what to do, and says when https was used on a port that serves plain http. The Smart Plugs page of the wiki explains how to add your own certificate authority, also on a native install.

+ 17 - 10
backend/app/api/routes/printers.py

@@ -112,7 +112,7 @@ from backend.app.utils.filament_types import is_material_name, printer_filament_
 from backend.app.utils.fts_routing import slot_extruder
 from backend.app.utils.http import build_content_disposition, download_error_response, safe_download_filename
 from backend.app.utils.kprofile_lookup import build_slot_k_resolver
-from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C, uses_exhaust_fan_label
+from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C, aux_fan_sides, uses_exhaust_fan_label
 
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["printers"])
@@ -3785,16 +3785,20 @@ async def set_chamber_temperature(
 @router.post("/{printer_id}/fan-speed")
 async def set_fan_speed(
     printer_id: int,
-    fan: str = Query(..., description="Fan to control: part, aux, aux2 (left aux), or chamber"),
+    fan: str = Query(
+        ...,
+        description="Fan to control: part, aux, aux2 (the second aux fan: left on the P2S, right on the X2D), or chamber",
+    ),
     speed: int = Query(..., ge=0, le=100, description="Fan speed percentage"),
     _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db, scope="function"),
 ):
     """Set a fan speed by percentage.
 
-    Fan index 10 ("aux2") is the optional left auxiliary part cooling fan on
-    P2S/X2D — driven with "M106 P10" exactly like Bambu's official machine
-    profile gcode does. It only exists when the printer reports airduct part 10,
+    Fan index 10 ("aux2") is the second auxiliary part cooling fan: the left
+    one on the P2S (an accessory kit), the right one on the X2D, where index 2
+    ("aux") is the left (#3373). Driven with "M106 P10" exactly like Bambu's
+    official machine profile gcode does. It only exists when the printer reports airduct part 10,
     so the request is rejected rather than sending M106 P10 into the void on a
     machine that has no such fan.
 
@@ -3823,9 +3827,9 @@ async def set_fan_speed(
     if fan == "aux2" and getattr(client.state, "left_aux_fan_speed", None) is None:
         raise HTTPException(
             400,
-            "This printer does not report a left auxiliary fan "
-            "(no airduct part 10). The fan is an accessory kit on the P2S "
-            "and factory-fitted on the X2D.",
+            "This printer does not report a second auxiliary fan "
+            "(no airduct part 10). It is the left fan, an accessory kit, "
+            "on the P2S, and the right fan, factory-fitted, on the X2D.",
         )
 
     pwm_speed = round(speed * 255 / 100)
@@ -3836,10 +3840,13 @@ async def set_fan_speed(
     # The enclosure fan is called "Exhaust" on P2S/X2D and "Chamber" elsewhere;
     # match whatever the printer card badge shows so the toast agrees with the
     # control the user just clicked.
+    # The aux fans' sides depend on the model (#3373); the P2S's right aux fan
+    # keeps its plain name, as on the printer card.
+    aux_side, aux2_side = aux_fan_sides(printer.model)
     fan_names = {
         "part": "Part cooling fan",
-        "aux": "Auxiliary fan",
-        "aux2": "Left auxiliary fan",
+        "aux": "Left auxiliary fan" if aux_side == "left" else "Auxiliary fan",
+        "aux2": f"{aux2_side.capitalize()} auxiliary fan",
         "chamber": "Exhaust fan" if uses_exhaust_fan_label(printer.model) else "Chamber fan",
     }
     return {"success": True, "message": f"{fan_names[fan]} set to {speed}%"}

+ 3 - 1
backend/app/schemas/printer.py

@@ -450,7 +450,9 @@ class PrinterStatus(BaseModel):
     big_fan1_speed: int | None = None  # Auxiliary fan
     big_fan2_speed: int | None = None  # Chamber/exhaust fan
     heatbreak_fan_speed: int | None = None  # Hotend heatbreak fan
-    # Left auxiliary part cooling fan (optional P2S/X2D accessory, airduct part id 10).
+    # Second auxiliary part cooling fan (airduct part id 10). Named for the P2S,
+    # where it is the left fan (an accessory kit); on the X2D it is the RIGHT one
+    # and big_fan1_speed (part 2) the left (#3373). The name stays for API clients.
     # None = not installed / not reported by this model.
     left_aux_fan_speed: int | None = None
     # Chamber exhaust fan present (P2S/X2D External Exhaust Fan kit; airduct part id 3).

+ 11 - 8
backend/app/services/bambu_mqtt.py

@@ -5063,11 +5063,13 @@ class BambuMQTTClient:
                 # Parse individual airduct fan parts (new-protocol models: P2S/X2D/H2*).
                 # Raw part ids are bit-packed — decoded id = raw_id >> 4 (bits 4-11),
                 # mirroring Bambu Studio DevFan::ParseV3_0. Decoded ids follow the
-                # AIR_FUN enum: 1=part cooling, 2=right aux, 3=chamber/exhaust,
-                # 10=left aux (FAN_REMOTE_COOLING_1). The airduct `parts` list only
+                # AIR_FUN enum: 1=part cooling, 2=aux, 3=chamber/exhaust,
+                # 10=second aux (FAN_REMOTE_COOLING_1). Aux 2 is the right fan and
+                # 10 the left on the P2S, the other way round on the X2D (#3373;
+                # Bambu Studio's N7.json / N6.json). The airduct `parts` list only
                 # contains the fans that physically exist, so it doubles as a
                 # presence signal for the two P2S/X2D add-on kits:
-                #   - id 10 (left auxiliary part cooling fan) — reported ONLY here,
+                #   - id 10 (second auxiliary part cooling fan) — reported ONLY here,
                 #     never mirrored into a flat big_fanX_speed field.
                 #   - id 3 (chamber exhaust fan) — its speed is mirrored into
                 #     big_fan2_speed, but the part is only listed when the External
@@ -5097,7 +5099,7 @@ class BambuMQTTClient:
                             continue
                         # Ids seen across the support-package archive:
                         #   1 part cooling, 2 aux, 3 chamber/exhaust,
-                        #   6 (H2 series, unmapped), 10 left aux.
+                        #   6 (H2 series, unmapped), 10 second aux.
                         speeds[part_id] = max(0, min(100, part_state))
 
                     # Absence in this list is what tells us a kit is NOT fitted,
@@ -5120,7 +5122,7 @@ class BambuMQTTClient:
 
                     left_aux_speed = speeds.get(10)
                     if left_aux_speed is None and not is_full_inventory:
-                        # Partial frame that didn't mention the left aux fan —
+                        # Partial frame that didn't mention the second aux fan —
                         # keep whatever we already knew about it.
                         left_aux_speed = self.state.left_aux_fan_speed
                     if left_aux_speed != self.state.left_aux_fan_speed:
@@ -5128,7 +5130,7 @@ class BambuMQTTClient:
                             f"[{self.serial_number}] left_aux_fan_speed changed: "
                             f"{self.state.left_aux_fan_speed} -> {left_aux_speed}"
                         )
-                    # A FULL parts list without id 10 means the left aux fan is
+                    # A FULL parts list without id 10 means the second aux fan is
                     # not installed — report None so the UI can hide the widget.
                     self.state.left_aux_fan_speed = left_aux_speed
                     # id 3 present == chamber exhaust fan installed (base P2S
@@ -7788,8 +7790,9 @@ class BambuMQTTClient:
         """Set fan speed.
 
         Args:
-            fan: Fan index (1=part cooling, 2=auxiliary, 3=chamber, 10=left auxiliary).
-                Index 10 is the optional left auxiliary part cooling fan on P2S/X2D
+            fan: Fan index (1=part cooling, 2=auxiliary, 3=chamber, 10=second auxiliary).
+                Index 10 is the second auxiliary part cooling fan: left on the P2S
+                (an accessory kit), right on the X2D, where 2 is the left (#3373)
                 (airduct part id 10); Bambu's official machine profiles drive it with
                 "M106 P10" in start/layer-change gcode.
             speed: Speed 0-255 (0=off, 255=full)

+ 15 - 0
backend/app/utils/printer_models.py

@@ -301,6 +301,21 @@ def uses_exhaust_fan_label(model: str | None) -> bool:
     return normalized in EXHAUST_FAN_LABEL_MODELS
 
 
+# Models whose airduct part 10 is the RIGHT auxiliary fan and part 2 the left
+# one (#3373). Bambu Studio names the fans per model (resources/printers/*.json):
+# on the X2D (N6) "2" is Left(Aux) and "10" Right(Aux); on the P2S (N7) it is
+# the other way round.
+AUX_FANS_LEFT_FIRST_MODELS = frozenset(["X2D", "N6"])
+
+
+def aux_fan_sides(model: str | None) -> tuple[str, str]:
+    """Which side the ``aux`` (part 2) and ``aux2`` (part 10) fans are on: ("left"|"right", ...)."""
+    normalized = (model or "").strip().upper().replace(" ", "").replace("-", "")
+    if normalized in AUX_FANS_LEFT_FIRST_MODELS:
+        return "left", "right"
+    return "right", "left"
+
+
 # Ceiling for every chamber-temperature target the UI and API accept (manual
 # M141, the preheat filament map, the per-item preheat override, the chamber
 # quick-select presets). The H2 series (H2C / H2D / H2D Pro / H2S) and X2D

+ 28 - 1
backend/tests/integration/test_printers_api.py

@@ -4603,9 +4603,36 @@ class TestSetFanSpeedAPI:
             mock_pm.get_client.return_value = mock_client
             response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=aux2&speed=50")
         assert response.status_code == 400
-        assert "left auxiliary fan" in response.json()["detail"]
+        assert "second auxiliary fan" in response.json()["detail"]
         mock_client.set_fan_speed.assert_not_called()
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        "model,fan_name,expected",
+        [
+            # P2S: part 2 is the right fan, part 10 the left (an accessory kit).
+            ("P2S", "aux", "Auxiliary fan set to 50%"),
+            ("P2S", "aux2", "Left auxiliary fan set to 50%"),
+            # X2D the other way round (#3373), also under its internal code.
+            ("X2D", "aux", "Left auxiliary fan set to 50%"),
+            ("X2D", "aux2", "Right auxiliary fan set to 50%"),
+            ("N6", "aux2", "Right auxiliary fan set to 50%"),
+        ],
+    )
+    async def test_aux_fans_named_by_their_side_per_model(
+        self, async_client: AsyncClient, printer_factory, model, fan_name, expected
+    ):
+        printer = await printer_factory(name="P", model=model)
+        mock_client = MagicMock()
+        mock_client.set_fan_speed.return_value = True
+        mock_client.state.left_aux_fan_speed = 0
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan={fan_name}&speed=50")
+        assert response.status_code == 200
+        assert response.json()["message"] == expected
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_other_fans_unaffected_by_the_aux2_gate(self, async_client: AsyncClient, printer_factory):

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

@@ -422,6 +422,40 @@ describe('PrintersPage', () => {
       });
     });
 
+    it('names the X2D aux fans by their side, left first (#3373)', async () => {
+      // On the X2D part 2 ("aux") is the left fan and part 10 ("aux2") the
+      // right one: Bambu Studio's N6.json, the other way round from the P2S.
+      renderWithStatus(
+        { ...mockPrinters[0], model: 'X2D' },
+        { ...statusWithFans, left_aux_fan_speed: 80, exhaust_fan_present: true },
+      );
+
+      await waitFor(() => {
+        expect(screen.getByTitle('Right Auxiliary Fan')).toBeInTheDocument();
+      });
+      expect(screen.queryByTitle('Auxiliary Fan')).not.toBeInTheDocument();
+
+      const order = ['Part Cooling Fan', 'Left Auxiliary Fan', 'Right Auxiliary Fan', 'Exhaust'].map(
+        (title) => screen.getByTitle(title),
+      );
+      for (let i = 1; i < order.length; i++) {
+        expect(order[i - 1].compareDocumentPosition(order[i])).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
+      }
+    });
+
+    it('keeps the P2S names: no right aux badge', async () => {
+      renderWithStatus(
+        { ...mockPrinters[0], model: 'P2S' },
+        { ...statusWithFans, left_aux_fan_speed: 80 },
+      );
+
+      await waitFor(() => {
+        expect(screen.getByTitle('Left Auxiliary Fan')).toBeInTheDocument();
+      });
+      expect(screen.getByTitle('Auxiliary Fan')).toBeInTheDocument();
+      expect(screen.queryByTitle('Right Auxiliary Fan')).not.toBeInTheDocument();
+    });
+
     it('shows left aux fan badge even at 0% while installed', async () => {
       renderWithStatus(
         { ...mockPrinters[0], model: 'P2S' },

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

@@ -884,6 +884,7 @@ export default {
       partCooling: 'Bauteilkühlung',
       auxiliary: 'Hilfsventilator',
       leftAuxiliary: 'Linker Hilfsventilator',
+      rightAuxiliary: 'Rechter Hilfsventilator',
       exhaust: 'Abluft',
       chamber: 'Kammerventilator',
     },

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

@@ -891,6 +891,7 @@ export default {
       partCooling: 'Part Cooling Fan',
       auxiliary: 'Auxiliary Fan',
       leftAuxiliary: 'Left Auxiliary Fan',
+      rightAuxiliary: 'Right Auxiliary Fan',
       exhaust: 'Exhaust',
       chamber: 'Chamber Fan',
     },

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

@@ -884,6 +884,7 @@ export default {
       partCooling: 'Ventilador de refrigeración de piezas',
       auxiliary: 'Ventilador auxiliar',
       leftAuxiliary: 'Ventilador auxiliar izquierdo',
+      rightAuxiliary: 'Ventilador auxiliar derecho',
       exhaust: 'Extracción',
       chamber: 'Ventilador de la cámara',
     },

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

@@ -884,6 +884,7 @@ export default {
       partCooling: 'Ventilateur pièce',
       auxiliary: 'Ventilateur auxiliaire',
       leftAuxiliary: 'Ventilateur auxiliaire gauche',
+      rightAuxiliary: 'Ventilateur auxiliaire droit',
       exhaust: 'Extraction',
       chamber: 'Ventilateur chambre',
     },

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

@@ -884,6 +884,7 @@ export default {
       partCooling: 'Ventola raffreddamento parte',
       auxiliary: 'Ventola ausiliaria',
       leftAuxiliary: 'Ventola ausiliaria sinistra',
+      rightAuxiliary: 'Ventola ausiliaria destra',
       exhaust: 'Estrazione',
       chamber: 'Ventola camera',
     },

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

@@ -883,6 +883,7 @@ export default {
       partCooling: 'パーツ冷却ファン',
       auxiliary: '補助ファン',
       leftAuxiliary: '左補助ファン',
+      rightAuxiliary: '右補助ファン',
       exhaust: '排気',
       chamber: 'チャンバーファン',
     },

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

@@ -840,6 +840,7 @@ export default {
       partCooling: '파트 냉각 팬',
       auxiliary: '보조 팬',
       leftAuxiliary: '왼쪽 보조 팬',
+      rightAuxiliary: '오른쪽 보조 팬',
       exhaust: '배기',
       chamber: '챔버 팬'
     },

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

@@ -889,6 +889,7 @@ export default {
       partCooling: 'Onderdeelkoelventilator',
       auxiliary: 'Hulpventilator',
       leftAuxiliary: 'Linker hulpventilator',
+      rightAuxiliary: 'Rechter hulpventilator',
       exhaust: 'Afvoer',
       chamber: 'Kamerventilator',
     },

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

@@ -884,6 +884,7 @@ export default {
       partCooling: 'Ventilador de resfriamento da peça',
       auxiliary: 'Ventilador auxiliar',
       leftAuxiliary: 'Ventilador auxiliar esquerdo',
+      rightAuxiliary: 'Ventilador auxiliar direito',
       exhaust: 'Exaustão',
       chamber: 'Ventilador da câmara',
     },

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

@@ -845,6 +845,7 @@ export default {
       partCooling: "Вентилятор обдува модели",
       auxiliary: "Дополнительный вентилятор",
       leftAuxiliary: "Левый дополнительный вентилятор",
+      rightAuxiliary: "Правый дополнительный вентилятор",
       exhaust: "Вытяжка",
       chamber: "Вентилятор камеры",
     },

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

@@ -889,6 +889,7 @@ export default {
       partCooling: 'Delkylningsfläkt',
       auxiliary: 'Hjälpfläkt',
       leftAuxiliary: 'Vänster hjälpfläkt',
+      rightAuxiliary: 'Höger hjälpfläkt',
       exhaust: 'Frånluft',
       chamber: 'Kammarfläkt',
     },

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

@@ -884,6 +884,7 @@ export default {
       partCooling: 'Parça Soğutma Fanı',
       auxiliary: 'Yardımcı Fan',
       leftAuxiliary: 'Sol Yardımcı Fan',
+      rightAuxiliary: 'Sağ Yardımcı Fan',
       exhaust: 'Egzoz',
       chamber: 'Hazne Fanı',
     },

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

@@ -888,6 +888,7 @@ export default {
       partCooling: "Вентилятор охолодження моделі",
       auxiliary: "Допоміжний вентилятор",
       leftAuxiliary: "Лівий допоміжний вентилятор",
+      rightAuxiliary: "Правий допоміжний вентилятор",
       exhaust: "Витяжка",
       chamber: "Камерний вентилятор",
     },

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

@@ -884,6 +884,7 @@ export default {
       partCooling: '零件冷却风扇',
       auxiliary: '辅助风扇',
       leftAuxiliary: '左辅助风扇',
+      rightAuxiliary: '右辅助风扇',
       exhaust: '排气',
       chamber: '腔室风扇',
     },

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

@@ -884,6 +884,7 @@ export default {
       partCooling: '零件冷卻風扇',
       auxiliary: '輔助風扇',
       leftAuxiliary: '左輔助風扇',
+      rightAuxiliary: '右輔助風扇',
       exhaust: '排氣',
       chamber: '腔室風扇',
     },

+ 35 - 23
frontend/src/pages/PrintersPage.tsx

@@ -1727,6 +1727,12 @@ const MODELS_WITH_EXHAUST_LABEL: ReadonlySet<string> = new Set([
   'X2D',
 ]);
 
+// Models whose aux fan (airduct part 2) is the LEFT one and part 10 the right
+// (#3373). Bambu Studio names them per model: on the X2D (N6) "2" is Left(Aux)
+// and "10" Right(Aux); on the P2S (N7) it is the other way round. Display
+// names, like MODELS_WITH_EXHAUST_LABEL above.
+const MODELS_WITH_LEFT_AUX_FIRST: ReadonlySet<string> = new Set(['X2D']);
+
 // ─── AMS Name Hover Card ──────────────────────────────────────────────────────
 // Wraps the AMS label (e.g. "AMS-A") and shows a popup with:
 //  • User-defined friendly name (editable, protected by printers:update)
@@ -4573,6 +4579,31 @@ function PrinterCard({
               // P2S/X2D entries in MODELS_WITH_CHAMBER_FAN became unreachable,
               // which reads as if removing them were safe.
               const showChamberFan = hasChamberFan && (!isExhaustModel || status.exhaust_fan_present);
+              // The second auxiliary part cooling fan (airduct part id 10):
+              // the left one on the P2S (an accessory kit), the right one on
+              // the X2D, where part 2 is the left. Only reported (non-null)
+              // when the firmware lists part 10, i.e. when the fan is fitted.
+              const leftAuxFirst = MODELS_WITH_LEFT_AUX_FIRST.has(printer.model ?? '');
+              const auxFan = {
+                key: 'aux',
+                label: leftAuxFirst ? t('printers.fans.leftAuxiliary') : t('printers.fans.auxiliary'),
+                value: status.big_fan1_speed ?? 0,
+                Icon: Wind,
+                activeClass: 'text-blue-600 dark:text-blue-400',
+              };
+              const aux2Fan =
+                status.left_aux_fan_speed != null
+                  ? {
+                      key: 'aux2',
+                      label: leftAuxFirst ? t('printers.fans.rightAuxiliary') : t('printers.fans.leftAuxiliary'),
+                      value: status.left_aux_fan_speed,
+                      Icon: Wind,
+                      activeClass: 'text-indigo-600 dark:text-indigo-400',
+                    }
+                  : null;
+              const auxFanItems = (leftAuxFirst ? [auxFan, aux2Fan] : [aux2Fan, auxFan]).filter(
+                (item): item is typeof auxFan => item !== null,
+              );
               const fanItems: {
                 key: string;
                 label: string;
@@ -4587,29 +4618,10 @@ function PrinterCard({
                   Icon: Fan,
                   activeClass: 'text-cyan-600 dark:text-cyan-400',
                 },
-                // Left auxiliary part cooling fan (optional P2S/X2D accessory).
-                // Only reported (non-null) when the firmware lists airduct part
-                // id 10, i.e. when the fan is physically installed. Placed
-                // before the right-hand auxiliary fan so the two aux badges read
-                // left-to-right in the same order as the physical hardware.
-                ...(status.left_aux_fan_speed != null
-                  ? [
-                      {
-                        key: 'aux2',
-                        label: t('printers.fans.leftAuxiliary'),
-                        value: status.left_aux_fan_speed,
-                        Icon: Wind,
-                        activeClass: 'text-indigo-600 dark:text-indigo-400',
-                      },
-                    ]
-                  : []),
-                {
-                  key: 'aux',
-                  label: t('printers.fans.auxiliary'),
-                  value: status.big_fan1_speed ?? 0,
-                  Icon: Wind,
-                  activeClass: 'text-blue-600 dark:text-blue-400',
-                },
+                // The two aux badges read left-to-right, in the same order as
+                // the physical hardware, so which comes first depends on the
+                // model (#3373).
+                ...auxFanItems,
                 ...(showChamberFan
                   ? [
                       {