shibosoftwaredev/f1c100s-linux-nema8-stepper-controller

Restores component part numbers and CAD models, removes test-point 3D bodies, and validates that solder paste exists only on top-side SMT pads without altering copper or DRC.

Version
0.3.5
License
unset
Stars
0

firmware/motor-control.py

#!/usr/bin/env python3
"""Reference Linux client for the STM32G030 NEMA8 motion 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 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
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


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)
            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")
    args = parser.parse_args()
    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 <= 100_000:
        parser.error("--hz must be between 1 and 100000")

    duration = abs(args.steps) / args.hz
    if args.dry_run:
        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)

        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"\x01")
            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()