Browse Source

feat(printer): add X2D support — camera, dual-nozzle, K-profile, maintenance (#988)

  The Bambu Lab X2D (launched April 2026, dual-nozzle, enclosed, hardened
  steel rod gantry, AMS 2 Pro compatible) identifies itself as internal
  model code N6 via SSDP/MQTT, and real serials begin with 20P9. None of
  these identifiers existed in Bambuddy's registries, so the camera
  service fell back to the chamber-image protocol on port 6000 (X2D
  doesn't speak it), firmware-check logged "Unknown printer model: N6",
  and the dual-nozzle K-profile paths — gated on the H2D serial prefix
  "094" — would have treated X2D as single-nozzle.

  Backend:
  - Register N6 → X2D across every registry (PRINTER_MODEL_ID_MAP,
    PRINTER_MODEL_MAP, STEEL_ROD_MODELS, ETHERNET_MODELS,
    CHAMBER_TEMP_SUPPORTED_MODELS, firmware-check API keys + wiki path,
    virtual-printer SSDP/product/serial tables, DB vp_model_fixes).
  - supports_rtsp(): match the X2 display-name prefix and the N6 internal
    code; camera now routes to RTSP on port 322.
  - Dual-nozzle serial prefix check in bambu_mqtt.delete_kprofile and
    kprofiles.set_kprofile broadened to ("094", "20P9") — X2D now takes
    the H2D-style cali_idx in-place edit path.
  - is_h2d model gate in bambu_mqtt.start_print extended with "X2D" so
    timelapse / bed_leveling / flow_cali / vibration_cali / layer_inspect
    are sent as integers and external-spool ams_id 254/255 routing is
    preserved (H2D-style deputy-nozzle addressing).

  X2D uses hardened steel rods like P2S — it is intentionally placed in
  STEEL_ROD_MODELS, not CARBON_ROD_MODELS. A regression-guard test pins
  the classification.

  Frontend:
  - mapModelCode in PrintersPage and SpoolBuddyAmsPage handle N6 and X2D.
  - Enclosure-door badge and airduct-mode whitelists include X2D.
  - MaintenancePage.getMaintenanceWikiUrl routes X2D to P2S wiki URLs for
    steel-rod lubrication, belt tension, cold-pull, and PTFE tube
    (exported to enable direct unit testing).

  Tests:
  - test_printer_models.py: TestX2DModel (10 assertions).
  - test_bambu_mqtt.py: X2D in start_print ams_mapping and is_h2d gate;
    TestDeleteKProfileDualNozzleDetection across H2D, X2D, P2S, X1C.
  - MaintenancePageWikiUrls.test.tsx: 15 assertions covering X2D, P2S
    regression, X1C/H2D/A1Mini regression, and model-name normalisation.

  Docs:
  - README: added X2 series to the supported printers table.
  - CHANGELOG: new entry under 0.2.3b4 Fixed.

  Credit to @krautech for the report and debug bundle, and to @legend813
  for PR #989 which seeded most of the registry changes — rod-type
  classification was corrected (steel, not carbon) and the dual-nozzle /
  K-profile / is_h2d gaps were added on top.
maziggy 1 month ago
parent
commit
6fb814c5ea

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 1 - 0
README.md

@@ -597,6 +597,7 @@ Full documentation available at **[wiki.bambuddy.cool](http://wiki.bambuddy.cool
 | Series | Models |
 |--------|--------|
 | X1 | X1, X1 Carbon, X1E |
+| X2 | X2D |
 | H2 | H2D, H2D Pro, H2C, H2S |
 | P1 | P1P, P1S |
 | P2 | P2S |

+ 3 - 2
backend/app/api/routes/kprofiles.py

@@ -113,8 +113,9 @@ async def set_kprofile(
     if not client or not client.state.connected:
         raise HTTPException(400, "Printer not connected")
 
-    # Detect H2D by serial number prefix
-    is_h2d = printer.serial_number.startswith("094")
+    # Detect dual-nozzle families by serial number prefix
+    # H2D series: "094"; X2D series: "20P9"
+    is_h2d = printer.serial_number.startswith(("094", "20P9"))
 
     if is_edit and is_h2d:
         # H2D in-place edit: use cali_idx with slot_id=0 and empty setting_id

+ 1 - 0
backend/app/core/database.py

@@ -1250,6 +1250,7 @@ async def run_migrations(conn):
         "X1C": "BL-P001",
         "X1": "BL-P002",
         "X1E": "C13",
+        "X2D": "N6",
         "P1P": "C11",
         "P1S": "C12",
         "P2S": "N7",

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

@@ -2927,7 +2927,7 @@ class BambuMQTTClient:
             # but use_ams MUST remain boolean — H2D Pro firmware interprets integer
             # values as nozzle index (1 = deputy nozzle), causing wrong extruder routing
             # Other printers (X1C, P1S, A1, etc.) require actual booleans for all fields
-            is_h2d = self.model and self.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "H2S")
+            is_h2d = self.model and self.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "H2S", "X2D")
 
             # Build ams_mapping2 from ams_mapping (detailed format with ams_id/slot_id)
             ams_mapping2 = []
@@ -3683,8 +3683,10 @@ class BambuMQTTClient:
         self._sequence_id += 1
 
         # Detect printer type by serial number prefix
-        # H2D series (dual nozzle): serial starts with "094"
-        is_dual_nozzle = self.serial_number.startswith("094")
+        # Dual-nozzle families:
+        #   H2D series: serial starts with "094"
+        #   X2D series: serial starts with "20P9"
+        is_dual_nozzle = self.serial_number.startswith(("094", "20P9"))
 
         if is_dual_nozzle:
             # H2D format: uses extruder_id, nozzle_id, nozzle_diameter

+ 7 - 6
backend/app/services/camera.py

@@ -1,7 +1,7 @@
 """Camera capture service for Bambu Lab printers.
 
 Supports two camera protocols:
-- RTSP: Used by X1, X1C, X1E, H2C, H2D, H2DPRO, H2S, P2S (port 322)
+- RTSP: Used by X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, P2S (port 322)
 - Chamber Image: Used by A1, A1MINI, P1P, P1S (port 6000, custom binary protocol)
 """
 
@@ -65,13 +65,14 @@ def get_ffmpeg_path() -> str | None:
 def supports_rtsp(model: str | None) -> bool:
     """Check if printer model supports RTSP camera streaming.
 
-    RTSP supported: X1, X1C, X1E, H2C, H2D, H2DPRO, H2S, P2S
+    RTSP supported: X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, P2S
     Chamber image only: A1, A1MINI, P1P, P1S
 
     Note: Model can be either display name (e.g., "P2S") or internal code (e.g., "N7").
     Internal codes from MQTT/SSDP:
       - BL-P001: X1/X1C
       - C13: X1E
+      - N6: X2D
       - O1D: H2D
       - O1C, O1C2: H2C
       - O1S: H2S
@@ -80,11 +81,11 @@ def supports_rtsp(model: str | None) -> bool:
     """
     if model:
         model_upper = model.upper()
-        # Display names: X1, X1C, X1E, H2C, H2D, H2DPRO, H2S, P2S
-        if model_upper.startswith(("X1", "H2", "P2")):
+        # Display names: X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, P2S
+        if model_upper.startswith(("X1", "X2", "H2", "P2")):
             return True
         # Internal codes for RTSP models
-        if model_upper in ("BL-P001", "C13", "O1D", "O1C", "O1C2", "O1S", "O1E", "O2D", "N7"):
+        if model_upper in ("BL-P001", "C13", "N6", "O1D", "O1C", "O1C2", "O1S", "O1E", "O2D", "N7"):
             return True
     # A1/P1 and unknown models use chamber image protocol
     return False
@@ -93,7 +94,7 @@ def supports_rtsp(model: str | None) -> bool:
 def get_camera_port(model: str | None) -> int:
     """Get the camera port based on printer model.
 
-    X1/H2/P2 series use RTSP on port 322.
+    X1/X2/H2/P2 series use RTSP on port 322.
     A1/P1 series use chamber image protocol on port 6000.
     """
     if supports_rtsp(model):

+ 4 - 0
backend/app/services/firmware_check.py

@@ -46,6 +46,7 @@ MODEL_TO_API_KEY = {
     "H2S": "h2s",
     "P2S": "p2s",
     "X1E": "x1e",
+    "X2D": "x2d",
     "H2D Pro": "h2d-pro",
     "H2D-Pro": "h2d-pro",
     "H2DPRO": "h2d-pro",
@@ -64,6 +65,7 @@ MODEL_TO_API_KEY = {
     "C13": "p2s",
     "N2S": "a1",
     "N1": "a1-mini",
+    "N6": "x2d",
     "N7": "p2s",
 }
 
@@ -78,6 +80,7 @@ API_KEY_TO_DEV_MODEL = {
     "h2s": "O1S",
     "p2s": "N7",
     "x1e": "C13",
+    "x2d": "N6",
     "h2d-pro": "O1E",
 }
 
@@ -92,6 +95,7 @@ API_KEY_TO_WIKI_PATH = {
     "h2c": "/en/h2c/manual/h2c-firmware-release-history",
     "h2s": "/en/h2s/manual/h2s-firmware-release-history",
     "p2s": "/en/p2s/manual/p2s-firmware-release-history",
+    "x2d": "/en/x2d/manual/x2d-firmware-release-history",
     "h2d-pro": "/en/h2d-pro/manual/firmware-release-history",
 }
 

+ 2 - 0
backend/app/services/printer_manager.py

@@ -21,6 +21,7 @@ CHAMBER_TEMP_SUPPORTED_MODELS = frozenset(
         "X1",
         "X1C",
         "X1E",  # X1 series
+        "X2D",  # X2 series
         "P2S",  # P2 series
         "H2C",
         "H2D",
@@ -29,6 +30,7 @@ CHAMBER_TEMP_SUPPORTED_MODELS = frozenset(
         # Internal codes (from MQTT/SSDP)
         "BL-P001",  # X1/X1C
         "C13",  # X1E
+        "N6",  # X2D
         "O1D",  # H2D
         "O1C",  # H2C
         "O1C2",  # H2C (dual nozzle variant)

+ 4 - 0
backend/app/services/virtual_printer/manager.py

@@ -31,6 +31,8 @@ VIRTUAL_PRINTER_MODELS = {
     "BL-P001": "X1C",  # X1 Carbon
     "BL-P002": "X1",  # X1
     "C13": "X1E",  # X1E
+    # X2 Series
+    "N6": "X2D",  # X2D
     # P Series
     "C11": "P1P",  # P1P
     "C12": "P1S",  # P1S
@@ -59,6 +61,8 @@ MODEL_SERIAL_PREFIXES = {
     "BL-P001": "00M00A",  # X1C
     "BL-P002": "00M00A",  # X1
     "C13": "03W00A",  # X1E
+    # X2 Series
+    "N6": "20P90A",  # X2D (first 4 chars "20P9" match real serials)
     # P Series
     "C11": "01S00A",  # P1P
     "C12": "01P00A",  # P1S

+ 1 - 0
backend/app/services/virtual_printer/mqtt_server.py

@@ -21,6 +21,7 @@ MODEL_PRODUCT_NAMES = {
     "BL-P001": "X1 Carbon",
     "BL-P002": "X1",
     "C13": "X1E",
+    "N6": "X2D",
     "C11": "P1P",
     "C12": "P1S",
     "N7": "P2S",

+ 9 - 2
backend/app/utils/printer_models.py

@@ -19,6 +19,7 @@ PRINTER_MODEL_MAP = {
     "Bambu Lab H2D Pro": "H2D Pro",
     "Bambu Lab H2C": "H2C",
     "Bambu Lab H2S": "H2S",
+    "Bambu Lab X2D": "X2D",
 }
 
 # Map from printer_model_id (internal codes in slice_info.config) to short names
@@ -33,6 +34,8 @@ PRINTER_MODEL_ID_MAP = {
     "P1S": "P1S",
     # P2 series
     "P2S": "P2S",
+    # X2 series
+    "N6": "X2D",
     # A1 series
     "A11": "A1",
     "A12": "A1 Mini",
@@ -51,7 +54,7 @@ PRINTER_MODEL_ID_MAP = {
 
 # Rod/rail type classification for maintenance tasks.
 # Carbon rods: X1, P1 series (CoreXY with carbon fiber rods)
-# Steel rods: P2S series (hardened steel linear shafts)
+# Steel rods: P2S, X2D series (hardened steel linear shafts)
 # Linear rails: A1, H2 series (linear rail motion system)
 # Values must be uppercase with spaces stripped for normalized comparison.
 CARBON_ROD_MODELS = frozenset(
@@ -73,8 +76,10 @@ STEEL_ROD_MODELS = frozenset(
     [
         # Display names (uppercase, no spaces)
         "P2S",
+        "X2D",
         # Internal codes
         "N7",  # P2S
+        "N6",  # X2D
     ]
 )
 
@@ -110,6 +115,7 @@ ETHERNET_MODELS = frozenset(
         # Display names (uppercase, no spaces)
         "X1C",
         "X1E",
+        "X2D",
         "P1S",
         "P2S",
         "H2D",
@@ -119,6 +125,7 @@ ETHERNET_MODELS = frozenset(
         # Internal codes
         "C11",  # X1C
         "C13",  # X1E
+        "N6",  # X2D
         "P1S",  # P1S
         "O1D",  # H2D
         "O1E",  # H2D Pro
@@ -143,7 +150,7 @@ def get_rod_type(model: str | None) -> str | None:
 
     Returns:
         "carbon" for X1/P1 series (carbon fiber rods),
-        "steel_rod" for P2S series (hardened steel rods),
+        "steel_rod" for P2S/X2D series (hardened steel rods),
         "linear_rail" for A1/H2 series (linear rails),
         None for unknown models.
     """

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

@@ -3354,6 +3354,127 @@ class TestStartPrintAmsMapping:
         assert "ams_mapping" not in cmd
         assert "ams_mapping2" not in cmd
 
+    def test_x2d_external_preserves_deputy_id(self, mqtt_client):
+        """X2D dual-nozzle (#988): 254 (deputy) stays 254, like H2D family.
+
+        X2D launched April 2026 and shares the H2D-style dual-extruder
+        firmware convention — external spool on the deputy (left) nozzle
+        is addressed as ams_id=254, not coerced to 255.
+        """
+        mqtt_client.model = "X2D"
+        mqtt_client.start_print("test.3mf", ams_mapping=[254, 255])
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["ams_mapping"] == [-1, -1]
+        assert cmd["ams_mapping2"] == [
+            {"ams_id": 254, "slot_id": 0},
+            {"ams_id": 255, "slot_id": 0},
+        ]
+
+    def test_x2d_uses_integer_format_for_calibration_fields(self, mqtt_client):
+        """X2D must use H2D-style integer (0/1) format for calibration fields (#988).
+
+        The reporter's support bundle showed X2D running firmware in the same
+        family as H2D. Booleans in these fields are interpreted as nozzle
+        indexes by H2D firmware; X2D is treated identically until proven
+        otherwise.
+        """
+        mqtt_client.model = "X2D"
+        mqtt_client.start_print(
+            "test.3mf",
+            timelapse=True,
+            bed_levelling=False,
+            flow_cali=True,
+            vibration_cali=False,
+            layer_inspect=True,
+        )
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["timelapse"] == 1
+        assert cmd["bed_leveling"] == 0
+        assert cmd["flow_cali"] == 1
+        assert cmd["vibration_cali"] == 0
+        assert cmd["layer_inspect"] == 1
+
+    def test_p2s_still_uses_boolean_format(self, mqtt_client):
+        """Regression guard: P2S is NOT in the is_h2d gate — must still use booleans.
+
+        Adding X2D to the is_h2d set must not accidentally affect P2S, which
+        is single-nozzle and uses boolean format like X1C/A1/P1.
+        """
+        mqtt_client.model = "P2S"
+        mqtt_client.start_print("test.3mf", timelapse=True, flow_cali=False)
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["timelapse"] is True
+        assert cmd["flow_cali"] is False
+
+
+class TestDeleteKProfileDualNozzleDetection:
+    """Regression guard: dual-nozzle detection by serial prefix (#988).
+
+    delete_kprofile branches on serial-prefix-derived dual-nozzle status.
+    H2D serials start with "094"; X2D serials start with "20P9". Non-dual
+    families (X1C "00M", P1S "01P", P2S "22E", A1 "039", etc.) must take
+    the single-nozzle branch.
+    """
+
+    def _make_client(self, serial: str):
+        from unittest.mock import MagicMock
+
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number=serial,
+            access_code="12345678",
+        )
+        client._client = MagicMock()
+        client.state.connected = True
+        return client
+
+    def _published(self, client):
+        return json.loads(client._client.publish.call_args[0][1])["print"]
+
+    def test_h2d_serial_uses_dual_nozzle_format(self):
+        client = self._make_client("09400A000000001")
+        client.delete_kprofile(cali_idx=1, filament_id="GFA00", nozzle_id="HH00-0.4")
+        cmd = self._published(client)
+        # Dual-nozzle command omits setting_id.
+        assert "setting_id" not in cmd
+        assert cmd["extruder_id"] == 0
+
+    def test_x2d_serial_uses_dual_nozzle_format(self):
+        client = self._make_client("20P90A000000001")
+        client.delete_kprofile(cali_idx=1, filament_id="GFA00", nozzle_id="HH00-0.4")
+        cmd = self._published(client)
+        assert "setting_id" not in cmd
+        assert cmd["extruder_id"] == 0
+
+    def test_p2s_serial_uses_single_nozzle_format(self):
+        """P2S is single-nozzle — must NOT take the dual-nozzle branch."""
+        client = self._make_client("22E00A000000001")
+        client.delete_kprofile(
+            cali_idx=1,
+            filament_id="GFA00",
+            nozzle_id="HH00-0.4",
+            setting_id="PFB123",
+        )
+        cmd = self._published(client)
+        # Single-nozzle command includes setting_id.
+        assert cmd["setting_id"] == "PFB123"
+
+    def test_x1c_serial_uses_single_nozzle_format(self):
+        client = self._make_client("00M00A000000001")
+        client.delete_kprofile(
+            cali_idx=1,
+            filament_id="GFA00",
+            nozzle_id="HH00-0.4",
+            setting_id="PFB123",
+        )
+        cmd = self._published(client)
+        assert cmd["setting_id"] == "PFB123"
+
 
 class TestStaleReconnect:
     """Tests for stale connection detection and reconnect without UI bouncing."""

+ 59 - 1
backend/tests/unit/test_printer_models.py

@@ -2,7 +2,15 @@
 
 import pytest
 
-from backend.app.utils.printer_models import get_rod_type
+from backend.app.services.camera import get_camera_port, supports_rtsp
+from backend.app.utils.printer_models import (
+    CARBON_ROD_MODELS,
+    STEEL_ROD_MODELS,
+    get_rod_type,
+    has_ethernet,
+    normalize_printer_model,
+    normalize_printer_model_id,
+)
 
 
 class TestGetRodType:
@@ -46,3 +54,53 @@ class TestGetRodType:
     def test_strips_whitespace_and_dashes(self):
         assert get_rod_type(" P2S ") == "steel_rod"
         assert get_rod_type("A1-Mini") == "linear_rail"
+
+
+class TestX2DModel:
+    """X2D printer support (issue #988).
+
+    The X2D is a dual-nozzle enclosed printer launched April 2026. It shares
+    the hardened steel rod hardware with P2S (NOT carbon rods) and uses
+    RTSP on port 322 like other X/H series printers. Internal SSDP/MQTT
+    model code is "N6"; serial numbers begin with "20P9".
+    """
+
+    def test_x2d_is_steel_rod_display_name(self):
+        assert get_rod_type("X2D") == "steel_rod"
+
+    def test_x2d_is_steel_rod_internal_code(self):
+        assert get_rod_type("N6") == "steel_rod"
+
+    def test_x2d_model_id_map(self):
+        assert normalize_printer_model_id("N6") == "X2D"
+
+    def test_x2d_model_map(self):
+        assert normalize_printer_model("Bambu Lab X2D") == "X2D"
+
+    def test_x2d_has_ethernet_display_name(self):
+        assert has_ethernet("X2D") is True
+
+    def test_x2d_has_ethernet_internal_code(self):
+        assert has_ethernet("N6") is True
+
+    def test_x2d_supports_rtsp_display_name(self):
+        assert supports_rtsp("X2D") is True
+
+    def test_x2d_supports_rtsp_internal_code(self):
+        assert supports_rtsp("N6") is True
+
+    def test_x2d_camera_port_is_rtsp(self):
+        assert get_camera_port("N6") == 322
+        assert get_camera_port("X2D") == 322
+
+    def test_x2d_not_in_carbon_rod_set(self):
+        """Regression guard: X2D has hardened steel rods, not carbon (#988).
+
+        A prior PR classified X2D as carbon; the reporter confirmed it uses
+        the same stainless steel rod gantry as P2S. This assertion pins the
+        classification so a future change that reverts it will fail loudly.
+        """
+        assert "X2D" not in CARBON_ROD_MODELS
+        assert "N6" not in CARBON_ROD_MODELS
+        assert "X2D" in STEEL_ROD_MODELS
+        assert "N6" in STEEL_ROD_MODELS

BIN
frontend/public/img/printers/x2d.png


+ 115 - 0
frontend/src/__tests__/utils/maintenanceWikiUrls.test.ts

@@ -0,0 +1,115 @@
+/**
+ * Unit tests for getMaintenanceWikiUrl — model-aware wiki URL resolver.
+ *
+ * Covers the X2D classification (#988): X2D has hardened steel rods like
+ * P2S, NOT carbon rods and NOT linear rails. It must resolve to the P2S
+ * wiki pages for steel-rod-specific tasks.
+ *
+ * Also guards against regressions for the existing families (X1, P1, A1,
+ * H2D, P2S) so that broadening the rod-type bucket for X2D did not
+ * accidentally change their mappings.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { getMaintenanceWikiUrl } from '../../utils/maintenanceWikiUrls';
+
+describe('getMaintenanceWikiUrl', () => {
+  describe('X2D (#988)', () => {
+    it('resolves "Lubricate Steel Rods" to the P2S wiki page', () => {
+      expect(getMaintenanceWikiUrl('Lubricate Steel Rods', 'X2D')).toBe(
+        'https://wiki.bambulab.com/en/p2s/maintenance/lubricate-x-y-z-axis',
+      );
+    });
+
+    it('resolves "Clean Steel Rods" to the P2S wiki page', () => {
+      expect(getMaintenanceWikiUrl('Clean Steel Rods', 'X2D')).toBe(
+        'https://wiki.bambulab.com/en/p2s/maintenance/lubricate-x-y-z-axis',
+      );
+    });
+
+    it('resolves belt tension to the P2S wiki page', () => {
+      expect(getMaintenanceWikiUrl('Check Belt Tension', 'X2D')).toBe(
+        'https://wiki.bambulab.com/en/p2s/maintenance/belt-tension',
+      );
+    });
+
+    it('resolves nozzle cold pull to the P2S wiki page', () => {
+      expect(getMaintenanceWikiUrl('Clean Nozzle/Hotend', 'X2D')).toBe(
+        'https://wiki.bambulab.com/en/p2s/maintenance/cold-pull-maintenance-hotend',
+      );
+    });
+
+    it('does not return a carbon-rod wiki URL for X2D', () => {
+      // "Clean Carbon Rods" is X1/P1-only; X2D must resolve to null so the
+      // task button renders without a link rather than pointing at the wrong page.
+      expect(getMaintenanceWikiUrl('Clean Carbon Rods', 'X2D')).toBeNull();
+    });
+
+    it('does not return a linear-rail wiki URL for X2D', () => {
+      // "Lubricate Linear Rails" is A1/H2-only.
+      expect(getMaintenanceWikiUrl('Lubricate Linear Rails', 'X2D')).toBeNull();
+    });
+  });
+
+  describe('regression: P2S still maps to P2S wiki pages', () => {
+    it('still resolves Lubricate Steel Rods for P2S', () => {
+      expect(getMaintenanceWikiUrl('Lubricate Steel Rods', 'P2S')).toBe(
+        'https://wiki.bambulab.com/en/p2s/maintenance/lubricate-x-y-z-axis',
+      );
+    });
+
+    it('still resolves belt tension for P2S', () => {
+      expect(getMaintenanceWikiUrl('Check Belt Tension', 'P2S')).toBe(
+        'https://wiki.bambulab.com/en/p2s/maintenance/belt-tension',
+      );
+    });
+  });
+
+  describe('regression: other families untouched', () => {
+    it('X1C belt tension unchanged', () => {
+      expect(getMaintenanceWikiUrl('Check Belt Tension', 'X1C')).toBe(
+        'https://wiki.bambulab.com/en/x1/maintenance/belt-tension',
+      );
+    });
+
+    it('H2D belt tension unchanged', () => {
+      expect(getMaintenanceWikiUrl('Check Belt Tension', 'H2D')).toBe(
+        'https://wiki.bambulab.com/en/h2/maintenance/belt-tension',
+      );
+    });
+
+    it('A1 Mini linear rails unchanged', () => {
+      expect(getMaintenanceWikiUrl('Lubricate Linear Rails', 'A1 Mini')).toBe(
+        'https://wiki.bambulab.com/en/a1-mini/maintenance/lubricate-y-axis',
+      );
+    });
+
+    it('X1C carbon rods unchanged', () => {
+      expect(getMaintenanceWikiUrl('Clean Carbon Rods', 'X1C')).toBe(
+        'https://wiki.bambulab.com/en/general/carbon-rods-clearance',
+      );
+    });
+
+    it('P2S still does not resolve linear-rail task', () => {
+      // Sanity check: the X2D broadening must not have widened P2S into
+      // unrelated task categories.
+      expect(getMaintenanceWikiUrl('Lubricate Linear Rails', 'P2S')).toBeNull();
+    });
+  });
+
+  describe('model name normalisation', () => {
+    it('matches X2D regardless of hyphens or spaces', () => {
+      expect(getMaintenanceWikiUrl('Lubricate Steel Rods', 'x-2d')).toBe(
+        'https://wiki.bambulab.com/en/p2s/maintenance/lubricate-x-y-z-axis',
+      );
+      expect(getMaintenanceWikiUrl('Lubricate Steel Rods', 'x 2d')).toBe(
+        'https://wiki.bambulab.com/en/p2s/maintenance/lubricate-x-y-z-axis',
+      );
+    });
+
+    it('returns null for empty model', () => {
+      expect(getMaintenanceWikiUrl('Lubricate Steel Rods', null)).toBeNull();
+      expect(getMaintenanceWikiUrl('Lubricate Steel Rods', '')).toBeNull();
+    });
+  });
+});

+ 75 - 0
frontend/src/__tests__/utils/printer.test.ts

@@ -0,0 +1,75 @@
+/**
+ * Tests for getPrinterImage — model → printer card image resolver.
+ *
+ * X2D support (#988): both the display name "X2D" and the internal SSDP
+ * code "N6" must resolve to /img/printers/x2d.png so the Printers page
+ * and PrinterInfoModal show the correct artwork instead of falling back
+ * to default.png.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { getPrinterImage } from '../../utils/printer';
+
+describe('getPrinterImage', () => {
+  describe('X2D (#988)', () => {
+    it('resolves display name "X2D" to x2d.png', () => {
+      expect(getPrinterImage('X2D')).toBe('/img/printers/x2d.png');
+    });
+
+    it('resolves case-insensitive variants', () => {
+      expect(getPrinterImage('x2d')).toBe('/img/printers/x2d.png');
+      expect(getPrinterImage(' X2D ')).toBe('/img/printers/x2d.png');
+    });
+
+    it('resolves the internal SSDP code "N6" to x2d.png', () => {
+      expect(getPrinterImage('N6')).toBe('/img/printers/x2d.png');
+    });
+
+    it('does not match X2D on unrelated model strings', () => {
+      // Regression guard: a hypothetical future "X2" model must not
+      // silently pick up x2d.png until it's explicitly mapped.
+      expect(getPrinterImage('X2E')).toBe('/img/printers/default.png');
+    });
+  });
+
+  describe('regression: existing families unchanged', () => {
+    it('X1C → x1c.png', () => {
+      expect(getPrinterImage('X1C')).toBe('/img/printers/x1c.png');
+    });
+
+    it('X1E → x1e.png', () => {
+      expect(getPrinterImage('X1E')).toBe('/img/printers/x1e.png');
+    });
+
+    it('H2D → h2d.png', () => {
+      expect(getPrinterImage('H2D')).toBe('/img/printers/h2d.png');
+    });
+
+    it('H2D Pro → h2dpro.png', () => {
+      expect(getPrinterImage('H2D Pro')).toBe('/img/printers/h2dpro.png');
+    });
+
+    it('P2S → p1s.png (shared with P1S)', () => {
+      // Pre-existing behaviour: P2S currently reuses the P1S artwork. Not
+      // changed by the X2D diff; asserted to catch accidental regressions.
+      expect(getPrinterImage('P2S')).toBe('/img/printers/p1s.png');
+    });
+
+    it('A1 Mini → a1mini.png (not a1.png)', () => {
+      // The "a1mini" branch must run before the generic "a1" branch —
+      // the X2D branch was inserted above both and must not break order.
+      expect(getPrinterImage('A1 Mini')).toBe('/img/printers/a1mini.png');
+    });
+
+    it('null / undefined → default.png', () => {
+      expect(getPrinterImage(null)).toBe('/img/printers/default.png');
+      expect(getPrinterImage(undefined)).toBe('/img/printers/default.png');
+    });
+
+    it('unknown model → default.png', () => {
+      expect(getPrinterImage('SomeFuturePrinter')).toBe(
+        '/img/printers/default.png',
+      );
+    });
+  });
+});

+ 1 - 95
frontend/src/pages/MaintenancePage.tsx

@@ -38,6 +38,7 @@ import {
 } from 'lucide-react';
 import { api } from '../api/client';
 import type { MaintenanceStatus, PrinterMaintenanceOverview, MaintenanceType, Permission } from '../api/client';
+import { getMaintenanceWikiUrl } from '../utils/maintenanceWikiUrls';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Toggle } from '../components/Toggle';
@@ -125,101 +126,6 @@ function formatIntervalLabel(value: number, type: 'hours' | 'days', t?: TFunctio
   return `${value}h`;
 }
 
-// Get Bambu Lab wiki URL for a maintenance task based on printer model
-function getMaintenanceWikiUrl(typeName: string, printerModel: string | null): string | null {
-  const model = (printerModel || '').toUpperCase().replace(/[- ]/g, '');
-
-  // Helper to match model families
-  const isX1 = model.includes('X1');
-  const isP1 = model.includes('P1');
-  const isA1Mini = model.includes('A1MINI');
-  const isA1 = model.includes('A1') && !isA1Mini;
-  const isH2D = model.includes('H2D');
-  const isH2C = model.includes('H2C');
-  const isH2S = model.includes('H2S');
-  const isH2 = isH2D || isH2C || isH2S;
-  const isP2S = model.includes('P2S');
-
-  switch (typeName) {
-    case 'Lubricate Steel Rods':
-      // P2S has hardened steel rods
-      if (isP2S) return 'https://wiki.bambulab.com/en/p2s/maintenance/lubricate-x-y-z-axis';
-      return null;
-
-    case 'Clean Steel Rods':
-      // P2S has hardened steel rods
-      if (isP2S) return 'https://wiki.bambulab.com/en/p2s/maintenance/lubricate-x-y-z-axis';
-      return null;
-
-    case 'Lubricate Linear Rails':
-      // A1 and H2 series have linear rails
-      if (isA1Mini) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/lubricate-y-axis';
-      if (isA1) return 'https://wiki.bambulab.com/en/a1/maintenance/lubricate-y-axis';
-      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication';
-      return null;
-
-    case 'Clean Nozzle/Hotend':
-      if (isX1 || isP1) return 'https://wiki.bambulab.com/en/x1/troubleshooting/nozzle-clog';
-      if (isA1Mini || isA1) return 'https://wiki.bambulab.com/en/a1-mini/troubleshooting/nozzle-clog';
-      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/nozzl-cold-pull-maintenance-and-cleaning';
-      if (isP2S) return 'https://wiki.bambulab.com/en/p2s/maintenance/cold-pull-maintenance-hotend';
-      return 'https://wiki.bambulab.com/en/x1/troubleshooting/nozzle-clog';
-
-    case 'Check Belt Tension':
-      if (isX1) return 'https://wiki.bambulab.com/en/x1/maintenance/belt-tension';
-      if (isP1) return 'https://wiki.bambulab.com/en/p1/maintenance/p1p-maintenance';
-      if (isA1Mini) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/belt_tension';
-      if (isA1) return 'https://wiki.bambulab.com/en/a1/maintenance/belt_tension';
-      if (isH2D) return 'https://wiki.bambulab.com/en/h2/maintenance/belt-tension';
-      if (isH2C) return 'https://wiki.bambulab.com/en/h2c/maintenance/belt-tension';
-      if (isH2S) return 'https://wiki.bambulab.com/en/h2s/maintenance/belt-tension';
-      if (isP2S) return 'https://wiki.bambulab.com/en/p2s/maintenance/belt-tension';
-      return 'https://wiki.bambulab.com/en/x1/maintenance/belt-tension';
-
-    case 'Clean Carbon Rods':
-      // X1, P1 series have carbon rods
-      if (isX1 || isP1) return 'https://wiki.bambulab.com/en/general/carbon-rods-clearance';
-      return null;
-
-    case 'Clean Linear Rails':
-      // A1 and H2 series have linear rails
-      if (isA1Mini) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/lubricate-y-axis';
-      if (isA1) return 'https://wiki.bambulab.com/en/a1/maintenance/lubricate-y-axis';
-      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication';
-      return null;
-
-    case 'Clean Build Plate':
-      // Same for all printers
-      return 'https://wiki.bambulab.com/en/filament-acc/acc/pei-plate-clean-guide';
-
-    case 'Check PTFE Tube':
-      if (isX1 || isP1) return 'https://wiki.bambulab.com/en/x1/maintenance/replace-ptfe-tube';
-      if (isA1Mini || isA1) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/ptfe-tube';
-      if (isH2D) return 'https://wiki.bambulab.com/en/h2/maintenance/replace-ptfe-tube-on-h2d-printer';
-      if (isH2S) return 'https://wiki.bambulab.com/en/h2s/maintenance/replace-ptfe-tube-on-h2s-printer';
-      if (isH2C) return 'https://wiki.bambulab.com/en/h2/maintenance/replace-ptfe-tube-on-h2d-printer'; // H2C uses H2D guide
-      if (isP2S) return 'https://wiki.bambulab.com/en/x1/maintenance/replace-ptfe-tube'; // P2S uses similar PTFE
-      return 'https://wiki.bambulab.com/en/x1/maintenance/replace-ptfe-tube';
-
-    case 'Replace HEPA Filter':
-    case 'HEPA Filter':
-    case 'Replace Carbon Filter':
-    case 'Carbon Filter':
-      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/replace-smoke-purifier-air-filte';
-      // X1/P1 use the activated carbon filter
-      return 'https://wiki.bambulab.com/en/x1/maintenance/replace-carbon-filter';
-
-    case 'Lubricate Left Nozzle Rail':
-    case 'Left Nozzle Rail':
-      // H2 series specific - dual nozzle system
-      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication';
-      return null;
-
-    default:
-      // Custom maintenance types don't have wiki URLs
-      return null;
-  }
-}
 
 // Maintenance item card - cleaner, more visual design
 function MaintenanceCard({

+ 7 - 4
frontend/src/pages/PrintersPage.tsx

@@ -1059,6 +1059,8 @@ function mapModelCode(ssdpModel: string | null): string {
     'BL-P001': 'X1C',
     'BL-P002': 'X1',
     'BL-P003': 'X1E',
+    // X2 Series
+    'N6': 'X2D',
     // P Series
     'C11': 'P1S',
     'C12': 'P1P',
@@ -1070,6 +1072,7 @@ function mapModelCode(ssdpModel: string | null): string {
     'X1C': 'X1C',
     'X1': 'X1',
     'X1E': 'X1E',
+    'X2D': 'X2D',
     'P1S': 'P1S',
     'P1P': 'P1P',
     'P2S': 'P2S',
@@ -2508,8 +2511,8 @@ function PrinterCard({
                 </span>
               ) : null}
 
-              {/* Enclosure Door Badge (X1/P1S/P2S/H2*) */}
-              {status?.connected && ['X1C', 'X1', 'X1E', 'P1S', 'P1P', 'P2S', 'H2D', 'H2D Pro', 'H2C', 'H2S'].includes(printer.model ?? '') && (
+              {/* Enclosure Door Badge (X1/X2D/P1S/P2S/H2*) */}
+              {status?.connected && ['X1C', 'X1', 'X1E', 'X2D', 'P1S', 'P1P', 'P2S', 'H2D', 'H2D Pro', 'H2C', 'H2S'].includes(printer.model ?? '') && (
                 <span
                   className={`flex items-center px-2 py-1 rounded-full text-xs ${
                     status.door_open
@@ -2877,8 +2880,8 @@ function PrinterCard({
                       {/* Separator */}
                       <div className="w-px h-5 bg-bambu-gray/30" />
 
-                      {/* Airduct Mode (P2S / H2*) */}
-                      {(['P2S', 'H2D', 'H2C', 'H2S'].includes(printer.model ?? '')) && (() => {
+                      {/* Airduct Mode (P2S / X2D / H2*) */}
+                      {(['P2S', 'X2D', 'H2D', 'H2C', 'H2S'].includes(printer.model ?? '')) && (() => {
                         const isHeating = status.airduct_mode === 1;
                         const Icon = isHeating ? Flame : Snowflake;
                         const color = isHeating ? 'text-orange-400' : 'text-sky-400';

+ 2 - 1
frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx

@@ -25,9 +25,10 @@ function mapModelCode(ssdpModel: string | null): string {
   const modelMap: Record<string, string> = {
     'O1D': 'H2D', 'O1E': 'H2D Pro', 'O2D': 'H2D Pro', 'O1C': 'H2C', 'O1C2': 'H2C', 'O1S': 'H2S',
     'BL-P001': 'X1C', 'BL-P002': 'X1', 'BL-P003': 'X1E',
+    'N6': 'X2D',
     'C11': 'P1S', 'C12': 'P1P', 'C13': 'P2S',
     'N2S': 'A1', 'N1': 'A1 Mini',
-    'X1C': 'X1C', 'X1': 'X1', 'X1E': 'X1E', 'P1S': 'P1S', 'P1P': 'P1P', 'P2S': 'P2S',
+    'X1C': 'X1C', 'X1': 'X1', 'X1E': 'X1E', 'X2D': 'X2D', 'P1S': 'P1S', 'P1P': 'P1P', 'P2S': 'P2S',
     'A1': 'A1', 'A1 Mini': 'A1 Mini', 'H2D': 'H2D', 'H2D Pro': 'H2D Pro', 'H2C': 'H2C', 'H2S': 'H2S',
   };
   return modelMap[ssdpModel] || ssdpModel;

+ 101 - 0
frontend/src/utils/maintenanceWikiUrls.ts

@@ -0,0 +1,101 @@
+/**
+ * Resolve a Bambu Lab wiki URL for a maintenance task based on the printer model.
+ *
+ * Model families:
+ *   - X1, P1         → carbon rods
+ *   - P2S, X2D       → hardened steel rods (X2D shares P2S's gantry — #988)
+ *   - A1, A1 Mini    → linear rails (Y axis)
+ *   - H2D, H2C, H2S  → linear rails (X-axis lubrication)
+ *
+ * Returns null when no wiki page applies (e.g. "Clean Carbon Rods" on an H2D),
+ * which the caller renders as a task with no clickable help link.
+ */
+export function getMaintenanceWikiUrl(typeName: string, printerModel: string | null): string | null {
+  const model = (printerModel || '').toUpperCase().replace(/[- ]/g, '');
+
+  const isX1 = model.includes('X1');
+  const isP1 = model.includes('P1');
+  const isA1Mini = model.includes('A1MINI');
+  const isA1 = model.includes('A1') && !isA1Mini;
+  const isH2D = model.includes('H2D');
+  const isH2C = model.includes('H2C');
+  const isH2S = model.includes('H2S');
+  const isH2 = isH2D || isH2C || isH2S;
+  const isP2S = model.includes('P2S');
+  const isX2D = model.includes('X2D');
+  // X2D shares the hardened steel rod hardware and belt layout with P2S,
+  // so its maintenance routes use the P2S wiki pages until dedicated
+  // X2D pages are published by Bambu Lab.
+  const isSteelRod = isP2S || isX2D;
+
+  switch (typeName) {
+    case 'Lubricate Steel Rods':
+      if (isSteelRod) return 'https://wiki.bambulab.com/en/p2s/maintenance/lubricate-x-y-z-axis';
+      return null;
+
+    case 'Clean Steel Rods':
+      if (isSteelRod) return 'https://wiki.bambulab.com/en/p2s/maintenance/lubricate-x-y-z-axis';
+      return null;
+
+    case 'Lubricate Linear Rails':
+      if (isA1Mini) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/lubricate-y-axis';
+      if (isA1) return 'https://wiki.bambulab.com/en/a1/maintenance/lubricate-y-axis';
+      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication';
+      return null;
+
+    case 'Clean Nozzle/Hotend':
+      if (isX1 || isP1) return 'https://wiki.bambulab.com/en/x1/troubleshooting/nozzle-clog';
+      if (isA1Mini || isA1) return 'https://wiki.bambulab.com/en/a1-mini/troubleshooting/nozzle-clog';
+      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/nozzl-cold-pull-maintenance-and-cleaning';
+      if (isSteelRod) return 'https://wiki.bambulab.com/en/p2s/maintenance/cold-pull-maintenance-hotend';
+      return 'https://wiki.bambulab.com/en/x1/troubleshooting/nozzle-clog';
+
+    case 'Check Belt Tension':
+      if (isX1) return 'https://wiki.bambulab.com/en/x1/maintenance/belt-tension';
+      if (isP1) return 'https://wiki.bambulab.com/en/p1/maintenance/p1p-maintenance';
+      if (isA1Mini) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/belt_tension';
+      if (isA1) return 'https://wiki.bambulab.com/en/a1/maintenance/belt_tension';
+      if (isH2D) return 'https://wiki.bambulab.com/en/h2/maintenance/belt-tension';
+      if (isH2C) return 'https://wiki.bambulab.com/en/h2c/maintenance/belt-tension';
+      if (isH2S) return 'https://wiki.bambulab.com/en/h2s/maintenance/belt-tension';
+      if (isSteelRod) return 'https://wiki.bambulab.com/en/p2s/maintenance/belt-tension';
+      return 'https://wiki.bambulab.com/en/x1/maintenance/belt-tension';
+
+    case 'Clean Carbon Rods':
+      if (isX1 || isP1) return 'https://wiki.bambulab.com/en/general/carbon-rods-clearance';
+      return null;
+
+    case 'Clean Linear Rails':
+      if (isA1Mini) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/lubricate-y-axis';
+      if (isA1) return 'https://wiki.bambulab.com/en/a1/maintenance/lubricate-y-axis';
+      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication';
+      return null;
+
+    case 'Clean Build Plate':
+      return 'https://wiki.bambulab.com/en/filament-acc/acc/pei-plate-clean-guide';
+
+    case 'Check PTFE Tube':
+      if (isX1 || isP1) return 'https://wiki.bambulab.com/en/x1/maintenance/replace-ptfe-tube';
+      if (isA1Mini || isA1) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/ptfe-tube';
+      if (isH2D) return 'https://wiki.bambulab.com/en/h2/maintenance/replace-ptfe-tube-on-h2d-printer';
+      if (isH2S) return 'https://wiki.bambulab.com/en/h2s/maintenance/replace-ptfe-tube-on-h2s-printer';
+      if (isH2C) return 'https://wiki.bambulab.com/en/h2/maintenance/replace-ptfe-tube-on-h2d-printer'; // H2C uses H2D guide
+      if (isSteelRod) return 'https://wiki.bambulab.com/en/x1/maintenance/replace-ptfe-tube'; // P2S/X2D use similar PTFE
+      return 'https://wiki.bambulab.com/en/x1/maintenance/replace-ptfe-tube';
+
+    case 'Replace HEPA Filter':
+    case 'HEPA Filter':
+    case 'Replace Carbon Filter':
+    case 'Carbon Filter':
+      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/replace-smoke-purifier-air-filte';
+      return 'https://wiki.bambulab.com/en/x1/maintenance/replace-carbon-filter';
+
+    case 'Lubricate Left Nozzle Rail':
+    case 'Left Nozzle Rail':
+      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication';
+      return null;
+
+    default:
+      return null;
+  }
+}

+ 1 - 0
frontend/src/utils/printer.ts

@@ -4,6 +4,7 @@ export function getPrinterImage(model: string | null | undefined): string {
   if (m.includes('x1e')) return '/img/printers/x1e.png';
   if (m.includes('x1c') || m.includes('x1carbon')) return '/img/printers/x1c.png';
   if (m.includes('x1')) return '/img/printers/x1c.png';
+  if (m.includes('x2d') || m === 'n6') return '/img/printers/x2d.png';
   if (m.includes('h2dpro') || m.includes('h2d-pro')) return '/img/printers/h2dpro.png';
   if (m.includes('h2d')) return '/img/printers/h2d.png';
   if (m.includes('h2c')) return '/img/printers/h2c.png';

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


BIN
static/img/printers/x2d.png


+ 1 - 1
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-CSgLWiF3.js"></script>
+    <script type="module" crossorigin src="/assets/index-Bre7b40i.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-3s5orqQ4.css">
   </head>
   <body>

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