app.py 43 KB

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