From 3a3570c62b002fb7226b0a33b832ceeb8923ddd0 Mon Sep 17 00:00:00 2001 From: Serge Bakharev Date: Sat, 18 Jul 2026 19:33:02 +1000 Subject: [PATCH 1/3] Comms uplift. Support for both smoothie/makera protocols, and improvements to usb reconnection --- CHANGELOG.md | 16 + carveracontroller/Controller.py | 411 ++++++++++++----- carveracontroller/USBStream.py | 93 +++- carveracontroller/Utils.py | 145 +++++- carveracontroller/WIFIStream.py | 13 +- carveracontroller/XMODEM.py | 322 ++++++++++++- carveracontroller/addons/pendant/pendant.py | 2 +- carveracontroller/controller_config.json | 19 +- carveracontroller/main.py | 471 ++++++++++++++------ carveracontroller/makera.kv | 11 +- carveracontroller/protocols/__init__.py | 25 ++ carveracontroller/protocols/base.py | 37 ++ carveracontroller/protocols/detector.py | 59 +++ carveracontroller/protocols/framing.py | 344 ++++++++++++++ carveracontroller/protocols/makera.py | 166 +++++++ carveracontroller/protocols/messages.py | 19 + carveracontroller/protocols/registry.py | 37 ++ carveracontroller/protocols/session.py | 148 ++++++ carveracontroller/protocols/smoothie.py | 53 +++ tests/unit/test_diagnose_parser.py | 30 ++ tests/unit/test_protocol_detector.py | 40 ++ tests/unit/test_protocol_makera.py | 83 ++++ tests/unit/test_protocol_smoothie.py | 43 ++ 23 files changed, 2306 insertions(+), 281 deletions(-) create mode 100644 carveracontroller/protocols/__init__.py create mode 100644 carveracontroller/protocols/base.py create mode 100644 carveracontroller/protocols/detector.py create mode 100644 carveracontroller/protocols/framing.py create mode 100644 carveracontroller/protocols/makera.py create mode 100644 carveracontroller/protocols/messages.py create mode 100644 carveracontroller/protocols/registry.py create mode 100644 carveracontroller/protocols/session.py create mode 100644 carveracontroller/protocols/smoothie.py create mode 100644 tests/unit/test_diagnose_parser.py create mode 100644 tests/unit/test_protocol_detector.py create mode 100644 tests/unit/test_protocol_makera.py create mode 100644 tests/unit/test_protocol_smoothie.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 603ca7b9..28e5f745 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ - Enhancement: Advanced TLO Calibration option. Here you can set the offset to use from tool setter, and/or the number of repeat probings to use - Enhancement: Added a warning popup if the controller version is lower than the firmware. This is not a supported config - Enhancement: Added Auto Ext. Out toggle to spindle dropdown and Config and Run screen. This can be used to automatically run a vacuum or compressor when the spindle is running +- Enhancement: Autodetect Smoothie vs Makera communication protocol on connect and use it for the session +- Enhancement: Show a connecting progress popup while opening a USB device +- Enhancement: Log connect and manual disconnect with the connection method and address +- Enhancement: Add a "Network..." option under Scan Wi-Fi in the connection dropdown to enter a machine network address +- Enhancement: Reconnect supports USB as well as WiFi. Configure preferred method for app-launch auto-connect - Fixed: Restore Keyboard Jogging state after Probing Popup is closed - Fixed: Repeated firmware checks now happen just once - Fixed: UI widget updates from the SerialMonitor() now dispatched via the main thread. This should reduce the number of RecycleView related crashes @@ -30,9 +35,20 @@ - Fixed: Fix incorrect "No Pendant" in UI when pendant is working - Fixed: Fix invalid initial coordinates when resuming in the middle of a modal command - Fixed: 3D Visualisation of GCode movement would always show the initial movement as originating from the WCS Origin, this doesn't match reality. Now the Visualisation correctly shows the line as originating from above the first movement command at the configured clearance_z (default of MCS Z-3) +- Fixed: When a USB connection was lost, the popup had a non-functioning reconnect button +- Fixed: When connecting over USB the UI thread would freeze while it was opening the device +- Fixed: USB higher-baud upgrade failed on Makera protocol (trailing newline in framed commands, race with config download, host baud switch). Upgrade now runs after config sync and verifies the link +- Fixed: Config download / MD5-match cache path could fail to load settings and block later USB baud upgrade +- Fixed: Status and diagnose parsers mishandled trailing newlines in Makera payloads (e.g. RSSI parse warnings) +- Fixed: Fresh USB-only connects could immediately show "Connection to machine lost" while the machine was still booting after DTR reset; "Connection to machine lost" is now also logged +- Change: Misleading "Download canceled by Controller!" MDI message is suppressed, in logs a message is recorded that cached version of the config.txt was used - Change: Remove remaining "Can not load config, Key:" messages from the MDI - Change: Resume playback will now use gcode loaded in the controller instead of cached local file - Change: Upgrade screen now will show the letter "c" at the end of the current firmware version if it's present. This indicates that it's Community firmware +- Change: Auto-connect on app launch now is only performed if auto-reconnect is enabled +- Change: Reconnect uses the last successful connection method. On fresh app launch it uses the configured prefered connection method +- Change: USB devices in connection dropdown are filtered to only show devices specifically with the FTDI chip found on the Makera machines +- Change: USB devices are now selected and stored by stable VID:PID:serial identity (instead of generic COM path) [2.1.0] - Enhancement: Add right-click menu option to clear resume-at-line setting diff --git a/carveracontroller/Controller.py b/carveracontroller/Controller.py index cabedd3a..8539df63 100644 --- a/carveracontroller/Controller.py +++ b/carveracontroller/Controller.py @@ -3,7 +3,6 @@ import logging import math -import os import re import sys import threading @@ -12,8 +11,6 @@ logger = logging.getLogger(__name__) -from datetime import datetime - try: from Queue import * except ImportError: @@ -23,9 +20,9 @@ from . import Utils from .CNC import CMDPAT, CNC, LASER_TOOL_NUMBER, PARENPAT, SEMIPAT, ZPROBE_TOOL_NUMBER +from .protocols import MessageKind, ProtocolSession from .USBStream import USBStream from .WIFIStream import WIFIStream -from .XMODEM import CAN, EOT try: from kivy.app import App @@ -159,6 +156,14 @@ def __init__(self, cnc, callback, log_sent_receive=False): self._baud_upgrade_attempted = False self._baud_switch_in_progress = False + self._refresh_heartbeat = False + # True from open() start until streamIO is running (hides half-open links from heartbeat). + self._connecting = False + # Epoch seconds; while time.time() < this, heartbeat will not drop the link. + # Used after USB DTR reset while the machine is still booting. + self._heartbeat_grace_until = 0.0 + # True while streamIO is idle (paused / no stream); used to sync baud switch. + self._stream_io_parked = True self._msg = None self._sumcline = 0 @@ -175,11 +180,27 @@ def __init__(self, cnc, callback, log_sent_receive=False): self.is_community_firmware = False + # Connection-scoped comms protocol (detect on open; follows M485 switches) + self.comms = ProtocolSession(on_change=self._on_comms_protocol_changed) + # Jog related variables self.jog_mode = Controller.JOG_MODE_STEP self.jog_speed = 10000 # mm/min. A value of 0 here would suggest to use last used feed self.continuous_jog_active = False + @property + def protocol_ready(self): + return self.comms.ready + + def _on_comms_protocol_changed(self, name, uses_framed_transfer): + """Keep transports' file-transfer mode aligned with the comms session.""" + if self.usb_stream is not None: + self.usb_stream.uses_framed_transfer = uses_framed_transfer + if self.wifi_stream is not None: + self.wifi_stream.uses_framed_transfer = uses_framed_transfer + if self.comms.ready: + self.log.put((self.MSG_NORMAL, f"Using {name} communication protocol")) + # ---------------------------------------------------------------------- def quit(self, event=None): pass @@ -211,19 +232,48 @@ def executeCommand(self, line): # time.sleep(0.5) if self.stream and line: try: - if line[-1] != "\n": + if isinstance(line, str) and not line.endswith("\n"): line += "\n" - self.stream.send(line.encode()) + payload = line.encode() if isinstance(line, str) else line + self.stream.send(self.comms.encode_command(payload)) if self.execCallback: - # 检查文件名是否以 ".lz" 结尾 - if line.endswith(".lz\n"): - # 删除 ".lz" 后缀 - new_line = line[:-4] + "\n" + display = line if isinstance(line, str) else line.decode(errors="ignore") + # Strip ".lz" suffix for display + if display.endswith(".lz\n"): + new_line = display[:-4] + "\n" else: - # 如果没有 ".lz" 后缀,直接赋值 - new_line = line + new_line = display self.execCallback(new_line) - except: + except Exception: + self.log.put((Controller.MSG_ERROR, str(sys.exc_info()[1]))) + + def executeRealtime(self, char): + """Send a single-byte realtime control through the active protocol.""" + if not self.stream: + return + try: + if isinstance(char, (bytes, bytearray)): + char = char[0] + self.stream.send(self.comms.encode_realtime(int(char))) + except Exception: + self.log.put((Controller.MSG_ERROR, str(sys.exc_info()[1]))) + + def executeFileCommand(self, line): + """Send an upload/download initiation command through the active protocol.""" + if self.stream and line: + try: + if isinstance(line, str) and not line.endswith("\n"): + line += "\n" + payload = line.encode() if isinstance(line, str) else line + self.stream.send(self.comms.encode_file_command(payload)) + if self.execCallback: + display = line if isinstance(line, str) else line.decode(errors="ignore") + if display.endswith(".lz\n"): + new_line = display[:-4] + "\n" + else: + new_line = display + self.execCallback(new_line) + except Exception: self.log.put((Controller.MSG_ERROR, str(sys.exc_info()[1]))) # ---------------------------------------------------------------------- @@ -611,13 +661,13 @@ def uploadCommand(self, filename): upload_command = "upload %s\n" % filename.replace(" ", "\x01") if "\\" in filename: upload_command = "upload %s\n" % "/".join(filename.split("\\")).replace(" ", "\x01") - self.executeCommand(self.escape(upload_command)) + self.executeFileCommand(self.escape(upload_command)) def downloadCommand(self, filename): download_command = "download %s\n" % filename.replace(" ", "\x01") if "\\" in filename: download_command = "download %s\n" % "/".join(filename.split("\\")).replace(" ", "\x01") - self.executeCommand(self.escape(download_command)) + self.executeFileCommand(self.escape(download_command)) def suspendCommand(self): self.executeCommand("suspend\n") @@ -1259,27 +1309,23 @@ def abortCommand(self): self.executeCommand("abort\n") def feedholdCommand(self): - if self.stream: - self.stream.send(b"!") + self.executeRealtime(ord("!")) def toggleFeedholdCommand(self, holding): - if self.stream: - if holding: - self.stream.send(b"~") - else: - self.stream.send(b"!") + if holding: + self.executeRealtime(ord("~")) + else: + self.executeRealtime(ord("!")) def cyclestartCommand(self): - if self.stream: - self.stream.send(b"~") + self.executeRealtime(ord("~")) def estopCommand(self): - if self.stream: - self.stream.send(b"\x18") + self.executeRealtime(0x18) # ---------------------------------------------------------------------- def hardResetPre(self): - self.stream.send(b"reset\n") + self.executeCommand("reset\n") def hardResetAfter(self): time.sleep(6) @@ -1291,7 +1337,13 @@ def parseBracketAngle( # R: Rotation Angle; G: active Coord System; # # F: Feed, overide | S: Spindle RPM - ln = line[1:-1] # strip off < .. > + # Comms delivers newline-trimmed text; keep delimiter extraction so a + # trailing junk byte after '>' cannot poison the last field. + start = line.find("<") + end = line.rfind(">") + if start < 0 or end <= start: + raise ValueError(f"Malformed status report: {line!r}") + ln = line[start + 1 : end] # split fields l = ln.split("|") @@ -1420,8 +1472,14 @@ def parseBracketAngle( self.posUpdate = True def parseBigParentheses(self, line): - # {S:0,5000|L:0,0|F:1,0|V:0,1|G:0|T:0|E:0,0,0,0,0,0|P:0,0|A:1,0} - ln = line[1:-1] # strip off < .. > + # {S:0,5000|L:0,0|F:1,0|V:0,1|G:0|T:0|E:0,0,0,0,0,0|P:0,0|A:1,0|RSSI:-57} + # Comms delivers newline-trimmed text; keep delimiter extraction so a + # trailing junk byte after '}' cannot poison the last field. + start = line.find("{") + end = line.rfind("}") + if start < 0 or end <= start: + raise ValueError(f"Malformed diagnose report: {line!r}") + ln = line[start + 1 : end] # split fields l = ln.split("|") @@ -1472,6 +1530,8 @@ def parseBigParentheses(self, line): CNC.vars["st_tool_sensor"] = int(d["A"][1]) if "I" in d: CNC.vars["st_e_stop"] = int(d["I"][0]) + if "RSSI" in d: + CNC.vars["RSSI"] = int(d["RSSI"][0]) self.diagnoseUpdate = True @@ -1482,23 +1542,60 @@ def help(self, event=None): # ---------------------------------------------------------------------- # Open serial port or wifi connect # ---------------------------------------------------------------------- + def _close_existing_connection(self): + """Stop streamIO and close any active transport before opening a new one.""" + if self.stream is None and self.thread is None: + return + self.stopRun() + thread = self.thread + self.thread = None + if thread is not None and thread.is_alive() and thread is not threading.current_thread(): + thread.join(timeout=1.0) + if self.stream is not None: + try: + self.stream.close() + except Exception: + pass + self.stream = None + self.comms.reset() + self.clearRun() + def open(self, conn_type, address): # init connection + method = "USB serial" if conn_type == CONN_USB else "WiFi" + # Single user-visible connect log (monitorSerial emits one MDI Received line). + self.log.put((self.MSG_NORMAL, f"Connecting via {method}: {address}")) + self.connection_type = conn_type # Persist the last connection target so callers can detect "same machine" # reconnects (e.g. for resume-at-line selection / loaded lines behavior). self.connection_address = address + self._connecting = True + self._heartbeat_grace_until = 0.0 + # Keep self.stream unset until open + protocol detect finish so heartbeat + # cannot treat a half-open link as a live connection and tear it down. if conn_type == CONN_USB: - self.stream = self.usb_stream + transport = self.usb_stream self._baud_upgrade_attempted = False else: - self.stream = self.wifi_stream + transport = self.wifi_stream + + try: + # Switching WiFi ↔ USB (or reconnecting) must tear down the old link first. + self._close_existing_connection() + + if not transport.open(address): + self.log.put((self.MSG_ERROR, "Connection Failed!")) + return False + + if conn_type == CONN_USB: + # USB open toggles DTR and resets the machine; wait for firmware boot + # before protocol probe / status polling. + time.sleep(2.0) - if self.stream.open(address): CNC.vars["state"] = CONNECTED CNC.vars["color"] = STATECOLOR[CNC.vars["state"]] self.log.put((self.MSG_NORMAL, "Connected to machine!")) - # self.stream.send(b"\n") self._gcount = 0 self._alarm = True CNC.vars["alarm_message"] = "" @@ -1506,12 +1603,18 @@ def open(self, conn_type, address): self._manual_disconnect = False try: self.clearRun() - except: + except Exception: self.log.put((self.MSG_ERROR, "Controller clear thread error!")) + self.comms.detect_and_select(transport) + self.stream = transport self.thread = threading.Thread(target=self.streamIO) self.thread.start() + self._refresh_heartbeat = True + # USB needs a longer post-reset grace; WiFi is usually ready immediately. + self._heartbeat_grace_until = time.time() + (20.0 if conn_type == CONN_USB else 5.0) return True - self.log.put((self.MSG_ERROR, "Connection Failed!")) + finally: + self._connecting = False # ---------------------------------------------------------------------- # Close connection port @@ -1521,39 +1624,44 @@ def close(self): return try: self.stopRun() - except: + except Exception: self.log.put((self.MSG_ERROR, "Controller stop thread error!")) self._runLines = 0 time.sleep(0.5) self.thread = None try: self.stream.close() - except: + except Exception: self.log.put((self.MSG_ERROR, "Controller close stream error!")) self.stream = None + self.comms.reset() CNC.vars["state"] = NOT_CONNECTED CNC.vars["color"] = STATECOLOR[CNC.vars["state"]] - # Start reconnection if enabled - if self.reconnect_enabled and self.reconnect_callback and self.connection_type == CONN_WIFI: + # Start reconnection if enabled (WiFi or USB; callback resolves the method). + if self.reconnect_enabled and self.reconnect_callback: self.start_reconnection() def close_manual(self): """Close connection manually (user initiated) - don't auto-reconnect""" if self.stream is None: return + method = "USB serial" if self.connection_type == CONN_USB else "WiFi" + address = self.connection_address or "unknown" + self.log.put((self.MSG_NORMAL, f"Disconnected via {method}: {address}")) try: self.stopRun() - except: + except Exception: self.log.put((self.MSG_ERROR, "Controller stop thread error!")) self._runLines = 0 time.sleep(0.5) self.thread = None try: self.stream.close() - except: + except Exception: self.log.put((self.MSG_ERROR, "Controller close stream error!")) self.stream = None + self.comms.reset() # Set a flag to indicate this was a manual disconnection self._manual_disconnect = True CNC.vars["state"] = NOT_CONNECTED @@ -1639,24 +1747,26 @@ def sendGCode(self, cmd): def sendHex(self, hexcode): if self.stream is None: return - self.stream.send(chr(int(hexcode, 16))) - self.stream.flush() + self.executeRealtime(int(hexcode, 16)) def viewStatusReport(self, sio_status): if self.loadNUM == 0 and self.sendNUM == 0: - if self.stream is None: + if self.stream is None or not self.protocol_ready: return + self.executeRealtime(ord("?")) if self.continuous_jog_active: - self.stream.send(b"?1") - else: - self.stream.send(b"?") + # Smoothie uses "?1"; Makera uses Ctrl+Z for keepalive. + if self.comms.uses_framed_transfer: + self.executeRealtime(0x1A) + else: + self.executeRealtime(ord("1")) self.sio_status = sio_status def viewDiagnoseReport(self, sio_diagnose): if self.loadNUM == 0 and self.sendNUM == 0: - if self.stream is None: + if self.stream is None or not self.protocol_ready: return - self.stream.send(b"diagnose\n") + self.executeCommand("diagnose\n") self.sio_diagnose = sio_diagnose # ---------------------------------------------------------------------- @@ -1674,8 +1784,7 @@ def hardReset(self): self.notBusy() def softReset(self, clearAlarm=True): - if self.stream: - self.stream.send(b"\030") + self.executeRealtime(0x18) self.stopProbe() if clearAlarm: self._alarm = False @@ -1704,7 +1813,7 @@ def viewState(self): self.sendGCode("$G") def viewBuild(self): - self.stream.send(b"version\n") + self.executeCommand("version\n") self.sendGCode("$I") def viewStartup(self): @@ -1714,7 +1823,7 @@ def checkGcode(self): pass def grblHelp(self): - self.stream.send(b"help\n") + self.executeCommand("help\n") def grblRestoreSettings(self): pass @@ -1758,9 +1867,9 @@ def stopContinuousJog(self): if self.jog_mode != Controller.JOG_MODE_CONTINUOUS: return - # Send Y^ (Ctrl+Y) to stop continuous jogging + # Send Ctrl+Y to stop continuous jogging if self.stream is not None and self.continuous_jog_active: - self.stream.send(b"\031") + self.executeRealtime(0x19) def jog(self, _dir, speed=None): if self.jog_mode == Controller.JOG_MODE_STEP: @@ -1864,8 +1973,7 @@ def feedHold(self, event=None): return if self.stream is None: return - self.stream.send(b"!") - self.stream.flush() + self.executeRealtime(ord("!")) self._pause = True def resume(self, event=None): @@ -1873,8 +1981,7 @@ def resume(self, event=None): return if self.stream is None: return - self.stream.send(b"~") - self.stream.flush() + self.executeRealtime(ord("~")) self._alarm = False CNC.vars["alarm_message"] = "" self._pause = False @@ -1888,20 +1995,102 @@ def pause(self, event=None): self.feedHold() # ---------------------------------------------------------------------- + def _drain_stream_messages(self, timeout_s=0.3): + """Read and parse pending RX bytes for up to timeout_s; return messages.""" + messages = [] + deadline = time.time() + timeout_s + while time.time() < deadline: + if not self.stream or not self.stream.waiting_for_recv(): + time.sleep(0.01) + continue + data = self.stream.recv() + if data: + messages.extend(self.comms.feed(data, allow_wire_switch=False)) + return messages + + def _flush_rx(self): + """Discard any unread host RX bytes and reset the protocol parser.""" + serial_port = getattr(self.stream, "serial", None) if self.stream else None + if serial_port is not None: + try: + serial_port.reset_input_buffer() + except Exception: + pass + try: + while serial_port.in_waiting: + serial_port.read(serial_port.in_waiting) + except Exception: + pass + self.comms.reset_parser() + + @staticmethod + def _looks_like_status(text): + t = (text or "").lstrip() + return t.startswith("<") and ("|" in t or "MPos" in t or "Idle" in t or "Run" in t) + + def _await_status_response(self, timeout_s=1.0): + """Send a status query and wait for a status frame/line.""" + self.executeRealtime(ord("?")) + deadline = time.time() + timeout_s + while time.time() < deadline: + for message in self._drain_stream_messages(timeout_s=0.05): + if self._looks_like_status(message.text): + return True + return False + def request_baud_upgrade(self, baud): - """Send firmware baud command and reopen serial at new baud (machine switches immediately).""" + """Send firmware baud command, then switch the host UART to match.""" if self.connection_type != CONN_USB or self.stream != self.usb_stream: return + if not hasattr(self.usb_stream, "reopen_at_baud"): + return + if self.sendNUM != 0 or self.loadNUM != 0: + self.log.put((self.MSG_ERROR, "Failed to change serial speed: transfer in progress")) + return + baud = int(baud) self._baud_switch_in_progress = True + self._refresh_heartbeat = True + # Stop streamIO and wait until it is parked so it cannot steal the "ok". + self.pauseStream(0.0) try: - self.stream.send(f"baud {baud}\n".encode()) - if hasattr(self.usb_stream, "reopen_at_baud"): - self.usb_stream.reopen_at_baud(baud) - self.log.put((self.MSG_NORMAL, f"Serial speed set to {baud} baud")) + self.executeCommand(f"baud {baud}\n") + # Firmware prints framed/text "ok" at the old baud, then switches. + # Give TX time to finish, then reopen the host port at the new rate. + time.sleep(0.15) + self._flush_rx() + if not self.usb_stream.reopen_at_baud(baud): + raise RuntimeError("Failed to set host serial baud rate") + # Ensure Controller still points at the reopened USB stream. + self.stream = self.usb_stream + self.comms.reset_parser() + if not self._await_status_response(timeout_s=1.5): + # Recover: try bringing both sides back to 115200. + self._recover_baud_after_failed_upgrade(baud) + raise RuntimeError(f"no status response after switching to {baud} baud") + self.log.put((self.MSG_NORMAL, f"Serial speed set to {baud} baud")) + self._refresh_heartbeat = True except Exception as e: self.log.put((self.MSG_ERROR, f"Failed to change serial speed: {e}")) + self._refresh_heartbeat = True finally: self._baud_switch_in_progress = False + self.resumeStream() + + def _recover_baud_after_failed_upgrade(self, attempted_baud): + """Best-effort restore of 115200 after a failed high-baud switch.""" + try: + # Machine may already be at attempted_baud — ask it to drop back. + self.executeCommand("baud 115200\n") + time.sleep(0.15) + except Exception: + pass + try: + self._flush_rx() + if self.usb_stream.reopen_at_baud(115200): + self.stream = self.usb_stream + self.comms.reset_parser() + except Exception: + logger.exception("Failed to revert host baud to 115200 after upgrade to %s", attempted_baud) # ---------------------------------------------------------------------- def parseLine(self, line): @@ -1935,7 +2124,7 @@ def parseLine(self, line): CNC.vars["alarm_message"] = msg else: self.log.put((self.MSG_NORMAL, line)) - except (LookupError, ArithmeticError) as e: + except (LookupError, ArithmeticError, ValueError) as e: self.log.put((self.MSG_ERROR, f"Failed to parse machine response: {line}")) logger.error(f"Parser error in parseLine: {e}, line: {line}") @@ -1954,15 +2143,55 @@ def emptyQueue(self): except Empty: break - def pauseStream(self, wait_s): + def pauseStream(self, wait_s=0.0): + """Stop the streamIO RX loop so file transfer owns the byte stream.""" self.pausing = True - time.sleep(wait_s) + # Pause RX immediately — do not wait before setting paused, or framed + # file-transfer packets (MD5/etc.) can be consumed by streamIO. self.paused = True + # Wait until streamIO acknowledges the pause before the caller touches RX. + deadline = time.time() + 1.0 + while not self._stream_io_parked and time.time() < deadline: + time.sleep(0.01) + if wait_s > 0: + time.sleep(wait_s) self.pausing = False def resumeStream(self): self.paused = False self.pausing = False + self._stream_io_parked = False + + def _handle_protocol_message(self, message): + """Dispatch a ParsedMessage from the active communication protocol.""" + if message.kind == MessageKind.LOAD_EOF: + self.loadEOF = True + return + if message.kind == MessageKind.LOAD_ERROR: + self.loadERR = True + return + + text = message.text or "" + if message.kind == MessageKind.LOAD_CHUNK: + cleaned = re.sub(r"<.*?>", "", text).strip() + if not cleaned: + return + for line2 in cleaned.replace("\r\n", "\n").split("\n"): + if line2: + self.load_buffer.put(line2) + self.load_buffer_size += len(line2) + 1 + return + + # LINE + if self.loadNUM == 0 or "|MPos" in text: + self.parseLine(text) + return + cleaned_line = re.sub(r"<.*?>", "", text).strip() + if cleaned_line: + for line2 in cleaned_line.replace("\r\n", "\n").split("\n"): + if line2: + self.load_buffer.put(line2) + self.load_buffer_size += len(line2) + 1 # ---------------------------------------------------------------------- # thread performing I/O on serial line @@ -1972,18 +2201,20 @@ def streamIO(self): self.sio_diagnose = False dynamic_delay = 0.1 tr = td = time.time() - line = b"" last_error = "" while not self.stop.is_set(): if not self.stream or self.paused: - time.sleep(1) + self._stream_io_parked = True + # Short sleep so baud-switch / file-transfer pause ends promptly. + time.sleep(0.05) continue + self._stream_io_parked = False t = time.time() # refresh machine position? running = self.sendNUM > 0 or self.loadNUM > 0 or self.pausing try: - if not running: + if not running and self.protocol_ready: if t - tr > STREAM_POLL: self.viewStatusReport(True) tr = t @@ -1995,37 +2226,11 @@ def streamIO(self): td = t if self.stream.waiting_for_recv(): - received = [bytes([b]) for b in self.stream.recv()] - for c in received: - if c in (EOT, CAN): - # Ctrl + Z means transmission complete, Ctrl + D means transmission cancel or error - if len(line) > 0: - self.load_buffer.put(line.decode(errors="ignore")) - if self.loadNUM > 0: - self.load_buffer_size += len(line) - line = b"" - if c == EOT: - self.loadEOF = True - else: - self.loadERR = True - else: - if c == b"\n": - # (line.decode(errors='ignore')) - if self.loadNUM == 0 or "|MPos" in line.decode(errors="ignore"): - self.parseLine(line.decode(errors="ignore")) - else: - # 将字节串解码为字符串 - decoded_line = line.decode(errors="ignore") - # 使用正则表达式去除以"<"开头,以">"结尾的部分 - cleaned_line = re.sub(r"<.*?>", "", decoded_line) - # 去除多余的空格(如果需要) - cleaned_line = cleaned_line.strip() - if len(cleaned_line) != 0: - self.load_buffer.put(cleaned_line) - self.load_buffer_size += len(cleaned_line) + 1 - line = b"" - else: - line += c + data = self.stream.recv() + if data: + allow_wire_switch = self.sendNUM == 0 and self.loadNUM == 0 + for message in self.comms.feed(data, allow_wire_switch=allow_wire_switch): + self._handle_protocol_message(message) dynamic_delay = 0 else: if self.sendNUM == 0 and self.loadNUM == 0: @@ -2034,7 +2239,7 @@ def streamIO(self): dynamic_delay = 0 except Exception: - line = b"" + self.comms.reset_parser() exc_msg = str(sys.exc_info()[1]) if self._baud_switch_in_progress: last_error = exc_msg diff --git a/carveracontroller/USBStream.py b/carveracontroller/USBStream.py index bf9244cb..03d60c82 100644 --- a/carveracontroller/USBStream.py +++ b/carveracontroller/USBStream.py @@ -28,9 +28,13 @@ def __init__(self, log_sent_receive=False): self.log_sent_receive = log_sent_receive self._send_log_buffer = b"" self._recv_log_buffer = b"" + # Set by Controller when the communication protocol is selected. + self.uses_framed_transfer = False # ---------------------------------------------------------------------- def send(self, data): + if self.serial is None: + return if isinstance(data, str): data = data.encode("utf-8", errors="replace") self.serial.write(data) @@ -52,6 +56,8 @@ def send(self, data): # ---------------------------------------------------------------------- def recv(self): + if self.serial is None: + return b"" data = self.serial.read() if self.log_sent_receive and data: self._recv_log_buffer += data @@ -100,29 +106,61 @@ def open(self, address, baud=115200): # ---------------------------------------------------------------------- def reopen_at_baud(self, baud): - """Close the current serial port and reopen at the given baud rate.""" - if self.serial is None: + """ + Close and reopen the serial port at a new baud rate. + """ + if not self._address: return False - try: - self.serial.close() - except Exception: - pass + baud = int(baud) + old = self.serial self.serial = None self._send_log_buffer = b"" self._recv_log_buffer = b"" + if old is not None: + try: + old.dtr = False + old.rts = False + except Exception: + pass + try: + old.close() + except Exception: + pass time.sleep(0.1) - self.serial = serial.serial_for_url( - self._address, - baud, - bytesize=serial.EIGHTBITS, - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE, - timeout=SERIAL_TIMEOUT, - write_timeout=SERIAL_TIMEOUT, - xonxoff=False, - rtscts=False, - ) - return True + try: + ser = serial.serial_for_url( + self._address, + baud, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=SERIAL_TIMEOUT, + write_timeout=SERIAL_TIMEOUT, + xonxoff=False, + rtscts=False, + dsrdtr=False, + do_not_open=True, + ) + try: + ser.dtr = False + ser.rts = False + except Exception: + pass + ser.open() + try: + ser.dtr = False + except Exception: + pass + try: + ser.reset_input_buffer() + ser.reset_output_buffer() + except Exception: + pass + self.serial = ser + return True + except Exception: + logger.exception("Failed to reopen serial at %s baud", baud) + return False # ---------------------------------------------------------------------- def close(self): @@ -141,29 +179,42 @@ def close(self): # ---------------------------------------------------------------------- def waiting_for_send(self): + if self.serial is None: + return False return self.serial.out_waiting < 1 # ---------------------------------------------------------------------- def waiting_for_recv(self): + if self.serial is None: + return 0 return self.serial.in_waiting # ---------------------------------------------------------------------- def getc(self, size, timeout=1): + if self.serial is None: + return None return self.serial.read(size) or None def putc(self, data, timeout=1): + if self.serial is None: + return None return self.serial.write(data) or None def upload(self, filename, local_md5, callback): - # do upload stream = open(filename, "rb") - result = self.modem.send(stream, md5=local_md5, retry=10, callback=callback) + if self.uses_framed_transfer: + result = self.modem.send(stream, md5=local_md5, retry=50, callback=callback) + else: + result = self.modem.send_legacy(stream, md5=local_md5, retry=10, callback=callback) stream.close() return result def download(self, filename, local_md5, callback): stream = open(filename, "wb") - result = self.modem.recv(stream, md5=local_md5, retry=10, callback=callback) + if self.uses_framed_transfer: + result = self.modem.recv(stream, md5=local_md5, retry=50, callback=callback) + else: + result = self.modem.recv_legacy(stream, md5=local_md5, retry=10, callback=callback) stream.close() return result diff --git a/carveracontroller/Utils.py b/carveracontroller/Utils.py index 12cf12e1..15318cad 100644 --- a/carveracontroller/Utils.py +++ b/carveracontroller/Utils.py @@ -7,11 +7,9 @@ __author__ = "Vasilis Vlachoudis" __email__ = "vvlachoudis@gmail.com" -import glob import hashlib import os import sys -import traceback try: import ConfigParser @@ -318,6 +316,149 @@ def comports(include_links=True): return comports +# ------------------------------------------------------------------------------ +# USB serial identity helpers (VID:PID + optional serial), independent of COM path. +# ------------------------------------------------------------------------------ +def usb_device_id_from_port(port): + """ + Build a USB device type id from a pyserial ListPortInfo-like object. + + Format: ``VID:PID`` (hex). Returns None if the port is not a USB device + (Bluetooth/debug ports without VID/PID, etc.). + """ + vid = getattr(port, "vid", None) + pid = getattr(port, "pid", None) + if vid is None or pid is None: + return None + return f"{int(vid):04X}:{int(pid):04X}" + + +def usb_device_label_from_port(port): + """Dropdown label: USB serial number when present, else a short product name.""" + serial_number = (getattr(port, "serial_number", None) or "").strip() + if serial_number: + return serial_number + return (getattr(port, "product", None) or getattr(port, "description", None) or "USB serial").strip() + + +def usb_port_short_name(device_path): + """Short OS path for disambiguation (COM3, ttyUSB0, cu.usbserial-...).""" + if not device_path: + return "" + name = os.path.basename(device_path.replace("\\", "/")) + return name or device_path + + +def same_usb_device_path(a, b): + """Compare serial device paths; case-insensitive on Windows (COM3 vs com3).""" + if not a or not b: + return False + if sys.platform.startswith("win"): + return a.lower() == b.lower() + return a == b + + +def same_usb_serial(a, b): + """Compare USB serial strings case-insensitively (OS reporting varies).""" + a = (a or "").strip() + b = (b or "").strip() + if not a or not b: + return False + return a.casefold() == b.casefold() + + +def parse_usb_device_id(device_id): + """ + Normalize a stored device id to ``(vid_pid, serial_or_empty)``. + + Accepts ``VID:PID`` or legacy ``VID:PID:SERIAL``. + """ + if not device_id: + return None, "" + parts = device_id.strip().split(":") + if len(parts) < 2: + return None, "" + vid_pid = f"{parts[0].upper()}:{parts[1].upper()}" + serial = parts[2].strip() if len(parts) >= 3 else "" + return vid_pid, serial + + +def list_identifiable_usb_serial_ports(port_iter=None): + """ + List USB serial ports that have a VID/PID. + + Returns a list of dicts: device_path, device_id, label, vid, pid, serial. + + On macOS pyserial returns ``/dev/cu.*`` callout devices (correct for open). + On Linux ``/dev/ttyUSB*`` / ``/dev/ttyACM*``. On Windows ``COMn``. + Duplicate labels (common when serial is missing) get a short path suffix. + """ + if port_iter is None: + try: + from serial.tools.list_ports import comports as list_ports_comports + except ImportError: + return [] + # include_links=False avoids duplicate /dev/serial/by-id entries on Linux. + port_iter = list_ports_comports() + + devices = [] + for port in port_iter: + device_id = usb_device_id_from_port(port) + if not device_id: + continue + devices.append( + { + "device_path": port.device, + "device_id": device_id, + "label": usb_device_label_from_port(port), + "vid": int(port.vid), + "pid": int(port.pid), + "serial": (getattr(port, "serial_number", None) or "").strip(), + } + ) + + # Disambiguate identical labels (empty/cloned serials) with the OS short name. + label_counts = {} + for entry in devices: + label_counts[entry["label"]] = label_counts.get(entry["label"], 0) + 1 + for entry in devices: + if label_counts[entry["label"]] > 1: + short = usb_port_short_name(entry["device_path"]) + if short and short != entry["label"]: + entry["label"] = f"{entry['label']} ({short})" + + devices.sort(key=lambda d: (d["label"].lower(), d["device_path"].lower())) + return devices + + +def find_usb_device_path_by_id(device_id=None, serial=None, port_iter=None): + """ + Resolve a stored USB identity to the current OS path. + + Prefers VID/PID + serial when available. If only a serial is known, matches + that serial across USB ports. Falls back to the first VID/PID match. + """ + vid_pid, legacy_serial = parse_usb_device_id(device_id) if device_id else (None, "") + prefer_serial = (serial or legacy_serial or "").strip() + devices = list_identifiable_usb_serial_ports(port_iter=port_iter) + + if prefer_serial: + serial_matches = [entry for entry in devices if same_usb_serial(entry["serial"], prefer_serial)] + if vid_pid: + for entry in serial_matches: + if entry["device_id"].upper() == vid_pid.upper(): + return entry["device_path"] + elif serial_matches: + return serial_matches[0]["device_path"] + + if not vid_pid: + return None + matches = [entry for entry in devices if entry["device_id"].upper() == vid_pid.upper()] + if not matches: + return None + return matches[0]["device_path"] + + suffixes = ["B", "KB", "MB", "GB", "TB", "PB"] diff --git a/carveracontroller/WIFIStream.py b/carveracontroller/WIFIStream.py index da0c7bac..fa770dd3 100644 --- a/carveracontroller/WIFIStream.py +++ b/carveracontroller/WIFIStream.py @@ -89,6 +89,8 @@ def __init__(self, log_sent_receive=False): handler.setFormatter(formatter) self.modem.log.addHandler(handler) self.log_sent_receive = log_sent_receive + # Set by Controller when the communication protocol is selected. + self.uses_framed_transfer = False # ---------------------------------------------------------------------- def send(self, data): @@ -161,15 +163,20 @@ def putc(self, data, timeout=0.5): return self.socket.send(data) or None def upload(self, filename, local_md5, callback): - # do upload stream = open(filename, "rb") - result = self.modem.send(stream, md5=local_md5, retry=10, callback=callback) + if self.uses_framed_transfer: + result = self.modem.send(stream, md5=local_md5, retry=50, callback=callback) + else: + result = self.modem.send_legacy(stream, md5=local_md5, retry=10, callback=callback) stream.close() return result def download(self, filename, local_md5, callback): stream = open(filename, "wb") - result = self.modem.recv(stream, md5=local_md5, retry=10, callback=callback) + if self.uses_framed_transfer: + result = self.modem.recv(stream, md5=local_md5, retry=50, callback=callback) + else: + result = self.modem.recv_legacy(stream, md5=local_md5, retry=10, callback=callback) stream.close() return result diff --git a/carveracontroller/XMODEM.py b/carveracontroller/XMODEM.py index 5228b198..01e00452 100644 --- a/carveracontroller/XMODEM.py +++ b/carveracontroller/XMODEM.py @@ -93,10 +93,26 @@ __version__ = "0.4.5" import logging +import math import platform +import struct import sys import time -from functools import partial +from enum import Enum, auto + +from .protocols.framing import ( + FRAME_END, + FRAME_HEADER, + MAX_FRAME_DATA_LENGTH, + PTYPE_FILE_CAN, + PTYPE_FILE_DATA, + PTYPE_FILE_END, + PTYPE_FILE_MD5, + PTYPE_FILE_RETRY, + PTYPE_FILE_VIEW, + build_frame, + validate_packet_data, +) # Protocol bytes SOH = b"\x01" @@ -109,6 +125,19 @@ CRC = b"C" +class RevPacketState(Enum): + WAIT_HEADER = auto() + READ_LENGTH = auto() + READ_DATA = auto() + CHECK_FOOTER = auto() + + +class FileTransState(Enum): + WAIT_MD5 = auto() + WAIT_FILE_VIEW = auto() + READ_FILE_DATA = auto() + + class XMODEM: """ XMODEM Protocol handler, expects two callables which encapsulate the read @@ -413,23 +442,290 @@ def __init__(self, getc, putc, mode="xmodem8k", pad=b"\x1a"): self.pad = pad self.log = logging.getLogger("xmodem.XMODEM") self.canceled = False + self.currentState = RevPacketState.WAIT_HEADER + self.packetData = bytearray() + self.headerBuffer = bytearray(2) + self.footerBuffer = bytearray(2) + self.bytesNeeded = 2 + self.expectedLength = 0 + self.FileRcvState = FileTransState.WAIT_MD5 def clear_mode_set(self): self.mode_set = False - def abort(self, count=2, timeout=60): - """ - Send an abort sequence using CAN bytes. + def _framed_packet_size(self): + try: + return { + "xmodem": 128, + "USBMode": 128, + "xmodem8k": 8192, + "wifiMode": 8192, + }[self.mode] + except KeyError as exc: + raise ValueError(f"Invalid mode specified: {self.mode!r}") from exc - :param count: how many abort characters to send - :type count: int - :param timeout: timeout in seconds - :type timeout: int - """ + def abort(self, count=2, timeout=60): + """Abort a legacy XMODEM transfer with CAN bytes.""" for _ in range(count): self.putc(CAN, timeout) + def abort_framed(self): + """Abort a Makera framed file transfer.""" + self._send_file_trans_command(PTYPE_FILE_CAN, b"") + + def _send_file_trans_command(self, cmd: int, data: bytes) -> None: + self.putc(build_frame(cmd, data)) + + def recv_packet(self, timeout=0.5): + """Read one Makera frame into ``self.packetData``. Returns 1 on success.""" + self.currentState = RevPacketState.WAIT_HEADER + while True: + byte = self.getc(1, timeout) + if not byte: + return None + byte = ord(byte) + if self.currentState == RevPacketState.WAIT_HEADER: + self.headerBuffer[0] = self.headerBuffer[1] + self.headerBuffer[1] = byte + checksum = (self.headerBuffer[0] << 8) | self.headerBuffer[1] + if checksum == FRAME_HEADER: + self.currentState = RevPacketState.READ_LENGTH + self.bytesNeeded = 2 + self.packetData.clear() + + elif self.currentState == RevPacketState.READ_LENGTH: + self.packetData.append(byte) + self.bytesNeeded -= 1 + if self.bytesNeeded == 0: + self.expectedLength = (self.packetData[0] << 8) | self.packetData[1] + if 0 <= self.expectedLength <= MAX_FRAME_DATA_LENGTH: + self.currentState = RevPacketState.READ_DATA + self.bytesNeeded = self.expectedLength + else: + self.currentState = RevPacketState.WAIT_HEADER + + elif self.currentState == RevPacketState.READ_DATA: + self.packetData.append(byte) + self.bytesNeeded -= 1 + while self.bytesNeeded > 0: + bytess = self.getc(self.bytesNeeded, timeout) + if bytess: + self.packetData.extend(bytess) + self.bytesNeeded = 0 + else: + return None + if self.bytesNeeded == 0: + self.currentState = RevPacketState.CHECK_FOOTER + self.bytesNeeded = 2 + + elif self.currentState == RevPacketState.CHECK_FOOTER: + self.footerBuffer[0] = self.footerBuffer[1] + self.footerBuffer[1] = byte + self.bytesNeeded -= 1 + if self.bytesNeeded == 0: + checksum = (self.footerBuffer[0] << 8) | self.footerBuffer[1] + self.currentState = RevPacketState.WAIT_HEADER + if checksum == FRAME_END and validate_packet_data(self.packetData): + return 1 + return None + + def recv(self, stream, md5="", crc_mode=1, retry=5, timeout=5, delay=0.1, quiet=0, callback=None): + """Receive a file using the Makera framed transfer protocol.""" + success_count = 0 + error_count = 0 + totalerr_count = 0 + total_packet = 0 + sequence = 0 + income_size = 0 + while True: + if self.canceled: + self._send_file_trans_command(PTYPE_FILE_CAN, b"") + self.log.info("Transmission canceled by user.") + self.canceled = False + return -1 + result = self.recv_packet(timeout) + if result: + cmd_type = self.packetData[2] + if cmd_type < PTYPE_FILE_MD5: + continue + if cmd_type == PTYPE_FILE_CAN: + self.log.info("Transmission canceled by Machine.") + self.FileRcvState = FileTransState.WAIT_MD5 + return None + + if self.FileRcvState == FileTransState.READ_FILE_DATA: + if self.packetData: + seq = ( + (self.packetData[3] << 24) + | (self.packetData[4] << 16) + | (self.packetData[5] << 8) + | self.packetData[6] + ) + if cmd_type == PTYPE_FILE_DATA and sequence == seq: + data_len = ((self.packetData[0] << 8) | self.packetData[1]) - 7 + income_size += data_len + stream.write(self.packetData[7 : (data_len + 7)]) + if sequence < total_packet: + sequence += 1 + data = sequence.to_bytes(4, byteorder="big", signed=False) + self._send_file_trans_command(PTYPE_FILE_DATA, data) + success_count = success_count + 1 + if callable(callback): + callback(seq, total_packet) + error_count = 0 + totalerr_count = 0 + if seq == total_packet: + self._send_file_trans_command(PTYPE_FILE_END, b"") + self.FileRcvState = FileTransState.WAIT_MD5 + self.log.info("Transmission complete, %d bytes", income_size) + return income_size + else: + error_count += 1 + if error_count >= retry: + data = sequence.to_bytes(4, byteorder="big", signed=False) + self._send_file_trans_command(PTYPE_FILE_DATA, data) + totalerr_count += 1 + + if self.FileRcvState == FileTransState.WAIT_FILE_VIEW: + if self.packetData: + if cmd_type == PTYPE_FILE_VIEW: + total_packet = ( + (self.packetData[3] << 24) + | (self.packetData[4] << 16) + | (self.packetData[5] << 8) + | self.packetData[6] + ) + sequence = 1 + data = sequence.to_bytes(4, byteorder="big", signed=False) + self._send_file_trans_command(PTYPE_FILE_DATA, data) + self.FileRcvState = FileTransState.READ_FILE_DATA + error_count = 0 + totalerr_count = 0 + else: + error_count += 1 + if error_count >= retry: + self._send_file_trans_command(PTYPE_FILE_VIEW, b"") + totalerr_count += 1 + + if self.FileRcvState == FileTransState.WAIT_MD5: + if self.packetData: + if cmd_type == PTYPE_FILE_MD5: + md5new = self.packetData[3 : len(self.packetData) - 2] + if (md5.encode() == md5new) and md5 != "": + self._send_file_trans_command(PTYPE_FILE_CAN, b"") + return 0 + self._send_file_trans_command(PTYPE_FILE_VIEW, b"") + self.FileRcvState = FileTransState.WAIT_FILE_VIEW + error_count = 0 + totalerr_count = 0 + else: + error_count += 1 + if error_count >= retry: + self._send_file_trans_command(PTYPE_FILE_MD5, b"") + totalerr_count += 1 + + if self.canceled: + self._send_file_trans_command(PTYPE_FILE_CAN, b"") + self.log.info("Transmission canceled by user.") + self.canceled = False + self.FileRcvState = FileTransState.WAIT_MD5 + return None + + self.packetData.clear() + else: + error_count += 1 + if error_count >= 1: + totalerr_count += 1 + self._send_file_trans_command(PTYPE_FILE_RETRY, b"") + self.packetData.clear() + + if totalerr_count >= retry: + self._send_file_trans_command(PTYPE_FILE_CAN, b"") + self.log.info("retry_count reached %d, aborting.", retry) + self.abort_framed() + self.FileRcvState = FileTransState.WAIT_MD5 + return None + def send(self, stream, md5, retry=16, timeout=5, quiet=False, callback=None): + """Send a file using the Makera framed transfer protocol.""" + packet_size = self._framed_packet_size() + data = md5.encode() + self._send_file_trans_command(PTYPE_FILE_MD5, data) + lastcmd = PTYPE_FILE_MD5 + lastseq = 0 + packetno = 0 + td = time.time() + while True: + if self.canceled: + self._send_file_trans_command(PTYPE_FILE_CAN, b"") + self.log.info("Transmission canceled by user.") + self.canceled = False + return None + result = self.recv_packet(timeout * 8) + if result: + td = time.time() + cmd_type = self.packetData[2] + if cmd_type < PTYPE_FILE_MD5: + continue + if cmd_type == PTYPE_FILE_CAN: + self.log.info("Transmission canceled by Machine.") + self.FileRcvState = FileTransState.WAIT_MD5 + return None + if cmd_type == PTYPE_FILE_RETRY: + self._send_file_trans_command(lastcmd, data) + + if cmd_type == PTYPE_FILE_MD5: + data = md5.encode() + self._send_file_trans_command(PTYPE_FILE_MD5, data) + + if cmd_type == PTYPE_FILE_VIEW: + stream.seek(0, 2) + file_size = stream.tell() + stream.seek(0) + packetno = math.ceil(file_size / packet_size) + data = struct.pack(">I", packetno) + struct.pack(">H", packet_size) + self._send_file_trans_command(PTYPE_FILE_VIEW, data) + lastcmd = PTYPE_FILE_VIEW + lastseq = 0 + + if cmd_type == PTYPE_FILE_DATA: + seq = ( + (self.packetData[3] << 24) + | (self.packetData[4] << 16) + | (self.packetData[5] << 8) + | self.packetData[6] + ) + if seq == lastseq: + self._send_file_trans_command(PTYPE_FILE_DATA, data) + elif seq == lastseq + 1: + seq_bytes = struct.pack(">I", seq) + file_data = stream.read(packet_size) + data = seq_bytes + file_data + self._send_file_trans_command(PTYPE_FILE_DATA, data) + else: + seq_bytes = struct.pack(">I", seq) + stream.seek((seq - 1) * packet_size, 0) + file_data = stream.read(packet_size) + data = seq_bytes + file_data + self._send_file_trans_command(PTYPE_FILE_DATA, data) + lastcmd = PTYPE_FILE_DATA + lastseq = seq + if callable(callback): + callback(packet_size, seq, 0, 0) + + if cmd_type == PTYPE_FILE_END: + self.log.info("Transmission successful (FILE end flag received).") + return True + + else: + t = time.time() + if t - td > 9: + self._send_file_trans_command(PTYPE_FILE_CAN, b"") + self.log.info("Info: Controller receive data timeout!") + self.FileRcvState = FileTransState.WAIT_MD5 + return None + + def send_legacy(self, stream, md5, retry=16, timeout=5, quiet=False, callback=None): """ Send a stream via the XMODEM protocol. @@ -613,7 +909,7 @@ def _make_send_checksum(self, crc_mode, data): _bytes.append(crc) return bytearray(_bytes) - def recv(self, stream, md5="", crc_mode=1, retry=16, timeout=1, delay=0.1, quiet=0, callback=None): + def recv_legacy(self, stream, md5="", crc_mode=1, retry=16, timeout=1, delay=0.1, quiet=0, callback=None): """ Receive a stream via the XMODEM protocol. @@ -931,7 +1227,7 @@ def _putc(data, timeout=timeout): return size xmodem = XMODEM(_getc, _putc, mode) - return xmodem.send(si) + return xmodem.send_legacy(si) def run(): @@ -1025,7 +1321,7 @@ def _pipe(*command): getc, putc = _func(*_pipe("sz", "--xmodem", args[2])) stream = open(args[1], "wb") xmodem = XMODEM(getc, putc, mode=options.mode) - status = xmodem.recv(stream, retry=8) + status = xmodem.recv_legacy(stream, retry=8) assert status, ("Transfer failed, status is", False) stream.close() @@ -1033,7 +1329,7 @@ def _pipe(*command): getc, putc = _func(*_pipe("rz", "--xmodem", args[2])) stream = open(args[1], "rb") xmodem = XMODEM(getc, putc, mode=options.mode) - sent = xmodem.send(stream, retry=8) + sent = xmodem.send_legacy(stream, retry=8) assert sent is not None, ("Transfer failed, sent is", sent) stream.close() diff --git a/carveracontroller/addons/pendant/pendant.py b/carveracontroller/addons/pendant/pendant.py index 51f5c392..ba8d4c6e 100644 --- a/carveracontroller/addons/pendant/pendant.py +++ b/carveracontroller/addons/pendant/pendant.py @@ -426,7 +426,7 @@ def __init__(self, *args, **kwargs) -> None: def close(self) -> None: if self._controller.stream is not None: - self._controller.stream.send(b"\031") + self._controller.executeRealtime(0x19) self._controller.continuous_jog_active = False self._active_continuous_jog_action = None self._manager.close() diff --git a/carveracontroller/controller_config.json b/carveracontroller/controller_config.json index 022f8656..9dc90b4c 100644 --- a/carveracontroller/controller_config.json +++ b/carveracontroller/controller_config.json @@ -211,11 +211,28 @@ { "type": "bool", "title": "Auto-Reconnection", - "desc": "Automatically attempt to reconnect when connection is lost", + "desc": "Automatically attempt to reconnect when connection is lost, and auto-connect on app launch", "section": "carvera", "key": "auto_reconnect_enabled", "default": "true" }, + { + "type": "options", + "title": "Preferred reconnection method", + "desc": "Connection method used for auto-connect on app launch. After you connect manually, Reconnect uses the last successful method instead", + "section": "carvera", + "key": "reconnect_method", + "default": "wifi", + "options": ["wifi", "usb"] + }, + { + "type": "string", + "title": "USB serial number", + "desc": "Preferred USB serial number for reconnect. Updated automatically when you connect over USB", + "section": "carvera", + "key": "usb_serial", + "default": "" + }, { "type": "numeric", "title": "Reconnection Wait Time", diff --git a/carveracontroller/main.py b/carveracontroller/main.py index be17e511..2fe17dab 100644 --- a/carveracontroller/main.py +++ b/carveracontroller/main.py @@ -140,11 +140,6 @@ def update_from_state(self, obj): BooleanProperty.set(self, obj, False) -try: - from serial.tools.list_ports import comports -except ImportError: - comports = None - import webbrowser from functools import partial @@ -203,6 +198,7 @@ def ios_webbrowser_open(url, new=None): from .Controller import ( CONN_USB, CONN_WIFI, + CONNECTED, LOAD_CONN_WIFI, LOAD_DIR, LOAD_MKDIR, @@ -3110,12 +3106,13 @@ def __init__(self, ctl_version): self.load_gcode_viewer_config() self.load_pendant_config() - self.usb_event = lambda instance, x: self.openUSB(x) + self.usb_event = lambda instance, device_path: self.openUSB(device_path) self.wifi_event = lambda instance, x: self.openWIFI(x) self.heartbeat_time = 0 self.machine_metadata_query_time = 0 self.file_just_loaded = False + self.last_connection_method = Config.get("carvera", "last_connection_method", fallback="") or "" self.fill_remote_dir_callback = None @@ -3170,8 +3167,9 @@ def __init__(self, ctl_version): # threading.Thread(target=self.monitorSerial).start() - # try to connect over wifi if we've used it before - Clock.schedule_once(self.reconnect_wifi_conn_quietly) + # Auto-connect on startup only when auto-reconnect is enabled. + if Config.getboolean("carvera", "auto_reconnect_enabled", fallback=True): + Clock.schedule_once(lambda dt: self.reconnect_last_connection(quiet=True, for_app_launch=True)) def _parse_active_color(self, value): """Parse a color string like '0,255,255,255' into an RGBA list (0-1 range).""" @@ -3634,12 +3632,29 @@ def blink_state(self, *args): # check heartbeat if self.controller.sendNUM != 0 or self.controller.loadNUM != 0: self.heartbeat_time = time.time() + if getattr(self.controller, "_refresh_heartbeat", False): + self.heartbeat_time = time.time() + self.controller._refresh_heartbeat = False + if getattr(self.controller, "_baud_switch_in_progress", False): + # Don't treat a temporary baud-switch pause as a dead connection. + self.heartbeat_time = time.time() + return + if getattr(self.controller, "_connecting", False) or getattr(self, "_usb_connect_in_progress", False): + # Open + protocol probe run off the UI thread; stream is unset until ready. + self.heartbeat_time = time.time() + return + grace_until = getattr(self.controller, "_heartbeat_grace_until", 0) or 0 + if grace_until and time.time() < grace_until: + # USB DTR reset leaves the machine booting; wait for first status. + self.heartbeat_time = time.time() + return if self.file_just_loaded: self.file_just_loaded = False return if time.time() - self.heartbeat_time > HEARTBEAT_TIMEOUT and self.controller.stream: + logger.error("Connection to machine lost") # Check reconnection configuration (only if not a manual disconnect and not already reconnecting) if not self.controller._manual_disconnect and not self.reconnection_popup._is_open: auto_reconnect_enabled = Config.getboolean("carvera", "auto_reconnect_enabled", fallback=True) @@ -3649,8 +3664,8 @@ def blink_state(self, *args): # Update controller reconnection settings self.controller.set_reconnection_config(auto_reconnect_enabled, reconnect_wait_time, reconnect_attempts) - if auto_reconnect_enabled and self.controller.connection_type == CONN_WIFI: - # Show reconnection popup with countdown + if auto_reconnect_enabled: + # Show reconnection popup with countdown (WiFi or USB) self.reconnection_popup.start_countdown( reconnect_attempts, reconnect_wait_time, self.attempt_reconnect, self.on_reconnect_failed ) @@ -3695,12 +3710,22 @@ def check_model_metadata(self, *args): # ----------------------------------------------------------------------- def open_comports_drop_down(self, button): + """Show USB serial devices that have a VID/PID; labels are the USB serial number.""" self.comports_drop_down.clear_widgets() - if comports: - devices = sorted([x[0] for x in comports()]) + devices = Utils.list_identifiable_usb_serial_ports() + if not devices: + btn = Button( + text=tr._("No USB serial devices found"), + size_hint_y=None, + height="35dp", + color=(180 / 255, 180 / 255, 180 / 255, 1), + ) + self.comports_drop_down.add_widget(btn) + else: for device in devices: - btn = Button(text=device, size_hint_y=None, height="35dp") - btn.bind(on_release=lambda btn: self.comports_drop_down.select(btn.text)) + btn = Button(text=device["label"], size_hint_y=None, height="35dp") + btn.device_path = device["device_path"] + btn.bind(on_release=lambda b: self.comports_drop_down.select(b.device_path)) self.comports_drop_down.add_widget(btn) self.comports_drop_down.unbind(on_select=self.usb_event) self.comports_drop_down.bind(on_select=self.usb_event) @@ -3797,38 +3822,109 @@ def open_remote_dir_drop_down(self, button): self.remote_dir_drop_down.open(button) # ----------------------------------------------------------------------- - def reconnect_wifi_conn_quietly(self, button): - if self.past_machine_addr: - if not self.machine_detector.is_machine_busy(self.past_machine_addr): - self.openWIFI(self.past_machine_addr) + def _remember_connection_method(self, method): + """Persist the last successful connection method (wifi|usb).""" + method = (method or "").lower() + if method not in ("wifi", "usb"): + return + self.last_connection_method = method + Config.set("carvera", "last_connection_method", method) + Config.write() - # ----------------------------------------------------------------------- - def reconnect_wifi_conn(self, button): + def _preferred_reconnect_method(self, for_app_launch=False): + """ + App launch uses the configured preferred method. + Otherwise prefer the last successful connection method. + """ + if for_app_launch: + return Config.get("carvera", "reconnect_method", fallback="wifi").lower() + method = ( + self.last_connection_method or Config.get("carvera", "last_connection_method", fallback="") or "" + ).lower() + if method in ("wifi", "usb"): + return method + return Config.get("carvera", "reconnect_method", fallback="wifi").lower() + + def _store_usb_device_identity(self, device_id, serial=""): + """Record VID:PID and preferred serial for reconnect.""" + if not device_id: + return + vid_pid, legacy_serial = Utils.parse_usb_device_id(device_id) + if not vid_pid: + return + Config.set("carvera", "usb_device_id", vid_pid) + Config.set("carvera", "usb_serial", (serial or legacy_serial or "").strip()) + Config.write() + + def _store_usb_device_id_for_path(self, device_path): + for entry in Utils.list_identifiable_usb_serial_ports(): + if Utils.same_usb_device_path(entry["device_path"], device_path): + self._store_usb_device_identity(entry["device_id"], entry["serial"]) + return + + def _resolve_usb_reconnect_path(self): + """Resolve configured VID:PID (+ preferred serial) to a current OS path.""" + device_id = Config.get("carvera", "usb_device_id", fallback="") or "" + serial = Config.get("carvera", "usb_serial", fallback="") or "" + path = None + if device_id or serial: + path = Utils.find_usb_device_path_by_id(device_id or None, serial=serial) + if path: + return path + # Fall back to last path only if that path still maps to an identifiable USB device. + last_path = getattr(self.controller, "connection_address", None) + if last_path: + for entry in Utils.list_identifiable_usb_serial_ports(): + if Utils.same_usb_device_path(entry["device_path"], last_path): + return entry["device_path"] + return None + + def reconnect_last_connection(self, *args, quiet=False, for_app_launch=False): + """Reconnect using preferred/last method (WiFi address or USB device id).""" + method = self._preferred_reconnect_method(for_app_launch=for_app_launch) + if method == "usb": + path = self._resolve_usb_reconnect_path() + if path: + self.openUSB(path) + return True + if not quiet: + Clock.schedule_once( + partial( + self.show_message_popup, + tr._("No matching USB device found. Connect once via USB... to record the serial number."), + False, + ), + 0, + ) + else: + logger.info("Startup USB auto-connect skipped: no matching USB device for stored identity") + return False + + # WiFi if self.past_machine_addr: if not self.machine_detector.is_machine_busy(self.past_machine_addr): self.openWIFI(self.past_machine_addr) - else: + return True + if not quiet: Clock.schedule_once( partial(self.show_message_popup, tr._("Cannot connect, machine is busy or not available."), False), 0, ) - else: + return False + if not quiet: Clock.schedule_once( partial(self.show_message_popup, tr._("No previous machine network address stored."), False), 0 ) self.manually_input_ip() + return False # ----------------------------------------------------------------------- def attempt_reconnect(self): """Attempt to reconnect to the last known connection""" - if self.controller.connection_type == CONN_WIFI and self.past_machine_addr: - # Try to reconnect to WiFi - if not self.machine_detector.is_machine_busy(self.past_machine_addr): - self.openWIFI(self.past_machine_addr) - # Stop the countdown timer if reconnection popup is open - if self.reconnection_popup._is_open: - Clock.unschedule(self.reconnection_popup.countdown_tick) - self.reconnection_popup.dismiss() + if self.reconnection_popup._is_open: + Clock.unschedule(self.reconnection_popup.countdown_tick) + self.reconnection_popup.dismiss() + self.reconnect_last_connection(quiet=False, for_app_launch=False) def on_reconnect_failed(self): """Called when all reconnection attempts have failed""" @@ -3890,10 +3986,12 @@ def load_machine_list(self, *args): # ----------------------------------------------------------------------- def manually_input_ip(self): self.input_popup.lb_title.text = tr._("Input machine network address:") - if self.past_machine_addr: - self.input_popup.txt_content.text = self.past_machine_addr - else: - self.input_popup.txt_content.text = "" + # Prefer the in-memory value; fall back to the saved config address. + saved = self.past_machine_addr + if not saved and Config.has_option("carvera", "address"): + saved = Config.get("carvera", "address") + self.past_machine_addr = saved + self.input_popup.txt_content.text = saved or "" self.input_popup.txt_content.password = False self.input_popup.confirm = self.manually_open_wifi self.input_popup.open(self) @@ -3971,19 +4069,9 @@ def monitorSerial(self): Clock.schedule_once(partial(self.onFirmwareDetected, self.fw_version), 0) if self.fw_version_new != "": self.check_fw_version() - # Request higher USB baud if firmware >= 2.1.0 and user has enabled it - if ( - app.is_community_firmware - and app.fw_version_digitized >= Utils.digitize_v("2.1.0") - and self.controller.connection_type == CONN_USB - and not self.controller._baud_upgrade_attempted - ): - use_higher_val = Config.get("carvera", "use_higher_baud", fallback="0") - use_higher = str(use_higher_val).lower() in ("1", "true", "yes", "on") - baud_str = Config.get("carvera", "usb_baud_rate", fallback="115200") - if use_higher and baud_str and int(baud_str) != 115200: - self.controller._baud_upgrade_attempted = True - self.controller.request_baud_upgrade(int(baud_str)) + # Baud upgrade is deferred until after config download / sync + # (see attempt_usb_baud_upgrade_if_eligible). Running it on the + # version line races framed config transfer and breaks the link. remote_model = re.search(r"model = (\w+), (\d+), (\d+), (\d+)", line) if remote_model != None: @@ -4011,6 +4099,12 @@ def monitorSerial(self): if "WP PAIR SUCCESS" in line: self.pairing_popup.pairing_success = True + # Framed MD5-match short-circuit uses FILE_CAN; firmware labels that as + # "canceled by Controller" even though the transfer succeeded via cache. + if "canceled by Controller" in line: + logger.debug("MDI Received (transfer short-circuit): %s", line) + continue + if msg == Controller.MSG_NORMAL: logger.info(f"MDI Received: {line}") entry = {"text": line, "color": (103 / 255, 150 / 255, 186 / 255, 1)} @@ -4575,53 +4669,63 @@ def finish_backing_up_config(self, downloaded_file_paths, selected_dir, _selecte # ----------------------------------------------------------------------- def download_config_file(self): - app = App.get_running_app() self.downloading_size = 1024 * 5 self.downloading_config = True remote_path = "/sd/config.txt" + self.downloading_file = remote_path local_path = os.path.join(self.temp_dir, "config.txt") threading.Thread(target=self.doDownload, args=(remote_path, local_path)).start() # ----------------------------------------------------------------------- def finishLoadConfig(self, success, *args): + self.downloading_config = False if success: - self.setting_list.clear() - self.load_machine_config_defaults() - # caching config file - config_path = os.path.join(self.temp_dir, "config.txt") - with open(config_path) as f: - config_string = "[dummy_section]\n" + f.read() - # remove notes - config_string = re.sub(r"#.*", "", config_string) - # replace spaces to = - config_string = re.sub(r"([a-zA-Z])( |\t)+([a-zA-Z0-9-])", r"\1=\3", config_string) - - setting_config = ConfigParser(allow_no_value=True) - setting_config.read_string(config_string) - for section_name in setting_config.sections(): - for key, value in setting_config.items(section_name): - try: - self.setting_list[key.strip()] = value.strip() - except AttributeError: - Clock.schedule_once( - partial( - self.load_error, - tr._( - "Error loading machine config setting. Possibly malformed value.\nSkipping setting key: " - ) - + str(key), - ), - 0, - ) + try: + self.setting_list.clear() + self.load_machine_config_defaults() + # caching config file + config_path = os.path.join(self.temp_dir, "config.txt") + if not os.path.exists(config_path): + raise FileNotFoundError(f"Cached config not found: {config_path}") + with open(config_path) as f: + config_string = "[dummy_section]\n" + f.read() + # remove notes + config_string = re.sub(r"#.*", "", config_string) + # replace spaces to = + config_string = re.sub(r"([a-zA-Z])( |\t)+([a-zA-Z0-9-])", r"\1=\3", config_string) + + setting_config = ConfigParser(allow_no_value=True) + setting_config.read_string(config_string) + for section_name in setting_config.sections(): + for key, value in setting_config.items(section_name): + try: + self.setting_list[key.strip()] = value.strip() + except AttributeError: + Clock.schedule_once( + partial( + self.load_error, + tr._( + "Error loading machine config setting. Possibly malformed value.\nSkipping setting key: " + ) + + str(key), + ), + 0, + ) - self.load_coordinates() - self.load_laser_offsets() - self.setting_change_list = {} + self.load_coordinates() + self.load_laser_offsets() + self.setting_change_list = {} - self.config_loaded = self.load_machine_config() - self.config_loading = False - self.config_popup.btn_apply.disabled = len(self.setting_change_list) == 0 + self.config_loaded = self.load_machine_config() + self.config_popup.btn_apply.disabled = len(self.setting_change_list) == 0 + except Exception as e: + logger.exception("Failed to load machine config") + self.config_loaded = False + self.controller.log.put((Controller.MSG_ERROR, tr._("Failed to load config file: {}").format(e))) + finally: + self.config_loading = False else: + self.config_loading = False self.controller.log.put((Controller.MSG_ERROR, tr._("Download config file error"))) # self.controller.close() @@ -4662,11 +4766,20 @@ def _get_current_machine_connection_key(self): # ----------------------------------------------------------------------- def doDownload(self, remote_path, local_path, show_progress=True): app = App.get_running_app() + was_config_download = self.downloading_config if not self.downloading_config and not os.path.exists(os.path.dirname(local_path)): # os.mkdir(os.path.dirname(local_path)) os.makedirs(os.path.dirname(local_path)) + tmp_filename = local_path + ".tmp" + # Only use a temp file for MD5 skip when it is a copy of the real local file. + # Orphan .tmp leftovers must not suppress a real download. if os.path.exists(local_path): - shutil.copyfile(local_path, local_path + ".tmp") + shutil.copyfile(local_path, tmp_filename) + elif os.path.exists(tmp_filename): + try: + os.remove(tmp_filename) + except OSError: + pass if show_progress: Clock.schedule_once( @@ -4678,19 +4791,25 @@ def doDownload(self, remote_path, local_path, show_progress=True): 0, ) self.downloading = True - download_result = False + # None = error/abort; never use False — `False >= 0` is True in Python. + download_result = None try: - tmp_filename = local_path + ".tmp" - md5 = "" - if os.path.exists(tmp_filename): - md5 = Utils.md5(tmp_filename) - self.controller.downloadCommand(remote_path) - self.controller.pauseStream(0.2) - download_result = self.controller.stream.download( - tmp_filename, md5, partial(self.downloadCallback, remote_path) if show_progress else None - ) - except: + md5 = Utils.md5(tmp_filename) if os.path.exists(tmp_filename) else "" + # Makera framed transfer: pause RX before the download command so + # streamIO cannot steal the MD5 / file frames from XMODEM. + # Smoothie/XMODEM legacy: send first, then pause (OEM timing). + if self.controller.comms.uses_framed_transfer: + self.controller.pauseStream(0.0) + self.controller.downloadCommand(remote_path) + progress_cb = self.downloadCallback_framed if show_progress else None + else: + self.controller.downloadCommand(remote_path) + self.controller.pauseStream(0.2) + progress_cb = partial(self.downloadCallback, remote_path) if show_progress else None + download_result = self.controller.stream.download(tmp_filename, md5, progress_cb) + except Exception: logger.error(sys.exc_info()[1]) + download_result = None self.controller.resumeStream() self.downloading = False @@ -4700,9 +4819,10 @@ def doDownload(self, remote_path, local_path, show_progress=True): self.heartbeat_time = time.time() if download_result is None: - os.remove(local_path + ".tmp") + if os.path.exists(tmp_filename): + os.remove(tmp_filename) # show message popup - if self.downloading_config: + if was_config_download: Clock.schedule_once(partial(self.finishLoadConfig, False), 0.1) Clock.schedule_once(partial(self.show_message_popup, tr._("Download config file error!"), False), 0.2) else: @@ -4712,11 +4832,20 @@ def doDownload(self, remote_path, local_path, show_progress=True): # download success if os.path.exists(local_path): os.remove(local_path) - os.rename(local_path + ".tmp", local_path) + os.rename(tmp_filename, local_path) else: - # MD5 same - os.remove(local_path + ".tmp") - if self.downloading_config: + # MD5 matched: firmware reports "Download canceled by Controller!" for this + # intentional FILE_CAN; keep/promote the local cache instead of re-fetching. + if not os.path.exists(local_path) and os.path.exists(tmp_filename): + os.rename(tmp_filename, local_path) + elif os.path.exists(tmp_filename): + os.remove(tmp_filename) + if was_config_download: + logger.info("Config unchanged (MD5 match), using cached file") + self.controller.log.put( + (Controller.MSG_NORMAL, tr._("Config unchanged (MD5 match), using cached file")) + ) + if was_config_download: if show_progress: Clock.schedule_once(partial(self.progressUpdate, 100, "", True), 0) Clock.schedule_once(partial(self.finishLoadConfig, True), 0.1) @@ -4734,6 +4863,8 @@ def doDownload(self, remote_path, local_path, show_progress=True): Clock.schedule_once(self.controller.queryFtype, 0.4) # Schedule a one off diagnostic command to get the machine's extended state Clock.schedule_once(self.controller.viewDiagnoseReport, 0.5) + # Baud upgrade after config + sync commands have had time to finish. + Clock.schedule_once(self.attempt_usb_baud_upgrade_if_eligible, 2.0) else: if show_progress: Clock.schedule_once( @@ -4742,13 +4873,14 @@ def doDownload(self, remote_path, local_path, show_progress=True): # Clock.schedule_once(partial(self.load_gcode_file, local_path), 0.1) self.load_gcode_file(local_path) - if not self.downloading_config: + if not was_config_download: self.update_recent_remote_dir_list(os.path.dirname(remote_path)) elif download_result < 0: - os.remove(local_path + ".tmp") + if os.path.exists(tmp_filename): + os.remove(tmp_filename) self.controller.log.put((Controller.MSG_NORMAL, tr._("Downloading is canceled manually."))) - if self.downloading_config: + if was_config_download: Clock.schedule_once(partial(self.finishLoadConfig, False), 0) if show_progress: @@ -4891,6 +5023,7 @@ def setUIForModel(self, model, *args): # ----------------------------------------------------------------------- def downloadCallback(self, remote_path, packet_size, success_count, error_count): + """Progress callback for legacy XMODEM downloads.""" packets = self.downloading_size / packet_size + (1 if self.downloading_size % packet_size > 0 else 0) Clock.schedule_once( partial( @@ -4899,6 +5032,21 @@ def downloadCallback(self, remote_path, packet_size, success_count, error_count) 0, ) + def downloadCallback_framed(self, seq_rev, totalpackets): + """Progress callback for Makera framed downloads (seq, total).""" + if not totalpackets: + return + remote_path = getattr(self, "downloading_file", "") or "" + Clock.schedule_once( + partial( + self.progressUpdate, + seq_rev * 100.0 / totalpackets, + tr._("Downloading") + " \n%s" % remote_path, + False, + ), + 0, + ) + # ----------------------------------------------------------------------- def cancelSelectFile(self): self.progress_popup.dismiss() @@ -5544,6 +5692,10 @@ def updateStatus(self, *args): if app is None: return + # First real machine state ends the post-connect heartbeat grace window. + if CNC.vars["state"] not in (NOT_CONNECTED, CONNECTED): + self.controller._heartbeat_grace_until = 0 + if app.state != CNC.vars["state"]: app.state = CNC.vars["state"] CNC.vars["color"] = STATECOLOR[app.state] @@ -5558,6 +5710,7 @@ def updateStatus(self, *args): self.status_data_view.minr_text = tr._("disconnect") self.status_drop_down.btn_connect_usb.disabled = False self.status_drop_down.btn_connect_wifi.disabled = False + self.status_drop_down.btn_connect_network.disabled = False self.status_drop_down.btn_disconnect.disabled = True self.config_loaded = False self.config_loading = False @@ -5578,15 +5731,12 @@ def updateStatus(self, *args): # Check if we should show reconnection popup (only if not a manual disconnect and not already reconnecting) if not self.controller._manual_disconnect and not self.reconnection_popup._is_open: auto_reconnect_enabled = Config.getboolean("carvera", "auto_reconnect_enabled", fallback=True) - if ( - auto_reconnect_enabled - and self.controller.connection_type == CONN_WIFI - and self.past_machine_addr - ): - # Show reconnection popup - reconnect_wait_time = Config.getint("carvera", "reconnect_wait_time", fallback=10) - reconnect_attempts = Config.getint("carvera", "reconnect_attempts", fallback=3) - + reconnect_wait_time = Config.getint("carvera", "reconnect_wait_time", fallback=10) + reconnect_attempts = Config.getint("carvera", "reconnect_attempts", fallback=3) + self.controller.set_reconnection_config( + auto_reconnect_enabled, reconnect_wait_time, reconnect_attempts + ) + if auto_reconnect_enabled: self.reconnection_popup.start_countdown( reconnect_attempts, reconnect_wait_time, @@ -5594,27 +5744,16 @@ def updateStatus(self, *args): self.on_reconnect_failed, ) self.reconnection_popup.open() - - # Start countdown timer Clock.schedule_interval(self.reconnection_popup.countdown_tick, 1.0) - - # Also trigger the controller reconnection logic - self.controller.set_reconnection_config( - auto_reconnect_enabled, reconnect_wait_time, reconnect_attempts - ) self.controller.start_reconnection() - elif ( - not auto_reconnect_enabled - and self.controller.connection_type == CONN_WIFI - and self.past_machine_addr - ): - # Show reconnection popup in manual mode + else: self.reconnection_popup.show_manual_reconnect(self.attempt_reconnect) self.reconnection_popup.open() else: self.status_data_view.minr_text = "WiFi" if self.controller.connection_type == CONN_WIFI else "USB" self.status_drop_down.btn_connect_usb.disabled = True self.status_drop_down.btn_connect_wifi.disabled = True + self.status_drop_down.btn_connect_network.disabled = True self.status_drop_down.btn_disconnect.disabled = False # If we just reconnected, stop any reconnection popup and timer @@ -6152,39 +6291,84 @@ def _append_to_mdi(self, entries, log_to_mdi_data=False, scroll_to_bottom=False) # ----------------------------------------------------------------------- def openUSB(self, device): + # Serial open + DTR reset sleeps (~1s) + protocol probe must not run on the UI thread. + if getattr(self, "_usb_connect_in_progress", False): + return + self._usb_connect_in_progress = True + self.heartbeat_time = time.time() + self.status_drop_down.select("") + # Keep VID:PID + serial in sync even when reconnecting by resolved path. + self._store_usb_device_id_for_path(device) + label = device + for entry in Utils.list_identifiable_usb_serial_ports(): + if Utils.same_usb_device_path(entry["device_path"], device): + label = entry["label"] + break + Clock.schedule_once( + partial(self.progressStart, tr._("Connecting via USB...\n%s") % label, None), + 0, + ) + threading.Thread(target=self._open_usb_worker, args=(device,), daemon=True).start() + + def _open_usb_worker(self, device): + success = False try: - self.controller.open(CONN_USB, device) + success = bool(self.controller.open(CONN_USB, device)) self.controller.connection_type = CONN_USB + except Exception: + logger.exception("USB connection failed for %s", device) + success = False + Clock.schedule_once(lambda dt, ok=success: self._finish_usb_open(ok), 0) + + def _finish_usb_open(self, success): + self._usb_connect_in_progress = False + if self.progress_popup._is_open: + self.progress_popup.dismiss() + if success: + self.heartbeat_time = time.time() + self._remember_connection_method("usb") # Fallback: attempt baud upgrade after 10s if version is > 2.1.0c and conditions met Clock.schedule_once(self.attempt_usb_baud_upgrade_if_eligible, 10) - except: - logger.error(sys.exc_info()[1]) + else: + logger.error("USB connection attempt finished without an active link") self.updateStatus() - self.status_drop_down.select("") def attempt_usb_baud_upgrade_if_eligible(self, dt): - """If on USB, firmware >= 2.1.0, and use_higher_baud is on, request higher baud (fallback if version line was missed).""" + """If on USB, firmware >= 2.1.0, and use_higher_baud is on, request higher baud.""" app = App.get_running_app() if self.controller.connection_type != CONN_USB or self.controller.stream != self.controller.usb_stream: return if self.controller._baud_upgrade_attempted: return + # Never interrupt framed file transfer / config download with a baud switch. + if self.downloading or self.downloading_config or self.uploading: + Clock.schedule_once(self.attempt_usb_baud_upgrade_if_eligible, 1.0) + return + if self.controller.paused or self.controller.sendNUM != 0 or self.controller.loadNUM != 0: + Clock.schedule_once(self.attempt_usb_baud_upgrade_if_eligible, 1.0) + return if not app.is_community_firmware or not self.fw_version or app.fw_version_digitized < Utils.digitize_v("2.1.0"): return use_higher_val = Config.get("carvera", "use_higher_baud", fallback="0") use_higher = str(use_higher_val).lower() in ("1", "true", "yes", "on") baud_str = Config.get("carvera", "usb_baud_rate", fallback="115200") if use_higher and baud_str and int(baud_str) != 115200: + baud = int(baud_str) self.controller._baud_upgrade_attempted = True - self.controller.request_baud_upgrade(int(baud_str)) + threading.Thread( + target=self.controller.request_baud_upgrade, + args=(baud,), + daemon=True, + ).start() # ----------------------------------------------------------------------- def openWIFI(self, address): try: - self.controller.open(CONN_WIFI, address) - self.controller.connection_type = CONN_WIFI - self.store_machine_address(address.split(":")[0]) - except: + if self.controller.open(CONN_WIFI, address): + self.controller.connection_type = CONN_WIFI + self.store_machine_address(address.split(":")[0]) + self._remember_connection_method("wifi") + except Exception: logger.error(sys.exc_info()[1]) self.updateStatus() self.status_drop_down.select("") @@ -6445,7 +6629,7 @@ def toggle_pendant_jog_control(self): def setup_pendant(self): self.handle_pendant_disconnected() if self.controller.continuous_jog_active and self.controller.stream is not None: - self.controller.stream.send(b"\031") + self.controller.executeRealtime(0x19) self.controller.continuous_jog_active = False type_name = Config.get("carvera", "pendant_type") @@ -7506,6 +7690,21 @@ def set_config_defaults(default_lang): Config.set("carvera", "use_higher_baud", "0") if not Config.has_option("carvera", "usb_baud_rate"): Config.set("carvera", "usb_baud_rate", "1500000") + if not Config.has_option("carvera", "reconnect_method"): + Config.set("carvera", "reconnect_method", "wifi") + if not Config.has_option("carvera", "usb_device_id"): + Config.set("carvera", "usb_device_id", "") + if not Config.has_option("carvera", "usb_serial"): + Config.set("carvera", "usb_serial", "") + if not Config.has_option("carvera", "last_connection_method"): + Config.set("carvera", "last_connection_method", "") + # Migrate legacy VID:PID:SERIAL stored in usb_device_id. + legacy_id = Config.get("carvera", "usb_device_id", fallback="") or "" + vid_pid, legacy_serial = Utils.parse_usb_device_id(legacy_id) + if vid_pid and legacy_serial and legacy_id.count(":") >= 2: + Config.set("carvera", "usb_device_id", vid_pid) + if not Config.get("carvera", "usb_serial", fallback=""): + Config.set("carvera", "usb_serial", legacy_serial) if not Config.has_option("carvera", "high_precision_reamining_time_estimate"): Config.set("carvera", "high_precision_reamining_time_estimate", "1") if not Config.has_option("carvera", "background_image"): diff --git a/carveracontroller/makera.kv b/carveracontroller/makera.kv index d00ff6d7..e0752bfc 100644 --- a/carveracontroller/makera.kv +++ b/carveracontroller/makera.kv @@ -1171,6 +1171,7 @@ : btn_connect_usb: btn_connect_usb btn_connect_wifi: btn_connect_wifi + btn_connect_network: btn_connect_network btn_unlock: btn_unlock btn_disconnect: btn_disconnect canvas.before: @@ -1186,7 +1187,8 @@ height: '40dp' disabled: app.state != 'N/A' on_release: - app.root.reconnect_wifi_conn(self) + app.root.reconnect_last_connection(quiet=False, for_app_launch=False) + root.select('') Button: id: btn_connect_wifi @@ -1195,6 +1197,13 @@ text: tr._('Scan Wi-Fi...') on_release: app.root.open_wifi_conn_drop_down(self) + Button: + id: btn_connect_network + size_hint_y: None + height: '40dp' + text: tr._('Network...') + on_release: + app.root.manually_input_ip() Button: id: btn_connect_usb size_hint_y: None diff --git a/carveracontroller/protocols/__init__.py b/carveracontroller/protocols/__init__.py new file mode 100644 index 00000000..010919dd --- /dev/null +++ b/carveracontroller/protocols/__init__.py @@ -0,0 +1,25 @@ +"""Extensible machine communication protocols.""" + +from .base import CommunicationProtocol +from .detector import detect_protocol_name +from .messages import MessageKind, ParsedMessage +from .registry import ( + DEFAULT_PROTOCOL, + available_protocols, + create_protocol, + register_protocol, +) +from .session import ProtocolSession, protocol_name_from_announcement + +__all__ = [ + "CommunicationProtocol", + "DEFAULT_PROTOCOL", + "MessageKind", + "ParsedMessage", + "ProtocolSession", + "available_protocols", + "create_protocol", + "detect_protocol_name", + "protocol_name_from_announcement", + "register_protocol", +] diff --git a/carveracontroller/protocols/base.py b/carveracontroller/protocols/base.py new file mode 100644 index 00000000..f0c76396 --- /dev/null +++ b/carveracontroller/protocols/base.py @@ -0,0 +1,37 @@ +"""Abstract communication protocol interface.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from .messages import ParsedMessage + + +class CommunicationProtocol(ABC): + """Strategy for encoding commands and parsing machine responses.""" + + name: str = "unknown" + uses_framed_transfer: bool = False + + def __init__(self) -> None: + self.ready: bool = False + + @abstractmethod + def encode_command(self, data: bytes) -> bytes: + """Encode a multi-byte / G-code / shell command for the wire.""" + + @abstractmethod + def encode_realtime(self, char: int) -> bytes: + """Encode a single-byte realtime control (e.g. '?', '!', '~', Ctrl-X).""" + + @abstractmethod + def encode_file_command(self, data: bytes) -> bytes: + """Encode an upload/download initiation command.""" + + @abstractmethod + def feed(self, data: bytes) -> list[ParsedMessage]: + """Consume inbound bytes and return zero or more parsed messages.""" + + @abstractmethod + def reset(self) -> None: + """Reset RX parser state for a new connection or after errors.""" diff --git a/carveracontroller/protocols/detector.py b/carveracontroller/protocols/detector.py new file mode 100644 index 00000000..46a7efd8 --- /dev/null +++ b/carveracontroller/protocols/detector.py @@ -0,0 +1,59 @@ +"""Detect which communication protocol the connected machine is using.""" + +from __future__ import annotations + +import logging +import time +from typing import Optional, Protocol, runtime_checkable + +logger = logging.getLogger(__name__) + +PROBE_COMMAND = b"echo echo\n" +PROBE_ATTEMPTS = 3 +PROBE_WAIT_S = 0.1 +PROBE_READ_SIZE = 10 + + +@runtime_checkable +class ProbeStream(Protocol): + def send(self, data: bytes) -> object: ... + + def getc(self, size: int, timeout: float = 1) -> bytes | None: ... + + +def _flush_rx(stream: ProbeStream) -> None: + """Drain any pending bytes from the transport.""" + while True: + data = stream.getc(1, timeout=0.01) + if not data: + break + + +def detect_protocol_name(stream: ProbeStream, attempts: int = PROBE_ATTEMPTS) -> str: + """ + Probe the machine with a raw ASCII echo command. + + OEM-compatible behaviour: + - plaintext response containing ``echo`` → ``smoothie`` + - ``attempts`` empty/timeout responses → ``makera`` + """ + _flush_rx(stream) + timeouts = 0 + for _ in range(attempts): + try: + stream.send(PROBE_COMMAND) + time.sleep(PROBE_WAIT_S) + echo = stream.getc(PROBE_READ_SIZE, timeout=PROBE_WAIT_S) + except Exception: + logger.debug("Protocol probe send/recv failed", exc_info=True) + timeouts += 1 + continue + + if echo is not None and b"echo" in echo: + logger.info("Detected smoothie communication protocol") + return "smoothie" + + timeouts += 1 + + logger.info("Detected makera communication protocol (no echo response)") + return "makera" diff --git a/carveracontroller/protocols/framing.py b/carveracontroller/protocols/framing.py new file mode 100644 index 00000000..55239e6d --- /dev/null +++ b/carveracontroller/protocols/framing.py @@ -0,0 +1,344 @@ +"""Makera binary frame encode/decode helpers (CRC-16/CCITT).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +FRAME_HEADER = 0x8668 +FRAME_END = 0x55AA + +PTYPE_CTRL_SINGLE = 0xA1 +PTYPE_CTRL_MULTI = 0xA2 +PTYPE_FILE_START = 0xB0 +PTYPE_FILE_MD5 = 0xB1 +PTYPE_FILE_VIEW = 0xB2 +PTYPE_FILE_DATA = 0xB3 +PTYPE_FILE_END = 0xB4 +PTYPE_FILE_CAN = 0xB5 +PTYPE_FILE_RETRY = 0xB6 +PTYPE_STATUS_RES = 0x81 +PTYPE_DIAG_RES = 0x82 +PTYPE_LOAD_INFO = 0x83 +PTYPE_LOAD_FINISH = 0x84 +PTYPE_LOAD_ERROR = 0x85 +PTYPE_NORMAL_INFO = 0x90 + +MAX_FRAME_DATA_LENGTH = 8200 + +# CRC-16/CCITT lookup table (Mark G. Mendel) +CRC_TABLE = [ + 0x0000, + 0x1021, + 0x2042, + 0x3063, + 0x4084, + 0x50A5, + 0x60C6, + 0x70E7, + 0x8108, + 0x9129, + 0xA14A, + 0xB16B, + 0xC18C, + 0xD1AD, + 0xE1CE, + 0xF1EF, + 0x1231, + 0x0210, + 0x3273, + 0x2252, + 0x52B5, + 0x4294, + 0x72F7, + 0x62D6, + 0x9339, + 0x8318, + 0xB37B, + 0xA35A, + 0xD3BD, + 0xC39C, + 0xF3FF, + 0xE3DE, + 0x2462, + 0x3443, + 0x0420, + 0x1401, + 0x64E6, + 0x74C7, + 0x44A4, + 0x5485, + 0xA56A, + 0xB54B, + 0x8528, + 0x9509, + 0xE5EE, + 0xF5CF, + 0xC5AC, + 0xD58D, + 0x3653, + 0x2672, + 0x1611, + 0x0630, + 0x76D7, + 0x66F6, + 0x5695, + 0x46B4, + 0xB75B, + 0xA77A, + 0x9719, + 0x8738, + 0xF7DF, + 0xE7FE, + 0xD79D, + 0xC7BC, + 0x48C4, + 0x58E5, + 0x6886, + 0x78A7, + 0x0840, + 0x1861, + 0x2802, + 0x3823, + 0xC9CC, + 0xD9ED, + 0xE98E, + 0xF9AF, + 0x8948, + 0x9969, + 0xA90A, + 0xB92B, + 0x5AF5, + 0x4AD4, + 0x7AB7, + 0x6A96, + 0x1A71, + 0x0A50, + 0x3A33, + 0x2A12, + 0xDBFD, + 0xCBDC, + 0xFBBF, + 0xEB9E, + 0x9B79, + 0x8B58, + 0xBB3B, + 0xAB1A, + 0x6CA6, + 0x7C87, + 0x4CE4, + 0x5CC5, + 0x2C22, + 0x3C03, + 0x0C60, + 0x1C41, + 0xEDAE, + 0xFD8F, + 0xCDEC, + 0xDDCD, + 0xAD2A, + 0xBD0B, + 0x8D68, + 0x9D49, + 0x7E97, + 0x6EB6, + 0x5ED5, + 0x4EF4, + 0x3E13, + 0x2E32, + 0x1E51, + 0x0E70, + 0xFF9F, + 0xEFBE, + 0xDFDD, + 0xCFFC, + 0xBF1B, + 0xAF3A, + 0x9F59, + 0x8F78, + 0x9188, + 0x81A9, + 0xB1CA, + 0xA1EB, + 0xD10C, + 0xC12D, + 0xF14E, + 0xE16F, + 0x1080, + 0x00A1, + 0x30C2, + 0x20E3, + 0x5004, + 0x4025, + 0x7046, + 0x6067, + 0x83B9, + 0x9398, + 0xA3FB, + 0xB3DA, + 0xC33D, + 0xD31C, + 0xE37F, + 0xF35E, + 0x02B1, + 0x1290, + 0x22F3, + 0x32D2, + 0x4235, + 0x5214, + 0x6277, + 0x7256, + 0xB5EA, + 0xA5CB, + 0x95A8, + 0x8589, + 0xF56E, + 0xE54F, + 0xD52C, + 0xC50D, + 0x34E2, + 0x24C3, + 0x14A0, + 0x0481, + 0x7466, + 0x6447, + 0x5424, + 0x4405, + 0xA7DB, + 0xB7FA, + 0x8799, + 0x97B8, + 0xE75F, + 0xF77E, + 0xC71D, + 0xD73C, + 0x26D3, + 0x36F2, + 0x0691, + 0x16B0, + 0x6657, + 0x7676, + 0x4615, + 0x5634, + 0xD94C, + 0xC96D, + 0xF90E, + 0xE92F, + 0x99C8, + 0x89E9, + 0xB98A, + 0xA9AB, + 0x5844, + 0x4865, + 0x7806, + 0x6827, + 0x18C0, + 0x08E1, + 0x3882, + 0x28A3, + 0xCB7D, + 0xDB5C, + 0xEB3F, + 0xFB1E, + 0x8BF9, + 0x9BD8, + 0xABBB, + 0xBB9A, + 0x4A75, + 0x5A54, + 0x6A37, + 0x7A16, + 0x0AF1, + 0x1AD0, + 0x2AB3, + 0x3A92, + 0xFD2E, + 0xED0F, + 0xDD6C, + 0xCD4D, + 0xBDAA, + 0xAD8B, + 0x9DE8, + 0x8DC9, + 0x7C26, + 0x6C07, + 0x5C64, + 0x4C45, + 0x3CA2, + 0x2C83, + 0x1CE0, + 0x0CC1, + 0xEF1F, + 0xFF3E, + 0xCF5D, + 0xDF7C, + 0xAF9B, + 0xBFBA, + 0x8FD9, + 0x9FF8, + 0x6E17, + 0x7E36, + 0x4E55, + 0x5E74, + 0x2E93, + 0x3EB2, + 0x0ED1, + 0x1EF0, +] + + +def crc16_ccitt(data: bytes, length: int = 0) -> int: + """CRC-16/CCITT over ``data`` (or the first ``length`` bytes if length > 0).""" + crc = 0 + if length == 0: + iterable = data + else: + iterable = data[:length] + for byte in iterable: + tmp = ((crc >> 8) ^ byte) & 0xFF + crc = ((crc << 8) ^ CRC_TABLE[tmp]) & 0xFFFF + return crc + + +def build_frame(ptype: int, payload: bytes = b"") -> bytes: + """ + Build a Makera frame. + + Wire layout (big-endian): + header(2) | length(2) | type(1) | payload(N) | crc(2) | footer(2) + + ``length`` = 1 (type) + N (payload) + 2 (crc). + CRC covers length + type + payload. + """ + if not isinstance(payload, (bytes, bytearray)): + raise TypeError("payload must be bytes") + data_length = 1 + len(payload) + 2 + crc_payload = data_length.to_bytes(2, "big") + bytes([ptype]) + bytes(payload) + crc = crc16_ccitt(crc_payload) + return FRAME_HEADER.to_bytes(2, "big") + crc_payload + crc.to_bytes(2, "big") + FRAME_END.to_bytes(2, "big") + + +@dataclass(frozen=True) +class ParsedFrame: + ptype: int + payload: bytes + # Full packetData as assembled by RX (length + type + payload + crc) + raw: bytes + + +def validate_packet_data(packet_data: bytes) -> ParsedFrame | None: + """ + Validate CRC on assembled packet body (length|type|payload|crc). + + Returns ParsedFrame on success, None on failure. + """ + if len(packet_data) < 5: + return None + calc = crc16_ccitt(packet_data, len(packet_data) - 2) + received = (packet_data[-2] << 8) | packet_data[-1] + if calc != received: + return None + ptype = packet_data[2] + # payload sits between type and CRC + payload = bytes(packet_data[3:-2]) + return ParsedFrame(ptype=ptype, payload=payload, raw=bytes(packet_data)) diff --git a/carveracontroller/protocols/makera.py b/carveracontroller/protocols/makera.py new file mode 100644 index 00000000..d640a247 --- /dev/null +++ b/carveracontroller/protocols/makera.py @@ -0,0 +1,166 @@ +"""Makera framed binary communication protocol.""" + +from __future__ import annotations + +from enum import Enum, auto + +from .base import CommunicationProtocol +from .framing import ( + FRAME_END, + FRAME_HEADER, + MAX_FRAME_DATA_LENGTH, + PTYPE_CTRL_MULTI, + PTYPE_CTRL_SINGLE, + PTYPE_FILE_CAN, + PTYPE_FILE_DATA, + PTYPE_FILE_END, + PTYPE_FILE_MD5, + PTYPE_FILE_RETRY, + PTYPE_FILE_START, + PTYPE_FILE_VIEW, + PTYPE_LOAD_ERROR, + PTYPE_LOAD_FINISH, + PTYPE_LOAD_INFO, + build_frame, + validate_packet_data, +) +from .messages import MessageKind, ParsedMessage + +# File-transfer frames are owned by XMODEM while streamIO is paused. If any +# leak into the control parser, ignore them rather than treating as MDI text. +_FILE_TRANSFER_TYPES = frozenset( + { + PTYPE_FILE_START, + PTYPE_FILE_MD5, + PTYPE_FILE_VIEW, + PTYPE_FILE_DATA, + PTYPE_FILE_END, + PTYPE_FILE_CAN, + PTYPE_FILE_RETRY, + } +) + + +class _RevPacketState(Enum): + WAIT_HEADER = auto() + READ_LENGTH = auto() + READ_DATA = auto() + CHECK_FOOTER = auto() + + +class MakeraProtocol(CommunicationProtocol): + name = "makera" + uses_framed_transfer = True + + def __init__(self) -> None: + super().__init__() + self._state = _RevPacketState.WAIT_HEADER + self._packet_data = bytearray() + self._header_buffer = bytearray(2) + self._footer_buffer = bytearray(2) + self._bytes_needed = 2 + self._expected_length = 0 + + def encode_command(self, data: bytes) -> bytes: + if not isinstance(data, (bytes, bytearray)): + raise TypeError("data must be bytes") + # Match OEM Makera framing: CTRL_MULTI payloads are not newline-terminated. + # A trailing \n breaks numeric parsers such as baud (strtol requires *end == '\0'). + payload = bytes(data).rstrip(b"\r\n") + return build_frame(PTYPE_CTRL_MULTI, payload) + + def encode_realtime(self, char: int) -> bytes: + return build_frame(PTYPE_CTRL_SINGLE, bytes([char & 0xFF])) + + def encode_file_command(self, data: bytes) -> bytes: + if not isinstance(data, (bytes, bytearray)): + raise TypeError("data must be bytes") + payload = bytes(data) + if not payload.endswith(b"\n"): + payload += b"\n" + return build_frame(PTYPE_FILE_START, payload) + + def feed(self, data: bytes) -> list[ParsedMessage]: + messages: list[ParsedMessage] = [] + for byte in data: + messages.extend(self._feed_byte(byte)) + return messages + + def _feed_byte(self, byte: int) -> list[ParsedMessage]: + if self._state == _RevPacketState.WAIT_HEADER: + self._header_buffer[0] = self._header_buffer[1] + self._header_buffer[1] = byte + checksum = (self._header_buffer[0] << 8) | self._header_buffer[1] + if checksum == FRAME_HEADER: + self._state = _RevPacketState.READ_LENGTH + self._bytes_needed = 2 + self._packet_data.clear() + return [] + + if self._state == _RevPacketState.READ_LENGTH: + self._packet_data.append(byte) + self._bytes_needed -= 1 + if self._bytes_needed == 0: + self._expected_length = (self._packet_data[0] << 8) | self._packet_data[1] + if 0 <= self._expected_length <= MAX_FRAME_DATA_LENGTH: + self._state = _RevPacketState.READ_DATA + self._bytes_needed = self._expected_length + else: + self._state = _RevPacketState.WAIT_HEADER + return [] + + if self._state == _RevPacketState.READ_DATA: + self._packet_data.append(byte) + self._bytes_needed -= 1 + if self._bytes_needed == 0: + self._state = _RevPacketState.CHECK_FOOTER + self._bytes_needed = 2 + return [] + + if self._state == _RevPacketState.CHECK_FOOTER: + self._footer_buffer[0] = self._footer_buffer[1] + self._footer_buffer[1] = byte + self._bytes_needed -= 1 + if self._bytes_needed != 0: + return [] + checksum = (self._footer_buffer[0] << 8) | self._footer_buffer[1] + self._state = _RevPacketState.WAIT_HEADER + if checksum != FRAME_END: + self._packet_data.clear() + return [] + return self._dispatch_packet() + + return [] + + def _dispatch_packet(self) -> list[ParsedMessage]: + parsed = validate_packet_data(self._packet_data) + self._packet_data.clear() + if parsed is None: + return [] + + if parsed.ptype in _FILE_TRANSFER_TYPES: + return [] + + if parsed.ptype == PTYPE_LOAD_FINISH: + return [ParsedMessage(MessageKind.LOAD_EOF)] + if parsed.ptype == PTYPE_LOAD_ERROR: + return [ParsedMessage(MessageKind.LOAD_ERROR)] + + text = parsed.payload.decode(errors="ignore") + if not text: + return [] + + # Status/diag/normal always become LINE. LOAD_INFO chunks go to the + # load buffer path; Controller decides via loadNUM. + if parsed.ptype == PTYPE_LOAD_INFO: + return [ParsedMessage(MessageKind.LOAD_CHUNK, text)] + return [ParsedMessage(MessageKind.LINE, text)] + + def reset(self) -> None: + self._state = _RevPacketState.WAIT_HEADER + self._packet_data.clear() + self._header_buffer = bytearray(2) + self._footer_buffer = bytearray(2) + self._bytes_needed = 2 + self._expected_length = 0 + self.ready = False diff --git a/carveracontroller/protocols/messages.py b/carveracontroller/protocols/messages.py new file mode 100644 index 00000000..27b2b003 --- /dev/null +++ b/carveracontroller/protocols/messages.py @@ -0,0 +1,19 @@ +"""Parsed messages produced by communication protocol RX parsers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum, auto + + +class MessageKind(Enum): + LINE = auto() + LOAD_CHUNK = auto() + LOAD_EOF = auto() + LOAD_ERROR = auto() + + +@dataclass(frozen=True) +class ParsedMessage: + kind: MessageKind + text: str = "" diff --git a/carveracontroller/protocols/registry.py b/carveracontroller/protocols/registry.py new file mode 100644 index 00000000..b88e8243 --- /dev/null +++ b/carveracontroller/protocols/registry.py @@ -0,0 +1,37 @@ +"""Protocol registry — register new protocols here to make them selectable.""" + +from __future__ import annotations + +from .base import CommunicationProtocol +from .makera import MakeraProtocol +from .smoothie import SmoothieProtocol + +# Map of protocol name -> implementation class. +# To add a third protocol: implement CommunicationProtocol and register it here. +PROTOCOL_CLASSES: dict[str, type[CommunicationProtocol]] = { + "smoothie": SmoothieProtocol, + "makera": MakeraProtocol, +} + +DEFAULT_PROTOCOL = "smoothie" + + +def available_protocols() -> tuple[str, ...]: + return tuple(PROTOCOL_CLASSES.keys()) + + +def create_protocol(name: str) -> CommunicationProtocol: + """Instantiate a protocol by registered name.""" + try: + cls = PROTOCOL_CLASSES[name] + except KeyError as exc: + known = ", ".join(sorted(PROTOCOL_CLASSES)) + raise ValueError(f"Unknown protocol {name!r}; known: {known}") from exc + return cls() + + +def register_protocol(name: str, cls: type[CommunicationProtocol]) -> None: + """Register or replace a protocol implementation (useful for tests / future protocols).""" + if not issubclass(cls, CommunicationProtocol): + raise TypeError("cls must be a CommunicationProtocol subclass") + PROTOCOL_CLASSES[name] = cls diff --git a/carveracontroller/protocols/session.py b/carveracontroller/protocols/session.py new file mode 100644 index 00000000..baca9348 --- /dev/null +++ b/carveracontroller/protocols/session.py @@ -0,0 +1,148 @@ +"""Connection-scoped protocol session with autodetection and mid-session switching.""" + +from __future__ import annotations + +import logging +from typing import Callable + +from .base import CommunicationProtocol +from .detector import detect_protocol_name +from .framing import FRAME_HEADER +from .messages import ParsedMessage +from .registry import DEFAULT_PROTOCOL, create_protocol + +logger = logging.getLogger(__name__) + +OnChangeCallback = Callable[[str, bool], None] + +_FRAME_HEADER_BYTES = FRAME_HEADER.to_bytes(2, "big") + + +def _normalize_message(message: ParsedMessage) -> ParsedMessage: + """Deliver text payloads without trailing CR/LF from the wire/frame layer.""" + if not message.text: + return message + cleaned = message.text.rstrip("\r\n") + if cleaned == message.text: + return message + return ParsedMessage(message.kind, cleaned) + + +def protocol_name_from_announcement(text: str) -> str | None: + """ + Parse firmware protocol announcements (M485 / M485.1 / M485.2). + + Returns a registered protocol name, or None if the text is unrelated. + """ + if not text: + return None + lower = text.lower() + if "makera communication protocol" in lower or "current communication protocol: makera" in lower: + return "makera" + if "smoothie communication protocol" in lower or "current communication protocol: smoothie" in lower: + return "smoothie" + return None + + +class ProtocolSession: + """ + Owns the active wire protocol for one machine connection. + + Responsibilities: + - encode commands / realtime / file-start for the selected protocol + - parse inbound bytes via ``feed`` + - initial selection (caller uses ``detect_and_select`` after transport open) + - mid-session switches from M485 announcements or unexpected Makera frames + """ + + def __init__( + self, + name: str = DEFAULT_PROTOCOL, + on_change: OnChangeCallback | None = None, + ) -> None: + self._on_change = on_change + self._protocol: CommunicationProtocol = create_protocol(name) + self.ready: bool = False + + @property + def name(self) -> str: + return self._protocol.name + + @property + def uses_framed_transfer(self) -> bool: + return self._protocol.uses_framed_transfer + + @property + def protocol(self) -> CommunicationProtocol: + return self._protocol + + def select(self, name: str) -> bool: + """ + Select a protocol by name. + + Returns True if the active protocol changed (or became ready for the + first time with this name). + """ + if self._protocol.name == name and self.ready: + return False + self._protocol = create_protocol(name) + self._protocol.ready = True + self.ready = True + logger.info("Using %s communication protocol", name) + if self._on_change is not None: + self._on_change(name, self._protocol.uses_framed_transfer) + return True + + def reset(self) -> None: + """Reset to the default protocol for a disconnected state.""" + if self._protocol is not None: + self._protocol.reset() + self._protocol = create_protocol(DEFAULT_PROTOCOL) + self.ready = False + if self._on_change is not None: + self._on_change(self._protocol.name, False) + + def reset_parser(self) -> None: + """Clear RX parser state without changing the selected protocol.""" + was_ready = self.ready + self._protocol.reset() + self._protocol.ready = was_ready + self.ready = was_ready + + def detect_and_select(self, stream) -> str: + """Probe the transport and select the matching protocol.""" + try: + name = detect_protocol_name(stream) + except Exception: + logger.exception("Protocol detection failed; defaulting to %s", DEFAULT_PROTOCOL) + name = DEFAULT_PROTOCOL + self.select(name) + return name + + def encode_command(self, data: bytes) -> bytes: + return self._protocol.encode_command(data) + + def encode_realtime(self, char: int) -> bytes: + return self._protocol.encode_realtime(char) + + def encode_file_command(self, data: bytes) -> bytes: + return self._protocol.encode_file_command(data) + + def feed(self, data: bytes, allow_wire_switch: bool = True) -> list[ParsedMessage]: + """ + Parse inbound bytes, switching protocol mid-session when needed. + + ``allow_wire_switch`` should be False during file transfers so XMODEM + binary cannot spuriously trigger a Makera header switch. + """ + if allow_wire_switch and not self._protocol.uses_framed_transfer and _FRAME_HEADER_BYTES in data: + # Machine is speaking Makera while we were still on Smoothie. + self.select("makera") + + messages = [_normalize_message(m) for m in self._protocol.feed(data)] + if allow_wire_switch: + for message in messages: + announced = protocol_name_from_announcement(message.text or "") + if announced and announced != self.name: + self.select(announced) + return messages diff --git a/carveracontroller/protocols/smoothie.py b/carveracontroller/protocols/smoothie.py new file mode 100644 index 00000000..c89d2222 --- /dev/null +++ b/carveracontroller/protocols/smoothie.py @@ -0,0 +1,53 @@ +"""Legacy Smoothieware newline-delimited text protocol.""" + +from __future__ import annotations + +from .base import CommunicationProtocol +from .messages import MessageKind, ParsedMessage + +EOT = b"\x04" +CAN = b"\x16" + + +class SmoothieProtocol(CommunicationProtocol): + name = "smoothie" + uses_framed_transfer = False + + def __init__(self) -> None: + super().__init__() + self._line = bytearray() + + def encode_command(self, data: bytes) -> bytes: + if not isinstance(data, (bytes, bytearray)): + raise TypeError("data must be bytes") + text = bytes(data) + if not text.endswith(b"\n"): + text += b"\n" + return text + + def encode_realtime(self, char: int) -> bytes: + return bytes([char & 0xFF]) + + def encode_file_command(self, data: bytes) -> bytes: + return self.encode_command(data) + + def feed(self, data: bytes) -> list[ParsedMessage]: + messages: list[ParsedMessage] = [] + for byte in data: + c = bytes([byte]) + if c in (EOT, CAN): + if self._line: + messages.append(ParsedMessage(MessageKind.LOAD_CHUNK, self._line.decode(errors="ignore"))) + self._line.clear() + messages.append(ParsedMessage(MessageKind.LOAD_EOF if c == EOT else MessageKind.LOAD_ERROR)) + elif c == b"\n": + line = self._line.decode(errors="ignore") + self._line.clear() + messages.append(ParsedMessage(MessageKind.LINE, line)) + else: + self._line.extend(c) + return messages + + def reset(self) -> None: + self._line.clear() + self.ready = False diff --git a/tests/unit/test_diagnose_parser.py b/tests/unit/test_diagnose_parser.py new file mode 100644 index 00000000..4855d3cb --- /dev/null +++ b/tests/unit/test_diagnose_parser.py @@ -0,0 +1,30 @@ +"""Diagnose ({...}) status parsing, including RSSI.""" + +from carveracontroller.CNC import CNC +from carveracontroller.Controller import Controller +from carveracontroller.protocols.framing import PTYPE_DIAG_RES, build_frame +from carveracontroller.protocols.session import ProtocolSession + + +def test_comms_strips_trailing_newline_from_diagnose_payload(): + session = ProtocolSession() + session.select("makera") + # Firmware get_diagnose_string() ends with "}\\n"; framing includes that payload. + frame = build_frame(PTYPE_DIAG_RES, b"{S:0,5000|RSSI:-57}\n") + msgs = session.feed(frame) + assert len(msgs) == 1 + assert msgs[0].text == "{S:0,5000|RSSI:-57}" + + +def test_diagnose_rssi_field(): + ctrl = Controller(CNC(), lambda _line: None, False) + ctrl.parseBigParentheses("{S:0,5000|L:0,0|F:1,0|V:0,1|G:0|T:0|R:0|I:0|RSSI:-57}") + assert CNC.vars["sw_spindle"] == 0 + assert CNC.vars["RSSI"] == -57 + + +def test_diagnose_with_junk_after_brace(): + ctrl = Controller(CNC(), lambda _line: None, False) + ctrl.parseBigParentheses("{S:1,1000|RSSI:-40}\x00") + assert CNC.vars["sw_spindle"] == 1 + assert CNC.vars["RSSI"] == -40 diff --git a/tests/unit/test_protocol_detector.py b/tests/unit/test_protocol_detector.py new file mode 100644 index 00000000..e4006041 --- /dev/null +++ b/tests/unit/test_protocol_detector.py @@ -0,0 +1,40 @@ +from carveracontroller.protocols.detector import PROBE_COMMAND, detect_protocol_name + + +class FakeStream: + def __init__(self, responses): + self.responses = list(responses) + self.sent = [] + self._rx = [] + + def send(self, data): + self.sent.append(data) + if self.responses: + self._rx = list(self.responses.pop(0) or b"") + + def getc(self, size, timeout=1): + if not self._rx: + return None + chunk = bytes(self._rx[:size]) + del self._rx[:size] + return chunk or None + + +def test_detect_smoothie_on_echo_response(monkeypatch): + monkeypatch.setattr("carveracontroller.protocols.detector.time.sleep", lambda *_: None) + stream = FakeStream([b"echo: echo"]) + assert detect_protocol_name(stream, attempts=3) == "smoothie" + assert stream.sent[0] == PROBE_COMMAND + + +def test_detect_makera_on_timeouts(monkeypatch): + monkeypatch.setattr("carveracontroller.protocols.detector.time.sleep", lambda *_: None) + stream = FakeStream([None, None, None]) + assert detect_protocol_name(stream, attempts=3) == "makera" + assert len(stream.sent) == 3 + + +def test_detect_smoothie_on_later_attempt(monkeypatch): + monkeypatch.setattr("carveracontroller.protocols.detector.time.sleep", lambda *_: None) + stream = FakeStream([None, b"echo"]) + assert detect_protocol_name(stream, attempts=3) == "smoothie" diff --git a/tests/unit/test_protocol_makera.py b/tests/unit/test_protocol_makera.py new file mode 100644 index 00000000..a9e3ce0f --- /dev/null +++ b/tests/unit/test_protocol_makera.py @@ -0,0 +1,83 @@ +from carveracontroller.protocols.framing import ( + PTYPE_FILE_MD5, + PTYPE_LOAD_ERROR, + PTYPE_LOAD_FINISH, + PTYPE_LOAD_INFO, + PTYPE_NORMAL_INFO, + PTYPE_STATUS_RES, + build_frame, +) +from carveracontroller.protocols.makera import MakeraProtocol +from carveracontroller.protocols.messages import MessageKind + + +def test_encode_realtime_and_command(): + proto = MakeraProtocol() + frame = proto.encode_realtime(ord("?")) + assert frame[:2] == b"\x86\x68" + assert frame[-2:] == b"\x55\xaa" + + cmd = proto.encode_command(b"version\n") + assert cmd[:2] == b"\x86\x68" + # Trailing newlines must be stripped from CTRL_MULTI payloads (OEM behavior). + assert b"version\n" not in cmd + assert b"version" in cmd + + baud = proto.encode_command(b"baud 230400\n") + assert b"230400\n" not in baud + assert b"baud 230400" in baud + + file_cmd = proto.encode_file_command(b"upload /sd/a.nc") + assert file_cmd[:2] == b"\x86\x68" + # File-start frames keep the trailing newline. + assert b"upload /sd/a.nc\n" in file_cmd + + +def test_feed_status_frame(): + proto = MakeraProtocol() + payload = b"" + frame = build_frame(PTYPE_STATUS_RES, payload) + msgs = proto.feed(frame) + assert len(msgs) == 1 + assert msgs[0].kind == MessageKind.LINE + assert msgs[0].text == payload.decode() + + +def test_feed_normal_info_and_load(): + proto = MakeraProtocol() + msgs = proto.feed(build_frame(PTYPE_NORMAL_INFO, b"ok\r\n")) + assert msgs[0].kind == MessageKind.LINE + assert "ok" in msgs[0].text + + msgs = proto.feed(build_frame(PTYPE_LOAD_INFO, b"file.nc\n")) + assert msgs[0].kind == MessageKind.LOAD_CHUNK + assert "file.nc" in msgs[0].text + + +def test_feed_load_finish_and_error(): + proto = MakeraProtocol() + assert proto.feed(build_frame(PTYPE_LOAD_FINISH, b"done"))[0].kind == MessageKind.LOAD_EOF + assert proto.feed(build_frame(PTYPE_LOAD_ERROR, b"err"))[0].kind == MessageKind.LOAD_ERROR + + +def test_feed_rejects_bad_footer(): + proto = MakeraProtocol() + frame = bytearray(build_frame(PTYPE_STATUS_RES, b"")) + frame[-1] = 0x00 + assert proto.feed(bytes(frame)) == [] + + +def test_feed_byte_at_a_time(): + proto = MakeraProtocol() + frame = build_frame(PTYPE_STATUS_RES, b"") + msgs = [] + for b in frame: + msgs.extend(proto.feed(bytes([b]))) + assert len(msgs) == 1 + assert msgs[0].text == "" + + +def test_file_transfer_frames_ignored_on_control_channel(): + proto = MakeraProtocol() + frame = build_frame(PTYPE_FILE_MD5, b"3bc28b19cfca32e413fd9029000117a3") + assert proto.feed(frame) == [] diff --git a/tests/unit/test_protocol_smoothie.py b/tests/unit/test_protocol_smoothie.py new file mode 100644 index 00000000..c850cfc3 --- /dev/null +++ b/tests/unit/test_protocol_smoothie.py @@ -0,0 +1,43 @@ +from carveracontroller.protocols.messages import MessageKind +from carveracontroller.protocols.smoothie import SmoothieProtocol + + +def test_encode_command_adds_newline(): + proto = SmoothieProtocol() + assert proto.encode_command(b"version") == b"version\n" + assert proto.encode_command(b"version\n") == b"version\n" + + +def test_encode_realtime_and_file_command(): + proto = SmoothieProtocol() + assert proto.encode_realtime(ord("?")) == b"?" + assert proto.encode_file_command(b"upload /sd/a.nc") == b"upload /sd/a.nc\n" + + +def test_feed_newline_line(): + proto = SmoothieProtocol() + msgs = proto.feed(b"\n") + assert len(msgs) == 1 + assert msgs[0].kind == MessageKind.LINE + assert msgs[0].text == "" + + +def test_feed_incremental(): + proto = SmoothieProtocol() + assert proto.feed(b"hel") == [] + msgs = proto.feed(b"lo\n") + assert len(msgs) == 1 + assert msgs[0].text == "hello" + + +def test_feed_eot_and_can(): + proto = SmoothieProtocol() + msgs = proto.feed(b"partial" + b"\x04") + assert msgs[0].kind == MessageKind.LOAD_CHUNK + assert msgs[0].text == "partial" + assert msgs[1].kind == MessageKind.LOAD_EOF + + proto.reset() + msgs = proto.feed(b"\x16") + assert len(msgs) == 1 + assert msgs[0].kind == MessageKind.LOAD_ERROR From 0421747997455f517f58be506c40c8d700bdb93e Mon Sep 17 00:00:00 2001 From: Serge Bakharev Date: Sat, 18 Jul 2026 22:48:38 +1000 Subject: [PATCH 2/3] Adds function for send sequence of chars, and improves continious jogging stop condition with a wait for firmware ack --- carveracontroller/Controller.py | 58 ++++++++++++++++----- carveracontroller/addons/pendant/pendant.py | 2 +- carveracontroller/main.py | 2 +- 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/carveracontroller/Controller.py b/carveracontroller/Controller.py index 8539df63..b48a73a1 100644 --- a/carveracontroller/Controller.py +++ b/carveracontroller/Controller.py @@ -187,6 +187,9 @@ def __init__(self, cnc, callback, log_sent_receive=False): self.jog_mode = Controller.JOG_MODE_STEP self.jog_speed = 10000 # mm/min. A value of 0 here would suggest to use last used feed self.continuous_jog_active = False + # True after Ctrl+Y until firmware acks (^Y) — suppresses keepalives without + # allowing a new $J -c to start before the previous jog has stopped. + self._continuous_jog_stopping = False @property def protocol_ready(self): @@ -249,12 +252,24 @@ def executeCommand(self, line): def executeRealtime(self, char): """Send a single-byte realtime control through the active protocol.""" - if not self.stream: + self.executeRealtimeSequence(char) + + def executeRealtimeSequence(self, *chars): + """Send one or more realtime bytes in a single write. + + Smoothie continuous-jog keepalive is the digram ``?1``. Sending ``?`` and + ``1`` as separate writes races with other commands and leaves orphaned + ``1`` bytes in the firmware command buffer (seen as ``111…$J …``). + """ + if not self.stream or not chars: return try: - if isinstance(char, (bytes, bytearray)): - char = char[0] - self.stream.send(self.comms.encode_realtime(int(char))) + payload = bytearray() + for char in chars: + if isinstance(char, (bytes, bytearray)): + char = char[0] + payload.extend(self.comms.encode_realtime(int(char))) + self.stream.send(bytes(payload)) except Exception: self.log.put((Controller.MSG_ERROR, str(sys.exc_info()[1]))) @@ -1753,13 +1768,15 @@ def viewStatusReport(self, sio_status): if self.loadNUM == 0 and self.sendNUM == 0: if self.stream is None or not self.protocol_ready: return - self.executeRealtime(ord("?")) - if self.continuous_jog_active: - # Smoothie uses "?1"; Makera uses Ctrl+Z for keepalive. + if self.continuous_jog_active and not self._continuous_jog_stopping: + # Smoothie uses "?1"; Makera uses "?" + Ctrl+Z keepalive. + # Always one write so keepalive can't be interleaved/orphaned. if self.comms.uses_framed_transfer: - self.executeRealtime(0x1A) + self.executeRealtimeSequence(ord("?"), 0x1A) else: - self.executeRealtime(ord("1")) + self.executeRealtimeSequence(ord("?"), ord("1")) + else: + self.executeRealtime(ord("?")) self.sio_status = sio_status def viewDiagnoseReport(self, sio_diagnose): @@ -1847,9 +1864,14 @@ def setJogMode(self, mode): def startContinuousJog(self, _dir, speed=None, scale_feed_override=None): """Start continuous jogging in the specified direction""" - if self.jog_mode != Controller.JOG_MODE_CONTINUOUS or self.continuous_jog_active: + if ( + self.jog_mode != Controller.JOG_MODE_CONTINUOUS + or self.continuous_jog_active + or self._continuous_jog_stopping + ): return self.continuous_jog_active = True + self._continuous_jog_stopping = False if speed is None: if self.jog_speed > 0 and self.jog_speed < 10000: self.executeCommand(f"$J -c {_dir} F{self.jog_speed}") @@ -1867,10 +1889,17 @@ def stopContinuousJog(self): if self.jog_mode != Controller.JOG_MODE_CONTINUOUS: return - # Send Ctrl+Y to stop continuous jogging - if self.stream is not None and self.continuous_jog_active: + # Send Ctrl+Y, then wait for firmware ^Y before allowing a new $J -c. + # Mark stopping immediately so status polls stop sending keepalives that + # would otherwise fight the stop request. + if self.stream is not None and self.continuous_jog_active and not self._continuous_jog_stopping: + self._continuous_jog_stopping = True self.executeRealtime(0x19) + def _clear_continuous_jog_state(self): + self.continuous_jog_active = False + self._continuous_jog_stopping = False + def jog(self, _dir, speed=None): if self.jog_mode == Controller.JOG_MODE_STEP: if speed is None: @@ -2115,7 +2144,7 @@ def parseLine(self, line): self.log.put((self.MSG_INTERIOR, line)) elif line[0] == "^": if line[1] == "Y": - self.continuous_jog_active = False + self._clear_continuous_jog_state() elif "error" in line.lower() or "alarm" in line.lower(): self.log.put((self.MSG_ERROR, line)) if line.upper().startswith("ERROR:"): @@ -2123,6 +2152,9 @@ def parseLine(self, line): if msg: CNC.vars["alarm_message"] = msg else: + # Firmware continuous-jog timeout: clear local state so jogging can restart. + if "Stop request timeout" in line or "Internal stop request reset" in line: + self._clear_continuous_jog_state() self.log.put((self.MSG_NORMAL, line)) except (LookupError, ArithmeticError, ValueError) as e: self.log.put((self.MSG_ERROR, f"Failed to parse machine response: {line}")) diff --git a/carveracontroller/addons/pendant/pendant.py b/carveracontroller/addons/pendant/pendant.py index ba8d4c6e..a9ee0446 100644 --- a/carveracontroller/addons/pendant/pendant.py +++ b/carveracontroller/addons/pendant/pendant.py @@ -427,7 +427,7 @@ def __init__(self, *args, **kwargs) -> None: def close(self) -> None: if self._controller.stream is not None: self._controller.executeRealtime(0x19) - self._controller.continuous_jog_active = False + self._controller._clear_continuous_jog_state() self._active_continuous_jog_action = None self._manager.close() self._report_disconnection() diff --git a/carveracontroller/main.py b/carveracontroller/main.py index cfa5839d..de38b3c4 100644 --- a/carveracontroller/main.py +++ b/carveracontroller/main.py @@ -6640,7 +6640,7 @@ def setup_pendant(self): self.handle_pendant_disconnected() if self.controller.continuous_jog_active and self.controller.stream is not None: self.controller.executeRealtime(0x19) - self.controller.continuous_jog_active = False + self.controller._clear_continuous_jog_state() type_name = Config.get("carvera", "pendant_type") pendant_type = SUPPORTED_PENDANTS.get(type_name, SUPPORTED_PENDANTS["None"]) From 0e1cf6cbab1c0a08d0dfba02a9d6fedc5d0678a1 Mon Sep 17 00:00:00 2001 From: Serge Bakharev Date: Sun, 19 Jul 2026 17:49:41 +1000 Subject: [PATCH 3/3] change default protocol to makera --- carveracontroller/protocols/registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/carveracontroller/protocols/registry.py b/carveracontroller/protocols/registry.py index b88e8243..efaa2520 100644 --- a/carveracontroller/protocols/registry.py +++ b/carveracontroller/protocols/registry.py @@ -13,7 +13,7 @@ "makera": MakeraProtocol, } -DEFAULT_PROTOCOL = "smoothie" +DEFAULT_PROTOCOL = "makera" def available_protocols() -> tuple[str, ...]: