From 9b5ab11781d6d50cb9ab65e97d802d35879daeea Mon Sep 17 00:00:00 2001 From: Ondrej Tuma Date: Thu, 8 Oct 2026 15:52:04 +0200 Subject: [PATCH 1/4] WIP: Handle SET_VALUE for speed, flow and temperatures; drop gcode force Connect now sends speed, flow, nozzle and bed temperature changes as the high-level SET_VALUE command instead of forced gcodes. PrusaLink accepts exactly one of "speed", "flow", "nozzle_temperature" or "bed_temperature" and runs it right away, even during a print. Out-of-range or non-integer values, unknown keys, disallowed states and a nozzle target below the extrusion minimum while printing are rejected. Executing gcode no longer honours the force flag, state checks always apply. Resetting the printer is not affected. --- ChangeLog | 5 +- prusa/link/__init__.py | 4 +- .../link/printer_adapter/command_handlers.py | 74 +++++++-- prusa/link/printer_adapter/prusa_link.py | 12 +- requirements.txt | 2 +- tests/test_set_value.py | 144 ++++++++++++++++++ 6 files changed, 225 insertions(+), 16 deletions(-) create mode 100644 tests/test_set_value.py diff --git a/ChangeLog b/ChangeLog index a002aac3..ca558ec5 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,6 +1,6 @@ # ChangeLog -0.9.0 (unreleased) +1.0.0 (unreleased) * Use the Lazy File Protocol: Connect gets the list of storages and asks for the content of folders (also on the SD card) when it needs it * Tell Connect to rescan the SD card when its content changes * Report the end of a file upload via PUT, so Connect and other clients get the transfer finished event @@ -9,6 +9,9 @@ * Fix a crash when rebooting a multi-instance PrusaLink * Fix a CRC issue * Point the setup wizard to the Prusa Downloads page and the Prusa-Firmware GitHub releases for the latest stable firmware + * Handle the SET_VALUE command for speed, flow and temperatures + * Drop the gcode force option + * Communicate with Connect over WebSocket instead of the previous protocol, for faster response times 0.8.1 (2024-06-28) * Add a v4l2 workaround so broken camera handles are disqualified from scan diff --git a/prusa/link/__init__.py b/prusa/link/__init__.py index 4ca22f73..0719160e 100644 --- a/prusa/link/__init__.py +++ b/prusa/link/__init__.py @@ -5,8 +5,8 @@ __application__ = "PrusaLink" __vendor__ = "Prusa Research" -__version__ = "0.9.0.dev0" -__date__ = "18 Dec 2024" +__version__ = "1.0.0.dev0" +__date__ = "9 Oct 2026" __copyright__ = "(c) 2024 Prusa 3D" __author_name__ = "PrusaLink Developers" __author_email__ = "link@prusa3d.cz" diff --git a/prusa/link/printer_adapter/command_handlers.py b/prusa/link/printer_adapter/command_handlers.py index bb4f689d..537b7abb 100644 --- a/prusa/link/printer_adapter/command_handlers.py +++ b/prusa/link/printer_adapter/command_handlers.py @@ -14,7 +14,7 @@ from sys import executable from threading import Event from time import monotonic, time -from typing import Dict, Optional, Set +from typing import ClassVar, Dict, Optional, Set, Tuple from prusa.connect.printer.const import Event as EventConst from prusa.connect.printer.const import Source, State @@ -25,6 +25,7 @@ RESET_PIN, SERIAL_QUEUE_TIMEOUT, STATE_CHANGE_TIMEOUT, + LimitsMK3, ) from ..serial.helpers import enqueue_instruction, enqueue_list_from_str from ..util import ( @@ -332,15 +333,13 @@ class ExecuteGcode(Command): """Class for executing an arbitrary gcode or gcode list""" command_name = "execute_gcode" - def __init__(self, gcode, force=False, **kwargs): + def __init__(self, gcode, **kwargs): """ If all checks pass, runs the specified gcode. :param gcode: "\n" separated gcodes to send to the printer"" - :param force: Whether to skip state checks """ super().__init__(**kwargs) self.gcode = gcode - self.force = force def _run_command(self): """ @@ -349,14 +348,10 @@ def _run_command(self): Doesn't renew the expected state change, so the other state changes will fall back onto defaults """ - if self.force: - log.debug("Force sending gcode: '%s'", self.gcode) - state = self.model.state_manager.current_state - if not self.force: - if state in {State.PRINTING, State.ATTENTION, State.ERROR}: - raise CommandFailed( - f"Can't run '{self.gcode}' while in f{state.name} state.") + if state in {State.PRINTING, State.ATTENTION, State.ERROR}: + raise CommandFailed( + f"Can't run '{self.gcode}' while in f{state.name} state.") self.state_manager.expect_change( StateChange(command_id=self.command_id, @@ -397,6 +392,63 @@ def _get_state_change(default_source): return StateChange(default_source=default_source) +class SetValue(Command): + """Sets one of the values, that used to be set by forced gcodes. + Runs right away, even during a print, the way a forced gcode did""" + command_name = "set_value" + + # key: (gcode template, minimum, maximum) + SETTINGS: ClassVar[Dict[str, Tuple[str, int, int]]] = { + "speed": ("M220 S{}", LimitsMK3.print_speed_min, + LimitsMK3.print_speed_max), + "flow": ("M221 S{}", LimitsMK3.print_flow_min, + LimitsMK3.print_flow_max), + "nozzle_temperature": ("M104 S{}", LimitsMK3.temp_nozzle_min, + LimitsMK3.temp_nozzle_max), + "bed_temperature": ("M140 S{}", LimitsMK3.temp_bed_min, + LimitsMK3.temp_bed_max), + } + ALLOWED_STATES: ClassVar[Set[State]] = { + State.IDLE, State.PAUSED, State.FINISHED, State.STOPPED, + State.PRINTING, State.READY} + + def __init__(self, parameters: Optional[Dict], **kwargs): + """ + :param parameters: the kwargs of the SET_VALUE command, expected + to contain exactly one of the SETTINGS keys + """ + super().__init__(**kwargs) + self.parameters = parameters + + def _run_command(self): + """Validates the value and the state, then sets the value""" + if not self.parameters or len(self.parameters) != 1: + raise CommandFailed("SET_VALUE needs exactly one value") + key, value = next(iter(self.parameters.items())) + if key not in self.SETTINGS: + raise CommandFailed(f"Can't set unknown value '{key}'") + gcode, minimum, maximum = self.SETTINGS[key] + + # bool is an int, but sending True as a temperature is a mistake + if not isinstance(value, int) or isinstance(value, bool): + raise CommandFailed(f"'{key}' has to be an integer") + if not minimum <= value <= maximum: + raise CommandFailed( + f"'{key}' has to be between {minimum} and {maximum}") + + state = self.model.state_manager.current_state + if state not in self.ALLOWED_STATES: + raise CommandFailed(f"Can't set '{key}' while in {state.name} " + f"state.") + if (key == "nozzle_temperature" and state == State.PRINTING + and value < LimitsMK3.min_temp_nozzle_e): + raise CommandFailed( + f"Can't set the nozzle below {LimitsMK3.min_temp_nozzle_e} " + f"while printing.") + + self.do_instruction(gcode.format(value)) + + class FilamentCommand(Command): """The shared code for Loading and Unloading of filament""" diff --git a/prusa/link/printer_adapter/prusa_link.py b/prusa/link/printer_adapter/prusa_link.py index 11782fbd..788103c9 100644 --- a/prusa/link/printer_adapter/prusa_link.py +++ b/prusa/link/printer_adapter/prusa_link.py @@ -69,6 +69,7 @@ ResetPrinter, ResumePrint, SetReady, + SetValue, StartPrint, StopPrint, UnloadFilament, @@ -200,6 +201,7 @@ def __init__(self, cfg: Config, settings: Settings) -> None: # Bind command handlers self.printer.set_handler(CommandType.GCODE, self.execute_gcode) + self.printer.set_handler(CommandType.SET_VALUE, self.set_value) self.printer.set_handler(CommandType.PAUSE_PRINT, self.pause_print) self.printer.set_handler(CommandType.RESET_PRINTER, self.reset_printer) self.printer.set_handler(CommandType.UPGRADE, self.upgrade_link) @@ -546,10 +548,18 @@ def execute_gcode(self, caller: SDKCommand) -> CommandResult: """ assert caller.kwargs command = ExecuteGcode(gcode=caller.kwargs["gcode"], - force=caller.force, command_id=caller.command_id) return self.command_queue.do_command(command) + def set_value(self, caller: SDKCommand) -> CommandResult: + """ + Connects the command to set speed, flow or temperatures from CONNECT + with its handler + """ + command = SetValue(parameters=caller.kwargs, + command_id=caller.command_id) + return self.command_queue.do_command(command) + def start_print(self, caller: SDKCommand) -> CommandResult: """ Connects the command to start print from CONNECT with its handler diff --git a/requirements.txt b/requirements.txt index c35132ac..40ae95ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -prusa.connect.sdk.printer @ git+https://github.com/prusa3d/Prusa-Connect-SDK-Printer.git@feat/lazy-file-protocol +prusa.connect.sdk.printer @ git+https://github.com/prusa3d/Prusa-Connect-SDK-Printer.git@websockets py-gcode-metadata pip>=22.2.0 bidict~=0.22.1 diff --git a/tests/test_set_value.py b/tests/test_set_value.py new file mode 100644 index 00000000..5726b957 --- /dev/null +++ b/tests/test_set_value.py @@ -0,0 +1,144 @@ +"""Tests for the SET_VALUE high-level command""" +from unittest.mock import Mock + +import pytest +from prusa.connect.printer.const import State + +from prusa.link.const import LimitsMK3 # type:ignore +from prusa.link.printer_adapter import command as command_module +from prusa.link.printer_adapter.command import CommandFailed # type:ignore +from prusa.link.printer_adapter.command_handlers import ( # type:ignore + SetValue, +) + +# pylint: disable=redefined-outer-name,duplicate-code + +ALL_KEYS = { + "speed": ("M220 S{}", LimitsMK3.print_speed_min, + LimitsMK3.print_speed_max), + "flow": ("M221 S{}", LimitsMK3.print_flow_min, LimitsMK3.print_flow_max), + "nozzle_temperature": ("M104 S{}", LimitsMK3.temp_nozzle_min, + LimitsMK3.temp_nozzle_max), + "bed_temperature": ("M140 S{}", LimitsMK3.temp_bed_min, + LimitsMK3.temp_bed_max), +} +ALLOWED_STATES = {State.IDLE, State.PAUSED, State.FINISHED, State.STOPPED, + State.PRINTING, State.READY} +FORBIDDEN_STATES = set(State) - ALLOWED_STATES + + +@pytest.fixture +def state_of_printer(monkeypatch): + """Replaces all the singletons a Command grabs, returns the state setter + and the list of gcodes, which were sent to the printer""" + for name in ("MonitoredSerialQueue", "SerialAdapter", + "ThreadedSerialParser", "MyPrinter", "StateManager", + "FilePrinter", "Job"): + monkeypatch.setattr(command_module, name, Mock()) + model = Mock() + monkeypatch.setattr(command_module, "Model", + Mock(get_instance=Mock(return_value=model))) + + sent = [] + monkeypatch.setattr(command_module.Command, "do_instruction", + lambda self, message: sent.append(message)) + + def set_state(state): + model.state_manager.current_state = state + + set_state(State.IDLE) + set_state.sent = sent + return set_state + + +def run(**kwargs): + """Runs the SET_VALUE command with the given kwargs""" + return SetValue(parameters=kwargs, command_id=1).run_command() + + +@pytest.mark.parametrize("key", ALL_KEYS) +def test_sends_gcode(state_of_printer, key): + """Every key sends its gcode""" + run(**{key: 42}) + assert state_of_printer.sent == [ALL_KEYS[key][0].format(42)] + + +@pytest.mark.parametrize("key", ALL_KEYS) +def test_limits_inclusive(state_of_printer, key): + """Limit values themselves are accepted""" + gcode, low, high = ALL_KEYS[key] + run(**{key: low}) + run(**{key: high}) + assert state_of_printer.sent == [gcode.format(low), gcode.format(high)] + + +@pytest.mark.parametrize("key", ALL_KEYS) +def test_out_of_limits(state_of_printer, key): + """Values outside of the limits are rejected without sending anything""" + _, low, high = ALL_KEYS[key] + for value in (low - 1, high + 1): + with pytest.raises(CommandFailed): + run(**{key: value}) + assert not state_of_printer.sent + + +@pytest.mark.parametrize("key", ALL_KEYS) +@pytest.mark.parametrize("value", [True, 42.5, "42", None]) +def test_not_an_integer(state_of_printer, key, value): + """Only real integers are valid, bool is not a number here""" + with pytest.raises(CommandFailed): + run(**{key: value}) + assert not state_of_printer.sent + + +@pytest.mark.parametrize("kwargs", [ + {}, + None, + {"unknown": 1}, + {"speed": 100, "flow": 100}, + {"speed": 100, "unknown": 1}, +]) +def test_exactly_one_known_key(state_of_printer, kwargs): + """Unknown keys and anything but exactly one value are rejected""" + with pytest.raises(CommandFailed): + SetValue(parameters=kwargs, command_id=1).run_command() + assert not state_of_printer.sent + + +@pytest.mark.parametrize("key", ["speed", "flow", "nozzle_temperature", + "bed_temperature"]) +def test_states(state_of_printer, key): + """Allowed states pass, all the others are rejected""" + gcode, low, high = ALL_KEYS[key] + # min_temp_nozzle_e only matters for printing, pick a safe value + value = min(high, max(low, LimitsMK3.min_temp_nozzle_e)) + for state in ALLOWED_STATES: + state_of_printer(state) + run(**{key: value}) + assert len(state_of_printer.sent) == len(ALLOWED_STATES) + assert set(state_of_printer.sent) == {gcode.format(value)} + + state_of_printer.sent.clear() + for state in FORBIDDEN_STATES: + state_of_printer(state) + with pytest.raises(CommandFailed): + run(**{key: value}) + assert not state_of_printer.sent + + +def test_nozzle_too_cold_while_printing(state_of_printer): + """Cooling below the extrusion minimum during a print is rejected""" + state_of_printer(State.PRINTING) + with pytest.raises(CommandFailed): + run(nozzle_temperature=LimitsMK3.min_temp_nozzle_e - 1) + assert not state_of_printer.sent + + run(nozzle_temperature=LimitsMK3.min_temp_nozzle_e) + assert state_of_printer.sent == [f"M104 S{LimitsMK3.min_temp_nozzle_e}"] + + +def test_nozzle_cooling_outside_print(state_of_printer): + """Turning the nozzle off is fine when not printing""" + state_of_printer(State.PAUSED) + run(nozzle_temperature=0) + assert state_of_printer.sent == ["M104 S0"] From 859d6c18f773f5636740aa6b6f1653f8d125ea3f Mon Sep 17 00:00:00 2001 From: Ondrej Tuma Date: Wed, 4 Nov 2026 16:40:46 +0100 Subject: [PATCH 2/4] Drop the parse_command override, the SDK no longer has it --- prusa/link/sdk_augmentation/printer.py | 17 ------------ tests/test_sdk_printer.py | 37 -------------------------- 2 files changed, 54 deletions(-) diff --git a/prusa/link/sdk_augmentation/printer.py b/prusa/link/sdk_augmentation/printer.py index d7187b66..0b43f12e 100644 --- a/prusa/link/sdk_augmentation/printer.py +++ b/prusa/link/sdk_augmentation/printer.py @@ -9,7 +9,6 @@ from prusa.connect.printer import Printer as SDKPrinter from prusa.connect.printer import const from prusa.connect.printer.command import Command -from prusa.connect.printer.conditions import API, HTTP, CondState from prusa.connect.printer.const import Source from prusa.connect.printer.files import File @@ -50,22 +49,6 @@ def __init__(self, *args, **kwargs): name="snapshot_sender", daemon=True) - def parse_command(self, res): - """Parse telemetry response. - - When response from connect is command (HTTP Status: 200 OK), it - will set command object. - """ - - if 500 > res.status_code >= 400: - API.state = CondState.NOK - elif res.status_code == 503: - HTTP.state = CondState.NOK - - res = super().parse_command(res) - - return res - def get_info(self) -> Dict[str, Any]: """Returns a dictionary containing the printers info.""" info = super().get_info() diff --git a/tests/test_sdk_printer.py b/tests/test_sdk_printer.py index 3f464c5f..5926c2b3 100644 --- a/tests/test_sdk_printer.py +++ b/tests/test_sdk_printer.py @@ -5,7 +5,6 @@ import pytest from prusa.connect.printer import Printer as SDKPrinter from prusa.connect.printer.command import CommandRejected -from prusa.connect.printer.conditions import API, HTTP, CondState from prusa.connect.printer.const import Event, PrinterType, Source, StorageType from prusa.link import __version__ # type:ignore @@ -40,42 +39,6 @@ def printer(): MyPrinter._MCSingleton__instance = None -@pytest.fixture -def parent_parse(): - """The parse_command of the SDK printer""" - with patch.object(SDKPrinter, "parse_command", create=True) as parse: - yield parse - - -@pytest.fixture -def conditions(): - """Conditions are restored after the test""" - previous = (API._state, HTTP._state) - yield - API._state, HTTP._state = previous - - -@pytest.mark.parametrize(("status", "broken"), [ - (400, "API"), - (404, "API"), - (503, "HTTP"), - (200, None), - (500, None), -]) -@pytest.mark.usefixtures("conditions") -def test_parse_command(printer, parent_parse, status, broken): - """Client errors break the API condition, 503 breaks HTTP""" - API._state = HTTP._state = CondState.OK - response = MagicMock(status_code=status) - assert printer.parse_command(response) is parent_parse.return_value - parent_parse.assert_called_once_with(response) - states = {"API": API._state, "HTTP": HTTP._state} - if broken: - assert states[broken] == CondState.NOK - else: - assert set(states.values()) == {CondState.OK} - - def test_get_info(printer): """Info has the diameter, storages without files and the version""" printer.nozzle_diameter = 0.4 From d4546834a27b33b0c0b6d421424f30f251e894f2 Mon Sep 17 00:00:00 2001 From: Ondrej Tuma Date: Wed, 4 Nov 2026 16:41:17 +0100 Subject: [PATCH 3/4] Test that the gcode force flag is gone --- tests/test_command_handlers.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/test_command_handlers.py b/tests/test_command_handlers.py index 703d392b..81320e7d 100644 --- a/tests/test_command_handlers.py +++ b/tests/test_command_handlers.py @@ -432,19 +432,17 @@ def test_execute_gcode(state_manager): @pytest.mark.parametrize("state", [State.PRINTING, State.ATTENTION, State.ERROR]) def test_execute_gcode_in_wrong_state(model, state): - """Gcodes are not run in these states unless forced""" + """Gcodes are never run in these states""" model.state_manager.current_state = state with pytest.raises(CommandFailed): ExecuteGcode("G28").run_command() -def test_execute_gcode_forced(model): - """Force skips the state checks""" +def test_execute_gcode_cannot_be_forced(model): + """The force flag is gone, so the state checks cannot be skipped""" model.state_manager.current_state = State.PRINTING - command = ExecuteGcode("G28", force=True) - with patch.object(module, "enqueue_list_from_str", return_value=[]), \ - patch.object(command, "wait_while_running"): - command.run_command() + with pytest.raises(TypeError): + ExecuteGcode("G28", force=True) @pytest.mark.parametrize(("group", "message"), [ From 171148ce7dbc7f1fb0633ea519523f3258877e2e Mon Sep 17 00:00:00 2001 From: Ondrej Tuma Date: Thu, 5 Nov 2026 15:24:39 +0100 Subject: [PATCH 4/4] Install the websockets branch of the SDK in README, CI and dev images --- .github/workflows/python-tests.yml | 2 +- README.md | 2 +- image_builder/image_builder.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index f1e22c37..eac4c7ba 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -48,7 +48,7 @@ jobs: pip install -U -r requirements-multi.txt pip install pylint~=2.17.7 pip install -U 'ruff~=0.16.10' pytest pytest-doctestplus pytest-mypy pytest-cov mock - pip install --force-reinstall git+https://github.com/prusa3d/prusa-connect-sdk-printer.git@feat/lazy-file-protocol + pip install --force-reinstall git+https://github.com/prusa3d/prusa-connect-sdk-printer.git@websockets pip install --force-reinstall git+https://github.com/prusa3d/gcode-metadata.git - name: Lint with ruff diff --git a/README.md b/README.md index 599cf8c5..6c59772e 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ pip install PrusaLink # Or install straight from GitHub pip install git+https://github.com/prusa3d/gcode-metadata.git -pip install git+https://github.com/prusa3d/Prusa-Connect-SDK-Printer.git@feat/lazy-file-protocol +pip install git+https://github.com/prusa3d/Prusa-Connect-SDK-Printer.git@websockets pip install git+https://github.com/prusa3d/Prusa-Link.git ``` diff --git a/image_builder/image_builder.py b/image_builder/image_builder.py index fac6a1b9..48f77695 100644 --- a/image_builder/image_builder.py +++ b/image_builder/image_builder.py @@ -475,7 +475,7 @@ def build_image(): run_over_ssh( "pip install --break-system-packages git+https://" "github.com/prusa3d/Prusa-Connect-SDK-Printer.git" - "@feat/lazy-file-protocol", + "@websockets", ) run_over_ssh( "pip install --break-system-packages git+https://"