33 Copyright (C) 2024 PrusaResearch
44"""
55import configparser
6+ import json
67import os
78import re
89from logging import getLogger
1112from typing import Any , Callable , Dict , List , Optional
1213
1314from gcode_metadata import get_metadata
14- from requests import RequestException , Response , Session # type: ignore
15+ from requests import RequestException , Session # type: ignore
1516from requests .exceptions import (
1617 ConnectionError as RequestsConnectionError , # type: ignore
1718)
3435 Telemetry ,
3536)
3637from .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 )
0 commit comments