1
0

connection_manager.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  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_board_urls), 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. # Health telemetry (present on fw that reports it; left unchanged otherwise).
  66. for src, dst in (
  67. ("heap", "board_heap"),
  68. ("heap_min", "board_heap_min"),
  69. ("heap_largest", "board_heap_largest"),
  70. ("last_reset", "board_last_reset"),
  71. ("sd_ok", "board_sd_ok"),
  72. ("uptime", "board_uptime"),
  73. ):
  74. if src in st:
  75. setattr(state, dst, st[src])
  76. def poll_status_once() -> dict | None:
  77. """Read /sand_status once and mirror it into state. Returns the raw dict."""
  78. if not state.conn:
  79. return None
  80. try:
  81. st = state.conn.get_status()
  82. apply_status(st)
  83. return st
  84. except Exception as e:
  85. logger.debug(f"Status poll failed: {e}")
  86. return None
  87. ###############################################################################
  88. # Connection lifecycle
  89. ###############################################################################
  90. def list_board_urls():
  91. """
  92. There are no serial ports anymore — the board is reached over HTTP. Return
  93. the board URL as the single selectable "port" so the frontend's connection
  94. panel keeps working unchanged.
  95. """
  96. return [board_url()]
  97. def _push_homing_settings():
  98. """Push the host's homing preferences onto the board (best effort)."""
  99. try:
  100. state.conn.set_homing_mode("sensor" if state.homing == 1 else "crash")
  101. state.conn.set_theta_offset(state.angular_homing_offset_degrees)
  102. except Exception as e:
  103. logger.warning(f"Could not push homing settings to board: {e}")
  104. def _sync_homing_settings():
  105. """
  106. Reconcile homing config between host and board. If the user has explicitly
  107. chosen a homing mode (homing_user_override), the host is authoritative and we
  108. push it to the board. Otherwise we *adopt* the board's configured mode/offset
  109. so we never clobber a correctly-configured board with a host default.
  110. """
  111. if state.homing_user_override:
  112. _push_homing_settings()
  113. return
  114. try:
  115. settings = state.conn.get_settings()
  116. board_mode = (settings.get("Sand/HomingMode") or "").lower()
  117. if board_mode in ("sensor", "crash"):
  118. state.homing = 1 if board_mode == "sensor" else 0
  119. offset = settings.get("Sand/ThetaOffset")
  120. if offset is not None:
  121. state.angular_homing_offset_degrees = float(offset)
  122. logger.info(
  123. f"Adopted board homing config: mode={board_mode}, "
  124. f"offset={state.angular_homing_offset_degrees}"
  125. )
  126. except Exception as e:
  127. logger.warning(f"Could not read board homing settings: {e}")
  128. def device_init(homing=True):
  129. """Read board status, record firmware, reconcile homing, optionally home."""
  130. try:
  131. st = state.conn.get_status()
  132. except Exception as e:
  133. logger.fatal(f"Board status unreadable: {e}")
  134. state.conn.close()
  135. return False
  136. state.firmware_type = "fluidnc"
  137. state.firmware_version = st.get("fw") or state.firmware_version or "fluidnc"
  138. state.board_hostname = st.get("hostname") or state.board_hostname
  139. mac = (st.get("mac") or "").lower() or None
  140. if mac and mac != state.board_mac:
  141. state.board_mac = mac
  142. state.save_debounced()
  143. apply_status(st)
  144. _sync_homing_settings()
  145. # Reconcile board-owned settings (clock, Still Sands, auto-home cadence) and
  146. # mirror playlists in the background — mirroring can be slow and must never
  147. # delay connect/homing.
  148. from modules.core import board_settings
  149. threading.Thread(
  150. target=board_settings.sync_on_connect, args=(state.conn,), daemon=True
  151. ).start()
  152. if homing and state.home_on_connect:
  153. home()
  154. return True
  155. def connect_device(homing=True):
  156. """Initialize LEDs, connect to the board over HTTP, and init the device."""
  157. # Initialize LED interface based on configured provider (unchanged from before).
  158. if state.led_provider == "wled" and state.wled_ip:
  159. state.led_controller = LEDInterface(provider="wled", ip_address=state.wled_ip)
  160. elif state.led_provider == "board":
  161. if not state.led_controller or not state.led_controller.is_configured:
  162. state.led_controller = LEDInterface(provider="board")
  163. else:
  164. state.led_controller = None
  165. if state.led_controller:
  166. state.led_controller.effect_loading()
  167. url = board_url()
  168. logger.info(f"Connecting to FluidNC board at {url}")
  169. state.user_disconnected = False
  170. state.conn = FluidNCClient(url, api_key=state.board_api_key)
  171. if not state.conn.reachable():
  172. state.board_locked = state.conn.locked
  173. if state.board_locked:
  174. logger.error(f"Board at {url} is password-protected (401) - set its password in Settings")
  175. else:
  176. logger.error(f"Board not reachable at {url}")
  177. state.conn = None
  178. else:
  179. state.board_locked = False
  180. state.port = url
  181. logger.info(f"Connected to board at {url}")
  182. device_init(homing)
  183. # Show connected effect, then transition to the configured idle effect.
  184. if state.led_controller:
  185. logger.info("Showing LED connected effect (green flash)")
  186. state.led_controller.effect_connected()
  187. state.led_controller.effect_idle(None)
  188. _start_idle_led_timeout()
  189. ###############################################################################
  190. # Homing
  191. ###############################################################################
  192. def home(timeout=120):
  193. """
  194. Home the table by delegating to the board's /sand_home (which honors its own
  195. $Sand/HomingMode — sensor or crash). Polls /sand_status until Idle.
  196. """
  197. if not state.conn or not state.conn.is_connected():
  198. logger.error("Cannot home: no board connection")
  199. return False
  200. state.is_homing = True
  201. state.sensor_homing_failed = False
  202. try:
  203. _push_homing_settings()
  204. logger.info("Sending home command to board...")
  205. state.conn.home()
  206. # Give the board a moment to enter the Home state before polling.
  207. time.sleep(1.0)
  208. start = time.time()
  209. while time.time() - start < timeout:
  210. try:
  211. st = state.conn.get_status()
  212. except Exception:
  213. time.sleep(1.0)
  214. continue
  215. machine_state = st.get("state", "")
  216. if machine_state == "Idle":
  217. apply_status(st)
  218. logger.info(
  219. f"Homing complete (theta={state.current_theta}, rho={state.current_rho})"
  220. )
  221. state.save()
  222. return True
  223. if machine_state == "Alarm":
  224. logger.error("Homing failed: board reports Alarm")
  225. # In sensor mode an alarm means the switch was not found.
  226. state.sensor_homing_failed = state.homing == 1
  227. return False
  228. time.sleep(1.0)
  229. logger.warning(f"Homing timed out after {timeout}s")
  230. return False
  231. except Exception as e:
  232. logger.error(f"Error during homing: {e}")
  233. return False
  234. finally:
  235. state.is_homing = False
  236. ###############################################################################
  237. # Idle / position / reset
  238. ###############################################################################
  239. async def check_idle_async(timeout: float = 30.0):
  240. """Wait until the board reports Idle (or timeout)."""
  241. start_time = asyncio.get_event_loop().time()
  242. while True:
  243. if asyncio.get_event_loop().time() - start_time > timeout:
  244. logger.warning(f"Timeout ({timeout}s) waiting for board idle state")
  245. return False
  246. st = await asyncio.to_thread(poll_status_once)
  247. if st and st.get("state") == "Idle":
  248. return True
  249. await asyncio.sleep(0.5)
  250. def is_machine_idle() -> bool:
  251. """Single, immediate check of whether the board is idle."""
  252. st = poll_status_once()
  253. return bool(st and st.get("state") == "Idle")
  254. async def update_machine_position():
  255. """Refresh theta/rho from the board and persist state."""
  256. if state.conn and state.conn.is_connected():
  257. try:
  258. await asyncio.to_thread(poll_status_once)
  259. await asyncio.to_thread(state.save)
  260. except Exception as e:
  261. logger.error(f"Error updating machine position: {e}")
  262. def perform_soft_reset_sync():
  263. """Reboot the controller via $Bye (loses position; caller re-homes)."""
  264. if not state.conn or not state.conn.is_connected():
  265. logger.warning("Cannot perform soft reset: no board connection")
  266. return False
  267. try:
  268. state.conn.soft_reset()
  269. time.sleep(2.0) # board reboots; give it a moment
  270. return True
  271. except Exception as e:
  272. logger.error(f"Error performing soft reset: {e}")
  273. return False
  274. async def perform_soft_reset():
  275. return await asyncio.to_thread(perform_soft_reset_sync)
  276. def restart_connection(homing=False):
  277. """Close any existing connection and reconnect to the board."""
  278. if state.conn:
  279. try:
  280. state.conn.close()
  281. except Exception:
  282. pass
  283. state.conn = None
  284. connect_device(homing)
  285. return state.conn is not None and state.conn.is_connected()