Ver Fonte

feat(a2l): add Bambu Lab A2L support (#1684)

  Internal code N9 (from BambuStudio resources/profiles/BBL/machine/Bambu Lab A2L.json),
  serial prefix 26A19 (5-char, same shape as H2C's late 31B8B). Capabilities from
  Bambu's official A2L specs page: linear rail, single FDM extruder + integrated
  cutter/plotter, no Ethernet (2.4 GHz Wi-Fi only), low-rate chamber camera on
  port 6000.

  The BambuStudio profile's use_double_extruder_default_texture: true flag
  describes two TOOL HEADS (FDM + cutter), not dual filament extrusion — A2L
  must NOT be classified as dual-nozzle or AMS routing will target the deputy
  slot and firmware rejects with 07FF_8012.

  Registry updates: printer_models.py, firmware_check.py, virtual_printer/manager.py,
  virtual_printer/mqtt_server.py, PrintersPage.tsx, SpoolBuddyAmsPage.tsx.
  12 new test cases in TestA2LModel pin every dimension.
maziggy há 2 meses atrás
pai
commit
defec334c4

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
CHANGELOG.md


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

@@ -62,6 +62,7 @@ MODEL_TO_API_KEY = {
     "A1 Mini": "a1-mini",
     "A1-Mini": "a1-mini",
     "A1mini": "a1-mini",
+    "A2L": "a2l",
     "H2D": "h2d",
     "H2C": "h2c",
     "H2S": "h2s",
@@ -88,6 +89,7 @@ MODEL_TO_API_KEY = {
     "N1": "a1-mini",
     "N6": "x2d",
     "N7": "p2s",
+    "N9": "a2l",
 }
 
 # Reverse mapping: API key to model codes
@@ -103,6 +105,7 @@ API_KEY_TO_DEV_MODEL = {
     "x1e": "C13",
     "x2d": "N6",
     "h2d-pro": "O1E",
+    "a2l": "N9",
 }
 
 # Wiki firmware release history pages (primary version source)
@@ -118,6 +121,10 @@ API_KEY_TO_WIKI_PATH = {
     "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",
+    # A2L wiki path follows the established pattern but isn't yet published —
+    # _fetch_all_versions_from_wiki silently returns [] on 404 so this is safe
+    # to ship before Bambu publishes the page.
+    "a2l": "/en/a2l/manual/a2l-firmware-release-history",
 }
 
 

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

@@ -44,6 +44,8 @@ VIRTUAL_PRINTER_MODELS = {
     "C13": "X1E",  # X1E
     # X2 Series
     "N6": "X2D",  # X2D
+    # A2 Series (single-FDM + integrated cutter/plotter)
+    "N9": "A2L",  # A2L
     # P Series
     "C11": "P1P",  # P1P
     "C12": "P1S",  # P1S
@@ -76,6 +78,8 @@ MODEL_SERIAL_PREFIXES = {
     "C13": "03W00A",  # X1E
     # X2 Series
     "N6": "20P90A",  # X2D (first 4 chars "20P9" match real serials)
+    # A2 Series
+    "N9": "26A19A",  # A2L (first 5 chars "26A19" match real serials)
     # P Series
     "C11": "01S00A",  # P1P
     "C12": "01P00A",  # P1S

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

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

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

@@ -23,6 +23,7 @@ PRINTER_MODEL_MAP = {
     "Bambu Lab H2C": "H2C",
     "Bambu Lab H2S": "H2S",
     "Bambu Lab X2D": "X2D",
+    "Bambu Lab A2L": "A2L",
 }
 
 # Map from printer_model_id (internal codes in slice_info.config) to short names
@@ -39,6 +40,8 @@ PRINTER_MODEL_ID_MAP = {
     "P2S": "P2S",
     # X2 series
     "N6": "X2D",
+    # A2 series (A2L is single-FDM + integrated cutter/plotter — single nozzle)
+    "N9": "A2L",
     # A1 series
     "A11": "A1",
     "A12": "A1 Mini",
@@ -91,6 +94,7 @@ LINEAR_RAIL_MODELS = frozenset(
         # Display names (uppercase, no spaces)
         "A1",
         "A1MINI",
+        "A2L",
         "H2D",
         "H2DPRO",
         "H2C",
@@ -98,6 +102,7 @@ LINEAR_RAIL_MODELS = frozenset(
         # Internal codes
         "N1",  # A1 Mini
         "N2S",  # A1
+        "N9",  # A2L
         "A04",  # A1 Mini (alternate)
         "A11",  # A1
         "A12",  # A1 Mini

+ 63 - 0
backend/tests/unit/test_printer_models.py

@@ -5,6 +5,7 @@ import pytest
 from backend.app.services.camera import get_camera_port, supports_rtsp
 from backend.app.utils.printer_models import (
     CARBON_ROD_MODELS,
+    LINEAR_RAIL_MODELS,
     STEEL_ROD_MODELS,
     get_rod_type,
     has_ethernet,
@@ -108,6 +109,68 @@ class TestX2DModel:
         assert "N6" in STEEL_ROD_MODELS
 
 
+class TestA2LModel:
+    """A2L printer support (#1684).
+
+    The A2L is a hybrid 3D printer + cutter/plotter announced June 2026. It
+    uses linear rails like the A1 family, has NO Ethernet (Wi-Fi 2.4 GHz only),
+    a low-rate chamber-image camera on port 6000 (no RTSP), and a single FDM
+    extruder (the second "tool head" in BambuStudio's profile is the cutter,
+    not a second extruder — must NOT be classified as dual-nozzle). Internal
+    SSDP/MQTT model code is "N9"; serial numbers begin with "26A19".
+    """
+
+    def test_a2l_is_linear_rail_display_name(self):
+        assert get_rod_type("A2L") == "linear_rail"
+
+    def test_a2l_is_linear_rail_internal_code(self):
+        assert get_rod_type("N9") == "linear_rail"
+
+    def test_a2l_model_id_map(self):
+        assert normalize_printer_model_id("N9") == "A2L"
+
+    def test_a2l_model_map(self):
+        assert normalize_printer_model("Bambu Lab A2L") == "A2L"
+
+    def test_a2l_has_no_ethernet_display_name(self):
+        """A2L specs (bambulab.com/de-de/a2l/specs) list Ethernet 'Nicht verfügbar'."""
+        assert has_ethernet("A2L") is False
+
+    def test_a2l_has_no_ethernet_internal_code(self):
+        assert has_ethernet("N9") is False
+
+    def test_a2l_does_not_support_rtsp_display_name(self):
+        """A2L uses the low-rate chamber-image protocol on port 6000, not RTSP."""
+        assert supports_rtsp("A2L") is False
+
+    def test_a2l_does_not_support_rtsp_internal_code(self):
+        assert supports_rtsp("N9") is False
+
+    def test_a2l_camera_port_is_chamber_image(self):
+        assert get_camera_port("A2L") == 6000
+        assert get_camera_port("N9") == 6000
+
+    def test_a2l_is_not_dual_nozzle(self):
+        """A2L has a single FDM extruder + a cutter/plotter head. The
+        BambuStudio profile flag ``use_double_extruder_default_texture`` flags
+        the dual TOOL HEADS, not dual filament extrusion — A2L must not land
+        in the dual-nozzle group or AMS routing will target the deputy slot
+        and the firmware will reject the print with 07FF_8012.
+        """
+        assert is_dual_nozzle_model("A2L") is False
+        assert is_dual_nozzle_model("N9") is False
+
+    def test_a2l_in_linear_rail_set(self):
+        assert "A2L" in LINEAR_RAIL_MODELS
+        assert "N9" in LINEAR_RAIL_MODELS
+
+    def test_a2l_not_in_carbon_or_steel_rod_sets(self):
+        assert "A2L" not in CARBON_ROD_MODELS
+        assert "N9" not in CARBON_ROD_MODELS
+        assert "A2L" not in STEEL_ROD_MODELS
+        assert "N9" not in STEEL_ROD_MODELS
+
+
 class TestA1SeriesModelIds:
     """Regression guard for the A1-family internal-code → display-name map.
 

+ 33 - 24
frontend/src/pages/PrintersPage.tsx

@@ -1194,6 +1194,8 @@ function mapModelCode(ssdpModel: string | null): string {
     'BL-P003': 'X1E',
     // X2 Series
     'N6': 'X2D',
+    // A2 Series
+    'N9': 'A2L',
     // P Series
     'C11': 'P1S',
     'C12': 'P1P',
@@ -1211,6 +1213,7 @@ function mapModelCode(ssdpModel: string | null): string {
     'P2S': 'P2S',
     'A1': 'A1',
     'A1 Mini': 'A1 Mini',
+    'A2L': 'A2L',
     'H2D': 'H2D',
     'H2D Pro': 'H2D Pro',
     'H2C': 'H2C',
@@ -5963,28 +5966,31 @@ export function AddPrinterModal({
                 onChange={(e) => setForm({ ...form, model: e.target.value })}
               >
                 <option value="">{t('printers.modal.selectModel')}</option>
+                <optgroup label="A1 Series">
+                  <option value="A1">A1</option>
+                  <option value="A1 Mini">A1 Mini</option>
+                </optgroup>
+                <optgroup label="A2 Series">
+                  <option value="A2L">A2L</option>
+                </optgroup>
                 <optgroup label="H2 Series">
                   <option value="H2C">H2C</option>
                   <option value="H2D">H2D</option>
                   <option value="H2D Pro">H2D Pro</option>
                   <option value="H2S">H2S</option>
                 </optgroup>
-                <optgroup label="X2 Series">
-                  <option value="X2D">X2D</option>
+                <optgroup label="P Series">
+                  <option value="P1P">P1P</option>
+                  <option value="P1S">P1S</option>
+                  <option value="P2S">P2S</option>
                 </optgroup>
                 <optgroup label="X1 Series">
-                  <option value="X1E">X1E</option>
-                  <option value="X1C">X1 Carbon</option>
                   <option value="X1">X1</option>
+                  <option value="X1C">X1 Carbon</option>
+                  <option value="X1E">X1E</option>
                 </optgroup>
-                <optgroup label="P Series">
-                  <option value="P2S">P2S</option>
-                  <option value="P1S">P1S</option>
-                  <option value="P1P">P1P</option>
-                </optgroup>
-                <optgroup label="A1 Series">
-                  <option value="A1">A1</option>
-                  <option value="A1 Mini">A1 Mini</option>
+                <optgroup label="X2 Series">
+                  <option value="X2D">X2D</option>
                 </optgroup>
               </select>
             </div>
@@ -6489,28 +6495,31 @@ function EditPrinterModal({
                 onChange={(e) => setForm({ ...form, model: e.target.value })}
               >
                 <option value="">{t('printers.modal.selectModel')}</option>
+                <optgroup label="A1 Series">
+                  <option value="A1">A1</option>
+                  <option value="A1 Mini">A1 Mini</option>
+                </optgroup>
+                <optgroup label="A2 Series">
+                  <option value="A2L">A2L</option>
+                </optgroup>
                 <optgroup label="H2 Series">
                   <option value="H2C">H2C</option>
                   <option value="H2D">H2D</option>
                   <option value="H2D Pro">H2D Pro</option>
                   <option value="H2S">H2S</option>
                 </optgroup>
-                <optgroup label="X2 Series">
-                  <option value="X2D">X2D</option>
+                <optgroup label="P Series">
+                  <option value="P1P">P1P</option>
+                  <option value="P1S">P1S</option>
+                  <option value="P2S">P2S</option>
                 </optgroup>
                 <optgroup label="X1 Series">
-                  <option value="X1E">X1E</option>
-                  <option value="X1C">X1 Carbon</option>
                   <option value="X1">X1</option>
+                  <option value="X1C">X1 Carbon</option>
+                  <option value="X1E">X1E</option>
                 </optgroup>
-                <optgroup label="P Series">
-                  <option value="P2S">P2S</option>
-                  <option value="P1S">P1S</option>
-                  <option value="P1P">P1P</option>
-                </optgroup>
-                <optgroup label="A1 Series">
-                  <option value="A1">A1</option>
-                  <option value="A1 Mini">A1 Mini</option>
+                <optgroup label="X2 Series">
+                  <option value="X2D">X2D</option>
                 </optgroup>
               </select>
             </div>

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

@@ -27,10 +27,11 @@ function mapModelCode(ssdpModel: string | null): 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',
+    'N9': 'A2L',
     'C11': 'P1S', 'C12': 'P1P', 'C13': 'P2S',
     'N2S': 'A1', 'N1': 'A1 Mini',
     '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',
+    'A1': 'A1', 'A1 Mini': 'A1 Mini', 'A2L': 'A2L', 'H2D': 'H2D', 'H2D Pro': 'H2D Pro', 'H2C': 'H2C', 'H2S': 'H2S',
   };
   return modelMap[ssdpModel] || ssdpModel;
 }

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
static/assets/index-lbRfAnFB.js


+ 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-DdAEkh5e.js"></script>
+    <script type="module" crossorigin src="/assets/index-lbRfAnFB.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-7s3X35pi.css">
   </head>
   <body>

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff