app.py 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288
  1. from flask import Flask, request, jsonify, render_template
  2. import atexit
  3. import os
  4. import serial
  5. import time
  6. import random
  7. import threading
  8. import serial.tools.list_ports
  9. import math
  10. import json
  11. from datetime import datetime
  12. import subprocess
  13. from tqdm import tqdm
  14. app = Flask(__name__)
  15. # Configuration
  16. THETA_RHO_DIR = './patterns'
  17. IGNORE_PORTS = ['/dev/cu.debug-console', '/dev/cu.Bluetooth-Incoming-Port']
  18. CLEAR_PATTERNS = {
  19. "clear_from_in": "./patterns/clear_from_in.thr",
  20. "clear_from_out": "./patterns/clear_from_out.thr",
  21. "clear_sideway": "./patterns/clear_sideway.thr"
  22. }
  23. os.makedirs(THETA_RHO_DIR, exist_ok=True)
  24. # Serial connection (First available will be selected by default)
  25. ser = None
  26. ser_port = None # Global variable to store the serial port name
  27. stop_requested = False
  28. pause_requested = False
  29. pause_condition = threading.Condition()
  30. # Global variables to store device information
  31. arduino_table_name = None
  32. arduino_driver_type = 'Unknown'
  33. # Table status
  34. current_playing_file = None
  35. execution_progress = None
  36. firmware_version = 'Unknown'
  37. current_playing_index = None
  38. current_playlist = None
  39. is_clearing = False
  40. serial_lock = threading.RLock()
  41. PLAYLISTS_FILE = os.path.join(os.getcwd(), "playlists.json")
  42. MOTOR_TYPE_MAPPING = {
  43. "TMC2209": "./firmware/arduino_code_TMC2209/arduino_code_TMC2209.ino",
  44. "DRV8825": "./firmware/arduino_code/arduino_code.ino",
  45. "esp32": "./firmware/esp32/esp32.ino"
  46. }
  47. # Ensure the file exists and contains at least an empty JSON object
  48. if not os.path.exists(PLAYLISTS_FILE):
  49. with open(PLAYLISTS_FILE, "w") as f:
  50. json.dump({}, f, indent=2)
  51. def get_ino_firmware_details(ino_file_path):
  52. """
  53. Extract firmware details, including version and motor type, from the given .ino file.
  54. Args:
  55. ino_file_path (str): Path to the .ino file.
  56. Returns:
  57. dict: Dictionary containing firmware details such as version and motor type, or None if not found.
  58. """
  59. try:
  60. if not ino_file_path:
  61. raise ValueError("Invalid path: ino_file_path is None or empty.")
  62. firmware_details = {"version": None, "motorType": None}
  63. with open(ino_file_path, "r") as file:
  64. for line in file:
  65. # Extract firmware version
  66. if "firmwareVersion" in line:
  67. start = line.find('"') + 1
  68. end = line.rfind('"')
  69. if start != -1 and end != -1 and start < end:
  70. firmware_details["version"] = line[start:end]
  71. # Extract motor type
  72. if "motorType" in line:
  73. start = line.find('"') + 1
  74. end = line.rfind('"')
  75. if start != -1 and end != -1 and start < end:
  76. firmware_details["motorType"] = line[start:end]
  77. if not firmware_details["version"]:
  78. print(f"Firmware version not found in file: {ino_file_path}")
  79. if not firmware_details["motorType"]:
  80. print(f"Motor type not found in file: {ino_file_path}")
  81. return firmware_details if any(firmware_details.values()) else None
  82. except FileNotFoundError:
  83. print(f"File not found: {ino_file_path}")
  84. return None
  85. except Exception as e:
  86. print(f"Error reading .ino file: {str(e)}")
  87. return None
  88. def check_git_updates():
  89. try:
  90. # Fetch the latest updates from the remote repository
  91. subprocess.run(["git", "fetch", "--tags", "--force"], check=True)
  92. # Get the latest tag from the remote
  93. latest_remote_tag = subprocess.check_output(
  94. ["git", "describe", "--tags", "--abbrev=0", "origin/main"]
  95. ).strip().decode()
  96. # Get the latest tag from the local branch
  97. latest_local_tag = subprocess.check_output(
  98. ["git", "describe", "--tags", "--abbrev=0"]
  99. ).strip().decode()
  100. # Count how many tags the local branch is behind
  101. tag_behind_count = 0
  102. if latest_local_tag != latest_remote_tag:
  103. tags = subprocess.check_output(
  104. ["git", "tag", "--merged", "origin/main"], text=True
  105. ).splitlines()
  106. found_local = False
  107. for tag in tags:
  108. if tag == latest_local_tag:
  109. found_local = True
  110. elif found_local:
  111. tag_behind_count += 1
  112. if tag == latest_remote_tag:
  113. break
  114. # Check if there are new commits
  115. updates_available = latest_remote_tag != latest_local_tag
  116. return {
  117. "updates_available": updates_available,
  118. "tag_behind_count": tag_behind_count, # Tags behind
  119. "latest_remote_tag": latest_remote_tag,
  120. "latest_local_tag": latest_local_tag,
  121. }
  122. except subprocess.CalledProcessError as e:
  123. print(f"Error checking Git updates: {e}")
  124. return {
  125. "updates_available": False,
  126. "tag_behind_count": 0,
  127. "latest_remote_tag": None,
  128. "latest_local_tag": None,
  129. }
  130. def list_serial_ports():
  131. """Return a list of available serial ports."""
  132. ports = serial.tools.list_ports.comports()
  133. return [port.device for port in ports if port.device not in IGNORE_PORTS]
  134. def connect_to_serial(port=None, baudrate=115200):
  135. """Automatically connect to the first available serial port or a specified port."""
  136. global ser, ser_port, arduino_table_name, arduino_driver_type, firmware_version
  137. try:
  138. if port is None:
  139. ports = list_serial_ports()
  140. if not ports:
  141. print("No serial port connected")
  142. return False
  143. port = ports[0] # Auto-select the first available port
  144. with serial_lock:
  145. if ser and ser.is_open:
  146. ser.close()
  147. ser = serial.Serial(port, baudrate, timeout=2) # Set timeout to avoid infinite waits
  148. ser_port = port # Store the connected port globally
  149. print(f"Connected to serial port: {port}")
  150. time.sleep(2) # Allow time for the connection to establish
  151. # Read initial startup messages from Arduino
  152. arduino_table_name = None
  153. arduino_driver_type = None
  154. while ser.in_waiting > 0:
  155. line = ser.readline().decode().strip()
  156. print(f"Arduino: {line}") # Print the received message
  157. # Store the device details based on the expected messages
  158. if "Table:" in line:
  159. arduino_table_name = line.replace("Table: ", "").strip()
  160. elif "Drivers:" in line:
  161. arduino_driver_type = line.replace("Drivers: ", "").strip()
  162. elif "Version:" in line:
  163. firmware_version = line.replace("Version: ", "").strip()
  164. # Display stored values
  165. print(f"Detected Table: {arduino_table_name or 'Unknown'}")
  166. print(f"Detected Drivers: {arduino_driver_type or 'Unknown'}")
  167. return True # Successfully connected
  168. except serial.SerialException as e:
  169. print(f"Failed to connect to serial port {port}: {e}")
  170. port = None # Reset the port to try the next available one
  171. print("Max retries reached. Could not connect to a serial port.")
  172. return False
  173. def disconnect_serial():
  174. """Disconnect the current serial connection."""
  175. global ser, ser_port
  176. if ser and ser.is_open:
  177. ser.close()
  178. ser = None
  179. ser_port = None # Reset the port name
  180. def restart_serial(port, baudrate=115200):
  181. """Restart the serial connection."""
  182. disconnect_serial()
  183. connect_to_serial(port, baudrate)
  184. def parse_theta_rho_file(file_path):
  185. """
  186. Parse a theta-rho file and return a list of (theta, rho) pairs.
  187. Normalizes the list so the first theta is always 0.
  188. """
  189. coordinates = []
  190. try:
  191. with open(file_path, 'r') as file:
  192. for line in file:
  193. line = line.strip()
  194. # Skip header or comment lines (starting with '#' or empty lines)
  195. if not line or line.startswith("#"):
  196. continue
  197. # Parse lines with theta and rho separated by spaces
  198. try:
  199. theta, rho = map(float, line.split())
  200. coordinates.append((theta, rho))
  201. except ValueError:
  202. print(f"Skipping invalid line: {line}")
  203. continue
  204. except Exception as e:
  205. print(f"Error reading file: {e}")
  206. return coordinates
  207. # ---- Normalization Step ----
  208. if coordinates:
  209. # Take the first coordinate's theta
  210. first_theta = coordinates[0][0]
  211. # Shift all thetas so the first coordinate has theta=0
  212. normalized = []
  213. for (theta, rho) in coordinates:
  214. normalized.append((theta - first_theta, rho))
  215. # Replace original list with normalized data
  216. coordinates = normalized
  217. return coordinates
  218. def send_coordinate_batch(ser, coordinates):
  219. """Send a batch of theta-rho pairs to the Arduino."""
  220. # print("Sending batch:", coordinates)
  221. batch_str = ";".join(f"{theta:.5f},{rho:.5f}" for theta, rho in coordinates) + ";\n"
  222. with serial_lock:
  223. ser.write(batch_str.encode())
  224. def send_command(command):
  225. """Send a single command to the Arduino."""
  226. with serial_lock:
  227. ser.write(f"{command}\n".encode())
  228. print(f"Sent: {command}")
  229. # Wait for "R" acknowledgment from Arduino
  230. while True:
  231. with serial_lock:
  232. if ser.in_waiting > 0:
  233. response = ser.readline().decode().strip()
  234. print(f"Arduino response: {response}")
  235. if response == "R":
  236. print("Command execution completed.")
  237. break
  238. def wait_for_start_time(schedule_hours):
  239. """
  240. Keep checking every 30 seconds if the time is within the schedule to resume execution.
  241. """
  242. global pause_requested
  243. start_time, end_time = schedule_hours
  244. while pause_requested:
  245. now = datetime.now().time()
  246. if start_time <= now < end_time:
  247. print("Resuming execution: Within schedule.")
  248. pause_requested = False
  249. with pause_condition:
  250. pause_condition.notify_all()
  251. break # Exit the loop once resumed
  252. else:
  253. time.sleep(30) # Wait for 30 seconds before checking again
  254. # Function to check schedule based on start and end time
  255. def schedule_checker(schedule_hours):
  256. """
  257. Pauses/resumes execution based on a given time range.
  258. Parameters:
  259. - schedule_hours (tuple): (start_time, end_time) as `datetime.time` objects.
  260. """
  261. global pause_requested
  262. if not schedule_hours:
  263. return # No scheduling restriction
  264. start_time, end_time = schedule_hours
  265. now = datetime.now().time() # Get the current time as `datetime.time`
  266. # Check if we are currently within the scheduled time
  267. if start_time <= now < end_time:
  268. if pause_requested:
  269. print("Starting execution: Within schedule.")
  270. pause_requested = False # Resume execution
  271. with pause_condition:
  272. pause_condition.notify_all()
  273. else:
  274. if not pause_requested:
  275. print("Pausing execution: Outside schedule.")
  276. pause_requested = True # Pause execution
  277. # Start a background thread to periodically check for start time
  278. threading.Thread(target=wait_for_start_time, args=(schedule_hours,), daemon=True).start()
  279. def run_theta_rho_file(file_path, schedule_hours=None):
  280. """Run a theta-rho file by sending data in optimized batches with tqdm ETA tracking."""
  281. global stop_requested, current_playing_file, execution_progress
  282. coordinates = parse_theta_rho_file(file_path)
  283. total_coordinates = len(coordinates)
  284. if total_coordinates < 2:
  285. print("Not enough coordinates for interpolation.")
  286. current_playing_file = None # Clear tracking if failed
  287. execution_progress = None
  288. return
  289. execution_progress = (0, total_coordinates, None) # Initialize progress with ETA as None
  290. batch_size = 10 # Smaller batches may smooth movement further
  291. # before trying to acuire the lock we send the stop command
  292. # so then we will just wait for the lock to be released so we can use the serial
  293. stop_actions()
  294. with serial_lock:
  295. current_playing_file = file_path # Track current playing file
  296. execution_progress = (0, 0, None) # Reset progress (ETA starts as None)
  297. stop_requested = False
  298. with tqdm(total=total_coordinates, unit="coords", desc=f"Executing Pattern {file_path}", dynamic_ncols=True, disable=None) as pbar:
  299. for i in range(0, total_coordinates, batch_size):
  300. if stop_requested:
  301. print("Execution stopped by user after completing the current batch.")
  302. # after stopping we free the stop rquest "lock"
  303. stop_requested= False
  304. break
  305. with pause_condition:
  306. while pause_requested:
  307. print("Execution paused...")
  308. pause_condition.wait() # This will block execution until notified
  309. batch = coordinates[i:i + batch_size]
  310. if i == 0:
  311. send_coordinate_batch(ser, batch)
  312. execution_progress = (i + batch_size, total_coordinates, None) # No ETA yet
  313. pbar.update(batch_size)
  314. continue
  315. while True:
  316. schedule_checker(schedule_hours) # Check if within schedule
  317. if ser.in_waiting > 0:
  318. response = ser.readline().decode().strip()
  319. if response == "R":
  320. send_coordinate_batch(ser, batch)
  321. pbar.update(batch_size) # Update tqdm progress
  322. # Use tqdm's built-in ETA tracking
  323. estimated_remaining_time = pbar.format_dict['elapsed'] / (i + batch_size) * (total_coordinates - (i + batch_size))
  324. # Update execution progress with formatted ETA
  325. execution_progress = (i + batch_size, total_coordinates, estimated_remaining_time)
  326. break
  327. elif response != "IGNORED: FINISHED" and response.startswith("IGNORE"): # Retry the previous batch
  328. print("Received IGNORE. Resending the previous batch...")
  329. print(response)
  330. # Calculate the previous batch indices
  331. prev_start = max(0, i - batch_size) # Ensure we don't go below 0
  332. prev_end = i # End of the previous batch is `i`
  333. previous_batch = coordinates[prev_start:prev_end]
  334. # Resend the previous batch
  335. send_coordinate_batch(ser, previous_batch)
  336. break # Exit the retry loop after resending
  337. else:
  338. print(f"Arduino response: {response}")
  339. reset_theta()
  340. ser.write("FINISHED\n".encode())
  341. # Clear tracking variables when done
  342. current_playing_file = None
  343. execution_progress = None
  344. print("Pattern execution completed.")
  345. def get_clear_pattern_file(clear_pattern_mode, path=None):
  346. """Return a .thr file path based on pattern_name."""
  347. if not clear_pattern_mode or clear_pattern_mode == 'none':
  348. return
  349. print("Clear pattern mode: " + clear_pattern_mode)
  350. if clear_pattern_mode == "random":
  351. # Randomly pick one of the three known patterns
  352. return random.choice(list(CLEAR_PATTERNS.values()))
  353. if clear_pattern_mode == 'adaptive':
  354. _, first_rho = parse_theta_rho_file(path)[0]
  355. if first_rho < 0.5:
  356. return CLEAR_PATTERNS['clear_from_out']
  357. else:
  358. return random.choice([CLEAR_PATTERNS['clear_from_in'], CLEAR_PATTERNS['clear_sideway']])
  359. else:
  360. return CLEAR_PATTERNS[clear_pattern_mode]
  361. def run_theta_rho_files(
  362. file_paths,
  363. pause_time=0,
  364. clear_pattern=None,
  365. run_mode="single",
  366. shuffle=False,
  367. schedule_hours=None
  368. ):
  369. """
  370. Runs multiple .thr files in sequence with options for pausing, clearing, shuffling, and looping.
  371. Parameters:
  372. - file_paths (list): List of file paths to run.
  373. - pause_time (float): Seconds to pause between patterns.
  374. - clear_pattern (str): Specific clear pattern to run ("clear_from_in", "clear_from_out", "clear_sideway", "adaptive", or "random").
  375. - run_mode (str): "single" for one-time run or "indefinite" for looping.
  376. - shuffle (bool): Whether to shuffle the playlist before running.
  377. """
  378. global stop_requested
  379. global current_playlist
  380. global current_playing_index
  381. stop_requested = False # Reset stop flag at the start
  382. if shuffle:
  383. random.shuffle(file_paths)
  384. print("Playlist shuffled.")
  385. current_playlist = file_paths
  386. while True:
  387. for idx, path in enumerate(file_paths):
  388. print("Upcoming pattern: " + path)
  389. current_playing_index = idx
  390. schedule_checker(schedule_hours)
  391. if stop_requested:
  392. print("Execution stopped before starting next pattern.")
  393. return
  394. if clear_pattern:
  395. if stop_requested:
  396. print("Execution stopped before running the next clear pattern.")
  397. return
  398. # Determine the clear pattern to run
  399. clear_file_path = get_clear_pattern_file(clear_pattern, path)
  400. print(f"Running clear pattern: {clear_file_path}")
  401. run_theta_rho_file(clear_file_path, schedule_hours)
  402. if not stop_requested:
  403. # Run the main pattern
  404. print(f"Running pattern {idx + 1} of {len(file_paths)}: {path}")
  405. run_theta_rho_file(path, schedule_hours)
  406. if idx < len(file_paths) -1:
  407. if stop_requested:
  408. print("Execution stopped before running the next clear pattern.")
  409. return
  410. # Pause after each pattern if requested
  411. if pause_time > 0:
  412. print(f"Pausing for {pause_time} seconds...")
  413. time.sleep(pause_time)
  414. # After completing the playlist
  415. if run_mode == "indefinite":
  416. print("Playlist completed. Restarting as per 'indefinite' run mode.")
  417. if pause_time > 0:
  418. print(f"Pausing for {pause_time} seconds before restarting...")
  419. time.sleep(pause_time)
  420. if shuffle:
  421. random.shuffle(file_paths)
  422. print("Playlist reshuffled for the next loop.")
  423. continue
  424. else:
  425. print("Playlist completed.")
  426. break
  427. # Reset theta after execution or stopping
  428. reset_theta()
  429. with serial_lock:
  430. ser.write("FINISHED\n".encode())
  431. print("All requested patterns completed (or stopped).")
  432. def reset_theta():
  433. """Reset theta on the Arduino."""
  434. with serial_lock:
  435. ser.write("RESET_THETA\n".encode())
  436. while True:
  437. with serial_lock:
  438. if ser.in_waiting > 0:
  439. response = ser.readline().decode().strip()
  440. print(f"Arduino response: {response}")
  441. if response == "THETA_RESET":
  442. print("Theta successfully reset.")
  443. break
  444. time.sleep(0.5) # Small delay to avoid busy waiting
  445. # Flask API Endpoints
  446. @app.route('/')
  447. def index():
  448. return render_template('index.html')
  449. @app.route('/list_serial_ports', methods=['GET'])
  450. def list_ports():
  451. return jsonify(list_serial_ports())
  452. @app.route('/connect_serial', methods=['POST'])
  453. def connect_serial():
  454. port = request.json.get('port')
  455. if not port:
  456. return jsonify({'error': 'No port provided'}), 400
  457. try:
  458. connect_to_serial(port)
  459. return jsonify({'success': True})
  460. except Exception as e:
  461. return jsonify({'error': str(e)}), 500
  462. @app.route('/disconnect_serial', methods=['POST'])
  463. def disconnect():
  464. try:
  465. disconnect_serial()
  466. return jsonify({'success': True})
  467. except Exception as e:
  468. return jsonify({'error': str(e)}), 500
  469. @app.route('/restart_serial', methods=['POST'])
  470. def restart():
  471. port = request.json.get('port')
  472. if not port:
  473. return jsonify({'error': 'No port provided'}), 400
  474. try:
  475. restart_serial(port)
  476. return jsonify({'success': True})
  477. except Exception as e:
  478. return jsonify({'error': str(e)}), 500
  479. @app.route('/list_theta_rho_files', methods=['GET'])
  480. def list_theta_rho_files():
  481. files = []
  482. for root, _, filenames in os.walk(THETA_RHO_DIR):
  483. for file in filenames:
  484. # Construct the relative file path
  485. relative_path = os.path.relpath(os.path.join(root, file), THETA_RHO_DIR)
  486. files.append(relative_path)
  487. return jsonify(sorted(files))
  488. @app.route('/upload_theta_rho', methods=['POST'])
  489. def upload_theta_rho():
  490. custom_patterns_dir = os.path.join(THETA_RHO_DIR, 'custom_patterns')
  491. os.makedirs(custom_patterns_dir, exist_ok=True) # Ensure the directory exists
  492. file = request.files['file']
  493. if file:
  494. file.save(os.path.join(custom_patterns_dir, file.filename))
  495. return jsonify({'success': True})
  496. return jsonify({'success': False})
  497. @app.route('/run_theta_rho', methods=['POST'])
  498. def run_theta_rho():
  499. file_name = request.json.get('file_name')
  500. pre_execution = request.json.get('pre_execution')
  501. if not file_name:
  502. return jsonify({'error': 'No file name provided'}), 400
  503. file_path = os.path.join(THETA_RHO_DIR, file_name)
  504. if not os.path.exists(file_path):
  505. return jsonify({'error': 'File not found'}), 404
  506. try:
  507. # Build a list of files to run in sequence
  508. files_to_run = []
  509. # Finally, add the main file
  510. files_to_run.append(file_path)
  511. # Run them in one shot using run_theta_rho_files (blocking call)
  512. threading.Thread(
  513. target=run_theta_rho_files,
  514. args=(files_to_run,),
  515. kwargs={
  516. 'pause_time': 0,
  517. 'clear_pattern': pre_execution
  518. }
  519. ).start()
  520. return jsonify({'success': True})
  521. except Exception as e:
  522. return jsonify({'error': str(e)}), 500
  523. def stop_actions():
  524. global pause_requested
  525. with pause_condition:
  526. pause_requested = False
  527. pause_condition.notify_all()
  528. global stop_requested, current_playing_index, current_playlist, is_clearing, current_playing_file, execution_progress
  529. stop_requested = True
  530. current_playing_index = None
  531. current_playlist = None
  532. is_clearing = False
  533. current_playing_file = None
  534. execution_progress = None
  535. @app.route('/stop_execution', methods=['POST'])
  536. def stop_execution():
  537. stop_actions()
  538. return jsonify({'success': True})
  539. @app.route('/send_home', methods=['POST'])
  540. def send_home():
  541. """Send the HOME command to the Arduino."""
  542. try:
  543. send_command("HOME")
  544. return jsonify({'success': True})
  545. except Exception as e:
  546. return jsonify({'error': str(e)}), 500
  547. @app.route('/run_theta_rho_file/<file_name>', methods=['POST'])
  548. def run_specific_theta_rho_file(file_name):
  549. """Run a specific theta-rho file."""
  550. file_path = os.path.join(THETA_RHO_DIR, file_name)
  551. if not os.path.exists(file_path):
  552. return jsonify({'error': 'File not found'}), 404
  553. threading.Thread(target=run_theta_rho_file, args=(file_path,)).start()
  554. return jsonify({'success': True})
  555. @app.route('/delete_theta_rho_file', methods=['POST'])
  556. def delete_theta_rho_file():
  557. data = request.json
  558. file_name = data.get('file_name')
  559. if not file_name:
  560. return jsonify({"success": False, "error": "No file name provided"}), 400
  561. file_path = os.path.join(THETA_RHO_DIR, file_name)
  562. if not os.path.exists(file_path):
  563. return jsonify({"success": False, "error": "File not found"}), 404
  564. try:
  565. os.remove(file_path)
  566. return jsonify({"success": True})
  567. except Exception as e:
  568. return jsonify({"success": False, "error": str(e)}), 500
  569. @app.route('/move_to_center', methods=['POST'])
  570. def move_to_center():
  571. """Move the sand table to the center position."""
  572. try:
  573. if ser is None or not ser.is_open:
  574. return jsonify({"success": False, "error": "Serial connection not established"}), 400
  575. coordinates = [(0, 0)] # Center position
  576. send_coordinate_batch(ser, coordinates)
  577. return jsonify({"success": True})
  578. except Exception as e:
  579. return jsonify({"success": False, "error": str(e)}), 500
  580. @app.route('/move_to_perimeter', methods=['POST'])
  581. def move_to_perimeter():
  582. """Move the sand table to the perimeter position."""
  583. try:
  584. if ser is None or not ser.is_open:
  585. return jsonify({"success": False, "error": "Serial connection not established"}), 400
  586. MAX_RHO = 1
  587. coordinates = [(0, MAX_RHO)] # Perimeter position
  588. send_coordinate_batch(ser, coordinates)
  589. return jsonify({"success": True})
  590. except Exception as e:
  591. return jsonify({"success": False, "error": str(e)}), 500
  592. @app.route('/preview_thr', methods=['POST'])
  593. def preview_thr():
  594. file_name = request.json.get('file_name')
  595. if not file_name:
  596. return jsonify({'error': 'No file name provided'}), 400
  597. file_path = os.path.join(THETA_RHO_DIR, file_name)
  598. if not os.path.exists(file_path):
  599. return jsonify({'error': 'File not found'}), 404
  600. try:
  601. # Parse the .thr file with transformations
  602. coordinates = parse_theta_rho_file(file_path)
  603. return jsonify({'success': True, 'coordinates': coordinates})
  604. except Exception as e:
  605. return jsonify({'error': str(e)}), 500
  606. @app.route('/send_coordinate', methods=['POST'])
  607. def send_coordinate():
  608. """Send a single (theta, rho) coordinate to the Arduino."""
  609. global ser
  610. if ser is None or not ser.is_open:
  611. return jsonify({"success": False, "error": "Serial connection not established"}), 400
  612. try:
  613. data = request.json
  614. theta = data.get('theta')
  615. rho = data.get('rho')
  616. if theta is None or rho is None:
  617. return jsonify({"success": False, "error": "Theta and Rho are required"}), 400
  618. # Send the coordinate to the Arduino
  619. send_coordinate_batch(ser, [(theta, rho)])
  620. return jsonify({"success": True})
  621. except Exception as e:
  622. return jsonify({"success": False, "error": str(e)}), 500
  623. # Expose files for download if needed
  624. @app.route('/download/<filename>', methods=['GET'])
  625. def download_file(filename):
  626. """Download a file from the theta-rho directory."""
  627. return send_from_directory(THETA_RHO_DIR, filename)
  628. @app.route('/serial_status', methods=['GET'])
  629. def serial_status():
  630. global ser, ser_port
  631. return jsonify({
  632. 'connected': ser.is_open if ser else False,
  633. 'port': ser_port # Include the port name
  634. })
  635. @app.route('/pause_execution', methods=['POST'])
  636. def pause_execution():
  637. """Pause the current execution."""
  638. global pause_requested
  639. with pause_condition:
  640. pause_requested = True
  641. return jsonify({'success': True, 'message': 'Execution paused'})
  642. @app.route('/status', methods=['GET'])
  643. def get_status():
  644. """Returns the current status of the sand table."""
  645. global is_clearing
  646. if current_playing_file in CLEAR_PATTERNS.values():
  647. is_clearing = True
  648. else:
  649. is_clearing = False
  650. return jsonify({
  651. "ser_port": ser_port,
  652. "stop_requested": stop_requested,
  653. "pause_requested": pause_requested,
  654. "current_playing_file": current_playing_file,
  655. "execution_progress": execution_progress,
  656. "current_playing_index": current_playing_index,
  657. "current_playlist": current_playlist,
  658. "is_clearing": is_clearing
  659. })
  660. @app.route('/resume_execution', methods=['POST'])
  661. def resume_execution():
  662. """Resume execution after pausing."""
  663. global pause_requested
  664. with pause_condition:
  665. pause_requested = False
  666. pause_condition.notify_all() # Unblock the waiting thread
  667. return jsonify({'success': True, 'message': 'Execution resumed'})
  668. def load_playlists():
  669. """
  670. Load the entire playlists dictionary from the JSON file.
  671. Returns something like: {
  672. "My Playlist": ["file1.thr", "file2.thr"],
  673. "Another": ["x.thr"]
  674. }
  675. """
  676. with open(PLAYLISTS_FILE, "r") as f:
  677. return json.load(f)
  678. def save_playlists(playlists_dict):
  679. """
  680. Save the entire playlists dictionary back to the JSON file.
  681. """
  682. with open(PLAYLISTS_FILE, "w") as f:
  683. json.dump(playlists_dict, f, indent=2)
  684. @app.route("/list_all_playlists", methods=["GET"])
  685. def list_all_playlists():
  686. """
  687. Returns a list of all playlist names.
  688. Example return: ["My Playlist", "Another Playlist"]
  689. """
  690. playlists_dict = load_playlists()
  691. playlist_names = list(playlists_dict.keys())
  692. return jsonify(playlist_names)
  693. @app.route("/get_playlist", methods=["GET"])
  694. def get_playlist():
  695. """
  696. GET /get_playlist?name=My%20Playlist
  697. Returns: { "name": "My Playlist", "files": [... ] }
  698. """
  699. playlist_name = request.args.get("name", "")
  700. if not playlist_name:
  701. return jsonify({"error": "Missing playlist 'name' parameter"}), 400
  702. playlists_dict = load_playlists()
  703. if playlist_name not in playlists_dict:
  704. return jsonify({"error": f"Playlist '{playlist_name}' not found"}), 404
  705. files = playlists_dict[playlist_name] # e.g. ["file1.thr", "file2.thr"]
  706. return jsonify({
  707. "name": playlist_name,
  708. "files": files
  709. })
  710. @app.route("/create_playlist", methods=["POST"])
  711. def create_playlist():
  712. """
  713. POST /create_playlist
  714. Body: { "name": "My Playlist", "files": ["file1.thr", "file2.thr"] }
  715. Creates or overwrites a playlist with the given name.
  716. """
  717. data = request.get_json()
  718. if not data or "name" not in data or "files" not in data:
  719. return jsonify({"success": False, "error": "Playlist 'name' and 'files' are required"}), 400
  720. playlist_name = data["name"]
  721. files = data["files"]
  722. # Load all playlists
  723. playlists_dict = load_playlists()
  724. # Overwrite or create new
  725. playlists_dict[playlist_name] = files
  726. # Save changes
  727. save_playlists(playlists_dict)
  728. return jsonify({
  729. "success": True,
  730. "message": f"Playlist '{playlist_name}' created/updated"
  731. })
  732. @app.route("/modify_playlist", methods=["POST"])
  733. def modify_playlist():
  734. """
  735. POST /modify_playlist
  736. Body: { "name": "My Playlist", "files": ["file1.thr", "file2.thr"] }
  737. Updates (or creates) the existing playlist with a new file list.
  738. You can 404 if you only want to allow modifications to existing playlists.
  739. """
  740. data = request.get_json()
  741. if not data or "name" not in data or "files" not in data:
  742. return jsonify({"success": False, "error": "Playlist 'name' and 'files' are required"}), 400
  743. playlist_name = data["name"]
  744. files = data["files"]
  745. # Load all playlists
  746. playlists_dict = load_playlists()
  747. # Optional: If you want to disallow creating a new playlist here:
  748. # if playlist_name not in playlists_dict:
  749. # return jsonify({"success": False, "error": f"Playlist '{playlist_name}' not found"}), 404
  750. # Overwrite or create new
  751. playlists_dict[playlist_name] = files
  752. # Save
  753. save_playlists(playlists_dict)
  754. return jsonify({"success": True, "message": f"Playlist '{playlist_name}' updated"})
  755. @app.route("/delete_playlist", methods=["DELETE"])
  756. def delete_playlist():
  757. """
  758. DELETE /delete_playlist
  759. Body: { "name": "My Playlist" }
  760. Removes the playlist from the single JSON file.
  761. """
  762. data = request.get_json()
  763. if not data or "name" not in data:
  764. return jsonify({"success": False, "error": "Missing 'name' field"}), 400
  765. playlist_name = data["name"]
  766. playlists_dict = load_playlists()
  767. if playlist_name not in playlists_dict:
  768. return jsonify({"success": False, "error": f"Playlist '{playlist_name}' not found"}), 404
  769. # Remove from dict
  770. del playlists_dict[playlist_name]
  771. save_playlists(playlists_dict)
  772. return jsonify({
  773. "success": True,
  774. "message": f"Playlist '{playlist_name}' deleted"
  775. })
  776. @app.route('/add_to_playlist', methods=['POST'])
  777. def add_to_playlist():
  778. data = request.json
  779. playlist_name = data.get('playlist_name')
  780. pattern = data.get('pattern')
  781. # Load existing playlists
  782. with open('playlists.json', 'r') as f:
  783. playlists = json.load(f)
  784. # Add pattern to the selected playlist
  785. if playlist_name in playlists:
  786. playlists[playlist_name].append(pattern)
  787. with open('playlists.json', 'w') as f:
  788. json.dump(playlists, f)
  789. return jsonify(success=True)
  790. else:
  791. return jsonify(success=False, error='Playlist not found'), 404
  792. @app.route("/run_playlist", methods=["POST"])
  793. def run_playlist():
  794. """
  795. POST /run_playlist
  796. Body (JSON):
  797. {
  798. "playlist_name": "My Playlist",
  799. "pause_time": 1.0, # Optional: seconds to pause between patterns
  800. "clear_pattern": "random", # Optional: "clear_from_in", "clear_from_out", "clear_sideway", "adaptive" or "random"
  801. "run_mode": "single", # 'single' or 'indefinite'
  802. "shuffle": True # true or false
  803. "start_time": ""
  804. "end_time": ""
  805. }
  806. """
  807. data = request.get_json()
  808. # Validate input
  809. if not data or "playlist_name" not in data:
  810. return jsonify({"success": False, "error": "Missing 'playlist_name' field"}), 400
  811. playlist_name = data["playlist_name"]
  812. pause_time = data.get("pause_time", 0)
  813. clear_pattern = data.get("clear_pattern", None)
  814. run_mode = data.get("run_mode", "single") # Default to 'single' run
  815. shuffle = data.get("shuffle", False) # Default to no shuffle
  816. start_time = data.get("start_time", None)
  817. end_time = data.get("end_time", None)
  818. # Validate pause_time
  819. if not isinstance(pause_time, (int, float)) or pause_time < 0:
  820. return jsonify({"success": False, "error": "'pause_time' must be a non-negative number"}), 400
  821. # Validate clear_pattern
  822. valid_patterns = ["clear_from_in", "clear_from_out", "clear_sideway", "random", "adaptive"]
  823. if clear_pattern not in valid_patterns:
  824. clear_pattern = None
  825. # Validate run_mode
  826. if run_mode not in ["single", "indefinite"]:
  827. return jsonify({"success": False, "error": "'run_mode' must be 'single' or 'indefinite'"}), 400
  828. # Validate shuffle
  829. if not isinstance(shuffle, bool):
  830. return jsonify({"success": False, "error": "'shuffle' must be a boolean value"}), 400
  831. schedule_hours = None
  832. if start_time and end_time:
  833. try:
  834. # Convert HH:MM to datetime.time objects
  835. start_time_obj = datetime.strptime(start_time, "%H:%M").time()
  836. end_time_obj = datetime.strptime(end_time, "%H:%M").time()
  837. # Ensure start_time is before end_time
  838. if start_time_obj >= end_time_obj:
  839. return jsonify({"success": False, "error": "'start_time' must be earlier than 'end_time'"}), 400
  840. # Create schedule tuple with full time
  841. schedule_hours = (start_time_obj, end_time_obj)
  842. except ValueError:
  843. return jsonify({"success": False, "error": "Invalid time format. Use HH:MM (e.g., '09:30')"}), 400
  844. # Load playlists
  845. playlists = load_playlists()
  846. if playlist_name not in playlists:
  847. return jsonify({"success": False, "error": f"Playlist '{playlist_name}' not found"}), 404
  848. file_paths = playlists[playlist_name]
  849. file_paths = [os.path.join(THETA_RHO_DIR, file) for file in file_paths]
  850. if not file_paths:
  851. return jsonify({"success": False, "error": f"Playlist '{playlist_name}' is empty"}), 400
  852. # Start the playlist execution in a separate thread
  853. try:
  854. threading.Thread(
  855. target=run_theta_rho_files,
  856. args=(file_paths,),
  857. kwargs={
  858. 'pause_time': pause_time,
  859. 'clear_pattern': clear_pattern,
  860. 'run_mode': run_mode,
  861. 'shuffle': shuffle,
  862. 'schedule_hours': schedule_hours
  863. },
  864. daemon=True # Daemonize thread to exit with the main program
  865. ).start()
  866. return jsonify({"success": True, "message": f"Playlist '{playlist_name}' is now running."})
  867. except Exception as e:
  868. return jsonify({"success": False, "error": str(e)}), 500
  869. @app.route('/set_speed', methods=['POST'])
  870. def set_speed():
  871. """Set the speed for the Arduino."""
  872. global ser
  873. if ser is None or not ser.is_open:
  874. return jsonify({"success": False, "error": "Serial connection not established"}), 400
  875. try:
  876. # Parse the speed value from the request
  877. data = request.json
  878. speed = data.get('speed')
  879. if speed is None:
  880. return jsonify({"success": False, "error": "Speed is required"}), 400
  881. if not isinstance(speed, (int, float)) or speed <= 0:
  882. return jsonify({"success": False, "error": "Invalid speed value"}), 400
  883. # Send the SET_SPEED command to the Arduino
  884. command = f"SET_SPEED {speed}"
  885. send_command(command)
  886. return jsonify({"success": True, "speed": speed})
  887. except Exception as e:
  888. return jsonify({"success": False, "error": str(e)}), 500
  889. @app.route('/get_firmware_info', methods=['GET', 'POST'])
  890. def get_firmware_info():
  891. """
  892. Compare the installed firmware version and motor type with the one in the .ino file.
  893. """
  894. global firmware_version, arduino_driver_type, ser
  895. if ser is None or not ser.is_open:
  896. return jsonify({"success": False, "error": "Arduino not connected or serial port not open"}), 400
  897. try:
  898. if request.method == "GET":
  899. # Attempt to retrieve installed firmware details from the Arduino
  900. time.sleep(0.5)
  901. installed_version = firmware_version
  902. installed_type = arduino_driver_type
  903. # If Arduino provides valid details, proceed with comparison
  904. if installed_version != 'Unknown' and installed_type != 'Unknown':
  905. ino_path = MOTOR_TYPE_MAPPING.get(installed_type)
  906. firmware_details = get_ino_firmware_details(ino_path)
  907. if not firmware_details or not firmware_details.get("version") or not firmware_details.get("motorType"):
  908. return jsonify({"success": False, "error": "Failed to retrieve .ino firmware details"}), 500
  909. update_available = (
  910. installed_version != firmware_details["version"] or
  911. installed_type != firmware_details["motorType"]
  912. )
  913. return jsonify({
  914. "success": True,
  915. "installedVersion": installed_version,
  916. "installedType": installed_type,
  917. "inoVersion": firmware_details["version"],
  918. "inoType": firmware_details["motorType"],
  919. "updateAvailable": update_available
  920. })
  921. # If Arduino details are unknown, indicate the need for POST
  922. return jsonify({
  923. "success": True,
  924. "installedVersion": installed_version,
  925. "installedType": installed_type,
  926. "updateAvailable": False
  927. })
  928. elif request.method == "POST":
  929. motor_type = request.json.get("motorType", None)
  930. if not motor_type or motor_type not in MOTOR_TYPE_MAPPING:
  931. return jsonify({
  932. "success": False,
  933. "error": "Invalid or missing motor type"
  934. }), 400
  935. # Fetch firmware details for the given motor type
  936. ino_path = MOTOR_TYPE_MAPPING[motor_type]
  937. firmware_details = get_ino_firmware_details(ino_path)
  938. if not firmware_details:
  939. return jsonify({
  940. "success": False,
  941. "error": "Failed to retrieve .ino firmware details"
  942. }), 500
  943. return jsonify({
  944. "success": True,
  945. "installedVersion": 'Unknown',
  946. "installedType": motor_type,
  947. "inoVersion": firmware_details["version"],
  948. "inoType": firmware_details["motorType"],
  949. "updateAvailable": True
  950. })
  951. except Exception as e:
  952. return jsonify({"success": False, "error": str(e)}), 500
  953. @app.route('/flash_firmware', methods=['POST'])
  954. def flash_firmware():
  955. """
  956. Flash the pre-compiled firmware to the connected device (Arduino or ESP32).
  957. """
  958. global ser_port
  959. # Ensure the device is connected
  960. if ser_port is None or ser is None or not ser.is_open:
  961. return jsonify({"success": False, "error": "No device connected or connection lost"}), 400
  962. try:
  963. data = request.json
  964. motor_type = data.get("motorType", None)
  965. # Validate motor type
  966. if not motor_type or motor_type not in MOTOR_TYPE_MAPPING:
  967. return jsonify({"success": False, "error": "Invalid or missing motor type"}), 400
  968. # Determine the firmware file
  969. ino_file_path = MOTOR_TYPE_MAPPING[motor_type] # Path to .ino file
  970. hex_file_path = f"{ino_file_path}.hex"
  971. bin_file_path = f"{ino_file_path}.bin" # For ESP32 firmware
  972. # Check the device type
  973. if motor_type.lower() == "esp32":
  974. if not os.path.exists(bin_file_path):
  975. return jsonify({"success": False, "error": f"Firmware binary not found: {bin_file_path}"}), 404
  976. # Flash ESP32 firmware
  977. flash_command = [
  978. "esptool.py",
  979. "--chip", "esp32",
  980. "--port", ser_port,
  981. "--baud", "115200",
  982. "write_flash", "-z", "0x1000", bin_file_path
  983. ]
  984. else:
  985. if not os.path.exists(hex_file_path):
  986. return jsonify({"success": False, "error": f"Hex file not found: {hex_file_path}"}), 404
  987. # Flash Arduino firmware
  988. flash_command = [
  989. "avrdude",
  990. "-v",
  991. "-c", "arduino",
  992. "-p", "atmega328p",
  993. "-P", ser_port,
  994. "-b", "115200",
  995. "-D",
  996. "-U", f"flash:w:{hex_file_path}:i"
  997. ]
  998. # Execute the flash command
  999. flash_process = subprocess.run(flash_command, capture_output=True, text=True)
  1000. if flash_process.returncode != 0:
  1001. return jsonify({
  1002. "success": False,
  1003. "error": flash_process.stderr
  1004. }), 500
  1005. return jsonify({"success": True, "message": "Firmware flashed successfully"})
  1006. except Exception as e:
  1007. return jsonify({"success": False, "error": str(e)}), 500
  1008. @app.route('/check_software_update', methods=['GET'])
  1009. def check_updates():
  1010. update_info = check_git_updates()
  1011. return jsonify(update_info)
  1012. @app.route('/update_software', methods=['POST'])
  1013. def update_software():
  1014. error_log = []
  1015. def run_command(command, error_message):
  1016. try:
  1017. subprocess.run(command, check=True)
  1018. except subprocess.CalledProcessError as e:
  1019. print(f"{error_message}: {e}")
  1020. error_log.append(error_message)
  1021. # Fetch the latest version tag from remote
  1022. try:
  1023. subprocess.run(["git", "fetch", "--tags"], check=True)
  1024. latest_remote_tag = subprocess.check_output(
  1025. ["git", "describe", "--tags", "--abbrev=0", "origin/main"]
  1026. ).strip().decode()
  1027. except subprocess.CalledProcessError as e:
  1028. error_log.append(f"Failed to fetch tags or get latest remote tag: {e}")
  1029. return jsonify({
  1030. "success": False,
  1031. "error": "Failed to fetch tags or determine the latest version.",
  1032. "details": error_log
  1033. }), 500
  1034. # Checkout the latest tag
  1035. run_command(["git", "checkout", latest_remote_tag, '--force'], f"Failed to checkout version {latest_remote_tag}")
  1036. # Restart Docker containers
  1037. run_command(["docker", "compose", "up", "-d"], "Failed to restart Docker containers")
  1038. # Check if the update was successful
  1039. update_status = check_git_updates()
  1040. if (
  1041. update_status["updates_available"] is False
  1042. and update_status["latest_local_tag"] == update_status["latest_remote_tag"]
  1043. ):
  1044. # Update was successful
  1045. return jsonify({"success": True})
  1046. else:
  1047. # Update failed; include the errors in the response
  1048. return jsonify({
  1049. "success": False,
  1050. "error": "Update incomplete",
  1051. "details": error_log
  1052. }), 500
  1053. def on_exit():
  1054. """Function to execute on application shutdown."""
  1055. print("Shutting down the application...")
  1056. stop_actions()
  1057. time.sleep(5)
  1058. print("Execution stopped and resources cleaned up.")
  1059. # Register the on_exit function
  1060. atexit.register(on_exit)
  1061. if __name__ == '__main__':
  1062. # Auto-connect to serial
  1063. connect_to_serial()
  1064. try:
  1065. app.run(debug=False, host='0.0.0.0', port=8080)
  1066. except KeyboardInterrupt:
  1067. print("Keyboard interrupt received. Shutting down.")
  1068. finally:
  1069. on_exit() # Ensure cleanup if app is interrupted