fix(mqtt): close #1822 — promote H2S tray_now to 254 on all-external prints

H2S firmware reports tray_now=0 (the AMS's idle slot) throughout
      external-spool prints instead of 254 like X1C/P1S/A1 do, so the
      single-nozzle branch's 0-3 passthrough landed state.tray_now on slot
      0 — UI highlighted AMS SLOT 1 instead of the external spool.
      Usage credit was unaffected (#1276 covers that via ams_mapping).

      The single-nozzle branch now checks _captured_ams_mapping (slicer-
      captured per-filament mapping that the request-topic intercept
      already tracks) before the existing P2S multi-AMS resolver. When
      every entry is -1, the print uses ONLY the external spool, so
      state.tray_now is promoted to 254.

      Narrow on purpose: AMS-only [5] and mixed [5, -1] are NOT
      overridden — we have no evidence H2S misreports mid-print swaps, and
      trusting the firmware preserves correctness for users with multi-
      filament setups. No-mapping prints (printer-screen start) fall
      through unchanged.
This commit is contained in:
maziggy
2026-06-28 12:45:06 +02:00
3 changed files with 134 additions and 28 deletions
@@ -5884,3 +5884,87 @@ class TestAmsFilamentBackupHoldTimer:
assert mqtt_client.state.ams_filament_backup is True
# Hold timer still armed — sub-second push didn't reset it.
assert mqtt_client._xcam_hold_start["print_option_auto_switch_filament"] == before_hold
# ---------------------------------------------------------------------------
# 2c. Single-nozzle H2S — external-spool tray_now override (#1822)
# ---------------------------------------------------------------------------
class TestTrayNowH2SExternalSpoolOverride:
"""H2S firmware reports tray_now as the AMS's idle slot (typically 0)
instead of 254 when the active feed is the external spool.
Bambuddy detects the all-external case via the slicer-captured
ams_mapping (every entry == -1) and promotes tray_now to 254 so the
UI active-tray highlight matches the real feed.
The override is intentionally narrow:
* only fires when ams_mapping is captured AND every entry is -1
* does not touch mixed prints ([5, -1]) or AMS-only prints ([5])
* does not fire when no ams_mapping is captured (printer-screen start)
"""
@pytest.fixture
def mqtt_client(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
return BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="TEST_H2S",
access_code="12345678",
)
def test_all_external_mapping_promotes_tray_now_to_254(self, mqtt_client):
"""Reporter's scenario: H2S, single nozzle, captured ams_mapping=[-1],
firmware sends tray_now=0 -> Bambuddy promotes to 254."""
mqtt_client._captured_ams_mapping = [-1]
mqtt_client._process_message(_ams_payload(0))
assert mqtt_client.state.tray_now == 254
def test_multi_external_mapping_also_promotes(self, mqtt_client):
"""Multi-filament print, every filament mapped to external. Still
all-external -> still promotes."""
mqtt_client._captured_ams_mapping = [-1, -1, -1]
mqtt_client._process_message(_ams_payload(0))
assert mqtt_client.state.tray_now == 254
def test_ams_only_mapping_does_not_override(self, mqtt_client):
"""ams_mapping=[5] (AMS slot 5 only) -> firmware value trusted as-is.
Without the all-external guard, this would falsely override real
AMS-slot prints."""
mqtt_client._captured_ams_mapping = [5]
mqtt_client._process_message(_ams_payload(0))
assert mqtt_client.state.tray_now == 0
def test_mixed_mapping_does_not_override(self, mqtt_client):
"""Mixed mapping (AMS slot 5 + external): we have no evidence the
firmware misreports mid-print swaps, so leave tray_now alone."""
mqtt_client._captured_ams_mapping = [5, -1]
mqtt_client._process_message(_ams_payload(0))
assert mqtt_client.state.tray_now == 0
def test_no_captured_mapping_does_not_override(self, mqtt_client):
"""Prints started from the printer screen (or before Bambuddy
connected) have no captured ams_mapping. Behaviour unchanged from
pre-#1822 — we accept the wrong value rather than guess."""
mqtt_client._captured_ams_mapping = None
mqtt_client._process_message(_ams_payload(0))
assert mqtt_client.state.tray_now == 0
def test_empty_captured_mapping_does_not_override(self, mqtt_client):
"""Empty list (defensive — should not happen in practice but
all([]) returns True). Treat as no signal, not as all-external."""
mqtt_client._captured_ams_mapping = []
mqtt_client._process_message(_ams_payload(0))
assert mqtt_client.state.tray_now == 0
def test_unloaded_after_print_still_resolves_correctly(self, mqtt_client):
"""When the firmware unloads (tray_now=255), the override is skipped
because the branch only fires for tray_now in 0-3."""
mqtt_client._captured_ams_mapping = [-1]
mqtt_client._process_message(_ams_payload(0))
assert mqtt_client.state.tray_now == 254
mqtt_client._process_message(_ams_payload(255))
assert mqtt_client.state.tray_now == 255