mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Pull a Bambu ABS Orange out of A1, put a PLA Matte Dark Blue in, and the slot card still read "Bambu ABS" against the new colour until the page was reloaded. Three things stood between the swap and a correct card. The RFID auto-assign rewrites the slot's slot_preset_mappings row and then broadcast an event that refreshed everything except the query that reads it. Only the manual assign path invalidated that one. Those queries then sat behind the 3s cascade debounce, which exists for print completion, where one event fans out across half the app. A swap touches one slot and the user is standing at the printer looking at the card; worse, the timer restarts on every further event, so a busy moment could defer it indefinitely. Slot changes now invalidate immediately. And the card trusted the stored preset over live telemetry outright. That priority is why a hand-picked preset name stays on a slot, but it also let a cached row outrank what the printer was reporting. The row is now skipped when it names a different official Bambu filament than the tray does, so the card is right from the status push alone. User and local presets carry ids that genuinely cannot be compared and are left exactly as they were. Spoolman mode was the worse half of the same bug: its AMS sync writes the same row but announced nothing at all, so there was no event to refresh on. It now reports each slot it changed or cleared.
164 lines
6.1 KiB
Python
164 lines
6.1 KiB
Python
"""Spoolman's AMS sync has to tell the browser the slot changed.
|
|
|
|
Swap a spool and the AMS slot card keeps showing the *previous* spool's preset
|
|
name. The card reads ``slot_preset_mappings.preset_name`` ahead of the live
|
|
``tray_info_idx``, so a cached row wins over correct data pushed over the
|
|
socket — and everything else on the card rides the status push, which is why it
|
|
surfaces as one wrong line rather than an obviously stale card.
|
|
|
|
Built-in inventory raised ``spool_auto_assigned`` for this (the frontend simply
|
|
forgot to invalidate ``slotPresets`` on it). This loop raised nothing at all,
|
|
even though it rewrites the very same row through
|
|
``upsert_slot_preset_for_spoolman_spool`` — so in Spoolman mode there was no
|
|
event to hang an invalidation on, and the stale name stood until an unrelated
|
|
refetch.
|
|
|
|
An emptied slot counts: its ``spoolman_slot_assignments`` row is deleted here,
|
|
and a card still drawing the removed spool is the same defect.
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.models.settings import Settings
|
|
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
|
|
|
|
|
|
def _status(ams_data):
|
|
status = MagicMock()
|
|
status.raw_data = {"ams": ams_data, "vt_tray": []}
|
|
status.gcode_state = "IDLE"
|
|
return status
|
|
|
|
|
|
def _tray(ams_id: int, tray_id: int):
|
|
"""What ``client.parse_ams_tray`` hands back for an occupied slot."""
|
|
tray = MagicMock()
|
|
tray.ams_id = ams_id
|
|
tray.tray_id = tray_id
|
|
tray.tray_uuid = "EB335968299543078925C71D83DA3864"
|
|
tray.tag_uid = "7757EF0100000100"
|
|
tray.tray_info_idx = "GFA01"
|
|
tray.tray_type = "PLA"
|
|
tray.tray_sub_brands = "PLA Matte"
|
|
tray.tray_color = "042F56FF"
|
|
return tray
|
|
|
|
|
|
async def _run_ams_change(printer_id: int, ams_data: list, *, parsed):
|
|
"""Drive ``on_ams_change`` with Spoolman standing in for the real server.
|
|
|
|
``parsed`` maps (ams_id, tray_id) to a parsed tray or ``None`` (empty slot),
|
|
which is the only thing that decides whether the sync treats the slot as
|
|
occupied or cleared.
|
|
"""
|
|
from backend.app.main import on_ams_change
|
|
|
|
spoolman = MagicMock()
|
|
spoolman.health_check = AsyncMock(return_value=True)
|
|
spoolman.get_spools = AsyncMock(return_value=[])
|
|
spoolman.parse_ams_tray = MagicMock(
|
|
side_effect=lambda ams_id, tray_data: parsed.get((ams_id, int(tray_data.get("id", 0))))
|
|
)
|
|
spoolman.sync_ams_tray = AsyncMock(return_value={"id": 4242})
|
|
|
|
status = _status(ams_data)
|
|
with (
|
|
patch("backend.app.main.printer_manager") as pm_main,
|
|
patch("backend.app.services.printer_manager.printer_manager") as pm_inv,
|
|
patch("backend.app.main.mqtt_relay") as relay,
|
|
patch("backend.app.main.ws_manager") as ws,
|
|
patch("backend.app.main.get_spoolman_client", AsyncMock(return_value=spoolman)),
|
|
patch(
|
|
"backend.app.services.slot_preset_writer.upsert_slot_preset_for_spoolman_spool",
|
|
AsyncMock(),
|
|
),
|
|
):
|
|
pm_main.get_printer.return_value = MagicMock(name="P", serial_number="SER")
|
|
pm_main.get_status.return_value = status
|
|
pm_main.get_client.return_value = MagicMock()
|
|
pm_main.get_model.return_value = "X1C"
|
|
pm_inv.get_status.return_value = status
|
|
pm_inv.get_client.return_value = MagicMock()
|
|
relay.on_ams_change = AsyncMock()
|
|
ws.send_printer_status = AsyncMock()
|
|
ws.broadcast = AsyncMock()
|
|
await on_ams_change(printer_id, ams_data)
|
|
return ws.broadcast, spoolman
|
|
|
|
|
|
def _slot_events(broadcast) -> set[tuple[int, int]]:
|
|
"""(ams_id, tray_id) of every assignment-changed event that went out."""
|
|
return {
|
|
(call.args[0]["ams_id"], call.args[0]["tray_id"])
|
|
for call in broadcast.call_args_list
|
|
if call.args and call.args[0].get("type") == "spool_assignment_changed"
|
|
}
|
|
|
|
|
|
async def _enable_spoolman(db: AsyncSession) -> None:
|
|
db.add(Settings(key="spoolman_enabled", value="true"))
|
|
db.add(Settings(key="spoolman_url", value="http://spoolman.invalid:7912"))
|
|
await db.commit()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_a_synced_slot_is_broadcast(async_client: AsyncClient, printer_factory, db_session: AsyncSession):
|
|
printer = await printer_factory(name="H2C")
|
|
await _enable_spoolman(db_session)
|
|
|
|
broadcast, spoolman = await _run_ams_change(
|
|
printer.id,
|
|
[{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "042F56FF", "state": 11}]}],
|
|
parsed={(0, 0): _tray(0, 0)},
|
|
)
|
|
|
|
spoolman.sync_ams_tray.assert_awaited()
|
|
assert (0, 0) in _slot_events(broadcast)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_an_emptied_slot_is_broadcast(async_client: AsyncClient, printer_factory, db_session: AsyncSession):
|
|
"""The row is deleted here; a card still drawing the removed spool is the
|
|
same bug seen from the other side."""
|
|
printer = await printer_factory(name="H2C")
|
|
await _enable_spoolman(db_session)
|
|
db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=1, spoolman_spool_id=7))
|
|
await db_session.commit()
|
|
|
|
broadcast, _ = await _run_ams_change(
|
|
printer.id,
|
|
[{"id": 0, "tray": [{"id": 1}]}],
|
|
parsed={(0, 1): None},
|
|
)
|
|
|
|
assert (0, 1) in _slot_events(broadcast)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_nothing_is_broadcast_when_no_slot_changed(
|
|
async_client: AsyncClient, printer_factory, db_session: AsyncSession
|
|
):
|
|
"""A push that changes no slot must stay quiet — this runs on every AMS
|
|
message, and an event per push would invalidate the browser's caches
|
|
continuously."""
|
|
printer = await printer_factory(name="H2C")
|
|
await _enable_spoolman(db_session)
|
|
|
|
broadcast, spoolman = await _run_ams_change(
|
|
printer.id,
|
|
[{"id": 0, "tray": []}],
|
|
parsed={},
|
|
)
|
|
|
|
# Guards the assertion below against passing because the sync bailed out
|
|
# early on a mis-set fixture rather than because it found nothing to say.
|
|
spoolman.get_spools.assert_awaited()
|
|
assert _slot_events(broadcast) == set()
|