mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 01:57:41 +02:00
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:
+21
-1
@@ -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
|
||||
Reference in New Issue
Block a user