connection_manager.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. import threading
  2. import time
  3. import logging
  4. import serial
  5. import serial.tools.list_ports
  6. import websocket
  7. from modules.core.state import state
  8. from modules.led.led_controller import effect_loading, effect_idle, effect_connected, LEDController
  9. logger = logging.getLogger(__name__)
  10. IGNORE_PORTS = ['/dev/cu.debug-console', '/dev/cu.Bluetooth-Incoming-Port']
  11. ###############################################################################
  12. # Connection Abstraction
  13. ###############################################################################
  14. class BaseConnection:
  15. """Abstract base class for a connection."""
  16. def send(self, data: str) -> None:
  17. raise NotImplementedError
  18. def flush(self) -> None:
  19. raise NotImplementedError
  20. def readline(self) -> str:
  21. raise NotImplementedError
  22. def in_waiting(self) -> int:
  23. raise NotImplementedError
  24. def is_connected(self) -> bool:
  25. raise NotImplementedError
  26. def close(self) -> None:
  27. raise NotImplementedError
  28. ###############################################################################
  29. # Serial Connection Implementation
  30. ###############################################################################
  31. class SerialConnection(BaseConnection):
  32. def __init__(self, port: str, baudrate: int = 115200, timeout: int = 2):
  33. self.port = port
  34. self.baudrate = baudrate
  35. self.timeout = timeout
  36. self.lock = threading.RLock()
  37. logger.info(f'Connecting to Serial port {port}')
  38. self.ser = serial.Serial(port, baudrate, timeout=timeout)
  39. state.port = port
  40. logger.info(f'Connected to Serial port {port}')
  41. def send(self, data: str) -> None:
  42. with self.lock:
  43. self.ser.write(data.encode())
  44. self.ser.flush()
  45. def flush(self) -> None:
  46. with self.lock:
  47. self.ser.flush()
  48. def readline(self) -> str:
  49. with self.lock:
  50. return self.ser.readline().decode().strip()
  51. def in_waiting(self) -> int:
  52. with self.lock:
  53. return self.ser.in_waiting
  54. def is_connected(self) -> bool:
  55. return self.ser is not None and self.ser.is_open
  56. def close(self) -> None:
  57. update_machine_position()
  58. with self.lock:
  59. if self.ser.is_open:
  60. self.ser.close()
  61. # Release the lock resources
  62. self.lock = None
  63. ###############################################################################
  64. # WebSocket Connection Implementation
  65. ###############################################################################
  66. class WebSocketConnection(BaseConnection):
  67. def __init__(self, url: str, timeout: int = 5):
  68. self.url = url
  69. self.timeout = timeout
  70. self.lock = threading.RLock()
  71. self.ws = None
  72. self.connect()
  73. def connect(self):
  74. logger.info(f'Connecting to Websocket {self.url}')
  75. self.ws = websocket.create_connection(self.url, timeout=self.timeout)
  76. state.port = self.url
  77. logger.info(f'Connected to Websocket {self.url}')
  78. def send(self, data: str) -> None:
  79. with self.lock:
  80. self.ws.send(data)
  81. def flush(self) -> None:
  82. # WebSocket sends immediately; nothing to flush.
  83. pass
  84. def readline(self) -> str:
  85. with self.lock:
  86. data = self.ws.recv()
  87. # Decode bytes to string if necessary
  88. if isinstance(data, bytes):
  89. data = data.decode('utf-8')
  90. return data.strip()
  91. def in_waiting(self) -> int:
  92. return 0 # Not applicable for WebSocket
  93. def is_connected(self) -> bool:
  94. return self.ws is not None
  95. def close(self) -> None:
  96. update_machine_position()
  97. with self.lock:
  98. if self.ws:
  99. self.ws.close()
  100. # Release the lock resources
  101. self.lock = None
  102. def list_serial_ports():
  103. """Return a list of available serial ports."""
  104. ports = serial.tools.list_ports.comports()
  105. available_ports = [port.device for port in ports if port.device not in IGNORE_PORTS]
  106. logger.debug(f"Available serial ports: {available_ports}")
  107. return available_ports
  108. def device_init(homing=True):
  109. try:
  110. if get_machine_steps():
  111. logger.info(f"x_steps_per_mm: {state.x_steps_per_mm}, y_steps_per_mm: {state.y_steps_per_mm}, gear_ratio: {state.gear_ratio}")
  112. except:
  113. logger.fatal("Not GRBL firmware")
  114. pass
  115. machine_x, machine_y = get_machine_position()
  116. if machine_x != state.machine_x or machine_y != state.machine_y:
  117. logger.info(f'x, y; {machine_x}, {machine_y}')
  118. logger.info(f'State x, y; {state.machine_x}, {state.machine_y}')
  119. if homing:
  120. home()
  121. else:
  122. logger.info('Machine position known, skipping home')
  123. logger.info(f'Theta: {state.current_theta}, rho: {state.current_rho}')
  124. logger.info(f'x, y; {machine_x}, {machine_y}')
  125. logger.info(f'State x, y; {state.machine_x}, {state.machine_y}')
  126. time.sleep(2) # Allow time for the connection to establish
  127. def connect_device(homing=True):
  128. if state.wled_ip:
  129. state.led_controller = LEDController(state.wled_ip)
  130. effect_loading(state.led_controller)
  131. ports = list_serial_ports()
  132. if state.port and state.port in ports:
  133. state.conn = SerialConnection(state.port)
  134. elif ports:
  135. state.conn = SerialConnection(ports[0])
  136. else:
  137. logger.warning("No serial ports found. Falling back to WebSocket.")
  138. # state.conn = WebSocketConnection('ws://fluidnc.local:81')
  139. return
  140. if (state.conn.is_connected() if state.conn else False):
  141. device_init(homing)
  142. if state.led_controller:
  143. effect_connected(state.led_controller)
  144. def get_status_response() -> str:
  145. """
  146. Send a status query ('?') and return the response if available.
  147. """
  148. while True:
  149. try:
  150. state.conn.send('?')
  151. response = state.conn.readline()
  152. if "MPos" in response:
  153. logger.debug(f"Status response: {response}")
  154. return response
  155. except Exception as e:
  156. logger.error(f"Error getting status response: {e}")
  157. return False
  158. time.sleep(1)
  159. def parse_machine_position(response: str):
  160. """
  161. Parse the work position (MPos) from a status response.
  162. Expected format: "<...|MPos:-994.869,-321.861,0.000|...>"
  163. Returns a tuple (work_x, work_y) if found, else None.
  164. """
  165. if "MPos:" not in response:
  166. return None
  167. try:
  168. wpos_section = next((part for part in response.split("|") if part.startswith("MPos:")), None)
  169. if wpos_section:
  170. wpos_str = wpos_section.split(":", 1)[1]
  171. wpos_values = wpos_str.split(",")
  172. work_x = float(wpos_values[0])
  173. work_y = float(wpos_values[1])
  174. return work_x, work_y
  175. except Exception as e:
  176. logger.error(f"Error parsing work position: {e}")
  177. return None
  178. def send_grbl_coordinates(x, y, speed=600, timeout=2, home=False):
  179. """
  180. Send a G-code command to FluidNC and wait for an 'ok' response.
  181. If no response after set timeout, sets state to stop and disconnects.
  182. """
  183. logger.debug(f"Sending G-code: X{x} Y{y} at F{speed}")
  184. # Track overall attempt time
  185. overall_start_time = time.time()
  186. while True:
  187. try:
  188. gcode = f"$J=G91 G21 Y{y} F{speed}" if home else f"G1 X{x} Y{y} F{speed}"
  189. state.conn.send(gcode + "\n")
  190. logger.debug(f"Sent command: {gcode}")
  191. start_time = time.time()
  192. while True:
  193. response = state.conn.readline()
  194. logger.debug(f"Response: {response}")
  195. if response.lower() == "ok":
  196. logger.debug("Command execution confirmed.")
  197. return
  198. except Exception as e:
  199. # Store the error string inside the exception block
  200. error_str = str(e)
  201. logger.warning(f"Error sending command: {error_str}")
  202. # Immediately return for device not configured errors
  203. if "Device not configured" in error_str or "Errno 6" in error_str:
  204. logger.error(f"Device configuration error detected: {error_str}")
  205. state.stop_requested = True
  206. state.conn = None
  207. state.is_connected = False
  208. logger.info("Connection marked as disconnected due to device error")
  209. return False
  210. logger.warning(f"No 'ok' received for X{x} Y{y}, speed {speed}. Retrying...")
  211. time.sleep(0.1)
  212. # If we reach here, the timeout has occurred
  213. logger.error(f"Failed to receive 'ok' response after {max_total_attempt_time} seconds. Stopping and disconnecting.")
  214. # Set state to stop
  215. state.stop_requested = True
  216. # Set connection status to disconnected
  217. if state.conn:
  218. try:
  219. state.conn.disconnect()
  220. except:
  221. pass
  222. state.conn = None
  223. # Update the state connection status
  224. state.is_connected = False
  225. logger.info("Connection marked as disconnected due to timeout")
  226. return False
  227. def get_machine_steps(timeout=10):
  228. """
  229. Get machine steps/mm from the GRBL controller.
  230. Returns True if successful, False otherwise.
  231. """
  232. if not state.conn or not state.conn.is_connected():
  233. logger.error("Cannot get machine steps: No connection available")
  234. return False
  235. x_steps_per_mm = None
  236. y_steps_per_mm = None
  237. gear_ratio = None
  238. start_time = time.time()
  239. # Clear any pending data in the buffer
  240. try:
  241. while state.conn.in_waiting() > 0:
  242. state.conn.readline()
  243. except Exception as e:
  244. logger.warning(f"Error clearing buffer: {e}")
  245. # Send the command to request all settings
  246. try:
  247. logger.info("Requesting GRBL settings with $$ command")
  248. state.conn.send("$$\n")
  249. time.sleep(0.5) # Give GRBL a moment to process and respond
  250. except Exception as e:
  251. logger.error(f"Error sending $$ command: {e}")
  252. return False
  253. # Wait for and process responses
  254. settings_complete = False
  255. while time.time() - start_time < timeout and not settings_complete:
  256. try:
  257. # Attempt to read a line from the connection
  258. if state.conn.in_waiting() > 0:
  259. response = state.conn.readline()
  260. logger.debug(f"Raw response: {response}")
  261. # Process the line
  262. if response.strip(): # Only process non-empty lines
  263. for line in response.splitlines():
  264. line = line.strip()
  265. logger.debug(f"Config response: {line}")
  266. if line.startswith("$100="):
  267. x_steps_per_mm = float(line.split("=")[1])
  268. state.x_steps_per_mm = x_steps_per_mm
  269. logger.info(f"X steps per mm: {x_steps_per_mm}")
  270. elif line.startswith("$101="):
  271. y_steps_per_mm = float(line.split("=")[1])
  272. state.y_steps_per_mm = y_steps_per_mm
  273. logger.info(f"Y steps per mm: {y_steps_per_mm}")
  274. elif line.startswith("$131="):
  275. gear_ratio = float(line.split("=")[1])
  276. state.gear_ratio = gear_ratio
  277. logger.info(f"Gear ratio: {gear_ratio}")
  278. elif line.startswith("$22="):
  279. # $22 reports if the homing cycle is enabled
  280. # returns 0 if disabled, 1 if enabled
  281. homing = int(line.split('=')[1])
  282. state.homing = homing
  283. logger.info(f"Homing enabled: {homing}")
  284. # Check if we've received all the settings we need
  285. if x_steps_per_mm is not None and y_steps_per_mm is not None and gear_ratio is not None:
  286. settings_complete = True
  287. else:
  288. # No data waiting, small sleep to prevent CPU thrashing
  289. time.sleep(0.1)
  290. # If it's taking too long, try sending the command again after 3 seconds
  291. elapsed = time.time() - start_time
  292. if elapsed > 3 and elapsed < 4:
  293. logger.warning("No response yet, sending $$ command again")
  294. state.conn.send("$$\n")
  295. except Exception as e:
  296. logger.error(f"Error getting machine steps: {e}")
  297. time.sleep(0.5)
  298. # Process results and determine table type
  299. if settings_complete:
  300. if y_steps_per_mm == 180:
  301. state.table_type = 'dune_weaver_mini'
  302. elif y_steps_per_mm >= 320:
  303. state.table_type = 'dune_weaver_pro'
  304. elif y_steps_per_mm == 287.5:
  305. state.table_type = 'dune_weaver'
  306. else:
  307. state.table_type = None
  308. logger.warning(f"Unknown table type with Y steps/mm: {y_steps_per_mm}")
  309. logger.info(f"Machine type detected: {state.table_type}")
  310. return True
  311. else:
  312. missing = []
  313. if x_steps_per_mm is None: missing.append("X steps/mm")
  314. if y_steps_per_mm is None: missing.append("Y steps/mm")
  315. if gear_ratio is None: missing.append("gear ratio")
  316. logger.error(f"Failed to get all machine parameters after {timeout}s. Missing: {', '.join(missing)}")
  317. return False
  318. def home():
  319. """
  320. Perform homing by checking device configuration and sending the appropriate commands.
  321. """
  322. try:
  323. if state.homing:
  324. logger.info("Using sensorless homing")
  325. state.conn.send("$H\n")
  326. state.conn.send("G1 Y0 F100\n")
  327. else:
  328. homing_speed = 400
  329. if state.table_type == 'dune_weaver_mini':
  330. homing_speed = 120
  331. logger.info("Sensorless homing not supported. Using crash homing")
  332. logger.info(f"Homing with speed {homing_speed}")
  333. if state.gear_ratio == 6.25:
  334. send_grbl_coordinates(0, - 30, homing_speed, home=True)
  335. state.machine_y -= 30
  336. else:
  337. send_grbl_coordinates(0, -22, homing_speed, home=True)
  338. state.machine_y -= 22
  339. state.current_theta = state.current_rho = 0
  340. except Exception as e:
  341. logger.error(f"Error homing: {e}")
  342. return False
  343. def check_idle():
  344. """
  345. Continuously check if the device is idle.
  346. """
  347. logger.info("Checking idle")
  348. while True:
  349. response = get_status_response()
  350. if response and "Idle" in response:
  351. logger.info("Device is idle")
  352. update_machine_position()
  353. return True
  354. time.sleep(1)
  355. def get_machine_position(timeout=5):
  356. """
  357. Query the device for its position.
  358. """
  359. start_time = time.time()
  360. while time.time() - start_time < timeout:
  361. try:
  362. state.conn.send('?')
  363. response = state.conn.readline()
  364. logger.debug(f"Raw status response: {response}")
  365. if "MPos" in response:
  366. pos = parse_machine_position(response)
  367. if pos:
  368. machine_x, machine_y = pos
  369. logger.debug(f"Machine position: X={machine_x}, Y={machine_y}")
  370. return machine_x, machine_y
  371. except Exception as e:
  372. logger.error(f"Error getting machine position: {e}")
  373. return
  374. time.sleep(0.1)
  375. logger.warning("Timeout reached waiting for machine position")
  376. return None, None
  377. def update_machine_position():
  378. if (state.conn.is_connected() if state.conn else False):
  379. try:
  380. logger.info('Saving machine position')
  381. state.machine_x, state.machine_y = get_machine_position()
  382. state.save()
  383. logger.info(f'Machine position saved: {state.machine_x}, {state.machine_y}')
  384. except Exception as e:
  385. logger.error(f"Error updating machine position: {e}")
  386. def restart_connection(homing=False):
  387. """
  388. Restart the connection. If a connection exists, close it and attempt to establish a new one.
  389. It will try to connect via serial first (if available), otherwise it will fall back to websocket.
  390. The new connection is saved to state.conn.
  391. Returns:
  392. True if the connection was restarted successfully, False otherwise.
  393. """
  394. try:
  395. if (state.conn.is_connected() if state.conn else False):
  396. logger.info("Closing current connection...")
  397. state.conn.close()
  398. except Exception as e:
  399. logger.error(f"Error while closing connection: {e}")
  400. # Clear the connection reference.
  401. state.conn = None
  402. logger.info("Attempting to restart connection...")
  403. try:
  404. connect_device(homing) # This will set state.conn appropriately.
  405. if (state.conn.is_connected() if state.conn else False):
  406. logger.info("Connection restarted successfully.")
  407. return True
  408. else:
  409. logger.error("Failed to restart connection.")
  410. return False
  411. except Exception as e:
  412. logger.error(f"Error restarting connection: {e}")
  413. return False