User Guide¶
Overview¶
chumicro-runner provides a standard pattern for active components in the ChuMicro ecosystem. Instead of each library inventing its own poll() / callback API, every active component implements two methods:
def check(self, now_ms: int) -> bool:
"""Check whether the handler should fire.
Args:
now_ms: Current tick timestamp in milliseconds.
Returns:
True if the handler should fire this tick.
"""
def handle(self, now_ms: int) -> None:
"""React to the condition detected by check().
Args:
now_ms: Current tick timestamp in milliseconds.
"""
A shared Runner captures time once per tick, checks each service, and batch-fires all due handlers. This replaces ad-hoc polling loops with a single standard contract.
The pattern¶
- Services implement
check(now_ms) -> bool: they check a condition and return whether the handler should fire. - Handlers implement
handle(now_ms): they react when the service says "go". - Runner ties it together: capture time → check all services → batch-fire all due handlers.
Services are objects with .check() and .handle() methods, or plain handler callables that fire every tick or on a period: one shape per registration, never both.
Getting started¶
from chumicro_runner import Runner
class TemperatureSensor:
"""Alert when temperature exceeds a threshold.
Args:
threshold: Temperature in °C that triggers an alert.
"""
def __init__(self, threshold: float = 30.0) -> None:
self._threshold = threshold
self._last_reading = 0.0
def read_temperature(self) -> float:
"""Read from hardware: fast I2C or ADC operation."""
# On a real board: return self._i2c_device.temperature
return self._last_reading
def check(self, now_ms: int) -> bool:
"""Return True when the reading exceeds the threshold.
Args:
now_ms: Current tick timestamp (unused here).
Returns:
True if the last reading exceeds the threshold.
"""
self._last_reading = self.read_temperature()
return self._last_reading > self._threshold
def handle(self, now_ms: int) -> None:
"""Print an alert with the current reading.
Args:
now_ms: Current tick timestamp.
"""
print(f"ALERT: {self._last_reading}°C exceeds {self._threshold}°C")
sensor = TemperatureSensor(threshold=30.0)
runner = Runner()
runner.add(sensor, period_ms=5000)
while True:
now_ms = runner.tick()
runner.wait(now_ms)
Shared timestamps¶
Runner.tick() captures ticks_ms() once and passes the resulting timestamp to every service. This ensures all services in the loop see the same moment in time, preventing drift between independent clock reads on slow microcontrollers.
The method returns now_ms so user code can use it alongside the service loop:
from chumicro_timing import Rate, ticks_ms
rate = Rate(500, ticks_ms())
while True:
now = runner.tick()
if rate.due(now):
do_something()
runner.wait(now)
Idling between ticks¶
Runner.wait(now_ms) is the loop's idle path and the runner's one sanctioned blocking point. Call it right after tick():
Each call to wait:
- Syncs the poll set from each service's optional
io_socketandio_interest(now_ms)bitmask (register newly wanted sockets, modify changed interest, unregister sockets that have gone away). Idempotent: a no-change loop touches the poller zero times. - Computes the timeout as the minimum across every entry's
next_due_msand every service's optionalnext_deadline(now_ms), minusnow_ms. - Blocks in
ipoll(timeout_ms)over the registered sockets when any are registered; otherwise sleeps the timeout viatime.sleep_ms. Returns immediately when the nearest deadline has already passed or no deadline applies.
Errors on a registered socket (POLLERR / POLLHUP) are routed to the owning service's optional io_error(now_ms, eventmask) hook so the service can transition cleanly to a failure state. POLLIN / POLLOUT are wake signals only: check and next_deadline decide what runs on the next tick.
Not sure which wait primitive a given job wants? The timing guide's Choosing a wait table maps each question to its primitive across chumicro_timing, chumicro_sockets, and this service contract.
Writing a service that participates in wait¶
A socket-owning service exposes the duck-typed attributes the runner reads each loop. All are optional: services without them work the same way they always did; the runner just won't wake for their I/O:
| Attribute | Type | Purpose |
|---|---|---|
io_socket |
socket-ish object or None |
The socket whose readiness should wake the loop. Either the pollable itself or an adapter wrapper exposing it on .sock; the runner unwraps .sock at registration, so producers never need to. |
io_interest(now_ms) |
int |
A bitmask OR-ing IO_READ (register POLLIN) and/or IO_WRITE (register POLLOUT); 0 registers nothing. Import the bits from chumicro_runner. |
next_deadline(now_ms) |
int or None |
The next tick the service must run even if no I/O arrives (timeouts, keepalives) |
io_error(now_ms, eventmask) |
callable | Notified when the registered socket reports POLLERR or POLLHUP |
The runner re-reads these every wait(), so a service can flip its interest between read and write (or set io_socket = None) between ticks and the poll set follows on the next call. The single io_interest call replaces the earlier paired io_wants_read / io_wants_write booleans; the runner caches the bound method once at add, so the sync stays allocation-free.
A minimal sketch:
from chumicro_runner import IO_READ, IO_WRITE
class EchoClient:
"""Read bytes from a connected socket, echo them back."""
def __init__(self, sock) -> None:
self.io_socket = sock
self._want = IO_READ
self._outbox = bytearray()
def io_interest(self, now_ms: int) -> int:
return self._want
def check(self, now_ms: int) -> bool:
return self._want != 0
def handle(self, now_ms: int) -> None:
# Drain whatever is ready; flip interest based on what's left
# (self._want = IO_WRITE when there are bytes to send, etc.).
...
def io_error(self, now_ms: int, eventmask: int) -> None:
self.io_socket = None # drops out of the poll set on next wait()
Every networked library in ChuMicro (wifi, sockets, requests, http_server, mqtt, websockets) implements this protocol, which is why their handlers share fairly with the rest of your loop.
Injecting a poller for tests¶
Runner(poller=...) accepts any object exposing register(obj, eventmask) / modify(obj, eventmask) / unregister(obj) / ipoll(timeout_ms). The default poller is built lazily on the first wait() that has a socket to register, so applications that never register an io_socket never pay for it.
chumicro_runner.testing.FakePoller is the test stand-in; see Testing tasks.
The service contract¶
A service is any object you hand to runner.add(service). The runner reads six members off it: two are required, the rest optional, each optional member carrying a coherence rule the dispatch relies on.
| Member | Required | When the runner reads it | For |
|---|---|---|---|
check(now_ms) -> bool |
yes | each tick, subject to any period gate |
deciding whether handle runs this tick |
handle(now_ms) |
yes | each tick, when check returned True |
one tick of work |
io_socket |
no | each wait, as an attribute (the socket, or None) |
the socket to poll |
io_interest(now_ms) -> int |
no | each wait |
OR-ing IO_READ / IO_WRITE into the poll set for io_socket |
next_deadline(now_ms) -> int \| None |
no | each wait |
bounding the idle timeout so the service still runs on time with no I/O |
io_error(now_ms, eventmask) |
no | on wait, when io_socket reports POLLERR / POLLHUP |
transitioning cleanly to a failure state |
The coherence rules. Only the first raises on its own (Runner.add reads both attributes at registration); the dispatch silently depends on the other two, where a violation no-ops rather than raising, which is what validate_service below exists to catch:
checkandhandleare both required.Runner.addreads both off the object at registration, so a service missing either cannot register.io_socketandio_interestcome as a pair. The poll sync reaches the socket throughio_interest; one member without the other never reaches the poller.io_errorrequiresio_socket. It is dispatched only when that socket reports a poll error.
chumicro_runner.testing.validate_service(service) checks exactly these rules and raises ValueError naming the offending member. It validates shape, never behavior, so drop it into your project's test suite to catch a malformed service before it reaches a live runner:
from chumicro_runner.testing import validate_service
def test_my_service_is_a_valid_runner_service():
validate_service(MySensorService(...))
Work runs on the runner in two shapes: services, the contract above, and generators registered with runner.add_generator. This section is the service side; the generator side is its own surface with its own helpers, and there is no third shape.
Registration patterns¶
Object-based¶
Pass an object with .check(now_ms) -> bool and .handle(now_ms):
class MotionDetector:
"""Gate-based motion detector using a PIR sensor."""
def __init__(self) -> None:
# On a real board: self._pin = digitalio.DigitalInOut(board.D5)
pass
def detect_motion(self) -> bool:
"""Read PIR sensor pin: fast digital read."""
# On a real board: return self._pin.value
return False
def check(self, now_ms: int) -> bool:
"""Return True when motion is detected.
Args:
now_ms: Current tick timestamp.
Returns:
True if the PIR sensor reads high.
"""
return self.detect_motion()
def handle(self, now_ms: int) -> None:
"""React to detected motion.
Args:
now_ms: Current tick timestamp.
"""
print("Motion!")
runner.add(MotionDetector())
Handler-only¶
Pass just a handler with no check; it fires every tick (or per period):
Periodic¶
No check needed. The handler fires on a schedule:
Generator-driven¶
Write the I/O as a generator function and register it with runner.add_generator(gen). Each yield from connect(...) / send_all(...) / recv_until(...) hands control back to the runner between steps, so the body reads top-to-bottom while other services keep ticking. Import the helpers explicitly from chumicro_sockets.generators; plain-runner consumers pay nothing.
from chumicro_runner import Runner
from chumicro_sockets.generators import connect, recv_until, send_all
from chumicro_sockets import connector
def echo_run(host, port, radio):
sock = yield from connect(connector(host, port, radio=radio))
try:
yield from send_all(sock, b"hello\n")
reply = yield from recv_until(sock, b"\n", max_bytes=4096)
print(f"got {reply!r}")
finally:
sock.close()
runner = Runner()
handle = runner.add_generator(echo_run("echo.example", 7, radio=wifi_radio))
runner.run_until(handle)
run_until(handle) drives the tick/wait loop until the generator finishes, and re-raises handle.error if the task died, so a broken flow fails loudly instead of exiting clean. Pass a callable instead for arbitrary conditions, or just timeout_ms= to run for a fixed window (a QoS-ack drain, a settling period).
Each yield from is a scheduler checkpoint; between yields, other services registered on the same runner get their turn. A bare yield suspends for exactly one tick. handle.done flips True the moment the generator returns, dies, or is cancelled; handle.error holds the exception when the body raised (None otherwise), so a while not handle.done loop can report why a task ended: check it after the loop, or wire Runner(on_handler_error=...) for a loud callback at the moment of death. handle.cancel() raises GeneratorExit inside the body so any finally block runs the cleanup.
What a generator yields, and driving one without the runner¶
A suspended generator yields a wait: a small object describing why it stopped. Waits are duck-typed and every hook is optional:
| Hook | Meaning | Who reads it |
|---|---|---|
ready(now_ms) -> bool |
The wait judges its own condition, with no clock involved | any driver |
next_deadline(now_ms) |
Resume once this tick lands | any driver, and Runner.wait() to bound its idle timeout |
io_socket + io_interest(now_ms) |
Resume when this socket is readable or writable | Runner.wait(), to sleep on ipoll |
No wait compares times itself, and that is deliberate: the deadline is published, and whoever drives the generator compares it with the clock it was built on. A wait that reached for its own clock would measure a Runner(ticks=...) sleep in the wrong units. So the gate is three cases, in order: honour ready when it answers True, resume once next_deadline lands, otherwise resume on any pass.
Socket waits carry no ready and no deadline, so they fall to that last case. Their helpers retry the syscall and re-suspend on EAGAIN, which makes an early resume cost a wasted pass and nothing else.
That gate is the whole cost of lifting chumicro_sockets.generators into a project with no runner:
def should_resume(wait, now_ms, ticks):
ready = getattr(wait, "ready", None)
if ready is not None and ready(now_ms):
return True
next_deadline = getattr(wait, "next_deadline", None)
deadline_ms = None if next_deadline is None else next_deadline(now_ms)
if deadline_ms is not None:
return ticks.ticks_diff(now_ms, deadline_ms) >= 0 # your clock, your units
return ready is None
wait = generator.send(None) # prime it to the first suspension
while True:
now_ms = ticks.ticks_ms()
if should_resume(wait, now_ms, ticks):
try:
wait = generator.send(now_ms)
except StopIteration:
break
This spins where Runner.wait() would sleep, which costs battery on a device, and it produces identical results. tests/test_socket_generators.py runs a full connect, send, and receive through exactly this loop, and a sleep_until through its deadline branch.
Waiting on a callback-completed event¶
Signal + wait_for (in chumicro_timing.waits) suspend a generator until a callback-style service reports a one-time completion, which removes the state-change-callback-plus-module-flag preamble from sequential flows. Hand signal.set (or a small wrapper) to the service as its callback, then yield from:
from chumicro_timing.waits import Signal, wait_for
link_up = Signal()
wifi.on_state_change(lambda old, new: link_up.set(new))
def main_run(wifi):
yield from wait_for(link_up) # suspend until wifi's callback fires
sock = yield from connect(connector(HOST, PORT, radio=wifi.adapter.radio))
...
wait_for(signal, deadline_ms=...) bounds the wait: past the absolute-ticks deadline it raises OSError(ETIMEDOUT) inside the generator body, where a try / except OSError can route to a retry or a clean shutdown. Reuse one signal across sequential waits by calling signal.clear() between them. Scope discipline: this is for one-time completions a sequential flow genuinely blocks on; reactive fan-out and fire-and-forget acks stay callbacks.
Choosing between add and add_generator¶
Use runner.add(service) when... |
Use runner.add_generator(gen) when... |
|---|---|
| The work is reactive: a condition fires, you respond | The work is a one-shot sequence: connect, send, recv, close |
| The state machine is small and stable (one state, or a handful of binary flags) | The state machine is long and linear: multiple I/O steps in order |
| Multiple instances run side by side and share resources cooperatively | A single attempt drives one connection to completion |
You want to expose set_period() / run_count for runtime control |
You want PEP 380 return value for the helper's terminal result |
Default to check / handle for reactive work; reach for add_generator when the work is naturally sequential I/O. "Everything is a generator" is the drift to avoid: reactive services read more clearly in the gated shape, and two service models coexisting is genuinely lower overhead than forcing all work into one or the other.
What the runner does NOT use¶
The runner deliberately does not use async / await or the asyncio module. Generators were picked over async syntax for four reasons in declining order of weight:
- Yield-point hygiene.
yield from helper()raisesTypeErrorifhelperisn't a generator, so the syntax enforces that every yield-point is a deliberate scheduler checkpoint.await helper()against a regular function silently produces a coroutine-without-await, and the asyncio community already has a class of linters chasing that footgun. - Transparency. A
yieldis one bytecode that hands control to the scheduler: single-steppable, breakpoint-able, visible in a traceback.awaithides the same handoff behind compile-time machinery that differs per runtime. - Allocation budget on CircuitPython. CircuitPython compiles
await xtoload_method __await__; call; YIELD_FROM; everyawaitallocates a fresh generator from the__await__()call.yield from xis one bytecode on every runtime. - Smaller lint surface. A user who has never seen
async defcannot reach forimport asyncio.
async def / await / async with / async for and import asyncio / import uasyncio are banned across every ChuMicro package, device-side and host-side alike.
Period-gated services¶
Pass period_ms to add() and the runner will only check the service when the period elapses. Services without a period are checked every tick.
runner = Runner()
# Sensor is only checked every 5 seconds.
handle = runner.add(sensor, period_ms=5000)
# Button scanner runs every tick.
runner.add(button_scanner)
You can change or remove the period at runtime via the TaskHandle:
# Speed up.
handle.set_period(1000)
# Remove the period; service runs every tick again.
handle.set_period(None)
# Remove the service entirely.
handle.remove()
Delayed start¶
Pass start_after_ms to delay the first check. Subsequent checks use period_ms:
# Wait 2 seconds, then check every 5 seconds.
runner.add(sensor, period_ms=5000, start_after_ms=2000)
Limited runs¶
Pass run_count to auto-remove a task after a set number of handler fires:
Phase anchoring¶
By default a fired periodic reschedules from the tick that fired it, so fires are always at least period_ms apart, but each fire inherits the tick's lateness, and the drift compounds. A 1 Hz publish whose handler takes 80 ms settles near 1.08 s per cycle.
Pass preserve_phase=True for sampling, metering, or telemetry tasks that must hold their long-run cadence. The next deadline then advances from the previous deadline in whole periods: fires stay aligned to the original schedule, and a stall longer than one period skips the missed fires instead of bursting to catch up.
# Holds 10 Hz cadence even when handlers run long.
runner.add_periodic(sample_adc, period_ms=100, preserve_phase=True)
One caveat: a phase-preserving fire that runs late catches back up to its schedule, so two fires can land closer together than period_ms. Code that needs a guaranteed minimum gap (throttles, debounce) should keep the default.
Multiple services¶
The pattern scales to many services with no extra boilerplate:
runner = Runner()
runner.add(motion_detector)
runner.add(temperature_sensor, period_ms=5000)
runner.add_periodic(toggle_led, period_ms=500)
runner.add_periodic(log_status, period_ms=10000)
while True:
now_ms = runner.tick()
runner.wait(now_ms)
Batch firing¶
All services are checked first, then all due handlers fire in sequence. This guarantees that handlers see a consistent view of the world: no handler modifies state while other services are still being checked.
tick():
1. Capture ticks_ms() → now_ms
2. For each entry:
- Period gate: skip if not due
- Check gate: skip if check(now_ms) returns False
- Queue handler
3. Fire all queued handlers with now_ms
Memory notes¶
- Handlers are collected into a reused scratch list that keeps its high-water capacity across ticks, so batch-firing allocates nothing once the list has grown to the working set.
- No
collections.dequeor ring buffers are required.
Testing tasks¶
The chumicro_runner.testing module provides three host-test helpers:
CallRecorder: a callable that records handler invocations for assertion in host-side tests.validate_service: checks a service object against the coherence rules in The service contract and raisesValueErrornaming the offending member.FakePoller: a stand-in forselect.poll().ipollso unit tests can driveRunner.wait()without real file descriptors (CPython'sselect.pollneeds real fds that in-memory fake sockets do not have). Records everyregister/modify/unregister/ipollcall so tests can assert on what the runner did with the poll set;set_ready(obj, eventmask)queues a ready pair for the nextipollreturn.
CallRecorder¶
from chumicro_runner.testing import CallRecorder
from chumicro_timing.testing import FakeTicks
fake = FakeTicks()
recorder = CallRecorder()
runner = Runner(ticks=fake)
runner.add_periodic(recorder, period_ms=100)
runner.tick()
assert len(recorder) == 0 # not due yet
fake.advance(100)
runner.tick()
assert recorder.calls == [100]
FakePoller¶
import select
from chumicro_runner import IO_READ, Runner
from chumicro_runner.testing import FakePoller
from chumicro_timing.testing import FakeTicks
poller = FakePoller()
runner = Runner(ticks=FakeTicks(), poller=poller)
class _Service:
def __init__(self, sock):
self.io_socket = sock
def io_interest(self, now_ms): return IO_READ
def check(self, now_ms): return False
def handle(self, now_ms): pass
sock = object()
runner.add(_Service(sock), period_ms=100)
runner.wait(0)
assert (sock, select.POLLIN) in poller.register_calls
assert poller.ipoll_calls == [100]
See the testing helpers page for detailed usage.
Platform notes¶
All classes use only basic Python features and work identically on CPython, MicroPython, and CircuitPython. No abc, typing, or asyncio dependencies.
Examples¶
The examples directory contains complete runnable scripts:
| Example | What it shows |
|---|---|
basic_handler.py |
Simplest handler-only registration |
periodic_blink.py |
Periodic handler with add_periodic() |
sensor_threshold.py |
Object-based check/handle with simulated sensor |
multi_service.py |
Multiple services in one runner |
runtime_control.py |
TaskHandle for dynamic period changes and removal |
generator_basic.py |
Generator-driven service using sleep_until (no hardware) |
circuitpython_blink.py |
LED blink on CircuitPython hardware |
micropython_blink.py |
LED blink on MicroPython hardware |
circuitpython_button_led.py |
Button + LED gate pattern on CircuitPython |
micropython_button_led.py |
Button + LED gate pattern on MicroPython |
Simulated examples run on CPython. Hardware examples (circuitpython_* / micropython_*) require a real board; see the setup notes in each file.