connection_manager.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. """
  2. Connection manager — drives the headless FluidNC board over HTTP.
  3. The board firmware now owns kinematics, `.thr` playback, progress and homing
  4. (contract: the firmware repo's API.md). This module is the thin seam the backend
  5. uses to talk to it: it builds a FluidNCClient, exposes the same public functions
  6. the rest of the app already calls (connect_device, device_init, home,
  7. check_idle_async, is_machine_idle, get_machine_position, update_machine_position,
  8. perform_soft_reset, restart_connection, list_serial_ports), and keeps the LED
  9. hooks intact. The old serial/websocket GRBL transport, coordinate streaming, and
  10. the $H/$J homing handshake are gone — the firmware does all of that itself.
  11. """
  12. import os
  13. import time
  14. import math
  15. import logging
  16. import asyncio
  17. from modules.core.state import state
  18. from modules.connection.fluidnc_client import FluidNCClient
  19. from modules.led.led_interface import LEDInterface
  20. from modules.led.idle_timeout_manager import idle_timeout_manager
  21. logger = logging.getLogger(__name__)
  22. DEFAULT_BOARD_URL = "http://192.168.68.160"
  23. def _normalize_board_url(value: str) -> str:
  24. """Accept a bare IP/host or a full URL and return a normalized base URL."""
  25. value = (value or "").strip().rstrip("/")
  26. if not value:
  27. return ""
  28. if not value.startswith(("http://", "https://")):
  29. value = "http://" + value
  30. return value
  31. def board_url() -> str:
  32. """Resolve the board base URL: explicit setting > env > default."""
  33. return (
  34. _normalize_board_url(getattr(state, "board_url", None) or "")
  35. or _normalize_board_url(os.environ.get("DUNE_BOARD_URL", ""))
  36. or DEFAULT_BOARD_URL
  37. )
  38. async def _check_table_is_idle() -> bool:
  39. """Helper for the idle-LED timeout: table counts as idle when nothing plays."""
  40. return not state.current_playing_file or state.pause_requested
  41. def _start_idle_led_timeout():
  42. """Start idle LED timeout if enabled."""
  43. if not state.dw_led_idle_timeout_enabled or state.dw_led_idle_timeout_minutes <= 0:
  44. return
  45. logger.debug(f"Starting idle LED timeout: {state.dw_led_idle_timeout_minutes} minutes")
  46. idle_timeout_manager.start_idle_timeout(
  47. timeout_minutes=state.dw_led_idle_timeout_minutes,
  48. state=state,
  49. check_idle_callback=_check_table_is_idle,
  50. )
  51. ###############################################################################
  52. # Status helpers
  53. ###############################################################################
  54. def apply_status(st: dict) -> None:
  55. """Mirror the board's /sand_status fields into app state."""
  56. if not st:
  57. return
  58. if "theta" in st:
  59. state.current_theta = st["theta"]
  60. if "rho" in st:
  61. state.current_rho = st["rho"]
  62. if st.get("feed"):
  63. state.speed = st["feed"]
  64. def poll_status_once() -> dict | None:
  65. """Read /sand_status once and mirror it into state. Returns the raw dict."""
  66. if not state.conn:
  67. return None
  68. try:
  69. st = state.conn.get_status()
  70. apply_status(st)
  71. return st
  72. except Exception as e:
  73. logger.debug(f"Status poll failed: {e}")
  74. return None
  75. def get_status_response() -> str:
  76. """Back-compat shim: return the board's machine state as a GRBL-like token."""
  77. st = poll_status_once()
  78. return st.get("state", "") if st else ""
  79. ###############################################################################
  80. # Connection lifecycle
  81. ###############################################################################
  82. def list_serial_ports():
  83. """
  84. There are no serial ports anymore — the board is reached over HTTP. Return
  85. the board URL as the single selectable "port" so the frontend's connection
  86. panel keeps working unchanged.
  87. """
  88. return [board_url()]
  89. def _push_homing_settings():
  90. """Push the host's homing preferences onto the board (best effort)."""
  91. try:
  92. state.conn.set_homing_mode("sensor" if state.homing == 1 else "crash")
  93. state.conn.set_theta_offset(state.angular_homing_offset_degrees)
  94. except Exception as e:
  95. logger.warning(f"Could not push homing settings to board: {e}")
  96. def _sync_homing_settings():
  97. """
  98. Reconcile homing config between host and board. If the user has explicitly
  99. chosen a homing mode (homing_user_override), the host is authoritative and we
  100. push it to the board. Otherwise we *adopt* the board's configured mode/offset
  101. so we never clobber a correctly-configured board with a host default.
  102. """
  103. if state.homing_user_override:
  104. _push_homing_settings()
  105. return
  106. try:
  107. settings = state.conn.get_settings()
  108. board_mode = (settings.get("Sand/HomingMode") or "").lower()
  109. if board_mode in ("sensor", "crash"):
  110. state.homing = 1 if board_mode == "sensor" else 0
  111. offset = settings.get("Sand/ThetaOffset")
  112. if offset is not None:
  113. state.angular_homing_offset_degrees = float(offset)
  114. logger.info(
  115. f"Adopted board homing config: mode={board_mode}, "
  116. f"offset={state.angular_homing_offset_degrees}"
  117. )
  118. except Exception as e:
  119. logger.warning(f"Could not read board homing settings: {e}")
  120. def device_init(homing=True):
  121. """Read board status, record firmware, reconcile homing, optionally home."""
  122. try:
  123. st = state.conn.get_status()
  124. except Exception as e:
  125. logger.fatal(f"Board status unreadable: {e}")
  126. state.conn.close()
  127. return False
  128. state.firmware_type = "fluidnc"
  129. state.firmware_version = state.firmware_version or "fluidnc"
  130. apply_status(st)
  131. _sync_homing_settings()
  132. if homing and state.home_on_connect:
  133. home()
  134. return True
  135. def connect_device(homing=True):
  136. """Initialize LEDs, connect to the board over HTTP, and init the device."""
  137. # Initialize LED interface based on configured provider (unchanged from before).
  138. if state.led_provider == "wled" and state.wled_ip:
  139. state.led_controller = LEDInterface(provider="wled", ip_address=state.wled_ip)
  140. elif state.led_provider == "dw_leds":
  141. # DW LEDs are initialized at startup in main.py; only set up here on reconnect.
  142. if not state.led_controller or not state.led_controller.is_configured:
  143. state.led_controller = LEDInterface(
  144. provider="dw_leds",
  145. num_leds=state.dw_led_num_leds,
  146. gpio_pin=state.dw_led_gpio_pin,
  147. pixel_order=state.dw_led_pixel_order,
  148. brightness=state.dw_led_brightness / 100.0,
  149. speed=state.dw_led_speed,
  150. intensity=state.dw_led_intensity,
  151. )
  152. elif state.led_provider == "none" or not state.led_provider:
  153. state.led_controller = None
  154. if state.led_controller:
  155. state.led_controller.effect_loading()
  156. url = board_url()
  157. logger.info(f"Connecting to FluidNC board at {url}")
  158. state.conn = FluidNCClient(url)
  159. if not state.conn.reachable():
  160. logger.error(f"Board not reachable at {url}")
  161. state.conn = None
  162. else:
  163. state.port = url
  164. logger.info(f"Connected to board at {url}")
  165. device_init(homing)
  166. # Show connected effect, then transition to the configured idle effect.
  167. if state.led_controller:
  168. logger.info("Showing LED connected effect (green flash)")
  169. state.led_controller.effect_connected()
  170. logger.info(f"Setting LED to idle effect: {state.dw_led_idle_effect}")
  171. state.led_controller.effect_idle(state.dw_led_idle_effect)
  172. _start_idle_led_timeout()
  173. def check_and_unlock_alarm():
  174. """
  175. Compatibility shim. The firmware manages its own alarm/unlock; if the board
  176. reports an Alarm state we ask it to home (which clears it). Returns True when
  177. the board is reachable.
  178. """
  179. st = poll_status_once()
  180. if st is None:
  181. return False
  182. if st.get("state") == "Alarm":
  183. logger.info("Board in Alarm state; a home will be required to clear it")
  184. return True
  185. ###############################################################################
  186. # Homing
  187. ###############################################################################
  188. def home(timeout=120):
  189. """
  190. Home the table by delegating to the board's /sand_home (which honors its own
  191. $Sand/HomingMode — sensor or crash). Polls /sand_status until Idle.
  192. """
  193. if not state.conn or not state.conn.is_connected():
  194. logger.error("Cannot home: no board connection")
  195. return False
  196. state.is_homing = True
  197. state.sensor_homing_failed = False
  198. try:
  199. _push_homing_settings()
  200. logger.info("Sending home command to board...")
  201. state.conn.home()
  202. # Give the board a moment to enter the Home state before polling.
  203. time.sleep(1.0)
  204. start = time.time()
  205. while time.time() - start < timeout:
  206. try:
  207. st = state.conn.get_status()
  208. except Exception:
  209. time.sleep(1.0)
  210. continue
  211. machine_state = st.get("state", "")
  212. if machine_state == "Idle":
  213. apply_status(st)
  214. logger.info(
  215. f"Homing complete (theta={state.current_theta}, rho={state.current_rho})"
  216. )
  217. state.save()
  218. return True
  219. if machine_state == "Alarm":
  220. logger.error("Homing failed: board reports Alarm")
  221. # In sensor mode an alarm means the switch was not found.
  222. state.sensor_homing_failed = state.homing == 1
  223. return False
  224. time.sleep(1.0)
  225. logger.warning(f"Homing timed out after {timeout}s")
  226. return False
  227. except Exception as e:
  228. logger.error(f"Error during homing: {e}")
  229. return False
  230. finally:
  231. state.is_homing = False
  232. ###############################################################################
  233. # Idle / position / reset
  234. ###############################################################################
  235. async def check_idle_async(timeout: float = 30.0):
  236. """Wait until the board reports Idle (or stop requested / timeout)."""
  237. start_time = asyncio.get_event_loop().time()
  238. while True:
  239. if state.stop_requested:
  240. logger.debug("Stop requested during idle check, exiting early")
  241. return False
  242. if asyncio.get_event_loop().time() - start_time > timeout:
  243. logger.warning(f"Timeout ({timeout}s) waiting for board idle state")
  244. return False
  245. st = await asyncio.to_thread(poll_status_once)
  246. if st and st.get("state") == "Idle":
  247. return True
  248. await asyncio.sleep(0.5)
  249. def is_machine_idle() -> bool:
  250. """Single, immediate check of whether the board is idle."""
  251. st = poll_status_once()
  252. return bool(st and st.get("state") == "Idle")
  253. def get_machine_position(timeout=5):
  254. """
  255. Position is owned by the board now (reported as theta/rho in /sand_status, not
  256. cartesian MPos). Return the last-known machine coordinates for persistence.
  257. """
  258. return state.machine_x, state.machine_y
  259. async def update_machine_position():
  260. """Refresh theta/rho from the board and persist state."""
  261. if state.conn and state.conn.is_connected():
  262. try:
  263. await asyncio.to_thread(poll_status_once)
  264. await asyncio.to_thread(state.save)
  265. except Exception as e:
  266. logger.error(f"Error updating machine position: {e}")
  267. def perform_soft_reset_sync():
  268. """Reboot the controller via $Bye (loses position; caller re-homes)."""
  269. if not state.conn or not state.conn.is_connected():
  270. logger.warning("Cannot perform soft reset: no board connection")
  271. return False
  272. try:
  273. state.conn.soft_reset()
  274. time.sleep(2.0) # board reboots; give it a moment
  275. return True
  276. except Exception as e:
  277. logger.error(f"Error performing soft reset: {e}")
  278. return False
  279. async def perform_soft_reset():
  280. return await asyncio.to_thread(perform_soft_reset_sync)
  281. def restart_connection(homing=False):
  282. """Close any existing connection and reconnect to the board."""
  283. if state.conn:
  284. try:
  285. state.conn.close()
  286. except Exception:
  287. pass
  288. state.conn = None
  289. connect_device(homing)
  290. return state.conn is not None and state.conn.is_connected()