shibosoftwaredev/f1c100s-linux-nema17-stepper-controller
A four-layer NEMA 17 motor-controller PCB combining an Allwinner F1C100S Linux computer, USB‑PD/buck power system, W5500 Ethernet, STM32 real-time motion control, DRV8825 stepper driver, protection/temperature/current telemetry, USB interfaces, and local LED/buzzer diagnostics.
- Version
- 0.4.56
- License
- unset
- Stars
- 0
firmware/motor-control.py
#!/usr/bin/env python3
"""Reference Linux client for the STM32G030 NEMA17 stepper controller.
The STM32, not Linux, generates STEP pulses and enforces the watchdog. Install
pyserial, program matching safety-reviewed MCU firmware, and keep the motor
mechanically unloaded during first bring-up.
"""
import argparse
import json
import struct
import threading
import time
SYNC = b"\xA5\x5A"
VERSION = 1
CMD_HEARTBEAT = 0x01
CMD_ARM = 0x02
CMD_MOVE = 0x10
CMD_STOP = 0x11
CMD_DISABLE = 0x12
CMD_STATUS = 0x20
CMD_CURRENT = 0x21
CMD_SET_LIMIT = 0x22
CMD_LIMIT = 0x23
STATUS_TEXT = {
0: "ok", 1: "bad command", 2: "bad argument", 3: "not armed",
4: "busy", 5: "driver fault", 6: "over-temperature",
}
def crc16_ccitt(data: bytes) -> int:
crc = 0xFFFF
for byte in data:
crc ^= byte << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
return crc
def decode_current(payload: bytes) -> dict:
"""Decode the 17 bytes after command status; invalid/clipped values are None."""
if len(payload) != 17:
raise ValueError("unexpected coil-current payload length")
flags, a_ma, b_ma, raw_a, raw_b, vdda_mv, age_ms = struct.unpack("<BiiHHHH", payload)
if flags & ~0x0F:
raise ValueError("unknown coil-current flags")
if (flags & 1 and flags & 4) or (flags & 2 and flags & 8):
raise ValueError("current cannot be both valid and clipped")
return {"phase_a_mA": a_ma if flags & 1 else None,
"phase_b_mA": b_ma if flags & 2 else None,
"phase_a_clipped": bool(flags & 4), "phase_b_clipped": bool(flags & 8),
"raw_a": raw_a, "raw_b": raw_b, "vdda_mV": vdda_mv, "sample_age_ms": age_ms}
def decode_limit(payload: bytes) -> dict:
if len(payload) != 9:
raise ValueError("unexpected current-limit payload length")
flags, requested, code, nominal, vdda = struct.unpack("<BHHHH", payload)
if (flags & ~3 or (flags & 1 and not flags & 2) or requested > 396
or (code > 4095 and not (code == 0xFFFF and not flags & 1)) or nominal > 433):
raise ValueError("invalid current-limit response")
return {"ready": bool(flags & 1), "dac_ok": bool(flags & 2),
"requested_mA": requested, "dac_code": None if code == 0xFFFF else code,
"nominal_applied_mA": nominal, "dac_vdda_mV": vdda}
def wait_limit(link, requested=None):
deadline = time.monotonic() + 0.3
while time.monotonic() < deadline:
value = decode_limit(link.request(CMD_LIMIT))
if not value["dac_ok"]:
raise RuntimeError("current-limit DAC is not healthy; motor remains disabled")
if value["ready"] and (requested is None or value["requested_mA"] == requested):
return value
time.sleep(0.005)
raise TimeoutError("current-limit write/readback or settling did not finish")
class MotionLink:
def __init__(self, serial_port):
self.serial = serial_port
self.sequence = 0
self.lock = threading.Lock()
def _read_exact(self, length: int) -> bytes:
data = self.serial.read(length)
if len(data) != length:
raise TimeoutError(f"motion MCU timeout ({len(data)}/{length} bytes)")
return data
def request(self, command: int, payload: bytes = b"") -> bytes:
with self.lock:
sequence = self.sequence
self.sequence = (self.sequence + 1) & 0xFF
body = struct.pack("<BBBH", VERSION, sequence, command, len(payload)) + payload
self.serial.write(SYNC + body + struct.pack("<H", crc16_ccitt(body)))
self.serial.flush()
# Re-synchronise without accepting unbounded input.
window = bytearray()
for _ in range(64):
window += self._read_exact(1)
if window[-2:] == SYNC:
break
else:
raise RuntimeError("motion MCU response has no sync word")
header = self._read_exact(5)
version, response_sequence, response_command, length = struct.unpack("<BBBH", header)
if length > 32:
raise RuntimeError("motion MCU response exceeds the protocol size limit")
response_payload = self._read_exact(length)
received_crc, = struct.unpack("<H", self._read_exact(2))
if version != VERSION or response_sequence != sequence or response_command != (command | 0x80):
raise RuntimeError("motion MCU response header mismatch")
if received_crc != crc16_ccitt(header + response_payload):
raise RuntimeError("motion MCU response CRC mismatch")
if not response_payload:
raise RuntimeError("motion MCU response has no status byte")
status = response_payload[0]
if status:
raise RuntimeError(f"motion MCU rejected command: {STATUS_TEXT.get(status, status)}")
return response_payload[1:]
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", default="/dev/ttyS0", help="F1C100S UART0 device")
parser.add_argument("--steps", type=int, default=3200,
help="signed microsteps; 3200 is one revolution at fixed 1/16 mode")
parser.add_argument("--hz", type=int, default=200, help="microsteps per second")
parser.add_argument("--timeout", type=float, default=0.2, help="UART response timeout")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--current", action="store_true",
help="read signed winding telemetry without arming, disabling, or moving the motor")
parser.add_argument("--limit", action="store_true", help="read current-limit state without motion")
parser.add_argument("--set-current-ma", type=int,
help="set 0–396 nominal peak mA while disarmed; does NOT arm or move")
parser.add_argument("--current-ma", type=int, default=396,
help="nominal peak mA for the requested move (default 396)")
args = parser.parse_args()
if sum([args.current, args.limit, args.set_current_ma is not None]) > 1:
parser.error("choose only one of --current, --limit or --set-current-ma")
for value in (args.current_ma, args.set_current_ma):
if value is not None and not 0 <= value <= 396:
parser.error("current limit must be between 0 and 396 nominal peak mA")
if not -2_147_483_648 <= args.steps <= 2_147_483_647:
parser.error("--steps is outside signed 32-bit range")
if not 1 <= args.hz <= 20_000:
parser.error("--hz must be between 1 and 20000")
duration = abs(args.steps) / args.hz
if args.dry_run:
if args.current:
print(f"query winding currents via {args.port}; no motion command")
return
if args.limit or args.set_current_ma is not None:
print(f"current-limit {'read' if args.limit else args.set_current_ma} via {args.port}; no motion command")
return
print(f"{args.steps} microsteps at {args.hz} microsteps/s ({duration:.3f} s), via {args.port}")
return
try:
import serial
except ImportError as error:
raise SystemExit("pyserial is required: python3 -m pip install pyserial") from error
stop_heartbeat = threading.Event()
heartbeat_error = []
with serial.Serial(args.port, 115200, timeout=args.timeout, write_timeout=args.timeout) as port:
link = MotionLink(port)
if args.current:
print(json.dumps(decode_current(link.request(CMD_CURRENT)), indent=2))
return
if args.limit:
print(json.dumps(decode_limit(link.request(CMD_LIMIT)), indent=2))
return
if args.set_current_ma is not None:
link.request(CMD_SET_LIMIT, struct.pack("<H", args.set_current_ma))
print(json.dumps(wait_limit(link, args.set_current_ma), indent=2))
return
def heartbeat():
while not stop_heartbeat.wait(0.08):
try:
link.request(CMD_HEARTBEAT)
except Exception as error: # surfaced by the main loop
heartbeat_error.append(error)
stop_heartbeat.set()
thread = threading.Thread(target=heartbeat, name="motion-heartbeat", daemon=True)
try:
link.request(CMD_DISABLE)
link.request(CMD_STATUS)
link.request(CMD_ARM, b"\x00")
link.request(CMD_SET_LIMIT, struct.pack("<H", args.current_ma))
wait_limit(link, args.current_ma)
link.request(CMD_HEARTBEAT)
link.request(CMD_ARM, b"\x01")
time.sleep(0.005) # DRV8825 wake delay; MCU also enforces >= 3 ticks.
thread.start()
link.request(CMD_MOVE, struct.pack("<iI", args.steps, args.hz))
deadline = time.monotonic() + duration + 0.5
while time.monotonic() < deadline and not stop_heartbeat.wait(0.05):
pass
if heartbeat_error:
raise heartbeat_error[0]
link.request(CMD_STATUS)
except KeyboardInterrupt:
link.request(CMD_STOP)
raise
finally:
stop_heartbeat.set()
if thread.is_alive():
thread.join(timeout=0.2)
try:
link.request(CMD_DISABLE)
except Exception:
pass
if __name__ == "__main__":
main()