Calls & errors
The Python patterns: blocking, exceptions, lifecycle, buildersWhat every Device call looks like in Python. The full call list is on API index; the value types on Types & errors. What each command does lives in the Rust Library and Native API.
Fire-and-forget vs blocking
What the two badges meanEvery Device call carries one of two badges, by whether it waits for the box to answer or fires and forgets.
| Badge | Means | Which calls |
|---|---|---|
| Fire-and-forget | Queues a frame and returns at once. No reply is read. | move_rel, wheel, inject, press, soft_release, scale/lock/unlock, led, reset, reapply, set_movement_riding, set_bearing, set_emit_pace … |
| Blocks | Sends, then waits for the box's reply (or times out). | Device.open / find (the handshake), every query_* / caps / counters, and a stream recv() |
A Fire-and-forget call returning without raising means the frame was queued, not that the box acted on it. Confirm with a Blocks query like dev.query_health(). Blocking calls use the default reply wait, medius.default_query_timeout_ms() (1000 ms).
Errors
A failed call raises MediusErrorThere is no status return in Python: a failed call raises. The base type is MediusError (an Exception subclass); each status code (a Status IntEnum) maps to its own subclass. Catch MediusError for all of them.
class MediusError(Exception):
status: Status # the failure code
message: str # the box's last error text, may be ""
proto_ver: int # offending byte for BadProtoVerError, else 0
str(err) # "ERR_NOT_FOUND: no medius port found" (or only the name)Status | Subclass raised | Typically means |
|---|---|---|
ERR_IO | IoError | serial read/write failed |
ERR_NOT_FOUND | NotFoundError | no box at that path / none present |
ERR_NO_REPLY | NoReplyError | box never answered |
ERR_BAD_PROTO_VER | BadProtoVerError | firmware protocol mismatch (see .proto_ver) |
ERR_QUERY_TIMEOUT | QueryTimeoutError | a query_* outran its wait |
ERR_DISCONNECTED | DisconnectedError | the link dropped (see below) |
ERR_FRAME_TOO_LONG | FrameTooLongError | payload over the frame limit |
ERR_UPDATE | UpdateError | the box refused a firmware update op |
ERR_INVALID_ARG | InvalidArgError | a bad argument value |
ERR_PANIC | PanicError | the native core panicked |
ERR_UNKNOWN | MediusError | anything unmapped |
ERR_CATCH_TABLE_FULL | CatchTableFullError | the subscription needs more than the box's 32 entries |
ERR_EMPTY_SUBSCRIPTION | EmptySubscriptionError | a subscription with no filters |
ERR_CAPTURE_NOT_APPLICABLE | CaptureNotApplicableError | a Capture on an input class |
ERR_NOT_AN_INPUT_FILTER | NotAnInputFilterError | a traffic class passed to input_events |
ERR_WILDCARD_NOT_INPUT | WildcardNotInputError | CatchFilter.everything() passed to input_events |
ERR_HALF_EDGE_INPUT_FILTER | HalfEdgeInputFilterError | an input filter narrowed to one edge |
ERR_RESERVED_ID | ReservedIdError | an exact id equal to the blanket sentinel |
ERR_RELATIVE_DIRECTION | RelativeDirectionError | WITH or AGAINST where only a fixed sign or edge fits |
The last six are subscription refusals, raised before a frame reaches the box.
from medius import Device, MediusError, DisconnectedError
try:
with Device.find() as dev:
dev.move_rel(10, 0)
print(dev.query_health())
except DisconnectedError:
print("the link dropped mid-session")
except MediusError as e:
print(e.status, "-", e.message) # e.g. Status.ERR_NOT_FOUND - no medius port foundA dropped link raises DisconnectedError from a normal call, but a stream iterator (for ev in dev.catch_events(CatchFilter.everything()):) ends cleanly instead of raising. Calling recv() directly still raises. Box-side telemetry behind these errors is on Diagnostics.
Lifecycle
Open, clone, and release the handleA Device owns a native handle. Open one, optionally clone() it, and release it three ways. Connection sharing is on Connection and Lifecycle.
| Open with | Does |
|---|---|
Device.find() | First box found + handshake. Blocks |
Device.open(path) | One serial path + handshake. Blocks |
dev.clone() | Another handle to the same link; the connection is shared. |
MockBox().open() | Open over an in-process mock box (needs the mock feature). |
Device.find() ──┐
├──▶ one USB-serial link (stays up while ANY handle is open)
dev.clone() ────┘
each handle is freed on its own; the link closes with the last one| Route | When the handle frees |
|---|---|
with Device.find() as dev: | When the context manager block exits (__exit__). Preferred. |
dev.close() | Immediately. Idempotent, so it's safe to call twice. |
| garbage collection | Best-effort via __del__. Don't rely on timing. |
# preferred: the context manager closes on exit
with Device.find() as dev:
dev.move_rel(5, 5)
# or hand a clone to a worker thread and close each when done
dev = Device.find()
worker = dev.clone()
worker.close()
dev.close()EventStream, InputStream, LogStream, Timeline, and MockBox follow the same pattern: a with block, .close(), or GC. Streams keep the device open while open; see Streams.
Building targets
Usage · Motion · LockTarget · CatchFilterThe inject, move, lock, and catch calls take a target object, not a bare value. Build it with a classmethod, then pass it in.
| Builder | Feeds | What it makes |
|---|---|---|
Usage.button(button) | dev.inject(input, action), dev.press(input)see Inject | a mouse-button usage |
Usage.key(key) | a keyboard-key usage (keycodes) | |
Usage.media(media) | a consumer/media usage (usages) | |
Motion.cursor(dx, dy) | dev.move_axis(motion, timing, pending)see Move | a relative cursor nudge |
Motion.wheel(delta) | a wheel turn | |
LockTarget.x() / y() / wheel() | dev.lock(target, direction) / unlocksee Lock | an axis lock target |
LockTarget.usage(usage) (or button/key/media) | a usage lock target | |
CatchFilter.watch(usage) / .watch_axis(axis) / .watch_class(cls) / .watch_axes() / .all_input() | dev.catch_events(filters), dev.input_events(filters)see Catch | one subscription entry on an input class, addressed exactly as a lock is |
.traffic(tc, id) / .traffic_class(tc) / .everything() | one entry on a TrafficClass, or the wildcard across every class | |
.with_direction(direction) / .with_capture(n) / .on_press() / .inbound() | a refinement of one, returned as a new filter |
from medius import (Device, Usage, Motion, LockTarget, CatchFilter, TrafficClass,
Button, Action, Direction)
with Device.find() as dev:
dev.inject(Usage.button(Button.LEFT), Action.PRESS) # generic inject
dev.move_axis(Motion.cursor(10, -4)) # generic move (Ride / Keep by default)
dev.lock(LockTarget.button(Button.LEFT), Direction.BOTH)
stream = dev.catch_events([ # generic subscribe
CatchFilter.watch_axes(),
CatchFilter.traffic(TrafficClass.VENDOR_INTERRUPT, 0x83).with_capture(16),
])action is an Action (PRESS / SOFT_RELEASE / FORCE_RELEASE); the injection model defines what each does.
Usage.button takes a Button; Usage.key/media accept a Key/MediaKey or a raw int.
A filter's direction is the same Direction: on an input class it picks the press or release edge (PRESS / RELEASE), on a traffic class the flow (IN / OUT).