#!/usr/bin/env python3 """ send_thetarho.py Reads a "thetarho" coordinate file (the format sandify exports, e.g.: # # File name: 'sandify' # File type: thetarho # # BEGIN LAYER: 0 Polygon 0.00000 0.00000 1.57080 0.00800 ... ) and streams each (theta, rho) point to the sand-table controller over serial as a COMMAND_POLAR message, matching the firmware's `struct command_message_t` / `struct polar_t` protocol. Usage examples: # Just stream the file, waiting for the completion ACK after every point ./send_thetarho.py pattern.thr # Home first, then stream, don't wait for ACKs (fire-and-forget) ./send_thetarho.py pattern.thr --home --no-wait-ack # Different port / baud ./send_thetarho.py pattern.thr -p /dev/ttyACM0 -b 115200 """ import argparse import struct import sys import time import serial # ----------------------------------------------------------------------------- # Protocol constants (must match firmware) # ----------------------------------------------------------------------------- COMMAND_PREFIX = 0x69 COMMAND_ID = 0x00 COMMAND_DATA_SIZE = 160 # sizeof(msg->data) on the firmware side # commands_e COMMAND_ACK = 0 COMMAND_NACK = 1 COMMAND_LED = 2 COMMAND_HOME = 3 COMMAND_DISABLE_MOTORS = 4 COMMAND_MOTOR_STEP = 5 COMMAND_MOTOR_SPEED = 6 COMMAND_POLAR = 7 COMMAND_GET_POLAR = 8 COMMAND_NAMES = { COMMAND_ACK: "ACK", COMMAND_NACK: "NACK", COMMAND_LED: "LED", COMMAND_HOME: "HOME", COMMAND_DISABLE_MOTORS: "DISABLE_MOTORS", COMMAND_MOTOR_STEP: "MOTOR_STEP", COMMAND_MOTOR_SPEED: "MOTOR_SPEED", COMMAND_POLAR: "POLAR", COMMAND_GET_POLAR: "GET_POLAR", } HEADER_LEN = 5 # prefix, length, id, command, crc # struct polar_t { float theta; float r; }; -> two 32-bit floats POLAR_STRUCT = struct.Struct(" bytearray: """Build a command_message_t packet. `data` is only the bytes actually used (the command does NOT need to fill the full 160-byte buffer).""" if len(data) > COMMAND_DATA_SIZE: raise ValueError( f"data length {len(data)} exceeds COMMAND_DATA_SIZE ({COMMAND_DATA_SIZE})" ) length = len(data) packet = bytearray( [ COMMAND_PREFIX, length, COMMAND_ID, command, 0x00, # crc placeholder ] ) packet += data crc_sum = 0 for i, byte in enumerate(packet): if i == 4: # skip the crc field itself continue crc_sum += byte packet[4] = (0x100 - (crc_sum & 0xFF)) & 0xFF return packet def polar_packet(theta: float, r: float) -> bytearray: return build_packet(COMMAND_POLAR, POLAR_STRUCT.pack(theta, r)) # ----------------------------------------------------------------------------- # Reading responses back from the device # ----------------------------------------------------------------------------- def read_message(ser: serial.Serial, timeout: float = 2.0, debug: bool = False): """Read one command_message_t from the serial port, resyncing on the 0x69 prefix byte. Returns a dict {prefix, length, id, command, crc, data} or None on timeout.""" deadline = time.monotonic() + timeout old_timeout = ser.timeout discarded = bytearray() raw_rx = bytearray() # every byte read during this call, success or not def flush_discarded(): if debug and discarded: print(f" [debug] discarded while resyncing: {bytes(discarded).hex(' ')}") discarded.clear() try: while time.monotonic() < deadline: ser.timeout = max(0.01, deadline - time.monotonic()) b = ser.read(1) if not b: continue raw_rx += b if debug: print(f" [debug] RX byte: {b.hex()}") if b[0] != COMMAND_PREFIX: discarded += b # resync: keep looking for the prefix byte continue header = ser.read(4) # length, id, command, crc raw_rx += header if debug and header: print(f" [debug] RX header: {header.hex(' ')}") if len(header) < 4: discarded += b + header continue length, msg_id, command, crc = header data = b"" if length: data = ser.read(length) raw_rx += data if debug: print(f" [debug] RX data ({len(data)}/{length}): {data.hex(' ')}") if len(data) < length: discarded += b + header + data continue # malformed/short read, keep resyncing flush_discarded() msg = { "prefix": b[0], "length": length, "id": msg_id, "command": command, "crc": crc, "data": data, } if debug: full = bytearray([b[0], length, msg_id, command, crc]) + bytearray(data) name = COMMAND_NAMES.get(command, hex(command)) print(f" [debug] recv {name} <- {bytes(full).hex(' ')}") return msg finally: flush_discarded() if debug and not raw_rx: print(" [debug] RX: nothing received before timeout") elif debug: print(f" [debug] RX total this call: {bytes(raw_rx).hex(' ')}") ser.timeout = old_timeout return None def wait_for_ack(ser: serial.Serial, expected_command: int, timeout: float = 10.0, debug: bool = False) -> bool: """Wait for an ACK/NACK message whose data[0] echoes expected_command (this is how the firmware signals a HOME/POLAR move has completed).""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: remaining = deadline - time.monotonic() msg = read_message(ser, timeout=remaining, debug=debug) if msg is None: return False if msg["command"] == COMMAND_NACK: print(" -> received NACK from device") return False if msg["command"] == COMMAND_ACK: acked = msg["data"][0] if msg["data"] else None if acked == expected_command: return True # ACK for something else (e.g. a stray immediate ACK) - keep waiting continue return False # ----------------------------------------------------------------------------- # Thetarho file parsing # ----------------------------------------------------------------------------- def parse_thetarho(path: str): """Yield (theta, r) tuples from a sandify-style thetarho file, skipping comments (#...) and blank lines.""" points = [] with open(path, "r") as f: for lineno, raw_line in enumerate(f, start=1): line = raw_line.strip() if not line or line.startswith("#"): continue parts = line.split() if len(parts) != 2: print(f"warning: skipping malformed line {lineno}: {raw_line!r}", file=sys.stderr) continue try: theta = float(parts[0]) r = float(parts[1]) except ValueError: print(f"warning: skipping non-numeric line {lineno}: {raw_line!r}", file=sys.stderr) continue points.append((theta, r)) return points # ----------------------------------------------------------------------------- # Main # ----------------------------------------------------------------------------- def main(): parser = argparse.ArgumentParser(description="Stream a thetarho file to the sand table over serial.") parser.add_argument("file", help="Path to the thetarho (.thr) file") parser.add_argument("-p", "--port", default="/dev/ttyACM1", help="Serial port (default: %(default)s)") parser.add_argument("-b", "--baud", type=int, default=115200, help="Baud rate (default: %(default)s)") parser.add_argument("--home", action="store_true", help="Send a HOME command before streaming points") parser.add_argument( "--no-wait-ack", dest="wait_ack", action="store_false", default=True, help="Don't wait for the completion ACK between points (fire-and-forget)", ) parser.add_argument( "--ack-timeout", type=float, default=15.0, help="Seconds to wait for a completion ACK before giving up on a point (default: %(default)s)", ) parser.add_argument( "--delay", type=float, default=0.0, help="Extra delay in seconds between points when not waiting for ACKs", ) parser.add_argument("-v", "--verbose", action="store_true", help="Print each packet sent") parser.add_argument( "--debug", action="store_true", help="Print every sent packet's bytearray, every parsed response, and any " "stray bytes discarded while resyncing on the serial line. Implies --verbose.", ) args = parser.parse_args() if args.debug: args.verbose = True points = parse_thetarho(args.file) if not points: print(f"No valid points found in {args.file}", file=sys.stderr) sys.exit(1) print(f"Loaded {len(points)} points from {args.file}") with serial.Serial(args.port, args.baud, timeout=1) as ser: # Give the device a moment in case it resets on port open (common on # AVR/USB-CDC boards). time.sleep(2) ser.reset_input_buffer() if args.home: print("Homing...") packet = build_packet(COMMAND_HOME) if args.verbose: print(" ->", bytes(packet).hex(" ")) ser.write(packet) ser.flush() if args.wait_ack: if not wait_for_ack(ser, COMMAND_HOME, timeout=args.ack_timeout, debug=args.debug): print("Timed out / failed waiting for HOME to complete", file=sys.stderr) sys.exit(1) print("Homed.") for i, (theta, r) in enumerate(points): packet = polar_packet(theta, r) if args.verbose: print(f"[{i + 1}/{len(points)}] theta={theta:.5f} r={r:.5f} -> {bytes(packet).hex(' ')}") else: print(f"\r[{i + 1}/{len(points)}] theta={theta:.5f} r={r:.5f}", end="", flush=True) ser.write(packet) ser.flush() if args.wait_ack: if not wait_for_ack(ser, COMMAND_POLAR, timeout=args.ack_timeout, debug=args.debug): print(f"\nTimed out / failed waiting for point {i + 1} to complete", file=sys.stderr) sys.exit(1) elif args.delay: time.sleep(args.delay) if not args.verbose: print() print("Done.") if __name__ == "__main__": main()