Fix wrong AMS unit displayed with dual AMS on P2S (#420)

P2S firmware sends local slot IDs (0-3) in tray_now, not global tray
IDs, when multiple AMS units are connected. The UI highlighted the
wrong AMS unit (e.g., AMS-A instead of AMS-B). Usage tracking was
unaffected because it uses the MQTT mapping field.

Added disambiguation in _handle_ams_data for single-nozzle printers
with ams_exist_bits indicating >1 AMS: cross-references tray_now with
the MQTT mapping field (snow-encoded) to resolve the correct global
tray ID. Falls back to raw value when no mapping is available or
ambiguous. No behavior change for single-AMS printers or dual-nozzle
(H2D) printers.
This commit is contained in:
maziggy
2026-02-21 11:07:24 +01:00
parent 16fa84bc16
commit 7c5741b23c
4 changed files with 281 additions and 3 deletions
+2
View File
@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.1b2] - Unreleased
### Fixed
- **Wrong AMS Unit Displayed With Dual AMS on P2S** ([#420](https://github.com/maziggy/bambuddy/issues/420)) — On P2S printers with two AMS units, the UI highlighted the wrong AMS when printing from the second unit (e.g., printing from AMS-B slot 2 but AMS-A slot 2 was shown as active). The P2S firmware sends local slot IDs (0-3) in `tray_now`, not global tray IDs — contrary to the previous assumption that all single-nozzle printers report global IDs. Filament usage tracking was unaffected because it uses the MQTT `mapping` field (snow-encoded with correct AMS hardware IDs). The display now cross-references `tray_now` with the MQTT mapping field to resolve the correct AMS unit when multiple AMS units are detected via `ams_exist_bits`. Falls back to the raw value when no mapping is available (e.g., manual filament load outside of a print) or when the mapping is ambiguous.
- **PCTG Filament Misidentified as PC** ([#478](https://github.com/maziggy/bambuddy/issues/478)) — Selecting "Generic PCTG" as a filament profile defaulted to PC material. The spool form's material parser listed PC before PCTG and used substring matching (`indexOf`), so "PCTG" matched "PC" first. The AMS slot configuration and local profiles views were also missing PCTG from their known material types. Additionally, the temperature range logic used `includes('PC')` which matched PCTG and assigned PC temperatures (260-300°C) instead of PETG-range temperatures (220-260°C). Fixed by reordering PCTG before PC in the spool form parser, adding PCTG to all material type arrays, and adding an exact-match temperature case for PCTG.
- **Phantom Prints From Lingering SD Card Files** ([#477](https://github.com/maziggy/bambuddy/issues/477)) — Prints could restart without user input hours after completing, because uploaded gcode files survived on the printer's SD card and were auto-started on firmware restart. Three bugs allowed files to linger. First, the post-print SD card cleanup retry loop always broke after the first attempt regardless of success, because `delete_file_async` catches errors internally and returns `False` instead of raising — the `except` retry branch never executed. Fixed by only breaking on successful delete and retrying with a 2-second delay on failure. Second, when `start_print()` failed after uploading a file (in both the background dispatcher and print scheduler), the uploaded file was never cleaned up since `on_print_complete` never fires for a print that never started. Now deletes the uploaded file on a best-effort basis when `start_print()` returns `False`. Third, cleanup failure logging was at `DEBUG` level, making failures invisible in normal operation — escalated to `WARNING`.
- **Non-Actionable HMS Errors Triggering Notifications** ([#470](https://github.com/maziggy/bambuddy/issues/470)) — Infrastructure and auth-related HMS error codes (like `0500_0007` "MQTT command verification failed") were triggering printer error notifications even though they don't indicate actual print problems. For example, a device with incorrect bind settings sending unauthorized MQTT commands caused repeated false-alarm nozzle/extruder error notifications with camera snapshots of perfectly fine prints. Now suppresses notifications for known non-actionable error codes: `0500_0007` (MQTT auth failure), `0500_4001` (Bambu Cloud connection failure), and `0500_400E` (print cancelled by user).
@@ -26,6 +27,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Include Beta Updates Setting** — New toggle in Settings → Updates to opt in to beta/prerelease update notifications. Default: off (stable only). The update checker now fetches `/releases` instead of `/releases/latest` and filters by `parse_version()` prerelease detection (not GitHub's `prerelease` flag, which may not be set correctly). Users on the Docker `latest` tag will no longer see notifications for beta releases they can't install.
### Improved
- **P2S Dual-AMS tray_now Test Coverage** — Added 14 integration tests for multi-AMS tray_now disambiguation on single-nozzle printers (resolving AMS-B slots via mapping field, AMS-A passthrough, multi-color mapping, ambiguous/missing mapping fallbacks, last_loaded_tray tracking). Added 9 unit tests for `_resolve_local_slot_from_mapping` (snow decoding, unmapped entry filtering, ambiguity detection, AMS-HT slot matching). All 66 tray_now-related tests pass.
- **Bulk Spool & Stock Test Coverage** — Added 13 backend unit tests covering `SpoolBulkCreate` schema validation (quantity bounds, field preservation, stock vs configured distinction) and bulk endpoint logic (correct spool count, single quantity, identical fields). Added 10 frontend tests covering `validateForm` with `quickAdd` flag (6 tests for relaxed vs full validation) and `SpoolFormModal` UI behavior (4 tests for quick-add toggle visibility, PA Profile tab hiding, quantity field rendering).
- **Filament Cost Tracking Test Coverage** — Added 2 backend unit tests for archive cost aggregation (zero-cost guard preserves existing costs, positive-cost updates archive correctly). Added 2 frontend unit tests for spool form cost_per_kg persistence. Fixed missing `archive_id` database migration, SQLAlchemy `is None` → `.is_(None)` in where clauses, duplicate archive cost write, and unconditional zero-cost overwrite.
- **Spool Assignment Snapshot Test Coverage** — Added 7 backend unit tests covering spool assignment snapshotting at print start, snapshot-preferred spool lookup in both 3MF and AMS delta paths, fallback to live query for pre-upgrade sessions, and the core mid-print unlink scenario from #459.
+61 -1
View File
@@ -875,6 +875,34 @@ class BambuMQTTClient:
if "filament_tangle_detect" in xcam_data:
self.state.print_options.filament_tangle_detect = bool(xcam_data.get("filament_tangle_detect"))
@staticmethod
def _resolve_local_slot_from_mapping(local_slot: int, mapping_raw: list | None) -> int | None:
"""Resolve a local AMS slot ID to a global tray ID using the MQTT mapping field.
The MQTT mapping field is an array of snow-encoded values:
each entry = ams_hw_id * 256 + slot_id (65535 = unmapped).
Finds entries where the local slot matches, then computes the global tray ID.
Returns the global ID if exactly one AMS matches, or None if ambiguous/unavailable.
"""
if not isinstance(mapping_raw, list) or not mapping_raw:
return None
candidates: set[int] = set()
for value in mapping_raw:
if not isinstance(value, int) or value >= 65535:
continue
ams_hw_id = value >> 8
slot = value & 0xFF
if 0 <= ams_hw_id <= 3 and (slot & 0x03) == local_slot:
candidates.add(ams_hw_id * 4 + local_slot)
elif 128 <= ams_hw_id <= 135 and local_slot == 0:
candidates.add(ams_hw_id)
if len(candidates) == 1:
return candidates.pop()
return None
def _handle_ams_data(self, ams_data):
"""Handle AMS data changes for Spoolman integration.
@@ -928,7 +956,8 @@ class BambuMQTTClient:
# H2D dual-nozzle printers report only slot number (0-3), not global tray ID
# Use active_extruder + ams_extruder_map to determine which AMS the slot belongs to
# Single-nozzle printers (X1C, P2S, etc.) always report global IDs, even with multiple AMS
# Single-nozzle printers with multiple AMS (e.g. P2S) also report local slot IDs (#420)
# — disambiguated below using MQTT mapping field
ams_map = self.state.ams_extruder_map
if self._is_dual_nozzle and 0 <= parsed_tray_now <= 3:
# First, check if we have a pending target that matches this slot
@@ -1050,6 +1079,37 @@ class BambuMQTTClient:
f"using slot {parsed_tray_now}"
)
self.state.tray_now = parsed_tray_now
elif not self._is_dual_nozzle and 0 <= parsed_tray_now <= 3:
# Single-nozzle printer with tray_now in 0-3 range.
# P2S (and possibly other models) with multiple AMS units sends LOCAL slot IDs
# in tray_now, not global tray IDs (#420). Use the MQTT mapping field
# (snow-encoded) to resolve the correct AMS unit.
ams_exist_raw = ams_data.get("ams_exist_bits", "0")
try:
ams_exist = int(ams_exist_raw, 16) if isinstance(ams_exist_raw, str) else int(ams_exist_raw)
except (ValueError, TypeError):
ams_exist = 0
num_ams = bin(ams_exist).count("1")
if num_ams > 1:
# Multiple AMS on single-nozzle — tray_now is likely a local slot ID.
# Cross-reference with MQTT mapping field to find the correct AMS unit.
mapping_raw = self.state.raw_data.get("mapping")
resolved = self._resolve_local_slot_from_mapping(parsed_tray_now, mapping_raw)
if resolved is not None:
if resolved != parsed_tray_now:
logger.debug(
f"[{self.serial_number}] Multi-AMS tray_now: "
f"local slot {parsed_tray_now} -> global ID {resolved} (from mapping)"
)
self.state.tray_now = resolved
else:
# No mapping available (not printing, or ambiguous) — use as-is.
# This matches the old behavior and is correct for AMS 0.
self.state.tray_now = parsed_tray_now
else:
# Single AMS — local slot 0-3 equals global ID
self.state.tray_now = parsed_tray_now
else:
# tray_now > 3 means it's already a global ID, or 255 means unloaded
# Note: Do NOT clear pending_tray_target on tray_now=255 here.
+193 -2
View File
@@ -1212,13 +1212,15 @@ class TestRequestTopicAmsMapping:
# ---------------------------------------------------------------------------
def _ams_payload(tray_now, ams_units=None, tray_exist_bits=None):
def _ams_payload(tray_now, ams_units=None, tray_exist_bits=None, ams_exist_bits=None):
"""Build minimal print.ams payload for tray_now disambiguation tests."""
ams = {"tray_now": str(tray_now)}
if ams_units is not None:
ams["ams"] = ams_units
if tray_exist_bits is not None:
ams["tray_exist_bits"] = tray_exist_bits
if ams_exist_bits is not None:
ams["ams_exist_bits"] = ams_exist_bits
return {"print": {"ams": ams}}
@@ -1301,7 +1303,7 @@ class TestTrayNowSingleNozzleX1E:
class TestTrayNowSingleNozzleP2S:
"""Single-nozzle, 2 AMS — global IDs 4-7 for AMS 1 pass through directly."""
"""Single-nozzle, 2 AMS — tray_now > 3 passes through as global ID."""
@pytest.fixture
def mqtt_client(self):
@@ -1328,6 +1330,195 @@ class TestTrayNowSingleNozzleP2S:
assert mqtt_client.state.tray_now == 6
# ---------------------------------------------------------------------------
# 2b. Single-nozzle P2S — multi-AMS local slot disambiguation (#420)
# ---------------------------------------------------------------------------
class TestTrayNowP2SMultiAmsDisambiguation:
"""P2S firmware sends local slot IDs (0-3) in tray_now even with dual AMS.
When ams_exist_bits indicates >1 AMS unit and tray_now is 0-3, the backend
should use the MQTT mapping field (snow-encoded) to resolve the correct
global tray ID.
"""
@pytest.fixture
def mqtt_client(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
client = BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="TEST_P2S_DUAL",
access_code="12345678",
)
return client
def test_resolves_ams1_slot1_from_mapping(self, mqtt_client):
"""tray_now=1 with mapping=[257] → global ID 5 (AMS1-T1).
257 snow-decoded: ams_hw_id=1, slot=1 → global 1*4+1=5.
"""
# Set mapping field in raw_data (as the MQTT handler would)
mqtt_client.state.raw_data["mapping"] = [257]
mqtt_client._process_message(
_ams_payload(1, ams_exist_bits="3") # '3' = 0b11 → AMS 0 and 1
)
assert mqtt_client.state.tray_now == 5
def test_resolves_ams1_slot0_from_mapping(self, mqtt_client):
"""tray_now=0 with mapping=[256] → global ID 4 (AMS1-T0).
256 snow-decoded: ams_hw_id=1, slot=0 → global 1*4+0=4.
"""
mqtt_client.state.raw_data["mapping"] = [256]
mqtt_client._process_message(_ams_payload(0, ams_exist_bits="3"))
assert mqtt_client.state.tray_now == 4
def test_resolves_ams1_slot3_from_mapping(self, mqtt_client):
"""tray_now=3 with mapping=[259] → global ID 7 (AMS1-T3).
259 snow-decoded: ams_hw_id=1, slot=3 → global 1*4+3=7.
"""
mqtt_client.state.raw_data["mapping"] = [259]
mqtt_client._process_message(_ams_payload(3, ams_exist_bits="3"))
assert mqtt_client.state.tray_now == 7
def test_ams0_slot_unchanged_when_mapping_confirms_ams0(self, mqtt_client):
"""tray_now=1 with mapping=[1] → stays 1 (AMS0-T1).
1 snow-decoded: ams_hw_id=0, slot=1 → global 0*4+1=1.
"""
mqtt_client.state.raw_data["mapping"] = [1]
mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
assert mqtt_client.state.tray_now == 1
def test_multicolor_resolves_ams1_from_multi_entry_mapping(self, mqtt_client):
"""Multi-color print: mapping=[0, 257] → tray_now=1 resolves to AMS1-T1 (5).
Entry 0: ams_hw_id=0, slot=0 (local 0) — doesn't match tray_now=1.
Entry 257: ams_hw_id=1, slot=1 (local 1) — matches tray_now=1 → global 5.
"""
mqtt_client.state.raw_data["mapping"] = [0, 257]
mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
assert mqtt_client.state.tray_now == 5
def test_multicolor_four_slot_mapping(self, mqtt_client):
"""mapping=[65535, 65535, 65535, 257] → tray_now=1 resolves to global 5.
Only entry 257 has local slot=1, other entries are unmapped (65535).
Reproduces exact data from issue #420 support package.
"""
mqtt_client.state.raw_data["mapping"] = [65535, 65535, 65535, 257]
mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
assert mqtt_client.state.tray_now == 5
def test_ambiguous_mapping_falls_back_to_local_slot(self, mqtt_client):
"""Two AMS units with same local slot in mapping → ambiguous, keep local slot.
mapping=[1, 257]: both have local slot 1 (AMS0-T1 and AMS1-T1).
Cannot disambiguate → fall back to tray_now=1.
"""
mqtt_client.state.raw_data["mapping"] = [1, 257]
mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
assert mqtt_client.state.tray_now == 1
def test_no_mapping_falls_back_to_local_slot(self, mqtt_client):
"""No mapping field available → fall back to raw tray_now."""
# No mapping in raw_data (e.g. manual filament load, not during print)
mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
assert mqtt_client.state.tray_now == 1
def test_empty_mapping_falls_back_to_local_slot(self, mqtt_client):
"""Empty mapping list → fall back to raw tray_now."""
mqtt_client.state.raw_data["mapping"] = []
mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
assert mqtt_client.state.tray_now == 1
def test_single_ams_passthrough(self, mqtt_client):
"""Single AMS (ams_exist_bits='1') → tray_now 0-3 is direct global ID."""
mqtt_client._process_message(_ams_payload(2, ams_exist_bits="1"))
assert mqtt_client.state.tray_now == 2
def test_no_ams_exist_bits_passthrough(self, mqtt_client):
"""No ams_exist_bits in payload → fall back to raw tray_now."""
mqtt_client._process_message(_ams_payload(1))
assert mqtt_client.state.tray_now == 1
def test_tray_now_255_unaffected_by_multi_ams(self, mqtt_client):
"""tray_now=255 (unloaded) passes through regardless of AMS count."""
mqtt_client.state.raw_data["mapping"] = [257]
mqtt_client._process_message(_ams_payload(255, ams_exist_bits="3"))
assert mqtt_client.state.tray_now == 255
def test_tray_now_above_3_unaffected(self, mqtt_client):
"""tray_now > 3 is already a global ID and passes through directly."""
mqtt_client._process_message(_ams_payload(6, ams_exist_bits="3"))
assert mqtt_client.state.tray_now == 6
def test_last_loaded_tray_uses_resolved_global_id(self, mqtt_client):
"""last_loaded_tray should reflect the resolved global ID, not local slot."""
mqtt_client.state.raw_data["mapping"] = [257]
mqtt_client.state.state = "RUNNING"
mqtt_client._process_message(_ams_payload(1, ams_exist_bits="3"))
assert mqtt_client.state.tray_now == 5
assert mqtt_client.state.last_loaded_tray == 5
class TestResolveLocalSlotFromMapping:
"""Unit tests for _resolve_local_slot_from_mapping static method."""
def test_single_match_ams0(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [1]) == 1
def test_single_match_ams1(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
# 257 = 1*256 + 1 → AMS1 slot1 → global 5
assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [257]) == 5
def test_single_match_ams2(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
# 514 = 2*256 + 2 → AMS2 slot2 → global 10
assert BambuMQTTClient._resolve_local_slot_from_mapping(2, [514]) == 10
def test_unmapped_entries_skipped(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [65535, 65535, 65535, 257]) == 5
def test_no_match_returns_none(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
# mapping has slot 0 only, looking for slot 2
assert BambuMQTTClient._resolve_local_slot_from_mapping(2, [0]) is None
def test_ambiguous_returns_none(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
# Both AMS0 slot1 (1) and AMS1 slot1 (257) → ambiguous
assert BambuMQTTClient._resolve_local_slot_from_mapping(1, [1, 257]) is None
def test_none_mapping_returns_none(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
assert BambuMQTTClient._resolve_local_slot_from_mapping(1, None) is None
def test_empty_mapping_returns_none(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
assert BambuMQTTClient._resolve_local_slot_from_mapping(1, []) is None
def test_ams_ht_slot0_match(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
# AMS-HT id=128: snow = 128*256 + 0 = 32768
assert BambuMQTTClient._resolve_local_slot_from_mapping(0, [32768]) == 128
# ---------------------------------------------------------------------------
# 3. H2D Pro — initial state detection
# ---------------------------------------------------------------------------
+25
View File
@@ -0,0 +1,25 @@
┌─────────────────┬─────────────────┬────────────────┬────────────────────────────────────┬─────────────────────┐
│ Model │ _is_dual_nozzle │ ams_exist_bits │ Branch hit │ Behavior │
├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
│ X1C/X1E (1 AMS) │ False │ "1" │ New elif → num_ams=1 → passthrough │ Unchanged │
├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
│ X1C (2 AMS) │ False │ "3" │ New elif → disambiguation │ Fixed (same as P2S) │
├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
│ P1S/P1P (1 AMS) │ False │ "1" │ New elif → num_ams=1 → passthrough │ Unchanged │
├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
│ A1/A1 Mini │ False │ "1" or missing │ New elif → num_ams≤1 → passthrough │ Unchanged │
├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
│ P2S (1 AMS) │ False │ "1" │ New elif → num_ams=1 → passthrough │ Unchanged │
├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
│ P2S (2 AMS) │ False │ "3" │ New elif → disambiguation │ Fixed │
├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
│ H2D/H2D Pro │ True │ any │ Branch 1 (H2D logic) │ Unchanged │
├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
│ H2C │ True │ any │ Branch 1 (H2D logic) │ Unchanged │
├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
│ tray_now=255 │ any │ any │ Branch 3 (passthrough) │ Unchanged │
├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
│ tray_now=254 │ any │ any │ Branch 3 (passthrough) │ Unchanged │
├─────────────────┼─────────────────┼────────────────┼────────────────────────────────────┼─────────────────────┤
│ tray_now 4-15 │ any (False) │ any │ Branch 3 (passthrough) │ Unchanged │
└─────────────────┴─────────────────┴────────────────┴────────────────────────────────────┴─────────────────────┘