mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
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.
This commit is contained in:
@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
## [0.2.5b1] - Unreleased
|
||||
|
||||
### Added
|
||||
- **Bambu Lab A2L support (#1684)** — Internal model code `N9`, serial prefix `26A19` (5 chars, same shape as H2C's late `31B8B`). Capabilities resolved from BambuStudio's `resources/profiles/BBL/machine/Bambu Lab A2L.json` cross-checked against Bambu's official A2L specs page: linear rail, single FDM extruder + integrated cutter/plotter head (the BambuStudio `use_double_extruder_default_texture: true` flag covers the dual TOOL HEADS, not dual filament extrusion — A2L must NOT route AMS to the deputy slot or firmware rejects with 07FF_8012). Specs page also confirms NO Ethernet (Wi-Fi 2.4 GHz 802.11 b/g/n only), `Low-Rate-Kamera` on the chamber-image protocol (port 6000, NOT RTSP:322), no heated chamber. **Registry updates**: `PRINTER_MODEL_MAP` + `PRINTER_MODEL_ID_MAP` + `LINEAR_RAIL_MODELS` in `utils/printer_models.py`; `MODEL_TO_API_KEY` + `API_KEY_TO_DEV_MODEL` + `API_KEY_TO_WIKI_PATH` in `firmware_check.py` (wiki path follows the established `/en/a2l/manual/a2l-firmware-release-history` pattern; the existing 404 handling in `_fetch_all_versions_from_wiki` makes this safe to ship before Bambu publishes the page); `VIRTUAL_PRINTER_MODELS` + `MODEL_SERIAL_PREFIXES` in `virtual_printer/manager.py` (prefix `26A19A` with the same revision-letter padding as X2D's `20P90A`); `MODEL_PRODUCT_NAMES` in `virtual_printer/mqtt_server.py`; `mapModelCode` + Add-Printer / Edit-Printer model dropdowns in `PrintersPage.tsx` (new "A2 Series" optgroup); `mapModelCode` in `SpoolBuddyAmsPage.tsx`. **Camera and dual-nozzle code paths need no edits**: `supports_rtsp()` correctly falls through to chamber-image for A2L because `N9` is neither in the internal-code RTSP set nor does the display name match the X1/X2/H2/P2 prefix tuple; `is_dual_nozzle_model()` correctly returns False because A2L is not in `DUAL_NOZZLE_MODELS`. The cutter/plotter capability surfaces in MQTT push fields Bambuddy doesn't yet model; ignored for v1, will surface as a follow-up only if a real-world A2L bundle reveals a confusing UI state. **Tests**: 12 new cases in `test_printer_models.py::TestA2LModel` pinning every dimension — rod type, model-id round-trip, both ethernet directions, both camera-port directions, the explicit non-dual-nozzle guard (regression guard for the BambuStudio profile flag misread), set membership in `LINEAR_RAIL_MODELS` and exclusion from `CARBON_ROD_MODELS` / `STEEL_ROD_MODELS`.
|
||||
|
||||
- **One-shot `device.*` identification probe in MQTT push parser (#1684 enabler)** — Adding support for a new Bambu printer model needs the internal model code the firmware sends in MQTT `device.dev_model_name` (e.g. A1 is `N2S`, H2C is `O1C`, X2D is `N6`). The field arrives on every push but Bambuddy never logged it, so even a debug-enabled support bundle from a new-model user (A2L on #1684 was the case that surfaced this) gave us no way to identify the model — `get_version` was also missing because the printer disconnected right after the request topic subscription, which is a separate firmware quirk. **Fix:** at the top of the existing `device.*` parsing block in `bambu_mqtt.py`, emit one INFO log per client session dumping `dev_model_name` / `dev_product_name` / `dev_id` / `project_name` if any are present; otherwise fall back to `device.keys()` so a future Bambu rename (e.g. `model_name` without the `dev_` prefix) still surfaces. INFO level so the line lands in every support bundle, not just debug-enabled ones; one-shot via a `_device_id_logged` flag matching the existing `_nozzle_fields_logged` pattern at line 2095 — no spam at every push_status. 3 unit tests in `TestDeviceIdentificationProbe` pin the one-shot behaviour, the known-id-field path, and the keys-fallback path. Full `test_bambu_mqtt.py` suite 281 / 281 green; ruff clean. Once this ships, a new-model issue self-resolves from the first bundle — no second round of "please enable debug and reupload" required.
|
||||
|
||||
- **Archives page banner: reactive install-step-4 nudge for the slicer-side setting** — Companion to the new `external_storage` diagnostic check. The diagnostic catches the printer-side variant of "Store sent files on external storage" via `home_flag` bit 11. The slicer-side variant on older BambuStudio / OrcaSlicer never reaches the printer, so the diagnostic passes even when the option is off in the slicer. The deterministic symptom is the archiver creating a row with `extra_data.no_3mf_available=True` (`main.py:2770`) — that's the signal this banner watches. New backend endpoint `GET /archives/no-3mf-warning` returns `{has_fallback: bool}` — true iff any archive in the last 30 days has the flag set AND isn't soft-deleted. The 30-day window prevents old never-fixed installs from showing the banner forever; the soft-delete filter respects the user clearing the evidence. Frontend banner sits at the top of the Archives page (amber, dismissible) — "Some recent prints couldn't be archived with thumbnails…" + link to install step 4 in the wiki. Dismissal is one-shot via `localStorage` key `archiveNo3MFWarningDismissed` (matches the existing `Layout.tsx` update-banner pattern but persistent across sessions, since "you've been told" should outlive a browser restart). React-Query is `enabled: !dismissed` so the endpoint isn't polled after dismissal. 5 backend integration tests (`TestNo3MFWarning`) cover: recent fallback returns true, no archives returns false, archives without the flag returns false, >30-day-old fallbacks ignored, soft-deleted fallbacks ignored. i18n: 4 new keys (`title`, `body`, `docsLink`, `dismissLabel`) under `archives.no3mfBanner` translated to all 11 locales — no English fallbacks.
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -45,6 +45,7 @@ MODEL_PRODUCT_NAMES = {
|
||||
"BL-P002": "X1",
|
||||
"C13": "X1E",
|
||||
"N6": "X2D",
|
||||
"N9": "A2L",
|
||||
"C11": "P1P",
|
||||
"C12": "P1S",
|
||||
"N7": "P2S",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user