Broadcast AMS slot changes that keep the same material

Configuring a slot from the printer card left the card showing the old
filament until a reload or the 30s fallback poll. The command reached the
printer and the printer applied it; the update just never got broadcast.

on_printer_status_change deduplicates WebSocket pushes against a status_key
whose AMS part carried id, tray_type and state. Configure Slot writes none of
those -- it writes tray_info_idx, tray_color, tray_sub_brands and cali_idx. So
PLA to another brand or colour of PLA produced an identical key and was
dropped, while PLA to PETG came through. Reset always worked because it clears
tray_type.

Those four fields only move when someone configures a slot or swaps a spool,
so this costs no broadcasts mid-print. remain stays out of the key for the
opposite reason.
This commit is contained in:
maziggy
2026-08-05 14:53:05 +02:00
parent 3bbe00784f
commit 684a328d6f
3 changed files with 269 additions and 1 deletions
+1
View File
@@ -16,6 +16,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests.
### Fixed
- **Configuring an AMS slot shows up on the printer card straight away, without a page reload** — Setting a slot to a different filament from the printer card left the card showing the old one. Nothing was lost: the command reached the printer, the printer applied it, and reloading the page or waiting out the thirty-second fallback poll showed the new filament. It simply never arrived on its own. Bambuddy compares each status push from a printer against the last one it broadcast and stays quiet when nothing has changed, which is what keeps a machine mid-print from flooding every open browser tab several times a second. The comparison looked at each AMS tray's slot number, material and load state — and **Configure Slot** writes none of those. It writes the filament id, the colour, the profile name and the calibration profile. So changing PLA to a different brand or colour of PLA produced a push that looked identical to its predecessor and was discarded, while changing PLA to PETG came through immediately because the material had moved. That is also why **Reset** always worked: it clears the material. The comparison now covers the filament identity as well, so every kind of slot change reaches the card. Those fields only move when someone configures a slot or swaps a spool, so this adds no traffic during a print — the amount of filament left, which does tick down continuously, is still deliberately excluded. Covered by backend tests.
- **"Any X2D" works on a printer that feeds from external spools instead of an AMS (#2771, reporter @Nick-C130)** — A fleet of five X2Ds with no AMS units, each printing PETG from its external spool holder, accepted a job sent to a named printer and refused the same job sent to **Any X2D**: the file uploaded, the printer answered "Failed to get AMS mapping table", and after three attempts the queue item failed. The two paths differ in one thing. A job queued for a named printer carries a filament mapping the browser worked out at the time you queued it; a job queued for a model has no printer yet, so the scheduler has to work the mapping out at dispatch — and its copy of that logic could not see an external spool on a dual-nozzle printer. On an X2D or H2D each filament in the sliced file names the nozzle it feeds, and Bambuddy will only offer a spool to the nozzle it is physically plumbed to. Which nozzle an external spool feeds was being read off a table the printer builds from its AMS units, so a printer with no AMS published an empty table, every external spool came back belonging to no nozzle at all, and the per-nozzle check discarded the only filament on the machine. Nothing matched, and the print went out claiming to use an AMS while carrying no mapping — which is the message the firmware was objecting to. The left and right external feeds identify themselves well enough to be routed without that table, and the printer reports its two nozzles directly, so both are now used. This is the same fault that was corrected in the browser last May for exactly this hardware; the scheduler kept the old logic, which is why the browser-resolved mapping worked and the scheduler-resolved one did not. Single-nozzle printers are untouched — they have no nozzle to route to and never took this branch. Separately, a job whose filament genuinely cannot be matched on a printer with no AMS now fails immediately and says which filament is missing and which nozzle wants it, instead of uploading several megabytes, collecting the firmware's error and failing anyway two retries later; where there *is* an AMS the firmware error still stands, because there the job can be recovered by loading a spool and pressing **Resume**. Covered by backend tests.
- **LDAP login works again on directories that define no POSIX group class (#2769, reporter @peterskotte)** — Every LDAP user on an lldap directory was rejected with "Incorrect username or password", including users whose credentials, search filter and group membership all checked out when tested by hand with `ldapsearch`, and on an install where **Test Connection** reported success. The password was never the problem and the directory never saw the request. When resolving a user's groups Bambuddy looks for POSIX groups alongside the usual `memberOf` ones, and both of those searches name the `posixGroup` object class. The LDAP client validates class names in a filter against the schema the server publishes, and rejects an unknown one while building the request, before anything is sent. lldap marks every account it creates as `posixAccount`, which is what makes Bambuddy look for POSIX groups in the first place, but defines no group class beyond `groupOfNames` — so the search was refused, the refusal travelled all the way out of the login routine, and the login route reports any LDAP failure as bad credentials. A directory with no `posixGroup` class has no `posixGroup` entries, which is precisely the answer those searches would have returned, so Bambuddy now treats the refusal as the empty result it stands for, notes it once in the log and carries on with the `memberOf` groups. The reporter's mapped group is one of those, so it resolves as configured. This is not a regression from the recent primary-group work, though that is the natural suspect: the `memberUid` search has named the same class since LDAP support first shipped, and it runs for every user whether or not they have a `gidNumber`, so login has never worked against a directory of this shape. **Test Connection** passed throughout because it asks only whether any entry exists, a form of filter that carries no class name to validate. Nothing changes for Active Directory or for an OpenLDAP that loads the standard NIS schema — both define the class, and their POSIX groups are still read. Wiki updated. Covered by backend tests.
- **Spoolman no longer charges a Bambu Studio print to the wrong spool (#2768)** — A sliced file numbers its filaments 1, 2, 3, 4, and which AMS tray each of those came from is a separate decision made when the job is sent. Bambuddy learns that decision one of two ways: it made the choice itself, for a print started from Bambuddy, or it read the print command as it crossed the local network, for a print sent from a slicer. A job dispatched from Bambu Studio while the printer is signed in to Bambu's cloud satisfies neither — the command travels through Bambu's own broker and never appears on the network Bambuddy is listening to. With nothing recorded, the Spoolman writer fell back to assuming the AMS was loaded in slicer order: filament 1 from the first loaded tray, filament 2 from the second. The reporter's X1C was loaded in the order 2, 4, 1, AMS-HT, so every one of the four was deducted from the wrong spool. It also changed what the print looked like afterwards: on completion Bambuddy stamps the archive with the material and colour of the spools it charged, so the print showed the right filament while it ran and switched to a different one the moment it finished — which is how the reporter noticed. The printer knew the answer all along. It publishes the running job's slot-to-tray assignment in its own status, and Bambuddy's built-in filament inventory has read that field for as long as it has resolved mappings at completion; only the Spoolman writer, which resolves at print start instead, never learned to. It now consults the same two fallbacks at the same moment: the printer's report first, and failing that a colour match of the sliced filaments against the loaded trays, which covers the A1, A1 Mini, P1S and P2S — those models publish no such field, so their owners were on the positional guess no matter how the print was sent. Reading the field at completion rather than at print start is deliberate: a printer keeps publishing the last job's mapping while it sits idle, so consulting it early risks stamping the previous print's mapping onto this one. A mapping Bambuddy or the slicer actually recorded is never second-guessed, so nothing changes for prints started from Bambuddy, from the queue, or over LAN. Cancelled and failed prints take the same correction, since partial usage is charged through the same mapping. The resolved mapping and where it came from are now logged at both print start and completion, so the next report of a wrong deduction can be read straight out of a support bundle. Wiki updated. Covered by backend tests.
+21 -1
View File
@@ -1279,9 +1279,29 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
# Include AMS dry_time and tray state values so drying/slot changes trigger broadcasts
ams_dry_key = tuple(a.get("dry_time", 0) for a in (state.raw_data.get("ams") or [])) if state.raw_data else ()
# Include tray states so load/unload transitions (state 11→10) trigger broadcasts (#784)
#
# The filament identity fields are here because Configure Slot writes
# exactly those and nothing else. Re-configuring a slot from PLA to another
# brand or colour of PLA leaves id/tray_type/state identical, so the key
# matched, this function returned before broadcasting, and the card kept
# showing the old filament until the 30s fallback poll or a page reload —
# even though the configure route asks the printer for a fresh pushall and
# that push does carry the new values. Reset always worked, because it
# clears tray_type.
#
# These fields only change when someone configures a slot or swaps a spool,
# so unlike temperature or progress they add no broadcast traffic mid-print.
ams_tray_key = (
tuple(
(t.get("id"), t.get("tray_type", ""), t.get("state"))
(
t.get("id"),
t.get("tray_type", ""),
t.get("state"),
t.get("tray_color", ""),
t.get("tray_info_idx", ""),
t.get("tray_sub_brands", ""),
t.get("cali_idx"),
)
for a in (state.raw_data.get("ams") or [])
for t in a.get("tray", [])
)
@@ -0,0 +1,247 @@
"""Configuring an AMS slot must reach the printer card without a page reload.
`on_printer_status_change` deduplicates WebSocket broadcasts against a
`status_key`. Its AMS component used to carry only id / tray_type / state, so
re-configuring a slot to a different brand or colour of the SAME material
produced an identical key: the printer's pushall arrived with the new values,
the handler compared, found no change, and returned without broadcasting. The
card then showed the old filament until the 30s fallback poll or an F5.
Reset never had the bug — it clears tray_type, which was always in the key.
That asymmetry is what these tests pin: every field Configure Slot writes has
to move the key, and the fields that churn every second still must not.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app import main as main_module
def _spawn_patch():
"""Close the reconcile coroutine the handler builds as a call argument.
Same reason as test_printer_offline_notification.py: a bare MagicMock keeps
it alive in call_args and it finalises unawaited during a later test's GC.
"""
return patch(
"backend.app.main.spawn_background_task",
side_effect=lambda coro, **kwargs: coro.close(),
)
def _tray(**overrides) -> dict:
"""One AMS tray as the firmware reports it, mid-way through a print job.
Defaults describe a configured slot: Bambu PLA Basic in black, bound to
calibration slot 3.
"""
tray = {
"id": "0",
"tray_type": "PLA",
"state": 10,
"tray_color": "000000FF",
"tray_info_idx": "GFA00",
"tray_sub_brands": "PLA Basic",
"cali_idx": 3,
"remain": 42,
}
tray.update(overrides)
return tray
def _state(trays: list[dict]) -> SimpleNamespace:
"""Minimal PrinterState stub carrying one AMS unit.
Idle and unheated, so the handler runs straight from the dedup check to the
broadcast without touching progress milestones, HMS notifications or the DB.
"""
return SimpleNamespace(
connected=True,
state="IDLE",
progress=0,
layer_num=0,
temperatures={},
raw_data={"ams": [{"id": "0", "dry_time": 0, "tray": trays}]},
stg_cur=0,
cooling_fan_speed=0,
big_fan1_speed=0,
big_fan2_speed=0,
chamber_light="",
active_extruder=0,
tray_now=0,
door_open=False,
subtask_name="",
gcode_file="",
remaining_time=None,
hms_errors=[],
ams_filament_backup=None,
)
@pytest.fixture(autouse=True)
def _reset_edge_state():
main_module._last_status_broadcast.clear()
main_module._printer_last_connected.clear()
main_module._printer_reconciled_since_connect.clear()
yield
main_module._last_status_broadcast.clear()
main_module._printer_last_connected.clear()
main_module._printer_reconciled_since_connect.clear()
async def _push(ws_mgr, trays: list[dict]) -> None:
"""Deliver one status push to the handler."""
relay = MagicMock()
relay.on_printer_status = AsyncMock()
pm = MagicMock()
pm.get_printer.return_value = None # Skip the relay payload branch.
pm.get_model.return_value = ""
with (
patch("backend.app.main.ws_manager", ws_mgr),
patch("backend.app.main.mqtt_relay", relay),
patch("backend.app.main.printer_manager", pm),
_spawn_patch(),
patch("backend.app.main.printer_state_to_dict", return_value={}),
):
await main_module.on_printer_status_change(1, _state(trays))
@pytest.fixture
def ws_mgr():
mgr = MagicMock()
mgr.send_printer_status = AsyncMock()
return mgr
class TestConfigureSlotBroadcasts:
"""Each field Configure Slot writes must break the dedup on its own —
the user may change only the colour, or only the K-profile."""
@pytest.mark.asyncio
@pytest.mark.parametrize(
"field,new_value",
[
("tray_color", "FF0000FF"),
("tray_info_idx", "GFA01"),
("tray_sub_brands", "PLA Matte"),
("cali_idx", 7),
],
)
async def test_a_changed_filament_field_broadcasts(self, ws_mgr, field, new_value):
await _push(ws_mgr, [_tray()])
assert ws_mgr.send_printer_status.await_count == 1
await _push(ws_mgr, [_tray(**{field: new_value})])
assert ws_mgr.send_printer_status.await_count == 2, (
f"changing {field} did not reach the frontend — the card would keep "
"showing the old filament until the fallback poll"
)
@pytest.mark.asyncio
async def test_the_realistic_reconfigure_broadcasts(self, ws_mgr):
"""Black Bambu PLA Basic → red eSUN PLA+ with its own K-profile.
The whole point of the report: same material, so every field the old key
looked at is unchanged.
"""
await _push(ws_mgr, [_tray()])
await _push(
ws_mgr,
[
_tray(
tray_color="C1121FFF",
tray_info_idx="GFL99",
tray_sub_brands="eSUN PLA+",
cali_idx=5,
)
],
)
assert ws_mgr.send_printer_status.await_count == 2
@pytest.mark.asyncio
async def test_a_second_slot_is_watched_too(self, ws_mgr):
"""The key spans every tray, so configuring slot 2 must broadcast even
though slot 1 is untouched."""
trays = [_tray(id="0"), _tray(id="1", tray_type="PETG", tray_info_idx="GFG00")]
await _push(ws_mgr, trays)
changed = [_tray(id="0"), _tray(id="1", tray_type="PETG", tray_info_idx="GFG01")]
await _push(ws_mgr, changed)
assert ws_mgr.send_printer_status.await_count == 2
class TestDedupStillHolds:
"""The dedup exists to keep a printing machine from flooding the socket.
Widening the key must not have cost that."""
@pytest.mark.asyncio
async def test_an_identical_push_is_still_suppressed(self, ws_mgr):
await _push(ws_mgr, [_tray()])
await _push(ws_mgr, [_tray()])
assert ws_mgr.send_printer_status.await_count == 1
@pytest.mark.asyncio
async def test_remaining_filament_does_not_broadcast(self, ws_mgr):
"""`remain` ticks down throughout a print and is deliberately absent
from the key. It sits in the same tray dict as the fields we added, so
this pins that we widened the key rather than hashing the whole tray."""
await _push(ws_mgr, [_tray(remain=42)])
await _push(ws_mgr, [_tray(remain=41)])
assert ws_mgr.send_printer_status.await_count == 1
class TestExistingBehaviourUnchanged:
"""The cases that already worked, kept working."""
@pytest.mark.asyncio
async def test_a_load_unload_transition_still_broadcasts(self, ws_mgr):
"""#784 — tray state 11→10."""
await _push(ws_mgr, [_tray(state=11)])
await _push(ws_mgr, [_tray(state=10)])
assert ws_mgr.send_printer_status.await_count == 2
@pytest.mark.asyncio
async def test_resetting_a_slot_still_broadcasts(self, ws_mgr):
"""Reset clears the filament identity outright."""
await _push(ws_mgr, [_tray()])
await _push(
ws_mgr,
[_tray(tray_type="", tray_color="", tray_info_idx="", tray_sub_brands="", cali_idx=-1)],
)
assert ws_mgr.send_printer_status.await_count == 2
@pytest.mark.asyncio
async def test_a_printer_with_no_ams_still_broadcasts_once(self, ws_mgr):
"""The `else ()` branch — an AMS-less printer must not crash or
double-broadcast."""
relay = MagicMock()
relay.on_printer_status = AsyncMock()
pm = MagicMock()
pm.get_printer.return_value = None
pm.get_model.return_value = ""
state = _state([])
state.raw_data = {}
for _ in range(2):
with (
patch("backend.app.main.ws_manager", ws_mgr),
patch("backend.app.main.mqtt_relay", relay),
patch("backend.app.main.printer_manager", pm),
_spawn_patch(),
patch("backend.app.main.printer_state_to_dict", return_value={}),
):
await main_module.on_printer_status_change(1, state)
assert ws_mgr.send_printer_status.await_count == 1