Files
bambuddy/backend/tests/unit/test_external_spool_mapping_3087.py
T
maziggy 5be18ff57a fix(queue): print a plate whose filaments are all on the external spool (issue #3087)
The reporter's P1S heated up, sat at Heatbed preheating for ten and a half
minutes, then paused with 07FF_8012, "Failed to get AMS mapping table".
Resuming only reheated it. Prints that fed from the AMS were fine.

The plate was one filament of a seven-filament MakerWorld project, mapped by
hand to the external spool. slice_info.config numbers filaments across the
whole project, so the mapping for that plate is [-1,-1,-1,-1,-1,-1,254]: six
placeholders and the spool holder. The command builder decides whether a print
needs the AMS by asking whether the mapping is entirely external, and six -1s
answer no. So the print went out as use_ams=true carrying a flat mapping of
nothing but -1 -- 254 is deliberately never sent raw, the firmware reads it as
AMS tray 0 -- which is exactly the mapping table the firmware then could not
find.

The builder cannot fix this itself. Down there a -1 is either padding for a
filament this plate does not print, which is BambuStudio's own convention, or a
slot that never resolved to a tray, and sending the second one to the spool
holder is what #2589 exists to prevent. They are the same byte.

The scheduler knows. extract_filament_requirements drops every filament with
used_g <= 0, so it names precisely the slots the plate prints. When all of those
are an explicit 254/255, dispatch now sends use_ams=false and the print runs.
When one of them resolved to nothing, the flag is left alone and the firmware
rejects the print as it does today -- deliberately, because that is the case
where guessing would print a filament in the wrong material without saying so.

Single-nozzle only, mirroring the reconcile in the command builder: on a
two-extruder printer use_ams selects which nozzle to feed rather than whether to
use the AMS, so an H2D with a spool on each side must keep the flag it was
given. Judged generously from the model name and from live telemetry -- a second
nozzle reporting a diameter, an extruder map, or more than one external feed --
because a wrong yes only preserves existing behaviour while a wrong no would
reroute the print. H2S stays single-nozzle (#1386).

Nothing in the command builder changed. Its own reconcile keeps the exact
semantics #2589, #2595 and #797 gave it, and now usually agrees with a decision
that was already made one layer up. Where the file has no parseable filament
list the mapping is left exactly as before, the same evidence-only convention
as #2771, and the parse itself sits behind a check for anything external at all
so an AMS-only print never opens the file.

Covered end to end at the dispatcher, including the reporter's seven-filament
shape, a plate mixing the spool holder with an AMS tray, a consumed slot that
never resolved, both external feeds on a dual-nozzle machine, and a 3MF with no
filament list at all.
2026-09-18 17:55:06 +02:00

157 lines
6.8 KiB
Python

"""Which mapping entries a plate actually prints, and what the builder does
with the answer (#3087).
The dispatch-level behaviour is covered in
``backend/tests/integration/test_external_spool_use_ams_3087.py``. This is the
pure part: separating a padding ``-1`` from a slot that never resolved, which
is the distinction the MQTT command builder cannot make and the reason the
decision was put in the scheduler.
"""
import json
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from backend.app.services.bambu_mqtt import BambuMQTTClient
from backend.app.services.print_scheduler import (
_consumed_mapping_entries,
_is_external_tray,
_might_be_dual_nozzle,
)
def _required(*slot_ids):
"""What `extract_filament_requirements` returns: one entry per filament the
plate consumes, keyed by its project-wide slot_id. Anything with
`used_g <= 0` is already dropped there, so everything here is printed."""
return [{"slot_id": s, "type": "PLA", "used_grams": 10.0} for s in slot_ids]
class TestConsumedMappingEntries:
def test_it_picks_out_the_slot_the_plate_prints(self):
# The reporter's plate: seven project filaments, only #7 printed.
assert _consumed_mapping_entries([-1, -1, -1, -1, -1, -1, 254], _required(7)) == [254]
def test_padding_is_not_reported_as_unresolved(self):
assert _consumed_mapping_entries([-1, 5, -1], _required(2)) == [5]
def test_a_slot_the_plate_prints_reports_its_own_unresolved_entry(self):
assert _consumed_mapping_entries([-1, -1, -1, -1, -1, -1, 254], _required(1, 7)) == [-1, 254]
def test_several_printed_slots_come_back_in_slot_order(self):
assert _consumed_mapping_entries([4, -1, 254], _required(1, 3)) == [4, 254]
@pytest.mark.parametrize(
"mapping,required",
[
(None, _required(1)),
([], _required(1)),
([254], None),
([254], []),
],
)
def test_it_declines_to_answer_without_both_halves(self, mapping, required):
# No evidence, no decision — the caller then leaves use_ams alone.
assert _consumed_mapping_entries(mapping, required) is None
def test_a_requirement_the_mapping_is_too_short_for_declines(self):
# The two were read at different times and disagree about the file.
assert _consumed_mapping_entries([254], _required(7)) is None
def test_a_junk_slot_id_declines(self):
assert _consumed_mapping_entries([254], [{"slot_id": "7"}]) is None
assert _consumed_mapping_entries([254], [{"slot_id": 0}]) is None
assert _consumed_mapping_entries([254], [{}]) is None
class TestIsExternalTray:
@pytest.mark.parametrize("tray_id", [254, 255, "254"])
def test_the_spool_holder(self, tray_id):
assert _is_external_tray(tray_id) is True
@pytest.mark.parametrize("tray_id", [None, -1, 0, 5, 253, 128, "", "x", 1.5])
def test_everything_else(self, tray_id):
# 128-253 are AMS-HT units, -1/None unresolved, and junk is not a
# licence to redirect a print to the spool holder.
assert _is_external_tray(tray_id) is False
class TestMightBeDualNozzle:
"""Over-eager on purpose: a wrong yes only leaves a printer with the
behaviour it already had, a wrong no rewrites which nozzle prints."""
def _status(self, *, nozzles=(), **raw):
return SimpleNamespace(nozzles=list(nozzles), raw_data=dict(raw))
@pytest.mark.parametrize("model", ["H2D", "H2C", "X2D", "H2D Pro"])
def test_the_model_name_is_enough(self, model):
assert _might_be_dual_nozzle(model, self._status()) is True
@pytest.mark.parametrize("model", ["P1S", "X1C", "A1", "P2S", "H2S", None, ""])
def test_single_nozzle_models_pass(self, model):
# H2S is the #1386 case: H2 family, one extruder.
assert _might_be_dual_nozzle(model, self._status()) is False
def test_two_external_feeds_give_it_away(self):
# Only a two-extruder printer reports more than one vt_tray.
assert _might_be_dual_nozzle("P1S", self._status(vt_tray=[{"id": "254"}, {"id": "255"}])) is True
def test_one_external_feed_does_not(self):
assert _might_be_dual_nozzle("P1S", self._status(vt_tray=[{"id": "254"}])) is False
def test_a_second_nozzle_with_a_diameter_gives_it_away(self):
nozzles = [SimpleNamespace(nozzle_diameter="0.4"), SimpleNamespace(nozzle_diameter="0.4")]
assert _might_be_dual_nozzle("P1S", self._status(nozzles=nozzles)) is True
def test_a_stub_second_nozzle_does_not(self):
# The status model can carry placeholder NozzleInfo entries; only a
# populated diameter means real hardware.
nozzles = [SimpleNamespace(nozzle_diameter="0.4"), SimpleNamespace(nozzle_diameter="")]
assert _might_be_dual_nozzle("P1S", self._status(nozzles=nozzles)) is False
def test_an_extruder_map_gives_it_away(self):
assert _might_be_dual_nozzle("P1S", self._status(ams_extruder_map={"0": 1})) is True
def test_no_status_at_all_is_not_evidence_of_two(self):
assert _might_be_dual_nozzle("P1S", None) is False
def test_a_vt_tray_dict_is_not_counted_as_many_trays(self):
# bambu_mqtt normalises vt_tray to a list, but a dict here would
# otherwise count its keys and read as dual-nozzle.
assert _might_be_dual_nozzle("P1S", self._status(vt_tray={"id": "254", "tray_type": "PLA"})) is False
class TestTheBuilderHonoursTheDecision:
"""The scheduler's answer has to survive the command builder, which has its
own use_ams reconcile (#2589/#2595). It must not promote the flag back."""
@pytest.fixture
def mqtt_client(self):
client = BambuMQTTClient(ip_address="192.168.1.100", serial_number="01P00A452600691", access_code="x")
client.model = "P1S"
client._client = MagicMock()
client.state.connected = True
return client
def _sent(self, mqtt_client):
return json.loads(mqtt_client._client.publish.call_args.args[1])["print"]
def test_the_reporters_command_now_goes_out_printable(self, mqtt_client):
mqtt_client.start_print("plate_4.3mf", ams_mapping=[-1] * 6 + [254], use_ams=False)
cmd = self._sent(mqtt_client)
assert cmd["use_ams"] is False
# 254 is still never sent raw in the flat array — the firmware reads it
# as AMS tray 0 — and ams_mapping2 still carries the spool holder.
assert cmd["ams_mapping"] == [-1] * 7
assert cmd["ams_mapping2"][6] == {"ams_id": 255, "slot_id": 0}
assert cmd["ams_mapping2"][0] == {"ams_id": 255, "slot_id": 255}
def test_the_builder_still_rejects_an_unresolved_mapping_as_external(self, mqtt_client):
"""The #2589 contract, unchanged: nothing here treats -1 as the spool."""
mqtt_client.start_print("plate_4.3mf", ams_mapping=[-1] * 6 + [254], use_ams=True)
assert self._sent(mqtt_client)["use_ams"] is True