401 lines
13 KiB
Python
401 lines
13 KiB
Python
#!/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("<ff") # struct polar_t { float theta; float r; }
|
|
|
|
ser = None
|
|
PORT = None # set once startSerial() successfully opens a port
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Connection
|
|
# -----------------------------------------------------------------------------
|
|
|
|
def _find_port():
|
|
"""Look for a /dev/ttyACMx device. Returns the first match (sorted), or
|
|
None if nothing is plugged in."""
|
|
candidates = sorted(glob.glob("/dev/ttyACM*"))
|
|
if not candidates:
|
|
return None
|
|
if len(candidates) > 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 <ff> 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("<H", speed)
|
|
result = sendCommand(COMMAND_MOTOR_SPEED, data, wait_ack=wait_ack, ack_timeout=ack_timeout)
|
|
if not wait_ack:
|
|
return result
|
|
return result is not None
|
|
|
|
|
|
def setOffset(m1_delta, m2_delta, wait_ack=True, ack_timeout=5.0):
|
|
"""Add to the firmware's stored per-motor calibration offset (steps).
|
|
This is ADDITIVE (stepper_add_offset), not absolute - each call nudges
|
|
the offset further, matching the old "+1/-1/+10/-10" calibrate button
|
|
behavior. Both deltas are signed 8-bit (-128..127)."""
|
|
|
|
m1 = max(-128, min(int(m1_delta), 127))
|
|
m2 = max(-128, min(int(m2_delta), 127))
|
|
data = struct.pack("<bb", m1, m2)
|
|
result = sendCommand(COMMAND_SET_OFFSET, data, wait_ack=wait_ack, ack_timeout=ack_timeout)
|
|
if not wait_ack:
|
|
return result
|
|
return result is not None
|
|
|
|
|
|
def resetOffset(wait_ack=True, ack_timeout=5.0):
|
|
"""Zero out the firmware's stored calibration offset entirely."""
|
|
result = sendCommand(COMMAND_RESET_OFFSET, b"", wait_ack=wait_ack, ack_timeout=ack_timeout)
|
|
if not wait_ack:
|
|
return result
|
|
return result is not None
|
|
|
|
|
|
def setLedTrack(colors, speed, wait_ack=True, ack_timeout=5.0):
|
|
"""colors: list of (r, g, b, w) tuples, 0-255 each. speed: track speed
|
|
(uint16). A single solid color is just a one-element list. The device
|
|
holds no LED track state of its own, so the *full* track has to be
|
|
re-sent on every change - matches the reference LED script's
|
|
"speed + count + colors" payload layout."""
|
|
|
|
if not colors:
|
|
raise ValueError("colors must contain at least one (r, g, b, w) tuple")
|
|
# 3 header bytes (speed lo/hi + count) + 4 bytes/color must fit in 160
|
|
if 3 + len(colors) * 4 > 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() |