mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-04 13:11:35 +02:00
With a switch fitted, an AMS is not wired to a nozzle any more. It is plumbed into one of the switch's two inlets and reaches both nozzles through it, so every unit reports its extruder as "not fixed" (0xE) and ams_extruder_map comes back empty. Bambuddy had nothing to fall back on but the AMS unit number, so AMS-A was badged R and AMS-B was badged L purely because their unit ids are 0 and 1, a third unit got no badge at all, and every one of those labels was wrong. The binding needed no new telemetry. BambuStudio reads it out of bits 24-27 of the same AMS info string we already parse for the type and the extruder id -- 0 is In-B, 1 is In-A -- and it only means anything when a switch is installed, because without one 0xE really does mean an uninitialised unit. That gates the read, which forced the switch block to be parsed before the AMS block: _handle_ams_data runs early in _process_message and _update_state only much later, so the binding was lost on every frame that carried both. The badge letters stay L and R, In-A reading as L, with the inlet named in full in the tooltip -- the letter is the inlet's position and not a claim about which nozzle that AMS feeds, since the switch can route either inlet to either outlet. Both views update live now. The switch fields were missing from the WebSocket payload, and the frontend shallow-merges each push over its cached status, so an absent field kept whatever the last full fetch left behind. The broadcast dedup key had no term for them either, so "Join IN-B" on the printer screen moved nothing: the binding is not in the tray component of that key, and not in the AMS change-hash, which covers tray fields only and must stay that way because it drives Spoolman sync. The rest of this is the calibration half, which is where it actually bites. K-profiles are calibrated per nozzle and the printer numbers its calibration table per nozzle too, so entry 16 exists on both hotends and means a different profile on each. A tray holds exactly one index. Move an AMS to the other inlet and every configured slot in it silently keeps pointing at the old hotend's table -- measured on the maintainer's H2C, a black PLA calibrated 0.018 left and 0.020 right stayed on the left profile after the move, and a manual RFID re-read only re-asserted the same wrong one. Bambu has not solved this either; their AMS dialog carries "TODO: fila_switcher broken the connection of ams->extruder" above the line that decides which nozzle's profiles to offer. Three copies of "which extruder is this slot on" each ended in else 0, which on a switch machine filed every profile under the right-hand nozzle. They now share one resolver that returns None for "unknown", because unknown and extruder 0 are very different answers on a dual-nozzle machine and conflating them is what bound a left-nozzle profile to a slot sitting on the right. The per-slot K value on the printer card had the same confusion from the other direction: its lookup was keyed on cali_idx alone, so one nozzle's K silently overwrote the other's. Moving an AMS now re-selects each configured slot's counterpart profile for the nozzle it has arrived on. Only the calibration binding moves, and only for slots whose spool already has a profile for that nozzle: configuring a slot is a deliberate preparation step, so a slot we know nothing about, or a spool calibrated on one hotend only, is left exactly as the operator set it. Nothing fires on the first sighting of a binding either, since every reconnect learns them afresh and re-applying there would overwrite a choice made by hand. Configure Slot resolves against the slot's own nozzle throughout. Option identity carries the extruder, so a filament calibrated on both hotends gives two distinguishable entries instead of two that collapse into whichever the printer listed first; options name the hotend; matches are scoped to the nozzle the slot feeds, with the other hotend's profiles still reachable under Other K profiles; and the slot's active index is resolved as a pair rather than followed into the wrong table. Inlet to nozzle is one table, In-A to the left hotend and In-B to the right, measured rather than assumed -- fila_switch.out cannot be used for it, reporting [1, 1] unchanged across a 90-second capture, both outlets claiming the same extruder. The print dialog picks up the same inlet labelling in its slot dropdown, replacing a left/right hint that never rendered because it matched snow-encoded values against global tray ids; decoding it correctly would not have saved it, since the firmware never reports which inlet is currently paired with which outlet. The dialog also notes when every filament a print needs sits behind one inlet, which is legal but slow -- a change between two filaments on the same inlet retracts the outgoing spool all the way back to its AMS, where a change across the two only retracts as far as the switch. Assigning an AMS to an inlet remains printer-side. BambuStudio can read that binding and has no command to write it, so there is no wire format to copy.
231 lines
9.0 KiB
Python
231 lines
9.0 KiB
Python
"""K-profiles follow an AMS when it moves between Filament Track Switch inlets.
|
|
|
|
Measured on the maintainer's H2C, 2026-08-16. Spool 75, "HF Bambu PLA Matte
|
|
Black", is calibrated on both hotends and Bambuddy stores both:
|
|
|
|
extruder 1 (left) K 0.018 cali_idx 16
|
|
extruder 0 (right) K 0.020 cali_idx 15
|
|
|
|
A tray holds exactly one ``cali_idx``, and the printer's calibration table is
|
|
numbered per nozzle — so index 16 exists on both hotends and means a different
|
|
profile on each. Moving that AMS from In-A to In-B therefore leaves the slot
|
|
bound to the *left* profile while it now feeds the right nozzle. The printer
|
|
does not re-resolve it, and an RFID re-read only re-asserts the same wrong
|
|
index. That is the state the live printer was in when this was written:
|
|
|
|
AMS 1 info=10002E03 inlet=IN-B tray 0: PLA 000000 cali_idx=16
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from backend.app.utils.fts_routing import FTS_INLET_EXTRUDER, extruder_for_inlet, slot_extruder
|
|
|
|
|
|
class TestInletToNozzle:
|
|
def test_in_a_is_the_left_hotend(self):
|
|
assert extruder_for_inlet("A") == 1
|
|
|
|
def test_in_b_is_the_right_hotend(self):
|
|
assert extruder_for_inlet("B") == 0
|
|
|
|
@pytest.mark.parametrize("value", [None, "", "C", "left"])
|
|
def test_anything_else_is_unknown(self, value):
|
|
assert extruder_for_inlet(value) is None
|
|
|
|
def test_the_two_inlets_do_not_share_a_nozzle(self):
|
|
"""A table that mapped both inlets to one hotend would silently bind
|
|
every slot to the same K-profile."""
|
|
assert sorted(FTS_INLET_EXTRUDER.values()) == [0, 1]
|
|
|
|
|
|
class TestSlotExtruder:
|
|
def test_a_real_extruder_id_wins(self):
|
|
"""A non-0xE binding is authoritative even on a machine with a switch,
|
|
which is how BambuStudio treats it too."""
|
|
assert slot_extruder(2, 0, {"2": 1}, {"2": "B"}) == 1
|
|
|
|
def test_falls_back_to_the_switch_inlet(self):
|
|
"""The FTS case: every AMS reports 0xE, so the map is empty."""
|
|
assert slot_extruder(1, 0, {}, {"1": "B"}) == 0
|
|
assert slot_extruder(2, 3, {}, {"2": "A"}) == 1
|
|
|
|
def test_unknown_is_none_not_zero(self):
|
|
"""The bug this replaced: three call sites ended in `else 0`, which on a
|
|
switch machine filed every profile under the right-hand nozzle."""
|
|
assert slot_extruder(1, 0, {}, {}) is None
|
|
assert slot_extruder(1, 0, None, None) is None
|
|
|
|
def test_external_slots_name_their_own_side(self):
|
|
assert slot_extruder(255, 0, {}, {}) == 1 # Ext-L
|
|
assert slot_extruder(255, 1, {}, {}) == 0 # Ext-R
|
|
|
|
def test_an_unmapped_ams_does_not_borrow_another_units_inlet(self):
|
|
assert slot_extruder(3, 0, {}, {"1": "A", "2": "B"}) is None
|
|
|
|
def test_the_maintainers_h2c(self):
|
|
"""The live layout: AMS 0/1/128 on In-B, AMS 2 on In-A, empty map."""
|
|
inlets = {"0": "B", "1": "B", "2": "A", "128": "B"}
|
|
assert slot_extruder(0, 0, {}, inlets) == 0
|
|
assert slot_extruder(1, 0, {}, inlets) == 0
|
|
assert slot_extruder(2, 1, {}, inlets) == 1
|
|
assert slot_extruder(128, 0, {}, inlets) == 0
|
|
|
|
|
|
def _profile(cali_idx: int, extruder: int, k: float):
|
|
return MagicMock(
|
|
cali_idx=cali_idx, extruder=extruder, k_value=k, name="HF Bambu PLA Matte Black", filament_id="GFA01"
|
|
)
|
|
|
|
|
|
class TestReSelectOnInletMove:
|
|
"""The callback that re-points a moved AMS's slots."""
|
|
|
|
@staticmethod
|
|
def _run(inlet, tray, profile, *, connected=True):
|
|
"""Drive on_fts_inlet_change for one AMS holding one tray."""
|
|
from backend.app import main as main_module
|
|
|
|
client = MagicMock()
|
|
client.extrusion_cali_sel = MagicMock(return_value=True)
|
|
state = MagicMock()
|
|
state.raw_data = {"ams": [{"id": "1", "tray": [tray]}]}
|
|
state.nozzles = [MagicMock(nozzle_diameter="0.4")]
|
|
|
|
pm = MagicMock()
|
|
pm.get_client.return_value = client if connected else None
|
|
pm.get_status.return_value = state
|
|
|
|
session = AsyncMock()
|
|
session.__aenter__ = AsyncMock(return_value=session)
|
|
session.__aexit__ = AsyncMock()
|
|
|
|
with (
|
|
patch("backend.app.main.printer_manager", pm),
|
|
patch("backend.app.main.async_session", return_value=session),
|
|
patch(
|
|
"backend.app.main.find_slot_kprofile_for_extruder",
|
|
new=AsyncMock(return_value=profile),
|
|
) as lookup,
|
|
):
|
|
import asyncio
|
|
|
|
asyncio.run(main_module.on_fts_inlet_change(7, 1, inlet))
|
|
return client, lookup
|
|
|
|
def test_moving_to_in_b_selects_the_right_hotends_profile(self):
|
|
"""The reported case, with the maintainer's real numbers."""
|
|
tray = {"id": "0", "tray_type": "PLA", "cali_idx": 16, "tray_info_idx": "GFA01"}
|
|
client, lookup = self._run("B", tray, _profile(15, 0, 0.020))
|
|
|
|
assert lookup.await_args.args[4] == 0, "must look up the RIGHT hotend's profile"
|
|
client.extrusion_cali_sel.assert_called_once()
|
|
assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 15
|
|
|
|
def test_moving_to_in_a_selects_the_left_hotends_profile(self):
|
|
tray = {"id": "0", "tray_type": "PLA", "cali_idx": 15, "tray_info_idx": "GFA01"}
|
|
client, lookup = self._run("A", tray, _profile(16, 1, 0.018))
|
|
|
|
assert lookup.await_args.args[4] == 1
|
|
assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 16
|
|
|
|
def test_an_already_correct_slot_is_left_alone(self):
|
|
"""No point re-sending a binding the printer already holds, and every
|
|
avoided write is one less chance of the firmware mislinking it."""
|
|
tray = {"id": "0", "tray_type": "PLA", "cali_idx": 15, "tray_info_idx": "GFA01"}
|
|
client, _ = self._run("B", tray, _profile(15, 0, 0.020))
|
|
|
|
client.extrusion_cali_sel.assert_not_called()
|
|
|
|
def test_a_spool_with_no_profile_for_that_nozzle_is_untouched(self):
|
|
"""Calibrated on one hotend only: keep whatever the operator set by hand
|
|
rather than swapping it for a guess."""
|
|
tray = {"id": "0", "tray_type": "PLA", "cali_idx": 16, "tray_info_idx": "GFA01"}
|
|
client, _ = self._run("B", tray, None)
|
|
|
|
client.extrusion_cali_sel.assert_not_called()
|
|
|
|
def test_an_empty_slot_is_skipped(self):
|
|
tray = {"id": "0", "tray_type": "", "cali_idx": -1}
|
|
client, lookup = self._run("B", tray, _profile(15, 0, 0.020))
|
|
|
|
lookup.assert_not_awaited()
|
|
client.extrusion_cali_sel.assert_not_called()
|
|
|
|
def test_a_disconnected_printer_is_a_no_op(self):
|
|
tray = {"id": "0", "tray_type": "PLA", "cali_idx": 16, "tray_info_idx": "GFA01"}
|
|
client, _ = self._run("B", tray, _profile(15, 0, 0.020), connected=False)
|
|
|
|
client.extrusion_cali_sel.assert_not_called()
|
|
|
|
def test_an_unknown_inlet_is_a_no_op(self):
|
|
tray = {"id": "0", "tray_type": "PLA", "cali_idx": 16, "tray_info_idx": "GFA01"}
|
|
client, lookup = self._run("C", tray, _profile(15, 0, 0.020))
|
|
|
|
lookup.assert_not_awaited()
|
|
client.extrusion_cali_sel.assert_not_called()
|
|
|
|
|
|
class TestTheMoveIsDetected:
|
|
"""The MQTT side: only a genuine move fires the callback."""
|
|
|
|
@pytest.fixture
|
|
def mqtt_client(self):
|
|
from backend.app.services.bambu_mqtt import BambuMQTTClient
|
|
|
|
return BambuMQTTClient(ip_address="192.168.1.100", serial_number="TEST123", access_code="12345678")
|
|
|
|
@staticmethod
|
|
def _info(inlet_bits: int) -> str:
|
|
return f"{(inlet_bits << 24) | (0xE << 8) | 1:08X}"
|
|
|
|
def _push(self, client, inlet_bits):
|
|
client._process_message(
|
|
{
|
|
"print": {
|
|
"gcode_state": "IDLE",
|
|
"device": {"fila_switch": {"in": [-1, -1], "out": [1, 1], "stat": 1, "info": 0}},
|
|
"ams": {"ams": [{"id": "1", "info": self._info(inlet_bits), "tray": []}]},
|
|
}
|
|
}
|
|
)
|
|
|
|
def test_a_move_fires_once(self, mqtt_client):
|
|
seen = []
|
|
mqtt_client.on_fts_inlet_change = lambda ams_id, inlet: seen.append((ams_id, inlet))
|
|
|
|
self._push(mqtt_client, 1) # In-A
|
|
self._push(mqtt_client, 0) # In-B
|
|
|
|
assert seen == [(1, "B")]
|
|
|
|
def test_the_first_sighting_does_not_fire(self, mqtt_client):
|
|
"""Every reconnect learns the bindings afresh. Re-applying K-profiles
|
|
there would fight a binding the operator set deliberately."""
|
|
seen = []
|
|
mqtt_client.on_fts_inlet_change = lambda ams_id, inlet: seen.append((ams_id, inlet))
|
|
|
|
self._push(mqtt_client, 1)
|
|
|
|
assert seen == []
|
|
|
|
def test_repeated_frames_do_not_fire(self, mqtt_client):
|
|
seen = []
|
|
mqtt_client.on_fts_inlet_change = lambda ams_id, inlet: seen.append((ams_id, inlet))
|
|
|
|
for _ in range(4):
|
|
self._push(mqtt_client, 1)
|
|
|
|
assert seen == []
|
|
|
|
def test_moving_back_fires_again(self, mqtt_client):
|
|
seen = []
|
|
mqtt_client.on_fts_inlet_change = lambda ams_id, inlet: seen.append((ams_id, inlet))
|
|
|
|
self._push(mqtt_client, 1)
|
|
self._push(mqtt_client, 0)
|
|
self._push(mqtt_client, 1)
|
|
|
|
assert seen == [(1, "B"), (1, "A")]
|