mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-05 13:41:36 +02:00
ams_get_rfid was published and reported as success without reading the printer's answer. X1Plus on base 01.08.02.00 answers FAIL / ERROR STATE, so the user saw "Refreshing" and the K profile was re-applied to a slot that was never read. - Wait for the ams_get_rfid answer (matched by sequence_id). - On a refusal from an idle X1/P1/A1, send the legacy M620 R<ams*4+slot> gcode Bambu Studio uses for those models, AMS units 0-3 only. Never during a job, never on newer-protocol models; printers that accept ams_get_rfid never see it. - Refused: the route returns 400 with the printer's reason and no PA re-apply is scheduled. No answer still counts as accepted. - Log the answers at INFO so refusals reach support bundles.
174 lines
6.2 KiB
Python
174 lines
6.2 KiB
Python
"""RFID refresh reports what the printer said, and falls back to M620 R (#3206).
|
|
|
|
An X1C on X1Plus (base 01.08.02.00) answers ams_get_rfid with
|
|
``result: FAIL, reason: ERROR STATE``, yet the refresh returned success and
|
|
scheduled the K-profile re-apply. The refresh now waits for the answer, sends
|
|
the legacy ``M620 R<global tray>`` gcode Bambu Studio uses for printers without
|
|
the new protocol when ams_get_rfid is refused, and reports a refusal of both.
|
|
"""
|
|
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from backend.app.services.bambu_mqtt import BambuMQTTClient
|
|
from backend.app.utils.printer_models import uses_legacy_rfid_refresh
|
|
|
|
|
|
def _client(answers: dict[str, dict | None], model: str = "X1C") -> tuple[BambuMQTTClient, list[dict]]:
|
|
"""A connected, idle client whose printer answers each command per ``answers``.
|
|
|
|
``answers`` maps a command name to the result fields the printer sends
|
|
back, or None for no answer at all. Returns the client and the list of
|
|
commands it published.
|
|
"""
|
|
client = BambuMQTTClient(ip_address="10.0.0.1", serial_number="X1C", access_code="c", model=model)
|
|
client._client = MagicMock()
|
|
client.state.connected = True
|
|
client.state.tray_now = 255
|
|
sent: list[dict] = []
|
|
|
|
def publish(_topic, body, **_kw):
|
|
command = json.loads(body)["print"]
|
|
sent.append(command)
|
|
answer = answers.get(command["command"])
|
|
if answer is not None:
|
|
client._process_message({"print": {**command, **answer}})
|
|
|
|
client._client.publish.side_effect = publish
|
|
return client, sent
|
|
|
|
|
|
ACCEPT = {"result": "SUCCESS", "reason": "SUCCESS"}
|
|
REFUSE = {"result": "FAIL", "reason": "ERROR STATE"}
|
|
|
|
|
|
class TestRfidRefreshAck:
|
|
@pytest.mark.asyncio
|
|
async def test_accepted_sends_only_ams_get_rfid(self):
|
|
client, sent = _client({"ams_get_rfid": ACCEPT})
|
|
|
|
ok, message = await client.ams_refresh_tray(0, 3)
|
|
|
|
assert ok
|
|
assert message == "Refreshing AMS 0 tray 3"
|
|
assert [c["command"] for c in sent] == ["ams_get_rfid"]
|
|
assert sent[0]["ams_id"] == 0 and sent[0]["slot_id"] == 3
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refused_falls_back_to_m620_with_the_global_tray(self):
|
|
client, sent = _client({"ams_get_rfid": REFUSE, "gcode_line": ACCEPT})
|
|
|
|
ok, message = await client.ams_refresh_tray(2, 1)
|
|
|
|
assert ok
|
|
assert "legacy" in message
|
|
assert [c["command"] for c in sent] == ["ams_get_rfid", "gcode_line"]
|
|
assert sent[1]["param"] == "M620 R9\n"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_both_refused_reports_the_printer_reason(self):
|
|
client, _ = _client({"ams_get_rfid": REFUSE, "gcode_line": REFUSE})
|
|
|
|
ok, message = await client.ams_refresh_tray(0, 3)
|
|
|
|
assert not ok
|
|
assert "ERROR STATE" in message
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_fallback_for_units_without_a_legacy_index(self):
|
|
# AMS-HT (128+) has no M620 R index; only firmware with ams_get_rfid has one.
|
|
client, sent = _client({"ams_get_rfid": REFUSE})
|
|
|
|
ok, message = await client.ams_refresh_tray(128, 0)
|
|
|
|
assert not ok
|
|
assert "ERROR STATE" in message
|
|
assert [c["command"] for c in sent] == ["ams_get_rfid"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_fallback_on_a_newer_protocol_model(self):
|
|
# Bambu Studio never sends M620 R to these; a refusal there is reported.
|
|
client, sent = _client({"ams_get_rfid": REFUSE}, model="H2D")
|
|
|
|
ok, message = await client.ams_refresh_tray(0, 1)
|
|
|
|
assert not ok
|
|
assert "ERROR STATE" in message
|
|
assert [c["command"] for c in sent] == ["ams_get_rfid"]
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("state", ["RUNNING", "PAUSE", "PREPARE", "SLICING"])
|
|
async def test_no_fallback_during_a_job(self, state):
|
|
# A gcode_line would be executed inside the running print.
|
|
client, sent = _client({"ams_get_rfid": REFUSE})
|
|
client.state.state = state
|
|
|
|
ok, _ = await client.ams_refresh_tray(0, 1)
|
|
|
|
assert not ok
|
|
assert [c["command"] for c in sent] == ["ams_get_rfid"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_answer_counts_as_accepted(self):
|
|
client, sent = _client({})
|
|
|
|
client._rfid_ack_timeout = 0.1
|
|
ok, _ = await client.ams_refresh_tray(0, 0)
|
|
|
|
assert ok
|
|
assert [c["command"] for c in sent] == ["ams_get_rfid"]
|
|
assert client._pending_rfid_acks == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unrelated_gcode_line_ack_is_not_taken_for_ours(self):
|
|
client, _ = _client({"ams_get_rfid": REFUSE})
|
|
client._process_message({"print": {"command": "gcode_line", "sequence_id": "0", **REFUSE}})
|
|
|
|
client._rfid_ack_timeout = 0.1
|
|
ok, _ = await client.ams_refresh_tray(0, 0)
|
|
|
|
# ams_get_rfid refused, M620 R sent but unanswered -> accepted.
|
|
assert ok
|
|
|
|
|
|
class TestRefreshRoute:
|
|
@pytest.mark.asyncio
|
|
async def test_refused_refresh_is_400_and_schedules_no_pa_reapply(self):
|
|
from fastapi import HTTPException
|
|
|
|
from backend.app.api.routes import printers as printers_routes
|
|
|
|
client, _ = _client({"ams_get_rfid": REFUSE, "gcode_line": REFUSE})
|
|
db = MagicMock()
|
|
result = MagicMock()
|
|
result.scalar_one_or_none.return_value = MagicMock(id=1)
|
|
|
|
async def execute(*_a, **_kw):
|
|
return result
|
|
|
|
db.execute = execute
|
|
|
|
with (
|
|
patch.object(printers_routes, "printer_manager") as pm,
|
|
patch.object(printers_routes, "spawn_background_task") as spawn,
|
|
):
|
|
pm.get_client.return_value = client
|
|
with pytest.raises(HTTPException) as exc:
|
|
await printers_routes.refresh_ams_slot(1, 0, 3, None, db)
|
|
|
|
assert exc.value.status_code == 400
|
|
assert "ERROR STATE" in exc.value.detail
|
|
spawn.assert_not_called()
|
|
|
|
|
|
class TestLegacyRfidModels:
|
|
@pytest.mark.parametrize("model", ["X1C", "X1", "X1E", "P1P", "P1S", "A1", "A1 Mini", "BL-P001", "C12", "N1"])
|
|
def test_legacy(self, model):
|
|
assert uses_legacy_rfid_refresh(model)
|
|
|
|
@pytest.mark.parametrize("model", ["H2D", "H2C", "H2S", "P2S", "A2L", "X2D", "O1D", "N7", "", None])
|
|
def test_not_legacy(self, model):
|
|
assert not uses_legacy_rfid_refresh(model)
|