Files
bambuddy/backend/tests/unit/test_printer_kill_switch.py
T
maziggy 14f46510ba File the printer's calibration table under the nozzle it belongs to (issue #2854)
The K value on an AMS slot card went blank after a while and came back after a
backend restart. It is not the MQTT merge: that preserves a tray's k correctly.
H2-series trays have no k to preserve. Verified against the H2 wire capture in
logs/vp_wire -- every tray reports cali_idx and nothing else -- so the number on
the card is resolved from that index against the printer's calibration table in
state.kprofiles, and that table was a single global list.

An extrusion_cali_get response is the complete table for one nozzle diameter,
and the printer answers whoever asks; BambuStudio's queries arrive on the same
report topic we subscribe to. Every response was assigned straight to
state.kprofiles, so any one answer stood for the whole printer. The nightly
GitHub backup asks for 0.2, 0.4, 0.6 and 0.8 in turn and finishes on 0.8, which
holds nothing on a 0.4+0.6 machine: logs/bambuddy.log records exactly that at
17:15 on 2026-08-25, and the table was empty from then until something refilled
it. Responses are now bucketed by the diameter they describe, so an empty answer
for a size the printer does not have clears only that size. The three assign
paths that look an index up by nozzle_diameter get the same fix for free -- they
were quietly finding nothing whenever the last response was for another nozzle,
which is what spoolman_inventory has been logging as a stale kp.

Bucketing makes state.kprofiles a union, and cali_idx is numbered per nozzle, so
the index alone no longer identifies a profile. The REST serializer has keyed on
(extruder, cali_idx) since c5e005586; the WebSocket one still keyed on the index
alone, which meant the first render of a card could be right and every update
after it wrong. Both now share one resolver. It goes through the extruder the
slot feeds, and where that does not single out one profile -- a single-nozzle
printer that has been swapped, so both its tables sit under extruder 0 -- it
falls back to which diameters are actually fitted. Where neither settles it the
card shows nothing, because a blank space is a smaller error than confidently
printing the other nozzle's number. Deliberately no loosening to a bare cali_idx
lookup on a miss: that is the cross-nozzle bleed the extruder keying was added
to stop.

Nothing read the table on connect, which is the other half of the report. It
arrived by luck -- a visit to Profiles or Configure Slot, a backup, or the
printer answering someone else -- so a Bambuddy nobody had opened showed a card
with no K values at all, and "restart and they come back" was the printer
happening to broadcast rather than anything we did. It is now read once per
connection, on the same latch the stale-print reconcile uses. Only the fitted
diameters are asked for, one request on a single-nozzle printer and two on a
dual; probing the four sizes blind is what the backup does and what blanked the
table. The edge is gated on a nozzle diameter being known as well as on the
state being known, because the first push_status is what makes the state known
and does not always carry the nozzle fields -- latching there would spend the
connection's one attempt on a printer that could not yet say what was fitted.

Adopting an unsolicited table now logs at debug. It was the quietest way for the
card to change underneath us and there was no way to see it in a support bundle.

Three test files gained nozzles=[] on their PrinterState stubs. The field has
always been on the dataclass; the connect edge is simply the first thing on that
path to read it.
2026-08-25 17:52:02 +02:00

494 lines
18 KiB
Python

from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from backend.app import main as main_module
@pytest.fixture(autouse=True)
def clear_kill_switch_state():
main_module._kill_switch_setting_cache = None
main_module._unauthorized_print_kill_sent.clear()
main_module._kill_switch_notification_tasks.clear()
main_module._expected_prints.clear()
main_module._active_prints.clear()
main_module._expected_print_registered_at.clear()
main_module._printer_reconciled_since_connect.clear()
yield
for task in main_module._kill_switch_notification_tasks.values():
if not task.done():
task.cancel()
main_module._unauthorized_print_kill_sent.clear()
main_module._kill_switch_notification_tasks.clear()
main_module._expected_prints.clear()
main_module._active_prints.clear()
main_module._expected_print_registered_at.clear()
main_module._printer_reconciled_since_connect.clear()
main_module._kill_switch_setting_cache = None
def test_gcode_3mf_status_filename_matches_registered_expected_print():
state = SimpleNamespace(
current_print=None,
subtask_name="",
gcode_file="foreign_job.gcode.3mf",
)
keys = main_module._build_status_print_keys(7, state)
assert (7, "foreign_job.gcode.3mf") in keys
assert (7, "foreign_job.gcode") in keys
@pytest.mark.asyncio
async def test_unauthorized_active_print_triggers_stop(monkeypatch):
stop_calls: list[int] = []
broadcast = AsyncMock()
provider_notification = AsyncMock(return_value=True)
async def fake_status(*args, **kwargs):
return None
async def kill_switch_enabled(_db):
return True
unauthorized = AsyncMock(return_value=False)
monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
monkeypatch.setattr(
main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
)
monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
monkeypatch.setattr(main_module.ws_manager, "broadcast", broadcast)
monkeypatch.setattr(main_module, "_is_bambuddy_authorized_print", unauthorized)
monkeypatch.setattr(main_module, "_send_kill_switch_provider_notification", provider_notification)
monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
state = SimpleNamespace(
connected=True,
state="RUNNING",
progress=0,
remaining_time=0,
layer_num=0,
temperatures={},
nozzles=[],
raw_data={},
stg_cur=0,
# Real PrinterState always carries these; the status-broadcast dedup
# key reads them so a Filament Track Switch rebind reaches the card.
fila_switch=None,
ams_switch_inlet={},
cooling_fan_speed=None,
big_fan1_speed=None,
big_fan2_speed=None,
chamber_light=False,
active_extruder=0,
tray_now=255,
door_open=False,
ams_filament_backup=False,
current_print=None,
subtask_name="foreign_job",
subtask_id="external-task-1",
gcode_file="foreign_job.gcode",
)
await main_module.on_printer_status_change(7, state)
await main_module.on_printer_status_change(7, state)
assert stop_calls == [7]
unauthorized.assert_awaited_once()
assert 7 in main_module._unauthorized_print_kill_sent
broadcast.assert_awaited_once_with(
{
"type": "kill_switch_triggered",
"printer_id": 7,
"printer_name": "Printer 7",
"filename": "foreign_job",
"reason": "unauthorized_print",
}
)
notification_task = main_module._kill_switch_notification_tasks[7]
assert await notification_task is True
provider_notification.assert_awaited_once_with(
7,
"Printer 7",
{
"status": "stopped",
"filename": "foreign_job.gcode",
"subtask_name": "foreign_job",
"progress": 0,
"reason": "unauthorized_print",
},
)
@pytest.mark.asyncio
async def test_failed_immediate_notification_allows_completion_retry():
task = main_module.spawn_background_task(_return_false(), name="test-kill-switch-notification-failure")
assert await main_module._kill_switch_notification_already_sent(task) is False
async def _return_false():
return False
@pytest.mark.asyncio
async def test_bambuddy_authorized_print_is_not_stopped(monkeypatch):
monkeypatch.setitem(main_module._expected_prints, (7, "foreign_job"), 123)
stop_calls: list[int] = []
async def fake_status(*args, **kwargs):
return None
kill_switch_enabled = AsyncMock(return_value=True)
monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
monkeypatch.setattr(
main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
)
monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
state = SimpleNamespace(
connected=True,
state="RUNNING",
progress=0,
remaining_time=0,
layer_num=0,
temperatures={},
nozzles=[],
raw_data={},
stg_cur=0,
# Real PrinterState always carries these; the status-broadcast dedup
# key reads them so a Filament Track Switch rebind reaches the card.
fila_switch=None,
ams_switch_inlet={},
cooling_fan_speed=None,
big_fan1_speed=None,
big_fan2_speed=None,
chamber_light=False,
active_extruder=0,
tray_now=255,
door_open=False,
ams_filament_backup=False,
current_print=None,
subtask_name="foreign_job",
gcode_file="foreign_job.gcode",
)
await main_module.on_printer_status_change(7, state)
assert stop_calls == []
assert 7 not in main_module._unauthorized_print_kill_sent
kill_switch_enabled.assert_not_awaited()
@pytest.mark.asyncio
async def test_kill_switch_setting_is_cached(monkeypatch):
kill_switch_enabled = AsyncMock(return_value=True)
class FakeSessionContext:
async def __aenter__(self):
return SimpleNamespace()
async def __aexit__(self, *_args):
return False
monkeypatch.setattr(main_module, "async_session", FakeSessionContext)
monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
assert await main_module._is_printer_kill_switch_enabled_cached() is True
assert await main_module._is_printer_kill_switch_enabled_cached() is True
kill_switch_enabled.assert_awaited_once()
@pytest.mark.asyncio
async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
stop_calls: list[int] = []
async def fake_status(*args, **kwargs):
return None
async def kill_switch_enabled(_db):
return True
async def unauthorized(*_args):
return False
monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
monkeypatch.setattr(
main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
)
monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
monkeypatch.setattr(main_module, "_is_bambuddy_authorized_print", unauthorized)
monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
active_state = SimpleNamespace(
connected=True,
state="RUNNING",
progress=0,
remaining_time=0,
layer_num=0,
temperatures={},
nozzles=[],
raw_data={},
stg_cur=0,
# Real PrinterState always carries these; the status-broadcast dedup
# key reads them so a Filament Track Switch rebind reaches the card.
fila_switch=None,
ams_switch_inlet={},
cooling_fan_speed=None,
big_fan1_speed=None,
big_fan2_speed=None,
chamber_light=False,
active_extruder=0,
tray_now=255,
door_open=False,
ams_filament_backup=False,
current_print=None,
subtask_name="foreign_job",
subtask_id="external-task-1",
gcode_file="foreign_job.gcode",
)
idle_state = SimpleNamespace(
connected=True,
state="IDLE",
progress=0,
remaining_time=0,
layer_num=0,
temperatures={},
nozzles=[],
raw_data={},
stg_cur=0,
# Real PrinterState always carries these; the status-broadcast dedup
# key reads them so a Filament Track Switch rebind reaches the card.
fila_switch=None,
ams_switch_inlet={},
cooling_fan_speed=None,
big_fan1_speed=None,
big_fan2_speed=None,
chamber_light=False,
active_extruder=0,
tray_now=255,
door_open=False,
ams_filament_backup=False,
current_print=None,
subtask_name="",
subtask_id=None,
gcode_file=None,
)
await main_module.on_printer_status_change(7, active_state)
assert stop_calls == [7]
assert 7 in main_module._unauthorized_print_kill_sent
await main_module.on_printer_status_change(7, idle_state)
assert 7 not in main_module._unauthorized_print_kill_sent
@pytest.mark.asyncio
@pytest.mark.parametrize("printer_state", ["RUNNING", "PAUSE"])
async def test_persisted_print_is_authorized_after_restart(monkeypatch, printer_state):
# billing_run_id is the marker the scheduler stamps on its own dispatches;
# an archive without one proves only that Bambuddy watched the print.
archive = SimpleNamespace(
id=123,
filename="owned_job.gcode.3mf",
billing_run_id="d7c1f0b2-0000-4000-8000-000000000001",
created_by_id=None,
)
query_result = SimpleNamespace(scalar_one_or_none=lambda: archive)
db = SimpleNamespace(execute=AsyncMock(return_value=query_result))
class FakeSessionContext:
async def __aenter__(self):
return db
async def __aexit__(self, *_args):
return False
stop_calls: list[int] = []
async def fake_status(*args, **kwargs):
return None
async def kill_switch_enabled(_db):
return True
def discard_background_task(coro, **_kwargs):
coro.close()
monkeypatch.setattr(main_module, "async_session", FakeSessionContext)
monkeypatch.setattr(main_module, "spawn_background_task", discard_background_task)
monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
monkeypatch.setattr(
main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
)
monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
state = SimpleNamespace(
connected=True,
state=printer_state,
progress=42,
remaining_time=600,
layer_num=50,
temperatures={},
nozzles=[],
raw_data={},
stg_cur=0,
# Real PrinterState always carries these; the status-broadcast dedup
# key reads them so a Filament Track Switch rebind reaches the card.
fila_switch=None,
ams_switch_inlet={},
cooling_fan_speed=None,
big_fan1_speed=None,
big_fan2_speed=None,
chamber_light=False,
active_extruder=0,
tray_now=255,
door_open=False,
ams_filament_backup=False,
current_print=None,
subtask_name="owned_job",
subtask_id="bambuddy-task-123",
gcode_file="owned_job.gcode.3mf",
)
await main_module.on_printer_status_change(7, state)
assert stop_calls == []
assert (7, "owned_job.gcode.3mf") in main_module._active_prints
assert main_module._active_prints[(7, "owned_job.gcode.3mf")] == 123
assert 7 not in main_module._unauthorized_print_kill_sent
@pytest.mark.asyncio
async def test_kill_switch_defers_when_restart_identity_is_not_available(monkeypatch):
state = SimpleNamespace(
current_print=None,
subtask_name="owned_job",
subtask_id=None,
gcode_file="owned_job.gcode.3mf",
)
db = SimpleNamespace(execute=AsyncMock())
monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
authorization = await main_module._is_bambuddy_authorized_print(7, state, db)
assert authorization is None
db.execute.assert_not_awaited()
def _authorization_db(archive, dispatched_queue_item_id=None):
"""Fake session answering the two lookups `_is_bambuddy_authorized_print` makes."""
query_result = SimpleNamespace(scalar_one_or_none=lambda: archive)
return SimpleNamespace(
execute=AsyncMock(return_value=query_result),
scalar=AsyncMock(return_value=dispatched_queue_item_id),
)
def _running_state(subtask_id="external-task-9"):
return SimpleNamespace(
current_print=None,
subtask_name="some_job",
subtask_id=subtask_id,
gcode_file="some_job.gcode.3mf",
)
@pytest.mark.asyncio
async def test_archive_without_a_dispatch_marker_is_not_authorization(monkeypatch):
"""on_print_start archives prints started from Studio or Handy too.
Those rows carry the same status and subtask_id as Bambuddy's own, so treating
the row's existence as proof would switch the feature off a few seconds into
every foreign print — as soon as the 3MF finished downloading.
"""
monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
observed_only = SimpleNamespace(
id=55,
filename="some_job.gcode.3mf",
billing_run_id=None,
created_by_id=None,
)
db = _authorization_db(observed_only, dispatched_queue_item_id=None)
assert await main_module._is_bambuddy_authorized_print(9, _running_state(), db) is False
assert (9, "some_job.gcode.3mf") not in main_module._active_prints
@pytest.mark.asyncio
@pytest.mark.parametrize(
"marker",
[
{"billing_run_id": "9f0c2b6e-0000-4000-8000-00000000abcd", "created_by_id": None},
{"billing_run_id": None, "created_by_id": 4},
],
ids=["billing_run_id", "created_by_id"],
)
async def test_either_dispatch_marker_authorizes_after_a_restart(monkeypatch, marker):
monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
archive = SimpleNamespace(id=77, filename="some_job.gcode.3mf", **marker)
db = _authorization_db(archive, dispatched_queue_item_id=None)
assert await main_module._is_bambuddy_authorized_print(9, _running_state(), db) is True
assert main_module._active_prints[(9, "some_job.gcode.3mf")] == 77
# The fast path is rehydrated, so the queue is never consulted.
db.scalar.assert_not_awaited()
@pytest.mark.asyncio
async def test_defers_while_bambuddy_has_a_job_running_on_that_printer(monkeypatch):
"""A library-file dispatch has no archive at send time, and the row created for
it moments later by on_print_start carries neither marker. The queue row is the
only durable trace, and it cannot be tied to a subtask_id — so it defers."""
monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
unmarked = SimpleNamespace(id=56, filename="some_job.gcode.3mf", billing_run_id=None, created_by_id=None)
db = _authorization_db(unmarked, dispatched_queue_item_id=310)
assert await main_module._is_bambuddy_authorized_print(9, _running_state(), db) is None
# Deferring must not authorize the print for every later frame.
assert (9, "some_job.gcode.3mf") not in main_module._active_prints
@pytest.mark.asyncio
async def test_defers_when_the_dispatch_has_not_been_archived_yet(monkeypatch):
"""Restart during the window between the MQTT send and the 3MF download."""
monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
db = _authorization_db(None, dispatched_queue_item_id=311)
assert await main_module._is_bambuddy_authorized_print(9, _running_state(), db) is None
@pytest.mark.asyncio
async def test_foreign_print_with_no_archive_and_no_dispatch_is_unauthorized(monkeypatch):
monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
db = _authorization_db(None, dispatched_queue_item_id=None)
assert await main_module._is_bambuddy_authorized_print(9, _running_state(), db) is False