#!/usr/bin/env python3 """ Low level serial protocol for the sand table controller. Speaks the framed protocol used by the firmware: struct command_message_t { uint8_t prefix; // COMMAND_PREFIX (0x69) uint8_t length; // number of valid bytes in `data` uint8_t id; // COMMAND_ID (0x00) uint8_t command; // commands_e uint8_t crc; // 0x100 - (sum of every other byte in the message) uint8_t data[160]; } Every command the firmware accepts correctly is acknowledged with a COMMAND_ACK message whose single data byte echoes the command that was processed. For COMMAND_HOME and COMMAND_POLAR that ACK is only sent once the steppers have physically finished moving, so waiting for it doubles as "block until the move is done". Position is now tracked entirely by the firmware (COMMAND_GET_POLAR), so this module no longer keeps or persists any position of its own - callers that need the current position call getPolar(). """ import glob import struct import time import serial # ----------------------------------------------------------------------------- # Configuration # ----------------------------------------------------------------------------- BAUDRATE = 115200 COMMAND_PREFIX = 0x69 COMMAND_ID = 0x00 COMMAND_DATA_SIZE = 160 # commands_e - order must match the firmware enum exactly 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_SET_OFFSET = 9 COMMAND_RESET_OFFSET = 10 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", COMMAND_SET_OFFSET: "SET_OFFSET", COMMAND_RESET_OFFSET: "RESET_OFFSET", } MIN_TRACK_SPEED = 1 MAX_TRACK_SPEED = 1000 MAX_BRIGHTNESS = 255 POLAR_STRUCT = struct.Struct(" 1: print(f"Multiple ttyACM ports found {candidates}, using {candidates[0]}") return candidates[0] def startSerial(port=None): """Open the serial connection. If `port` isn't given, auto-detects a /dev/ttyACM* device.""" global ser, PORT port = port or _find_port() if port is None: ser = None print("Failed to start serial connection: no /dev/ttyACM* device found") return try: ser = serial.Serial(port, BAUDRATE, timeout=1) PORT = port # Give the board a moment in case it resets on port open (common on # AVR / USB-CDC boards) before we start talking to it. time.sleep(2) ser.reset_input_buffer() except Exception as e: ser = None print(f"Failed to start serial connection on {port}: {e}") def resetBuffer(): if ser is not None: ser.reset_input_buffer() def _ensure_serial(): if ser is None: startSerial() return ser is not None # ----------------------------------------------------------------------------- # Packet building / CRC (matches firmware's command_calculate_crc()) # ----------------------------------------------------------------------------- def _build_packet(command, data=b""): 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]) 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 # ----------------------------------------------------------------------------- # Reading responses # ----------------------------------------------------------------------------- def _read_message(timeout=2.0): """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 / no connection.""" if not _ensure_serial(): return None deadline = time.monotonic() + timeout old_timeout = ser.timeout try: while time.monotonic() < deadline: ser.timeout = max(0.01, deadline - time.monotonic()) b = ser.read(1) if not b: continue if b[0] != COMMAND_PREFIX: continue # resync: keep looking for the prefix byte header = ser.read(4) # length, id, command, crc if len(header) < 4: continue length, msg_id, command, crc = header data = b"" if length: data = ser.read(length) if len(data) < length: continue # malformed/short read, keep resyncing return { "prefix": b[0], "length": length, "id": msg_id, "command": command, "crc": crc, "data": data, } finally: ser.timeout = old_timeout return None def _wait_for_ack(expected_command, timeout=10.0): """Wait for a COMMAND_ACK whose data[0] echoes expected_command. Returns the ACK payload (bytes) on success, or None on timeout/NACK.""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: remaining = deadline - time.monotonic() msg = _read_message(timeout=remaining) if msg is None: return None if msg["command"] == COMMAND_NACK: name = COMMAND_NAMES.get(expected_command, expected_command) print(f" -> device NACKed command {name}") return None if msg["command"] == COMMAND_ACK: acked = msg["data"][0] if msg["data"] else None if acked == expected_command: return msg["data"] continue # ACK for something else - keep waiting return None # ----------------------------------------------------------------------------- # High level command helpers # ----------------------------------------------------------------------------- def sendCommand(command, data=b"", wait_ack=True, ack_timeout=10.0): """Build, send, and (optionally) block for the completion ACK of a command. Returns the ACK payload (bytes, possibly empty) on success, True if wait_ack=False and the write succeeded, or None/False on failure.""" if not _ensure_serial(): return None if wait_ack else False packet = _build_packet(command, data) try: ser.write(packet) ser.flush() except Exception as e: print(f"Failed to write to serial: {e}") return None if wait_ack else False if not wait_ack: return True ack_data = _wait_for_ack(command, timeout=ack_timeout) if ack_data is None: name = COMMAND_NAMES.get(command, command) print(f"Timed out waiting for ACK on {name}") return ack_data def sendPolar(theta, r, wait_ack=True, ack_timeout=15.0): """Move to (theta, r). Blocks until the steppers report the move is complete (unless wait_ack=False). Returns True on success, False/None on failure/timeout.""" data = POLAR_STRUCT.pack(theta, r) result = sendCommand(COMMAND_POLAR, data, wait_ack=wait_ack, ack_timeout=ack_timeout) if not wait_ack: return result return result is not None def sendPolarWithRetry(theta, r, retries=3, ack_timeout=15.0): """Same as sendPolar() but retries a few times on timeout/NACK before giving up (returns False).""" for attempt in range(retries): if sendPolar(theta, r, wait_ack=True, ack_timeout=ack_timeout): return True print(f" retrying polar move to ({theta}, {r}) [{attempt + 1}/{retries}]") resetBuffer() time.sleep(0.05) return False def getPolar(timeout=5.0): """Ask the device for its current (theta, r). ASSUMPTION: the reply is a command_message_t with command == COMMAND_GET_POLAR and an 8-byte polar_t payload (same layout as what's sent to COMMAND_POLAR). I don't have the firmware's COMMAND_GET_POLAR case handler, so if the real reply looks different (e.g. comes back wrapped as an ACK instead) let me know and I'll adjust this. Returns (theta, r) or None on failure/timeout. """ if not _ensure_serial(): return None packet = _build_packet(COMMAND_GET_POLAR) try: ser.write(packet) ser.flush() except Exception as e: print(f"Failed to write to serial: {e}") return None deadline = time.monotonic() + timeout while time.monotonic() < deadline: msg = _read_message(timeout=deadline - time.monotonic()) if msg is None: break if msg["command"] == COMMAND_NACK: print(" -> device NACKed GET_POLAR") return None if msg["command"] == COMMAND_GET_POLAR and len(msg["data"]) >= 8: return POLAR_STRUCT.unpack(msg["data"][:8]) # anything else (e.g. a stray ACK for a previous command) - ignore and keep waiting print("Timed out waiting for GET_POLAR reply") return None def home(wait_ack=True, ack_timeout=120.0): result = sendCommand(COMMAND_HOME, b"", wait_ack=wait_ack, ack_timeout=ack_timeout) if not wait_ack: return result return result is not None def disableMotors(wait_ack=True, ack_timeout=5.0): result = sendCommand(COMMAND_DISABLE_MOTORS, b"", wait_ack=wait_ack, ack_timeout=ack_timeout) if not wait_ack: return result return result is not None def setMotorSpeed(speed, wait_ack=True, ack_timeout=5.0): """ASSUMPTION: motor speed is encoded as a uint16 little-endian value, matching the LED track speed convention. I don't have the firmware's COMMAND_MOTOR_SPEED handler, so confirm this - if it actually expects a float or a different width, this needs to change.""" speed = max(0, min(int(speed), 0xFFFF)) data = struct.pack(" COMMAND_DATA_SIZE: raise ValueError("too many colors for a single COMMAND_LED payload") payload = bytearray() speed = max(0, min(int(speed), 0xFFFF)) payload.append(speed & 0xFF) payload.append((speed >> 8) & 0xFF) payload.append(len(colors)) for color in colors: payload += struct.pack("BBBB", *(max(0, min(int(c), 255)) for c in color)) result = sendCommand(COMMAND_LED, bytes(payload), wait_ack=wait_ack, ack_timeout=ack_timeout) if not wait_ack: return result return result is not None startSerial()