connection_manager.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  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, update_machine_position, perform_soft_reset,
  8. restart_connection, list_serial_ports), and keeps the LED hooks intact. The old
  9. serial/websocket GRBL transport, coordinate streaming, and the $H/$J homing
  10. 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. import threading
  18. from modules.core.state import state
  19. from modules.connection.fluidnc_client import FluidNCClient
  20. from modules.led.led_interface import LEDInterface
  21. from modules.led.idle_timeout_manager import idle_timeout_manager
  22. logger = logging.getLogger(__name__)
  23. DEFAULT_BOARD_URL = "http://192.168.68.160"
  24. def _normalize_board_url(value: str) -> str:
  25. """Accept a bare IP/host or a full URL and return a normalized base URL."""
  26. value = (value or "").strip().rstrip("/")
  27. if not value:
  28. return ""
  29. if not value.startswith(("http://", "https://")):
  30. value = "http://" + value
  31. return value
  32. def board_url() -> str:
  33. """Resolve the board base URL: explicit setting > env > default."""
  34. return (
  35. _normalize_board_url(getattr(state, "board_url", None) or "")
  36. or _normalize_board_url(os.environ.get("DUNE_BOARD_URL", ""))
  37. or DEFAULT_BOARD_URL
  38. )
  39. async def _check_table_is_idle() -> bool:
  40. """Helper for the idle-LED timeout: table counts as idle when nothing plays."""
  41. return not state.current_playing_file or state.pause_requested
  42. def _start_idle_led_timeout():
  43. """Start idle LED timeout if enabled."""
  44. if not state.dw_led_idle_timeout_enabled or state.dw_led_idle_timeout_minutes <= 0:
  45. return
  46. logger.debug(f"Starting idle LED timeout: {state.dw_led_idle_timeout_minutes} minutes")
  47. idle_timeout_manager.start_idle_timeout(
  48. timeout_minutes=state.dw_led_idle_timeout_minutes,
  49. state=state,
  50. check_idle_callback=_check_table_is_idle,
  51. )
  52. ###############################################################################
  53. # Status helpers
  54. ###############################################################################
  55. def apply_status(st: dict) -> None:
  56. """Mirror the board's /sand_status fields into app state."""
  57. if not st:
  58. return
  59. if "theta" in st:
  60. state.current_theta = st["theta"]
  61. if "rho" in st:
  62. state.current_rho = st["rho"]
  63. if st.get("feed"):
  64. state.speed = st["feed"]
  65. def poll_status_once() -> dict | None:
  66. """Read /sand_status once and mirror it into state. Returns the raw dict."""
  67. if not state.conn:
  68. return None
  69. try:
  70. st = state.conn.get_status()
  71. apply_status(st)
  72. return st
  73. except Exception as e:
  74. logger.debug(f"Status poll failed: {e}")
  75. return None
  76. ###############################################################################
  77. # Connection lifecycle
  78. ###############################################################################
  79. def list_serial_ports():
  80. """
  81. There are no serial ports anymore — the board is reached over HTTP. Return
  82. the board URL as the single selectable "port" so the frontend's connection
  83. panel keeps working unchanged.
  84. """
  85. return [board_url()]
  86. def _push_homing_settings():
  87. """Push the host's homing preferences onto the board (best effort)."""
  88. try:
  89. state.conn.set_homing_mode("sensor" if state.homing == 1 else "crash")
  90. state.conn.set_theta_offset(state.angular_homing_offset_degrees)
  91. except Exception as e:
  92. logger.warning(f"Could not push homing settings to board: {e}")
  93. def _sync_homing_settings():
  94. """
  95. Reconcile homing config between host and board. If the user has explicitly
  96. chosen a homing mode (homing_user_override), the host is authoritative and we
  97. push it to the board. Otherwise we *adopt* the board's configured mode/offset
  98. so we never clobber a correctly-configured board with a host default.
  99. """
  100. if state.homing_user_override:
  101. _push_homing_settings()
  102. return
  103. try:
  104. settings = state.conn.get_settings()
  105. board_mode = (settings.get("Sand/HomingMode") or "").lower()
  106. if board_mode in ("sensor", "crash"):
  107. state.homing = 1 if board_mode == "sensor" else 0
  108. offset = settings.get("Sand/ThetaOffset")
  109. if offset is not None:
  110. state.angular_homing_offset_degrees = float(offset)
  111. logger.info(
  112. f"Adopted board homing config: mode={board_mode}, "
  113. f"offset={state.angular_homing_offset_degrees}"
  114. )
  115. except Exception as e:
  116. logger.warning(f"Could not read board homing settings: {e}")
  117. def device_init(homing=True):
  118. """Read board status, record firmware, reconcile homing, optionally home."""
  119. try:
  120. st = state.conn.get_status()
  121. except Exception as e:
  122. logger.fatal(f"Board status unreadable: {e}")
  123. state.conn.close()
  124. return False
  125. state.firmware_type = "fluidnc"
  126. state.firmware_version = st.get("fw") or state.firmware_version or "fluidnc"
  127. apply_status(st)
  128. _sync_homing_settings()
  129. # Reconcile board-owned settings (clock, Still Sands, auto-home cadence) and
  130. # mirror playlists in the background — mirroring can be slow and must never
  131. # delay connect/homing.
  132. from modules.core import board_settings
  133. threading.Thread(
  134. target=board_settings.sync_on_connect, args=(state.conn,), daemon=True
  135. ).start()
  136. if homing and state.home_on_connect:
  137. home()
  138. return True
  139. def connect_device(homing=True):
  140. """Initialize LEDs, connect to the board over HTTP, and init the device."""
  141. # Initialize LED interface based on configured provider (unchanged from before).
  142. if state.led_provider == "wled" and state.wled_ip:
  143. state.led_controller = LEDInterface(provider="wled", ip_address=state.wled_ip)
  144. elif state.led_provider == "board":
  145. if not state.led_controller or not state.led_controller.is_configured:
  146. state.led_controller = LEDInterface(provider="board")
  147. else:
  148. state.led_controller = None
  149. if state.led_controller:
  150. state.led_controller.effect_loading()
  151. url = board_url()
  152. logger.info(f"Connecting to FluidNC board at {url}")
  153. state.conn = FluidNCClient(url)
  154. if not state.conn.reachable():
  155. logger.error(f"Board not reachable at {url}")
  156. state.conn = None
  157. else:
  158. state.port = url
  159. logger.info(f"Connected to board at {url}")
  160. device_init(homing)
  161. # Show connected effect, then transition to the configured idle effect.
  162. if state.led_controller:
  163. logger.info("Showing LED connected effect (green flash)")
  164. state.led_controller.effect_connected()
  165. state.led_controller.effect_idle(None)
  166. _start_idle_led_timeout()
  167. ###############################################################################
  168. # Homing
  169. ###############################################################################
  170. def home(timeout=120):
  171. """
  172. Home the table by delegating to the board's /sand_home (which honors its own
  173. $Sand/HomingMode — sensor or crash). Polls /sand_status until Idle.
  174. """
  175. if not state.conn or not state.conn.is_connected():
  176. logger.error("Cannot home: no board connection")
  177. return False
  178. state.is_homing = True
  179. state.sensor_homing_failed = False
  180. try:
  181. _push_homing_settings()
  182. logger.info("Sending home command to board...")
  183. state.conn.home()
  184. # Give the board a moment to enter the Home state before polling.
  185. time.sleep(1.0)
  186. start = time.time()
  187. while time.time() - start < timeout:
  188. try:
  189. st = state.conn.get_status()
  190. except Exception:
  191. time.sleep(1.0)
  192. continue
  193. machine_state = st.get("state", "")
  194. if machine_state == "Idle":
  195. apply_status(st)
  196. logger.info(
  197. f"Homing complete (theta={state.current_theta}, rho={state.current_rho})"
  198. )
  199. state.save()
  200. return True
  201. if machine_state == "Alarm":
  202. logger.error("Homing failed: board reports Alarm")
  203. # In sensor mode an alarm means the switch was not found.
  204. state.sensor_homing_failed = state.homing == 1
  205. return False
  206. time.sleep(1.0)
  207. logger.warning(f"Homing timed out after {timeout}s")
  208. return False
  209. except Exception as e:
  210. logger.error(f"Error during homing: {e}")
  211. return False
  212. finally:
  213. state.is_homing = False
  214. ###############################################################################
  215. # Idle / position / reset
  216. ###############################################################################
  217. async def check_idle_async(timeout: float = 30.0):
  218. """Wait until the board reports Idle (or timeout)."""
  219. start_time = asyncio.get_event_loop().time()
  220. while True:
  221. if asyncio.get_event_loop().time() - start_time > timeout:
  222. logger.warning(f"Timeout ({timeout}s) waiting for board idle state")
  223. return False
  224. st = await asyncio.to_thread(poll_status_once)
  225. if st and st.get("state") == "Idle":
  226. return True
  227. await asyncio.sleep(0.5)
  228. def is_machine_idle() -> bool:
  229. """Single, immediate check of whether the board is idle."""
  230. st = poll_status_once()
  231. return bool(st and st.get("state") == "Idle")
  232. async def update_machine_position():
  233. """Refresh theta/rho from the board and persist state."""
  234. if state.conn and state.conn.is_connected():
  235. try:
  236. await asyncio.to_thread(poll_status_once)
  237. await asyncio.to_thread(state.save)
  238. except Exception as e:
  239. logger.error(f"Error updating machine position: {e}")
  240. def perform_soft_reset_sync():
  241. """Reboot the controller via $Bye (loses position; caller re-homes)."""
  242. if not state.conn or not state.conn.is_connected():
  243. logger.warning("Cannot perform soft reset: no board connection")
  244. return False
  245. try:
  246. state.conn.soft_reset()
  247. time.sleep(2.0) # board reboots; give it a moment
  248. return True
  249. except Exception as e:
  250. logger.error(f"Error performing soft reset: {e}")
  251. return False
  252. async def perform_soft_reset():
  253. return await asyncio.to_thread(perform_soft_reset_sync)
  254. def restart_connection(homing=False):
  255. """Close any existing connection and reconnect to the board."""
  256. if state.conn:
  257. try:
  258. state.conn.close()
  259. except Exception:
  260. pass
  261. state.conn = None
  262. connect_device(homing)
  263. return state.conn is not None and state.conn.is_connected()