fix(spoolman): AMS syncs write only the slots whose spool they changed and keep a spool assigned meanwhile; the Sync buttons announce slot changes and update the slot preset; a new Spoolman spool is not seeded from leftover built-in assignments

This commit is contained in:
maziggy
2026-10-19 21:49:18 +02:00
parent c2bc41a5ef
commit fcc20cb62c
6 changed files with 486 additions and 115 deletions
@@ -48,12 +48,12 @@ def _tray(ams_id: int, tray_id: int):
return tray
async def _run_ams_change(printer_id: int, ams_data: list, *, parsed):
async def _run_ams_change(printer_id: int, ams_data: list, *, parsed, sync=None):
"""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.
occupied or cleared. ``sync`` stands in for ``sync_ams_tray``.
"""
from backend.app.main import on_ams_change
@@ -63,14 +63,17 @@ async def _run_ams_change(printer_id: int, ams_data: list, *, parsed):
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})
spoolman.sync_ams_tray = sync or AsyncMock(return_value={"id": 4242})
status = _status(ams_data)
ws = MagicMock()
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.ws_manager", ws),
# The slot writes are announced by the shared writer.
patch("backend.app.services.spoolman_slot_sync.ws_manager", 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",
@@ -0,0 +1,248 @@
"""What the Spoolman AMS syncs write to the slot table, and announce (SM-C3, SSA-2, SSA-3).
- The automatic pass read the slot table, then awaited Spoolman for every tray
and wrote every synced slot back, unchanged ones included. A spool assigned
by hand in between was overwritten with what the pass had read. It now writes
only slots whose spool it changed, and only while they still hold what it read.
- The Sync buttons in Settings wrote slots without announcing them, refreshing
the slot's preset or clearing its grace hold, as the automatic pass does.
- Both seeded a newly created spool's weight from the built-in inventory's
slot assignments, which in Spoolman mode are left over from before the switch.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select, update
from backend.app.models.spool import Spool
from backend.app.models.spool_assignment import SpoolAssignment
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
from backend.app.services.spoolman import AMSTray
from backend.tests.integration.test_spoolman_ams_sync_broadcast import (
_enable_spoolman,
_run_ams_change,
_slot_events,
_tray,
)
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
async def _slots(db_session, printer_id) -> dict[tuple[int, int], int]:
rows = (
await db_session.execute(
select(SpoolmanSlotAssignment)
.where(SpoolmanSlotAssignment.printer_id == printer_id)
.execution_options(populate_existing=True)
)
).scalars()
return {(row.ams_id, row.tray_id): row.spoolman_spool_id for row in rows}
async def _printer_with_slot(db_session, printer_factory, spool_id: int = 7) -> int:
await _enable_spoolman(db_session)
printer = await printer_factory()
db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=0, spoolman_spool_id=spool_id))
await db_session.commit()
return printer.id
def _assigning_by_hand_meanwhile(db_session, printer_id, slot, spool_id, returns):
"""sync_ams_tray, during which the user assigns *spool_id* to *slot*."""
async def sync(tray, *args, **kwargs):
await db_session.execute(
update(SpoolmanSlotAssignment)
.where(
SpoolmanSlotAssignment.printer_id == printer_id,
SpoolmanSlotAssignment.ams_id == slot[0],
SpoolmanSlotAssignment.tray_id == slot[1],
)
.values(spoolman_spool_id=spool_id)
)
await db_session.commit()
return returns
return AsyncMock(side_effect=sync)
AMS = [{"id": 0, "tray": [{"id": 0}, {"id": 1}]}]
@pytest.mark.parametrize("synced", [7, 8], ids=["unchanged", "changed"])
async def test_the_automatic_pass_keeps_a_spool_assigned_meanwhile(
async_client: AsyncClient, db_session, printer_factory, synced
):
printer_id = await _printer_with_slot(db_session, printer_factory)
sync = _assigning_by_hand_meanwhile(db_session, printer_id, (0, 0), 9, {"id": synced})
broadcast, _client = await _run_ams_change(printer_id, AMS[:1], parsed={(0, 0): _tray(0, 0)}, sync=sync)
assert await _slots(db_session, printer_id) == {(0, 0): 9}
assert (0, 0) not in _slot_events(broadcast), "nothing the pass wrote changed"
async def test_the_automatic_pass_announces_no_slot_it_left_as_it_was(
async_client: AsyncClient, db_session, printer_factory
):
printer_id = await _printer_with_slot(db_session, printer_factory)
broadcast, _client = await _run_ams_change(
printer_id, AMS[:1], parsed={(0, 0): _tray(0, 0)}, sync=AsyncMock(return_value={"id": 7})
)
assert await _slots(db_session, printer_id) == {(0, 0): 7}
assert (0, 0) not in _slot_events(broadcast)
async def test_the_automatic_pass_still_writes_and_announces_what_it_changed(
async_client: AsyncClient, db_session, printer_factory
):
printer_id = await _printer_with_slot(db_session, printer_factory)
broadcast, _client = await _run_ams_change(
printer_id, AMS[:1], parsed={(0, 0): _tray(0, 0)}, sync=AsyncMock(return_value={"id": 8})
)
assert await _slots(db_session, printer_id) == {(0, 0): 8}
assert _slot_events(broadcast) == {(0, 0)}
# ── The Sync buttons ─────────────────────────────────────────────────────────
PATHS = ["/api/v1/spoolman/sync/{printer_id}", "/api/v1/spoolman/sync-all"]
def _parsed_tray(ams_id, tray_data):
return AMSTray(
ams_id=ams_id,
tray_id=int(tray_data.get("id", 0)),
tray_type="PLA",
tray_sub_brands="PLA Matte",
tray_color="042F56FF",
remain=-1,
tag_uid="7757EF0100000100",
tray_uuid="EB335968299543078925C71D83DA3864",
tray_info_idx="GFA01",
tray_weight=1000,
)
async def _manual_sync(async_client, printer_id, path, synced: int, *, trays=None, sync=None):
trays = trays or [{"id": 0, "tray_type": "PLA"}]
state = SimpleNamespace(state="IDLE", raw_data={"ams": [{"id": 0, "tray": trays}]})
client = MagicMock()
client.health_check = AsyncMock(return_value=True)
client.get_spools = AsyncMock(return_value=[])
# A tray without a type reads empty.
client.parse_ams_tray = MagicMock(
side_effect=lambda ams_id, tray: _parsed_tray(ams_id, tray) if tray.get("tray_type") else None
)
client.sync_ams_tray = sync or AsyncMock(return_value={"id": synced})
preset = AsyncMock()
with (
patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
patch("backend.app.api.routes.spoolman.printer_manager") as pm,
patch("backend.app.services.spoolman_slot_sync.ws_manager") as ws,
patch("backend.app.services.slot_preset_writer.upsert_slot_preset_for_spoolman_spool", preset),
patch("backend.app.services.slot_unlink_grace.forget_slot") as forget,
):
ws.broadcast = AsyncMock()
pm.get_status = MagicMock(return_value=state)
pm.get_all_statuses = MagicMock(return_value={printer_id: state})
response = await async_client.post(path.format(printer_id=printer_id))
assert response.status_code == 200, response.text
return client, ws.broadcast, preset, forget
@pytest.mark.parametrize("path", PATHS)
async def test_a_sync_button_announces_a_changed_slot(async_client: AsyncClient, db_session, printer_factory, path):
printer_id = await _printer_with_slot(db_session, printer_factory)
_client, broadcast, preset, forget = await _manual_sync(async_client, printer_id, path, synced=8)
assert await _slots(db_session, printer_id) == {(0, 0): 8}
assert _slot_events(broadcast) == {(0, 0)}
assert preset.await_args.kwargs["spoolman_spool"] == {"id": 8}
forget.assert_called_once_with(printer_id, 0, 0)
@pytest.mark.parametrize("path", PATHS)
async def test_a_sync_button_does_not_seed_a_spool_from_the_built_in_inventory(
async_client: AsyncClient, db_session, printer_factory, path
):
"""A slot assignment left from before the switch to Spoolman: 880 g of
1000 used, nothing to do with the spool now in the slot."""
printer_id = await _printer_with_slot(db_session, printer_factory)
stale = Spool(material="PLA", label_weight=1000, weight_used=880)
db_session.add(stale)
await db_session.flush()
db_session.add(SpoolAssignment(spool_id=stale.id, printer_id=printer_id, ams_id=0, tray_id=0))
await db_session.commit()
client, *_ = await _manual_sync(async_client, printer_id, path, synced=8)
assert client.sync_ams_tray.await_args.kwargs.get("inventory_remaining") is None
@pytest.mark.parametrize("path", PATHS)
async def test_a_sync_button_keeps_a_slot_that_was_filled_meanwhile(
async_client: AsyncClient, db_session, printer_factory, path
):
"""Slot 0 read empty, so the sync would unlink spool 7; by then it holds 9."""
printer_id = await _printer_with_slot(db_session, printer_factory)
sync = _assigning_by_hand_meanwhile(db_session, printer_id, (0, 0), 9, {"id": 5})
await _manual_sync(
async_client, printer_id, path, synced=5, trays=[{"id": 0}, {"id": 1, "tray_type": "PLA"}], sync=sync
)
assert await _slots(db_session, printer_id) == {(0, 0): 9, (0, 1): 5}
@pytest.mark.parametrize("path", PATHS)
async def test_a_sync_button_still_unlinks_a_slot_that_reads_empty(
async_client: AsyncClient, db_session, printer_factory, path
):
printer_id = await _printer_with_slot(db_session, printer_factory)
_client, broadcast, *_ = await _manual_sync(async_client, printer_id, path, synced=5, trays=[{"id": 0}])
assert await _slots(db_session, printer_id) == {}
assert _slot_events(broadcast) == {(0, 0)}
async def test_the_automatic_pass_keeps_a_spool_assigned_to_a_slot_it_read_empty(
async_client: AsyncClient, db_session, printer_factory
):
await _enable_spoolman(db_session)
printer_id = (await printer_factory()).id
async def sync(tray, *args, **kwargs):
db_session.add(SpoolmanSlotAssignment(printer_id=printer_id, ams_id=0, tray_id=0, spoolman_spool_id=9))
await db_session.commit()
return {"id": 8}
await _run_ams_change(printer_id, AMS[:1], parsed={(0, 0): _tray(0, 0)}, sync=AsyncMock(side_effect=sync))
assert await _slots(db_session, printer_id) == {(0, 0): 9}
async def test_without_the_slot_table_as_read_the_syncs_write_as_before(db_session, printer_factory):
"""Its load failed: nothing to compare against, so a swap and a removal are
written as before rather than dropped."""
from backend.app.services.spoolman_slot_sync import write_slot_results
printer_id = await _printer_with_slot(db_session, printer_factory, spool_id=10)
db_session.add(SpoolmanSlotAssignment(printer_id=printer_id, ams_id=0, tray_id=1, spoolman_spool_id=11))
await db_session.commit()
with patch("backend.app.services.spoolman_slot_sync.ws_manager") as ws:
ws.broadcast = AsyncMock()
changed = await write_slot_results(db_session, None, [(printer_id, 0, 0, 99)], [(printer_id, 0, 1)])
assert await _slots(db_session, printer_id) == {(0, 0): 99}
assert sorted(changed) == [(printer_id, 0, 0), (printer_id, 0, 1)]