Skip to content

Commit 9bb2874

Browse files
committed
feat: switch telemetry and events transport from HTTP to WebSocket
- Telemetry and events are now sent over a persistent WebSocket connection to /p/ws instead of individual HTTP POSTs to /p/telemetry and /p/events - Commands from Connect arrive asynchronously via server push (J-prefix for JSON commands, G-prefix for GCode, F-prefix for forced GCode) instead of as HTTP responses to telemetry - Registration (/p/register) and camera handling remain on HTTP unchanged - WS connection failure sets INTERNET error; send failure sets HTTP error
1 parent 0156d64 commit 9bb2874

6 files changed

Lines changed: 541 additions & 410 deletions

File tree

prusa/connect/printer/__init__.py

Lines changed: 119 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Copyright (C) 2024 PrusaResearch
44
"""
55
import configparser
6+
import json
67
import os
78
import re
89
from logging import getLogger
@@ -11,7 +12,7 @@
1112
from typing import Any, Callable, Dict, List, Optional
1213

1314
from gcode_metadata import get_metadata
14-
from requests import RequestException, Response, Session # type: ignore
15+
from requests import RequestException, Session # type: ignore
1516
from requests.exceptions import (
1617
ConnectionError as RequestsConnectionError, # type: ignore
1718
)
@@ -34,6 +35,7 @@
3435
Telemetry,
3536
)
3637
from .util import RetryingSession, get_timestamp
38+
from .ws import PrinterWS
3739

3840
__version__ = "0.8.2"
3941
__date__ = "5 May 2025" # version date
@@ -96,6 +98,7 @@ def __init__(self,
9698
max_retries: int = 1,
9799
mmu_supported: bool = True):
98100
# pylint: disable=too-many-positional-arguments
101+
# pylint: disable=too-many-statements
99102
self.__type = type_
100103
self.__sn = sn
101104
self.__fingerprint = fingerprint
@@ -172,6 +175,8 @@ def __init__(self,
172175
self.download_finished_cb)
173176
self.camera_controller = CameraController(self.conn, self.server,
174177
self.send_cb)
178+
self.ws = PrinterWS(self.handle_ws_message)
179+
self.__ws_token: Optional[str] = None
175180
self.__running_loop = False
176181

177182
@staticmethod
@@ -644,65 +649,106 @@ def wrapper(handler: Callable[[Command], Dict[str, Any]]):
644649

645650
return wrapper
646651

647-
def parse_command(self, res: Response):
648-
"""Parse telemetry response.
652+
def ws_url(self) -> str:
653+
"""Return the WebSocket URL derived from self.server."""
654+
assert self.server
655+
url = self.server.replace("https://", "wss://", 1)
656+
return url.replace("http://", "ws://", 1) + "/p/ws"
657+
658+
def ensure_ws_connected(self) -> None:
659+
"""Open (or reopen) the WS connection when credentials change."""
660+
if not self.server or not self.token:
661+
return
662+
if self.ws.connected and self.__ws_token == self.token:
663+
return
664+
self.ws.connect(self.ws_url(), self.make_headers())
665+
self.__ws_token = self.token
666+
667+
def handle_ws_message(self, message: str) -> None:
668+
"""Process a text frame pushed by the server over the WebSocket.
649669
650-
When response from connect is command (HTTP Status: 200 OK), it
651-
will set a command object, if the printer is initialized properly.
670+
Wire format (BuddyEncoder):
671+
J{08x command_id}{JSON body} — high-level JSON command
672+
G{08x command_id}{gcode text} — low-level GCode command
673+
F{08x command_id}{gcode text} — forced GCode command
674+
T… — file transfer block (ignored)
652675
"""
653-
if res.status_code == 200:
654-
command_id: Optional[int] = None
655-
command_id_string: str
676+
if not message or len(message) < 9:
677+
log.error("WS: message too short: %r", message)
678+
return
679+
680+
msg_type = message[0]
681+
try:
682+
command_id = int(message[1:9], 16)
683+
except ValueError:
684+
log.error("WS: invalid command_id in: %r", message[:9])
685+
self.event_cb(const.Event.REJECTED,
686+
const.Source.CONNECT,
687+
reason="Invalid command_id")
688+
return
689+
690+
body = message[9:]
691+
692+
if msg_type == 'T':
693+
log.debug("WS: file transfer message, ignoring")
694+
return
695+
696+
if not self.is_initialised():
697+
self.event_cb(const.Event.REJECTED,
698+
const.Source.WUI,
699+
command_id=command_id,
700+
reason=self.NOT_INITIALISED_MSG)
701+
return
702+
703+
if msg_type in ('G', 'F'):
704+
# Low-level GCode command; F prefix means force=True
705+
force = msg_type == 'F'
706+
command_name = const.Command.GCODE.value
707+
log.debug("WS GCode command: id=%s force=%s", command_id, force)
656708
try:
657-
command_id_string = res.headers.get("Command-Id", "")
658-
command_id = int(command_id_string)
659-
except (TypeError, ValueError):
660-
log.error("Invalid Command-Id header. Headers: %s",
661-
res.headers)
709+
if self.command.check_state(command_id, command_name):
710+
self.command.accept(command_id,
711+
command_name=command_name,
712+
args=[body],
713+
kwargs={"gcode": body},
714+
force=force)
715+
except Exception as exc: # pylint: disable=broad-except
716+
log.exception("")
662717
self.event_cb(const.Event.REJECTED,
663718
const.Source.CONNECT,
664-
reason="Invalid Command-Id header")
665-
return res
666-
if not self.is_initialised():
719+
command_id=command_id,
720+
reason=str(exc))
721+
722+
elif msg_type == 'J':
723+
# High-level JSON command — body is raw Connect HTTP response body
724+
try:
725+
data = json.loads(body)
726+
except json.JSONDecodeError:
727+
log.error("WS: invalid JSON body: %r", body)
667728
self.event_cb(const.Event.REJECTED,
668-
const.Source.WUI,
729+
const.Source.CONNECT,
669730
command_id=command_id,
670-
reason=self.NOT_INITIALISED_MSG)
671-
return res
672-
content_type = res.headers.get("content-type", "")
673-
log.debug("parse_command res: %s", res.text)
731+
reason="Invalid JSON body")
732+
return
733+
734+
command_name = data.get("command", "")
735+
log.debug("WS JSON command: id=%s cmd=%s", command_id,
736+
command_name)
674737
try:
675-
if content_type.startswith("application/json"):
676-
data = res.json()
677-
command_name = data.get("command", "")
678-
if self.command.check_state(command_id, command_name):
679-
self.command.accept(command_id,
680-
command_name=command_name,
681-
args=data.get("args"),
682-
kwargs=data.get('kwargs'))
683-
elif content_type == "text/x.gcode":
684-
command_name = const.Command.GCODE.value
685-
if self.command.check_state(command_id, command_name):
686-
force = ("Force" in res.headers
687-
and res.headers["Force"] == "1")
688-
self.command.accept(command_id,
689-
command_name, [res.text],
690-
{"gcode": res.text},
691-
force=force)
692-
else:
693-
raise ValueError("Invalid command content type")
694-
except Exception as e: # pylint: disable=broad-except
738+
if self.command.check_state(command_id, command_name):
739+
self.command.accept(command_id,
740+
command_name=command_name,
741+
args=data.get("args"),
742+
kwargs=data.get("kwargs"))
743+
except Exception as exc: # pylint: disable=broad-except
695744
log.exception("")
696745
self.event_cb(const.Event.REJECTED,
697746
const.Source.CONNECT,
698747
command_id=command_id,
699-
reason=str(e))
700-
elif res.status_code == 204: # no cmd in telemetry
701-
pass
748+
reason=str(exc))
749+
702750
else:
703-
log.info("Got unexpected telemetry response (%s): %s",
704-
res.status_code, res.text)
705-
return res
751+
log.warning("WS: unknown message type: %r", msg_type)
706752

707753
def register(self):
708754
"""Register the printer with Connect and return a registration
@@ -759,21 +805,19 @@ def loop(self):
759805

760806
def loop_step(self):
761807
"""
762-
Gets an item LoopObject from queue, sends it and handles the response
763-
The LoopObject is either an Event - in which case it's just sent,
764-
a Telemetry, in which case the response might contain a command to
765-
execute, a Register object in which case the response contains the
766-
credentials for further communication.
808+
Gets a LoopObject from the queue and sends it.
809+
810+
Telemetry and Event travel over the persistent WebSocket (/p/ws).
811+
Commands from Connect arrive asynchronously via handle_ws_message.
812+
Register (/p/register) and CameraRegister (/p/camera) remain HTTP.
767813
"""
768814
# pylint: disable=too-many-branches
769815
# pylint: disable=too-many-statements
770816
try:
771-
# Get the item to send
772817
item = self.queue.get(timeout=const.TIMESTAMP_PRECISION)
773818
except Empty:
774819
return
775820

776-
# Make sure we're able to send it
777821
if not self.server:
778822
log.warning("Server is not set, skipping item: %s", item)
779823
return
@@ -786,7 +830,26 @@ def loop_step(self):
786830
log.warning("No token, skipping item: %s", item)
787831
return
788832

789-
# Send it
833+
# --- WebSocket path: Telemetry and Event ---
834+
if isinstance(item, (Telemetry, Event)):
835+
self.ensure_ws_connected()
836+
if not self.ws.connected:
837+
# Can't reach server at all — network/internet issue
838+
errors.INTERNET.ok = False
839+
INTERNET.state = CondState.NOK
840+
log.warning("WS not connected, dropping item: %s", item)
841+
return
842+
sent = self.ws.send(item.to_payload())
843+
if sent:
844+
errors.API.ok = True
845+
API.state = CondState.OK
846+
else:
847+
# WS connected but send failed — HTTP/WS layer issue
848+
errors.HTTP.ok = False
849+
HTTP.state = CondState.NOK
850+
return
851+
852+
# --- HTTP path: Register and CameraRegister ---
790853
headers = self.make_headers(item.timestamp)
791854
try:
792855
res = item.send(self.conn, self.server, headers)
@@ -807,10 +870,7 @@ def loop_step(self):
807870
INTERNET.state = CondState.NOK
808871
log.exception('Unhandled error')
809872
else:
810-
# Handle the response
811-
if isinstance(item, Telemetry):
812-
self.parse_command(res)
813-
elif isinstance(item, Register):
873+
if isinstance(item, Register):
814874
if res.status_code == 200:
815875
self.token = res.headers["Token"]
816876
errors.TOKEN.ok = True
@@ -823,7 +883,6 @@ def loop_step(self):
823883
sleep(1)
824884
elif isinstance(item, CameraRegister):
825885
camera = item.camera
826-
# pylint: disable=unused-argument
827886
if res.status_code == 200:
828887
camera_token = res.headers["Token"]
829888
camera.set_token(camera_token)

prusa/connect/printer/ws.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""WebSocket transport for Connect SDK — telemetry and events."""
2+
import json
3+
from logging import getLogger
4+
from threading import Event, Lock, Thread
5+
from typing import Callable, Optional
6+
7+
import websocket # type: ignore
8+
9+
from . import const
10+
11+
log = getLogger("connect-printer")
12+
13+
14+
class PrinterWS:
15+
"""Persistent WebSocket connection to Connect.
16+
17+
Runs WebSocketApp.run_forever() in a background daemon thread so that
18+
Printer.loop (synchronous/threaded) can call send() without blocking.
19+
Incoming frames are forwarded to the on_message callback supplied at
20+
construction (Printer.handle_ws_message).
21+
"""
22+
23+
def __init__(self, on_message: Callable[[str], None]):
24+
self._on_message = on_message
25+
self._ws: Optional[websocket.WebSocketApp] = None
26+
self._thread: Optional[Thread] = None
27+
self._open_event = Event()
28+
self._send_lock = Lock()
29+
30+
@property
31+
def connected(self) -> bool:
32+
"""True when the WebSocket handshake completed and socket is open."""
33+
return (self._ws is not None and self._ws.sock is not None
34+
and self._ws.sock.connected)
35+
36+
def connect(self, url: str, headers: dict) -> None:
37+
"""(Re)connect to *url* using *headers* for the WS handshake.
38+
39+
Blocks until on_open fires or CONNECTION_TIMEOUT elapses.
40+
Safe to call when already connected — disconnects first.
41+
"""
42+
self.disconnect()
43+
self._open_event.clear()
44+
45+
header_list = [
46+
f"{key}: {value}" for key, value in headers.items()
47+
if value is not None
48+
]
49+
50+
self._ws = websocket.WebSocketApp(
51+
url,
52+
header=header_list,
53+
on_open=self._handle_open,
54+
on_message=self._handle_message,
55+
on_error=self._handle_error,
56+
on_close=self._handle_close,
57+
)
58+
59+
self._thread = Thread(
60+
target=self._ws.run_forever,
61+
kwargs={
62+
"ping_interval": 30,
63+
"ping_timeout": 10,
64+
},
65+
daemon=True,
66+
)
67+
self._thread.start()
68+
69+
if not self._open_event.wait(timeout=const.CONNECTION_TIMEOUT):
70+
log.warning("WS connect timed out: %s", url)
71+
72+
def disconnect(self) -> None:
73+
"""Close the WebSocket and wait for the background thread to stop."""
74+
if self._ws is not None:
75+
self._ws.close()
76+
if self._thread is not None and self._thread.is_alive():
77+
self._thread.join(timeout=const.CONNECTION_TIMEOUT)
78+
self._ws = None
79+
self._thread = None
80+
81+
def send(self, payload: dict) -> bool:
82+
"""Serialize *payload* to JSON and send as a text frame.
83+
84+
Returns True on success, False when not connected or on any error.
85+
Thread-safe (one send at a time via lock).
86+
"""
87+
if not self.connected:
88+
log.warning("WS not connected, dropping: %s", payload)
89+
return False
90+
try:
91+
with self._send_lock:
92+
self._ws.send(json.dumps(payload)) # type: ignore[union-attr]
93+
return True
94+
except Exception: # pylint: disable=broad-except
95+
log.exception("WS send failed")
96+
return False
97+
98+
# --- internal WebSocketApp callbacks ---
99+
100+
def _handle_open(self, _ws) -> None:
101+
log.debug("WS connection opened")
102+
self._open_event.set()
103+
104+
def _handle_message(self, _ws, message: str) -> None:
105+
self._on_message(message)
106+
107+
def _handle_error(self, _ws, error) -> None:
108+
log.error("WS error: %s", error)
109+
self._open_event.set() # unblock connect() if still waiting
110+
111+
def _handle_close(self, _ws, close_code, close_msg) -> None:
112+
log.debug("WS closed: %s %s", close_code, close_msg)

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ requests>=2.32.3
33
inotify_simple~=1.3.5
44
mypy-extensions~=1.0.0
55
urllib3>=1.21.1,<3
6+
websocket-client>=1.6.0

0 commit comments

Comments
 (0)