Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion ChangeLog
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
2 changes: 1 addition & 1 deletion image_builder/image_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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://"
Expand Down
4 changes: 2 additions & 2 deletions prusa/link/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
74 changes: 63 additions & 11 deletions prusa/link/printer_adapter/command_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
Expand Down Expand Up @@ -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):
"""
Expand All @@ -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,
Expand Down Expand Up @@ -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"""

Expand Down
12 changes: 11 additions & 1 deletion prusa/link/printer_adapter/prusa_link.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
ResetPrinter,
ResumePrint,
SetReady,
SetValue,
StartPrint,
StopPrint,
UnloadFilament,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
17 changes: 0 additions & 17 deletions prusa/link/sdk_augmentation/printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
12 changes: 5 additions & 7 deletions tests/test_command_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"), [
Expand Down
37 changes: 0 additions & 37 deletions tests/test_sdk_printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading