app.py 45 KB

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