This file contains instructions for AI coding assistants working on this project. It documents project conventions, constraints, and workflow rules. Human contributors should also read it — it reflects the project's coding standards.
This is the async rewrite branch of VarSpeedPython, a library for generating timed, eased value sequences for actuators (servos, LEDs, etc.). It is used in university coursework at TU Delft (Digital Interfaces course).
The library files live in the varspeed/ directory. There is no package __init__.py —
files are imported directly as flat modules via PYTHONPATH. Always work inside the .venv virtual environment.
Both sync and async versions coexist:
varspeed/varspeed.py— sync version (read-write).from varspeed import Vspeedvarspeed/varspeed_async.py— async version (canonical).from varspeed_async import Vspeedvarspeed/easing_functions.py— shared easing library (read-only)
- The public method names:
move(),sequence(),set_position(),set_bounds(),sequence_run(),sequence_change_seq_num() - Note:
move()was intentionally removed from the async version — do not add it back - The tuple format for sequences:
(position, time_secs, steps, easing_string[, delay_start]) - The three-value return signature:
position, running, changed - The
result="int"/result="float"parameter on__init__ - Google-style docstrings (Args/Returns/Yields sections). Every public method must have full docstrings — no shortcuts.
- Target:
asyncioviaadafruit_asyncio(v3.x). Assumeasync/awaitandasyncio.sleep()are available. - No PyPI packages.
easing_functions.pyis a local file and must remain so. - No f-strings with format specs in CircuitPython examples (use
str()or%formatting). - Do not use async generators (async def + yield) — use
__aiter__/__anext__classes instead. CircuitPython does not support PEP 525 async generators. asyncio.create_task()works on CircuitPython 10+ but flag it with a comment if used.asyncio.Queue()is NOT available on CircuitPython — do not use it.match/case(structural pattern matching) is NOT supported in CircuitPython — useif/elifinstead.
- CPython: 3.9+
- CircuitPython: 8.x / 9.x / 10.x compatible syntax only in the core library
- Indentation: 2 spaces. This is a project convention inherited from the original codebase — not a CircuitPython requirement. Do not reformat to 4 spaces.
- No type annotations — CircuitPython does not support them in all versions.
- Two blank lines between top-level functions and classes (PEP 8).
- One blank line between methods inside a class (PEP 8).
- Constants in ALL_CAPS at module level.
- No f-strings with format specs — use
str()or%formatting for CircuitPython compatibility.
- Bugs listed below have already been fixed — do not re-fix or revert them.
- Do not add new public methods without explicit instruction.
easing_functionsmust be imported as:import easing_functions as ease(bare import works because PYTHONPATH points to thevarspeed/directory).
easing_functions.pyis not to be modified unless explicitly instructed.- Easing class names must be passed as strings (existing behavior). Do not refactor to pass class references.
- The
GammaEaseIn/Out/InOutclasses take an optionalgammaparameter — this is not currently exposed inVspeed. Do not expose it unless instructed.
These conventions make async code more readable for beginners. Follow them in all examples and tutorial code.
Use async for as the default pattern. It is sequential and straightforward:
one move finishes, then the next begins. Only reach for create_task() when two
things genuinely need to run at the same time.
# PREFERRED for most examples — simple, sequential, no concurrency needed
async for position, running, changed in vs.move(100, time_secs=2.0, steps=20):
if changed:
print(position)
# Only use create_task() when true concurrency is the point of the example
task_a = asyncio.create_task(run_actuator(vs_a, ...))
task_b = asyncio.create_task(run_actuator(vs_b, ...))
await task_a
await task_bPrefer asyncio.create_task() over asyncio.gather() in examples. create_task() is
more explicit — each task is named and started individually, making it clear what is
running concurrently. gather() is a convenience wrapper that hides this.
# PREFERRED — explicit, readable, each task is named
task_servo = asyncio.create_task(run_actuator(vs_servo, ...))
task_led = asyncio.create_task(run_actuator(vs_led, ...))
task_sensor = asyncio.create_task(watch_sensor(...))
await task_servo
await task_led
task_sensor.cancel()
# AVOID — hides what is running concurrently
await asyncio.gather(
run_actuator(vs_servo, ...),
run_actuator(vs_led, ...),
watch_sensor(...),
)Always cancel tasks explicitly before main() exits, and always follow with
await asyncio.sleep(0) to let finally blocks run:
try:
await asyncio.sleep(30)
finally:
task_servo.cancel()
task_sensor.cancel()
await asyncio.sleep(0) # lets tasks run their finally blocks
state["pwm"].deinit()
state["analog_in"].deinit()Use try/finally in tasks for hardware cleanup — it runs on normal exit,
cancellation, and exceptions. Do not use except CancelledError unless you need
different behavior on cancellation vs normal exit:
async def run_actuator(...):
try:
async for position, running, changed in vs.move(...):
...
finally:
print("actuator stopped at " + str(vs.position))- Use a module-level
statedict for continuously updated values (sensor readings, current positions, phase). - Use
asyncio.Event()for signals between tasks (threshold exceeded, timeout, done). - Do NOT use
asyncio.Queue()— not available on CircuitPython. - Store hardware objects in
stateso all tasks can access them without globals:
state = {}
events = {
"sensor_high": asyncio.Event(),
"done": asyncio.Event(),
}
async def init():
state["analog_in"] = analogio.AnalogIn(board.A0)
state["pwm"] = pwmio.PWMOut(board.D13, duty_cycle=2**15, frequency=50)
state["servo"] = servo.Servo(state["pwm"])
state["servo"].angle = 0
state["sensor_value"] = 0
state["phase"] = "idle"vs— Vspeed instancetask_<noun>— task variables:task_servo,task_sensor,task_display- Helper coroutines:
run_actuator,run_sequence— notrun_servo,run_motor,run_led - Constants: ALL_CAPS at module level
Use map_range() from the varspeed library for all sensor value conversions. Always specify
result="int" for servo angles, result="float" for brightness values:
from varspeed import map_range # sync
from varspeed_async import map_range # async
angle = map_range(analog_in.value, 0, 65535, 0, 180, result="int")varspeed/
__init__.py ← exposes Vspeed from varspeed.py via `from varspeed import Vspeed`
varspeed.py ← sync version. READ-WRITE.
varspeed_async.py ← async version (canonical). READ-WRITE.
easing_functions.py ← shared easing library. READ-ONLY.
examples/ ← existing sync examples. READ-ONLY. Do not touch.
examples/basic/ ← sync examples.
examples/async/ ← async examples. Already created.
README.md ← update to document both sync and async options
docs/easings_cheatsheet/ ← easing cheatsheet (already created)
tutorial/ ← visual simulator app (to be created, CPython only)
pyproject.toml ← do not modify
.venv/ ← virtual environment, not committed to git
varspeed/varspeed.py may be modified. Fix bugs and make improvements as needed.
Existing files in examples/basic/ are read-only.
Do not move, rename, or reorganize them.
Library files (easing_functions.py, varspeed.py, varspeed_async.py) must
never be copied into the examples folders — examples import from the installed package.
- Never touch any existing file in
examples/basic/. They are read-only. - Async examples live in
examples/async/— already created, modify only if instructed. - All async examples import with
from varspeed_async import Vspeed. - All sync examples import with
from varspeed import Vspeed. - Each async example must open with a comment block stating:
- What it demonstrates
- A one-line note pointing to the equivalent sync example
- CPython: wrap main logic in
async def main(), call viaasyncio.run(main()). - CircuitPython: same
async def main()pattern, note the entry point difference in a comment. - The concurrent actuator and sequence examples already exist in
examples/async/— do not recreate or overwrite them without explicit instruction. - Follow the async conventions above — use
create_task()notgather(). - Pedagogical tone: beginner students. Comments explain the why, not just the what.
- Do not restructure or remove any existing sync documentation.
- Combine sync and async documentation in a single README.md using clear callout markers.
- Any async-specific section or method must be marked with
**async only**at the start, followed by the required import:**async only** — requires \varspeed_async.py`` - Use the following section structure (add new sections, do not reorder existing ones):
## Installation
## Quick Start (sync — start here)
## Quick Start (async)
## API Reference
### move() ← async only, clearly marked
### sequence()
### set_position()
### set_bounds()
### sequence_run()
### sequence_change_seq_num()
## Easing Functions
## Examples
### Sync examples
### Async examples
## CircuitPython Setup
## Migration: Sync → Async
- The "Migration: Sync → Async" section should explain the
async/awaitshift clearly for beginners, with a before/after code comparison. - The "Quick Start (async)" section should show the minimal working async example.
- Add a "Sync vs Async — which should I use?" callout box near the top: Sync = beginners, existing curriculum, CircuitPython without asyncio. Async = concurrent actuators, students ready to learn async patterns.
- Link to the easings cheatsheet once created.
- Do not add a second README file — one combined file only.
Do this once after cloning the repo:
pyenv global 3.12.5
python -m venv .venv
source .venv/bin/activate
echo 'export PYTHONPATH="/Users/phil/Documents/GitHub/VarSpeedPython/varspeed"' >> .venv/bin/activate
deactivate && source .venv/bin/activateFor each new terminal session:
source .venv/bin/activateAlways run examples from the repo root:
python examples/async/move_simple.py # correct
cd examples/async && python move_simple.py # will failNote: .venv/ is in .gitignore and must not be committed. The PYTHONPATH line
added to .venv/bin/activate is machine-specific — new contributors must run the
setup sequence above on their own machine.
Add this to pyproject.toml to prevent Zed/ruff from reformatting 2-space indentation:
[tool.ruff]
indent-width = 2- Branch:
async-rewrite - Stage all changes for review — do NOT commit automatically.
- One logical change per staged set (e.g., don't mix library fixes with example updates).
- Commit message format:
type(scope): description- Types:
fix,feat,refactor,docs,chore - Examples:
fix(sequence): initialize seq_loop_count in __init__docs(readme): add migration guide for async API
- Types:
- Refactor
easing_functions.py - Add type hints or dataclasses
- Switch to a different async framework (trio, anyio)
- Add external dependencies
- Rename any public method or parameter
- Auto-commit
- Modify the university tutorial page or any external resources
- Create the tutorial app UI until the library and examples are stable and reviewed
- Add sensor-reading functionality to either library file
- Use
asyncio.gather()in examples — usecreate_task()instead - Reformat indentation from 2 spaces to 4 spaces