{"id":47560637,"url":"https://github.com/bikeNomad/micropython-rp2-smartStepper","last_synced_at":"2026-04-13T10:01:21.860Z","repository":{"id":342410635,"uuid":"1170285961","full_name":"bikeNomad/micropython-rp2-smartStepper","owner":"bikeNomad","description":"Micropython library for driving stepper motors from RP2040/RP2350 using DMA and PIO","archived":false,"fork":false,"pushed_at":"2026-03-26T00:29:53.000Z","size":389,"stargazers_count":4,"open_issues_count":0,"forks_count":1,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-03-26T06:55:19.826Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"agpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/bikeNomad.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-02T00:24:51.000Z","updated_at":"2026-03-26T00:29:56.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/bikeNomad/micropython-rp2-smartStepper","commit_stats":null,"previous_names":["bikenomad/micropython-rp2-smartstepper"],"tags_count":3,"template":false,"template_full_name":null,"purl":"pkg:github/bikeNomad/micropython-rp2-smartStepper","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bikeNomad%2Fmicropython-rp2-smartStepper","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bikeNomad%2Fmicropython-rp2-smartStepper/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bikeNomad%2Fmicropython-rp2-smartStepper/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bikeNomad%2Fmicropython-rp2-smartStepper/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/bikeNomad","download_url":"https://codeload.github.com/bikeNomad/micropython-rp2-smartStepper/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bikeNomad%2Fmicropython-rp2-smartStepper/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31747172,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-13T09:16:15.125Z","status":"ssl_error","status_checked_at":"2026-04-13T09:16:05.023Z","response_time":93,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.6:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-03-29T17:00:22.952Z","updated_at":"2026-04-13T10:01:21.854Z","avatar_url":"https://github.com/bikeNomad.png","language":"Python","funding_links":[],"categories":["Libraries"],"sub_categories":["Motion"],"readme":"# smartStepper — MicroPython stepper motor library for RP2040\n\nA MicroPython library for smooth, non-blocking stepper motor control on the\nRaspberry Pi Pico (RP2040) and Pico2 (RP2350). Uses PIO state machines and DMA for precise,\nCPU-independent pulse generation and position counting.\n\n## Features\n\n- Non-blocking absolute and relative moves (`moveTo`)\n- Non-blocking continuous jog (`jog`) with configurable speed\n- Async homing with configurable sensor polarity; handles pre-asserted sensor (`homing.py`)\n- Smooth acceleration and deceleration (4 curves: linear, smooth1, smooth2/smootherstep, sine — see [Acceleration curves](#acceleration-curves))\n- Graceful stop with deceleration, or emergency hard stop\n- Dynamic parameter adjustment (speed, acceleration) mid-move with automatic motion replan\n  - **NOTE**: re-plan currently suffers from a ~50ms gap; I'm still thinking about how to do this better.\n- Position tracking via PIO pulse counter\n- Active-low enable pin support (auto-enabled on move start)\n- Move timeout with automatic emergency stop\n- Up to 4 simultaneous stepper instances (limited by RP2040 PIO state machines)\n- Multi-axis synchronized motion (`Axis` + `MultiAxis`): hardware-simultaneous DMA start via a single register write\n\n## Installation\n\n### On the Pico (via mip)\n\nFrom the MicroPython REPL:\n\n```python\nimport mip\nmip.install(\"github:bikeNomad/micropython-rp2-smartStepper\")\n```\n\nOr from the host via mpremote:\n\n```sh\nmpremote mip install github:bikeNomad/micropython-rp2-smartStepper\n```\n\nThis installs the `smartstepper` package into `/lib/smartstepper/` on the Pico.\n\n### Host-side test tools\n\n```sh\npip install -e .\n```\n\nThis installs `logic2-automation` and `mpremote`, which are required to run the HIL test suite (`tests/test_hil.py`).\n\n## Files\n\nLibrary files are in `smartstepper/` (installed as a package on the Pico):\n\n| File | Description |\n| --- | --- |\n| `smartstepper/__init__.py` | Package entry point; re-exports `SmartStepper`, `SmartStepperError`, `Axis`, `AxisError`, `MultiAxis`, `Arc`, `ArcError` |\n| `smartstepper/smartStepper.py` | High-level `SmartStepper` class |\n| `smartstepper/axis.py` | `Axis` wrapper with hard speed/acceleration limits; supports deferred DMA start |\n| `smartstepper/multiaxis.py` | `MultiAxis` synchronized multi-axis planner |\n| `smartstepper/arc.py` | `Arc` 2-axis circular arc motion (chord linearization; G02/G03 compatible) |\n| `smartstepper/homing.py` | Async homing routine (three-phase, handles pre-asserted sensor) |\n| `smartstepper/pulseGenerator.py` | PIO + DMA pulse generator (internal) |\n| `smartstepper/pulseCounter.py` | PIO-based pulse counter for position tracking (internal) |\n| `package.json` | MicroPython `mip` package descriptor |\n| `pyproject.toml` | Host-side test tool dependencies (`pip install -e .`) |\n\nTest scripts are in `tests/`:\n\n| File | Description |\n| --- | --- |\n| `tests/test_config.py` | Pin assignments for all Pico-side test scripts |\n| `tests/test_smartStepper.py` | Manual test / demo for `SmartStepper` |\n| `tests/test_pulseGenerator.py` | Manual test / demo for `PulseGenerator` |\n| `tests/test_pulseCounter.py` | Manual test / demo for `PulseCounter` |\n| `tests/hil_moveto.py` | Pico-side script used by the HIL test suite |\n| `tests/hil_config.py` | HIL wiring / port configuration |\n| `tests/test_hil.py` | Host-side hardware-in-the-loop test runner |\n\n## Testing\n\n### Manual tests (run on the Pico)\n\nEach module has a standalone demo script. Deploy the library and the test\nscript, then run it:\n\n```sh\nPORT=/dev/cu.usbmodem1\n\n# SmartStepper demo\nmpremote connect $PORT cp -r smartstepper/ : + cp tests/test_config.py tests/test_smartStepper.py :\nmpremote connect $PORT run tests/test_smartStepper.py\n\n# PulseGenerator demo\nmpremote connect $PORT cp -r smartstepper/ : + cp tests/test_config.py tests/test_pulseGenerator.py :\nmpremote connect $PORT run tests/test_pulseGenerator.py\n\n# PulseCounter demo\nmpremote connect $PORT cp -r smartstepper/ : + cp tests/test_config.py tests/test_pulseCounter.py :\nmpremote connect $PORT run tests/test_pulseCounter.py\n```\n\nAdjust the port (`/dev/cu.usbmodem1`) and pin numbers in `tests/test_config.py`\nto match your hardware.\n\n### Hardware-in-the-loop (HIL) tests\n\nThe HIL suite runs on the host PC. It deploys scripts to the Pico via\n`mpremote`, captures the step and direction signals with a\n[Saleae Logic analyzer](https://www.saleae.com/), and asserts correctness\nagainst the raw edge data.\n\n**Prerequisites:**\n\n- [Logic 2](https://www.saleae.com/downloads/) open with Automation enabled\n  (Settings → Automation, port 10430)\n- Saleae channels wired to the Pico per `tests/hil_config.py`\n- Pico connected via USB\n- Python package: `pip install logic2-automation`\n\n**Configuration:**\n\nEdit `tests/hil_config.py` to match your wiring and USB port:\n\n```python\nSTEP_CHANNEL = 0      # Saleae ch 0  →  Pico GPIO 13\nDIR_CHANNEL  = 1      # Saleae ch 1  →  Pico GPIO 14\nSTEP_PIN     = 13     # Pico GPIO for step signal\nPICO_PORT    = '/dev/cu.usbmodem314201'\n```\n\n**Run:**\n\n```sh\npython tests/test_hil.py\n```\n\n**Tests:**\n\n| Test | What it checks |\n| --- | --- |\n| `test_pulse_generator` | `PulseGenerator` timing: edge count and inter-pulse gaps for a two-speed sequence |\n| `test_moveto_pulse_count` | `moveTo(50)` produces the expected step count; Saleae edge count matches on-board `PulseCounter` |\n| `test_accel_profile` | Step frequency is monotonically increasing at the start and decreasing at the end of a move |\n| `test_replan_profile` | Mid-move `maxSpeed` change triggers `_replan()`; verifies the step frequency drops from the fast cruise speed to the new lower speed |\n\n## Usage\n\n### Basic setup\n\n```python\nfrom smartstepper import SmartStepper\n\nstepper = SmartStepper(\n    stepPin=27,       # step pulse output pin number (or machine.Pin)\n    dirPin=26,        # direction output pin number (or machine.Pin)\n    enablePin=25,     # optional active-low enable pin (or None)\n    accelCurve='smooth2'  # 'linear', 'smooth1', 'smooth2', or 'sine'\n)\n\nstepper.stepsPerUnit = 96.   # microsteps per mm (or whatever unit you use)\nstepper.minSpeed     = 1     # units/s — starting/stopping speed\nstepper.maxSpeed     = 50    # units/s — peak speed\nstepper.acceleration = 300   # units/s²\n```\n\n### Absolute move\n\n```python\nstepper.moveTo(100)          # move to 100 mm (absolute)\nstepper.waitEndOfMove()      # block until done\n\nstepper.moveTo(0)            # return to origin\nstepper.waitEndOfMove()\n```\n\n### Relative move\n\n```python\nstepper.moveTo(50, relative=True)   # move forward 50 mm\nstepper.waitEndOfMove()\n\nstepper.moveTo(-20, relative=True)  # move back 20 mm\nstepper.waitEndOfMove()\n```\n\n### Triangular move (no constant-velocity phase)\n\nA triangular move accelerates directly to its peak speed and then immediately\ndecelerates, with no constant-velocity cruise section. This is useful for\nshort, precise point-to-point moves or where a symmetric speed profile is\nrequired.\n\n```python\nstepper.moveTo(10, triangular=True)   # accel → decel, no cruise\nstepper.waitEndOfMove()\n```\n\nIf the natural peak speed for the given distance would exceed `maxSpeed`, the\nacceleration is automatically reduced so the move remains triangular at\n`maxSpeed` (still no cruise section).\n\n### Move with a fixed acceleration time\n\n`accel_time` specifies how long the acceleration phase should last (in\nseconds). The peak speed is `minSpeed + acceleration × accel_time`, clamped\nto `maxSpeed`. Any remaining distance is covered at that peak speed before\ndecelerating.\n\n```python\nstepper.moveTo(100, accel_time=0.5)   # accelerate for exactly 0.5 s, then cruise and decel\nstepper.waitEndOfMove()\n```\n\nThis is primarily used for multi-axis synchronization: compute the longest\nacceleration time across all axes, then give every axis the same `accel_time`\nso all acceleration phases take identical durations.\n\n### Multi-axis synchronized moves\n\n`Axis` wraps a `SmartStepper` and adds hard speed/acceleration limits that\ncannot be exceeded even when properties are changed at runtime. `MultiAxis`\nplans a coordinated move across any number of axes so that every axis\ncompletes its acceleration phase at the same instant, then uses a single\nhardware register write to start all DMA channels simultaneously.\n\n```python\nimport asyncio\nfrom smartstepper import SmartStepper, Axis, MultiAxis\n\nstepper_x = SmartStepper(stepPin=2, dirPin=3, enablePin=4)\nstepper_x.stepsPerUnit = 96\nstepper_x.minSpeed = 2\nstepper_x.maxSpeed = 100\nstepper_x.acceleration = 400\n\nstepper_y = SmartStepper(stepPin=5, dirPin=6, enablePin=7)\nstepper_y.stepsPerUnit = 96\nstepper_y.minSpeed = 2\nstepper_y.maxSpeed = 80      # Y axis is slower\nstepper_y.acceleration = 300\n\nx = Axis(stepper_x, hard_max_speed=100, hard_max_accel=400)\ny = Axis(stepper_y, hard_max_speed=80,  hard_max_accel=300)\n\nma = MultiAxis([x, y])\n\nasync def main():\n    # Both axes start their accel phase simultaneously and finish it\n    # at the same time, then cruise and decel independently.\n    ma.move({x: 100, y: 50})\n    await ma.wait_done()\n\nasyncio.run(main())\n```\n\n`MultiAxis.move()` computes the natural triangular peak speed for each\naxis and derives its acceleration time. The longest of these becomes the\ncommon accel time; every axis is given that same `accel_time` so their\nramps finish together. The move is then started with a single write to\nthe RP2040/RP2350 `DMA_MULTI_CHAN_TRIGGER` register — a hardware guarantee\nof simultaneous start within the same AHB bus cycle.\n\n### Circular arc motion (G02/G03)\n\n`Arc` linearizes a circular arc into chord segments and executes each segment\nas a synchronized `MultiAxis.move()`. The chord tolerance controls the\nmaximum deviation between the ideal arc and the straight-line chords.\n\n```python\nimport asyncio\nfrom smartstepper import SmartStepper, Axis, Arc\n\nstepper_x = SmartStepper(stepPin=2, dirPin=3, enablePin=4)\nstepper_x.stepsPerUnit = 100\nstepper_x.minSpeed = 2\nstepper_x.maxSpeed = 20\nstepper_x.acceleration = 10\n\nstepper_y = SmartStepper(stepPin=5, dirPin=6, enablePin=7)\nstepper_y.stepsPerUnit = 100\nstepper_y.minSpeed = 2\nstepper_y.maxSpeed = 20\nstepper_y.acceleration = 10\n\nx = Axis(stepper_x, hard_max_speed=20, hard_max_accel=10)\ny = Axis(stepper_y, hard_max_speed=20, hard_max_accel=10)\n\narc = Arc(x, y)\n\nasync def main():\n    # Quarter-circle CCW (G03) from (0, 0) to (0, 100).\n    # Center offset i=-0, j=0 =\u003e center=(0, 0).\n    # Same as G03 X0 Y100 I0 J0 (starting at X100 Y0).\n    x.position = 100\n    y.position = 0\n    await arc.move(0, 100, i=-100, j=0, direction='ccw', chord_tol=0.1)\n\nasyncio.run(main())\n```\n\n`direction='ccw'` corresponds to G03; `direction='cw'` to G02. The `i` and\n`j` parameters are the center offset from the *current* axis position (same\nconvention as G-code I/J). `chord_tol` is the maximum allowed deviation\nbetween the chord and the arc (in user units); smaller values produce more\nsegments and a smoother path.\n\nYou can also use `Axis` standalone with a deferred start:\n\n```python\nch_x = x.prepare_move(100)\nch_y = y.prepare_move(50)\n# ... set up other things ...\nx.start_move()   # fires only x; use MultiAxis for simultaneous start\n```\n\n### Move with timeout\n\n```python\nfrom smartstepper import SmartStepper, SmartStepperError\n\ntry:\n    stepper.moveTo(200, timeout=5.0)   # fail if not done in 5 seconds\n    stepper.waitEndOfMove()\nexcept SmartStepperError as e:\n    print(\"Error:\", e)   # \"Move timed out\" if motor stalled\n```\n\nAlternatively, poll without blocking:\n\n```python\nstepper.moveTo(200, timeout=5.0)\nwhile stepper.moving:\n    if stepper.timedOut:\n        stepper.stop(emergency=True)\n        break\n    # ... do other work\n```\n\n### Jog (continuous motion)\n\n```python\nstepper.jog(maxSpeed=30, direction='up')   # start jogging\n# ... application loop ...\nstepper.stop()                             # decelerate and stop\nstepper.waitEndOfMove()                    # wait for decel to finish\n```\n\n### Enable/disable motor driver\n\nThe enable pin is driven automatically when a move starts. You can also\ncontrol it manually:\n\n```python\nstepper.disable()   # de-energize coils (reduce heat / allow manual movement)\nstepper.enable()    # re-energize coils\n```\n\n### Dynamic parameter changes mid-move\n\nSpeed and acceleration can be updated while the motor is moving. The motion\nprofile is automatically rebuilt and handed to the DMA controller without\nstopping (~10 µs transition):\n\n```python\nstepper.moveTo(500)\ntime.sleep(0.5)\nstepper.maxSpeed = 20     # slow down on the fly\ntime.sleep(0.5)\nstepper.maxSpeed = 80     # speed back up\nstepper.waitEndOfMove()\n```\n\n### Position\n\n```python\nprint(stepper.position)    # current position in units (read from PIO counter)\nstepper.position = 0       # reset/home position (only when not moving)\n```\n\n### Emergency stop\n\n```python\nstepper.stop(emergency=True)   # cut pulses immediately (may lose steps)\n```\n\n### Homing\n\n`homing.py` provides an async homing routine that works with any sensor that\nhas a `value()` method (e.g. `machine.Pin`).\n\n**Phase 0 — initial backoff (if needed)**: if the sensor is already asserted\nwhen homing starts, jog away from it at `slowSpeed` until it de-asserts, then\nstop. This makes homing repeatable regardless of starting position.\n\n**Phase 1 — fast approach**: jog toward the sensor at `fastSpeed`. When the\nsensor asserts, decelerate smoothly to a stop.\n\n**Phase 2 — slow backoff**: jog away from the sensor at `slowSpeed`. The\ninstant the sensor de-asserts, stop immediately and define that position as\nhome (`position = 0`).\n\n```python\nimport asyncio\nfrom smartstepper import homing\n\nsensor = machine.Pin(15, machine.Pin.IN, machine.Pin.PULL_UP)\n\nasync def main():\n    await homing.home(\n        stepper,\n        sensor,\n        fastSpeed  = 40,     # units/s, approach speed\n        slowSpeed  = 2,      # units/s, backoff speed\n        direction  = 'down', # direction toward home sensor\n        activeState= 0,      # 0 = active-low (pull-up wiring)\n        timeout    = 10.0,   # raise HomingError if not done in 10 s\n    )\n    print(\"Homed at\", stepper.position)  # always 0\n\nasyncio.run(main())\n```\n\n`activeState=1` for active-high sensors (pull-down or open-collector with\nexternal pull-up to logic level). `timeout=None` disables the timeout.\n`minSpeed` and `maxSpeed` are saved and restored after homing completes\nor times out. `HomingError` is raised on timeout; import it from\n`smartstepper.homing`:\n\n```python\nfrom smartstepper.homing import HomingError\n\nasync def main():\n    try:\n        await homing.home(stepper, sensor, timeout=10.0)\n    except HomingError as e:\n        print(\"Homing failed:\", e)\n```\n\n## Acceleration curves\n\nThe `accelCurve` constructor argument selects the shape of the speed ramp\nused during acceleration and deceleration. All four curves cover the same\ndistance in the same time for a given speed change — they differ in how\nsmoothly they distribute jerk (rate of change of acceleration).\n\n| Curve | Description |\n| --- | --- |\n| `linear` | Constant acceleration; abrupt jerk spike at ramp start and end |\n| `smooth1` | Hermite smoothstep — zero acceleration at endpoints, moderate jerk |\n| `smooth2` | Smootherstep *(default)* — zero acceleration **and** zero jerk at endpoints |\n| `sine` | Half-cosine ramp — similar to `smooth1`, slightly different mid-ramp shape |\n\nThe plot below shows a 100-unit move with `min_speed=5`, `max_speed=50`,\n`acceleration=200` for all four curves. Speed and position are nearly\nidentical; the differences appear in the acceleration and jerk subplots.\n\n`smooth2` (green) is the recommended default: its zero-jerk endpoints\nproduce the smoothest motor behaviour and the least mechanical stress.\n`linear` (red) has the fastest transition through the ramp but imposes\nsharp jerk at the start and end of every phase.\n\n![Acceleration curve comparison](docs/accel_curves.png)\n\nTo regenerate this diagram after changing parameters:\n\n```sh\npython tools/plot_profiles.py --output docs/accel_curves.png\n```\n\n## API reference\n\n### Constructor\n\n```python\nSmartStepper(stepPin, dirPin, enablePin=None, accelCurve='smooth2')\n```\n\n### Properties\n\n| Property | Writable | Description |\n| --- | --- | --- |\n| `position` | yes (stopped only) | Current position in units |\n| `target` | no | Target position set by last `moveTo()` |\n| `speed` | no | Current speed in units/s |\n| `direction` | no | `'up'`, `'down'`, or `None` |\n| `moving` | no | `True` while motor is running |\n| `timedOut` | no | `True` if move deadline has passed |\n| `minSpeed` | yes | Start/stop speed in units/s (triggers replan if moving) |\n| `maxSpeed` | yes | Peak speed in units/s (triggers replan if moving) |\n| `acceleration` | yes | Acceleration in units/s² (triggers replan if moving) |\n| `stepsPerUnit` | yes (stopped only) | Microsteps per unit |\n| `reverse` | yes (stopped only) | Invert direction pin polarity |\n\n### Methods\n\n| Method | Description |\n| --- | --- |\n| `moveTo(target, relative=False, timeout=None, triangular=False, accel_time=None)` | Start a move; non-blocking |\n| `jog(maxSpeed=None, direction='up')` | Start continuous jogging; non-blocking |\n| `stop(emergency=False)` | Stop with decel (default) or immediately |\n| `waitEndOfMove()` | Block until stopped; raises on timeout |\n| `enable()` | Assert enable pin (active-low) |\n| `disable()` | Release enable pin |\n\n### Axis\n\n```python\nAxis(stepper, hard_max_speed=None, hard_max_accel=None)\n```\n\nWraps a `SmartStepper`. `hard_max_speed` and `hard_max_accel` are immutable\nafter construction (default to the stepper's current `maxSpeed` /\n`acceleration`). `AxisError` is raised if a property setter or motion method\nwould exceed these limits.\n\n| Property | Writable | Description |\n| --- | --- | --- |\n| `hard_max_speed` | no | Immutable upper bound on `maxSpeed` |\n| `hard_max_accel` | no | Immutable upper bound on `acceleration` |\n| `stepper` | no | The underlying `SmartStepper` |\n| `position` | yes (stopped) | Delegates to `stepper.position` |\n| `speed` | no | Delegates to `stepper.speed` |\n| `moving` | no | Delegates to `stepper.moving` |\n| `target` | no | Delegates to `stepper.target` |\n| `minSpeed` | yes | Delegates to `stepper.minSpeed` |\n| `maxSpeed` | yes | Validated against `hard_max_speed`; raises `AxisError` if exceeded |\n| `acceleration` | yes | Validated against `hard_max_accel`; raises `AxisError` if exceeded |\n| `stepsPerUnit` | yes (stopped) | Delegates to `stepper.stepsPerUnit` |\n| `reverse` | yes (stopped) | Delegates to `stepper.reverse` |\n\n| Method | Description |\n| --- | --- |\n| `prepare_move(target, relative=False, accel_time=None, triangular=False)` | Configure DMA without starting; returns DMA channel number |\n| `start_move()` | Trigger a previously prepared move (single-axis deferred start) |\n| `moveTo(target, relative=False, timeout=None, triangular=False, accel_time=None)` | Immediate move (validates hard limits first) |\n| `stop(emergency=False)` | Delegates to `stepper.stop()` |\n| `enable()` / `disable()` | Delegates to `stepper.enable()` / `disable()` |\n| `async wait_done()` | Async wait until this axis finishes moving |\n\n### MultiAxis\n\n```python\nMultiAxis(axes)\n```\n\n`axes` is a list of `Axis` objects.\n\n| Method | Description |\n| --- | --- |\n| `move(targets)` | Synchronized move; `targets` is a `{axis: position}` dict or a list parallel to the axes |\n| `async wait_done()` | Async wait until all axes finish moving |\n| `stop(emergency=False)` | Stop all moving axes |\n\n`move()` algorithm:\n\n1. For each axis, compute the natural triangular accel time over its distance\n   (clamped to `hard_max_speed`).\n2. `t_common = max(all accel times)`.\n3. Call `axis.prepare_move(target, accel_time=t_common)` for each axis.\n4. Write a DMA channel bitmask to `DMA_MULTI_CHAN_TRIGGER` — a single AHB\n   write that starts all channels in the same bus cycle.\n\n### Arc\n\n```python\nArc(x_axis, y_axis)\n```\n\n`x_axis` and `y_axis` are `Axis` objects. `ArcError` is raised if the arc\nradius is zero or `chord_tol` is too small relative to the radius.\n\n| Method | Description |\n| --- | --- |\n| `async move(x_end, y_end, i=0, j=0, direction='ccw', chord_tol=None, segment_min_speed=None)` | Execute arc from current position; awaits each chord segment |\n| `async wait_done()` | Async wait until the current chord segment completes |\n| `stop(emergency=False)` | Stop all axes |\n| `chord_segments(x_end, y_end, i=0, j=0, direction='ccw', chord_tol=None)` | Return list of `(x, y)` waypoints without moving |\n\n`move()` parameters:\n\n| Parameter | Description |\n| --- | --- |\n| `x_end, y_end` | Arc endpoint in user units |\n| `i, j` | Center offset from current position (G-code I, J convention) |\n| `direction` | `'ccw'` (G03, default) or `'cw'` (G02) |\n| `chord_tol` | Max chord-to-arc deviation in user units (default: `Arc.DEFAULT_CHORD_TOL = 0.1`) |\n| `segment_min_speed` | `minSpeed` used during arc segments; defaults to ¼ of the axes' `minSpeed` |\n\n## Tools\n\nHost-side Python scripts in `tools/` for visualizing motion profiles and\ncaptured signals. Both require `matplotlib` (`pip install matplotlib`).\n\n### tools/plot_profiles.py\n\nSimulates the SmartStepper motion planner in standard Python (no hardware\nrequired) and plots speed, position, acceleration, and jerk vs. time for\nall four acceleration curves side-by-side.\n\n```sh\npython tools/plot_profiles.py [options]\n```\n\n| Option | Default | Description |\n| --- | --- | --- |\n| `--distance F` | `100` | Move distance in units |\n| `--min-speed F` | `5` | Min speed in units/s |\n| `--max-speed F` | `50` | Max speed in units/s |\n| `--acceleration F` | `200` | Acceleration in units/s² |\n| `--steps-per-unit F` | `96` | Steps per unit |\n| `--triangular` | off | Triangular profile (no constant-velocity phase) |\n| `--accel-time F` | — | Fixed acceleration phase duration in seconds |\n| `--output FILE` | `profiles.png` | Save figure to FILE |\n| `--show` | off | Display interactively instead of saving |\n\n`--triangular` and `--accel-time` are mutually exclusive.\n\n### tools/plot_motion.py\n\nReads a `digital.csv` exported by the HIL test suite (via Saleae Logic 2)\nand reconstructs each axis's position over time. Produces two plots: the\nX–Y spatial trajectory and each axis's position vs. time. Optionally\noverlays an ideal arc for radial error analysis.\n\n```sh\npython tools/plot_motion.py \u003ccapture_dir_or_csv\u003e [options]\n```\n\n`\u003ccapture_dir_or_csv\u003e` is either a directory containing `digital.csv` (as\nproduced by `run_capture()` in `test_hil.py`) or the path to the CSV file\nitself.\n\n| Option | Default | Description |\n| --- | --- | --- |\n| `--steps-per-unit F` | `96` | Steps per unit for both axes |\n| `--x-init F` | `0` | Initial X position in units |\n| `--y-init F` | `0` | Initial Y position in units |\n| `--step-ch N` | `0` | Saleae channel for axis-1 STEP |\n| `--dir-ch N` | `1` | Saleae channel for axis-1 DIR |\n| `--step-ch2 N` | `2` | Saleae channel for axis-2 STEP |\n| `--dir-ch2 N` | `3` | Saleae channel for axis-2 DIR |\n| `--dir-high-positive` | on | DIR=1 → positive direction |\n| `--dir-high-negative` | off | DIR=1 → negative direction |\n| `--arc-cx F` | — | Arc center X for ideal arc overlay |\n| `--arc-cy F` | — | Arc center Y for ideal arc overlay |\n| `--arc-r F` | — | Arc radius for ideal arc overlay |\n| `--output FILE` | `motion.png` | Save figure to FILE |\n| `--show` | off | Display interactively instead of saving |\n\nExample — visualize an arc capture with ideal arc overlay:\n\n```sh\npython tools/plot_motion.py tests/captures/hil_arc/ \\\n    --steps-per-unit 100 --x-init 100 --y-init 0 \\\n    --arc-cx 0 --arc-cy 0 --arc-r 100 --show\n```\n\n## Hardware notes\n\n- **Enable pin**: Most stepper drivers use an active-low enable signal. The\n  pin is driven high (disabled) at startup and low (enabled) automatically\n  when a move begins.\n- **Direction pin setup time**: The RP2040 PIO begins pulsing immediately\n  after direction is set. If your driver requires a direction-setup hold time,\n  add a short `time.sleep_us()` before calling `moveTo()`.\n- **Step pulse width**: The PIO generates a 50% duty-cycle square wave. Pulse\n  width = `1 / (2 × freq)`. At 10 kHz step rate this is 50 µs per half-cycle,\n  which is compatible with all common stepper drivers.\n\n## Credits\n\nOriginal library by **Frédéric** (\u003cfma@gbiloba.org\u003e) (2023),\nposted at [framagit.org/fma38/micropython-lib](https://framagit.org/fma38/micropython-lib), and licensed under the\n[GNU Affero General Public License v3](LICENSE).\n\n`pulseCounter.py` is based on original work by\n[Dave Hylands](https://github.com/dhylands/upy-examples/blob/master/pico/pio_pulse_counter.py).\n\n### Changes by Ned Konz (\u003cned@metamagix.tech\u003e) (2026)\n\n- Fixed garbage-collector bug in `pulseGenerator.py`: DMA sequence array is\n  now pinned as an instance variable to prevent it from being collected while\n  DMA is active.\n- Added `PulseGenerator.update()` for non-blocking mid-run DMA replacement.\n- Generalized `_accelPoints()` to handle both acceleration and deceleration\n  (reversal of a symmetric smoothstep curve), replacing the separate\n  `_decelPoints()` method.\n- Added `_buildProfile()` unified motion planner used by `moveTo()` and\n  `_replan()`.\n- Added `_replan()` to rebuild the motion profile in-flight when speed or\n  acceleration is changed mid-move.\n- Made `minSpeed`, `maxSpeed`, and `acceleration` setters trigger `_replan()`\n  instead of raising an error when the motor is moving.\n- Added `stop(emergency=False)` with smooth deceleration by default.\n- Added active-low enable pin support (`enablePin` constructor argument,\n  `enable()` / `disable()` methods, auto-enable on move start).\n- Added `timeout` parameter to `moveTo()`, `timedOut` property, and timeout\n  handling in `waitEndOfMove()`.\n- Fixed `position` setter unit-conversion bug.\n- Fixed `waitEndOfMove()` self-reference bug.\n- Fixed multi-instance bug in `pulseGenerator.py`: SM index is now captured\n  as an instance variable at construction time rather than read from the class\n  variable (which could have been incremented by later instances).\n- Replaced custom `dma.py` with MicroPython's built-in `rp2.DMA`: uses\n  `pack_ctrl()` / `config()` API, drops `import uctypes` (array passed\n  directly via buffer protocol), and is inherently RP2350-compatible.\n- Extracted test/demo code into `test_smartStepper.py`.\n- Added `homing.py`: async three-phase homing with configurable sensor polarity,\n  speed parameters, and timeout.\n- Added `triangular=True` parameter to `moveTo()`: produces a profile with no\n  constant-velocity section; if the natural peak would exceed `maxSpeed`,\n  acceleration is automatically reduced.\n- Added `accel_time` parameter to `moveTo()`: fixes the duration of the\n  acceleration phase, enabling precise multi-axis synchronization.\n- Refactored `_accelPoints()` to accept an explicit `accel` override so that\n  triangular and `accel_time` moves can use a per-move effective acceleration\n  without altering the stepper's `acceleration` property.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2FbikeNomad%2Fmicropython-rp2-smartStepper","html_url":"https://awesome.ecosyste.ms/projects/github.com%2FbikeNomad%2Fmicropython-rp2-smartStepper","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2FbikeNomad%2Fmicropython-rp2-smartStepper/lists"}