Streams
The box has two live channels: the traffic it carries (Catch: native input and the USB bytes behind it) and its log lines (Logs & counters). What an event means is on those pages.
real mouse / keyboard the traffic the box carries
│ (also relayed (vendor endpoints, control
│ to the game PC) transactions, bus events)
▼ ▼
┌─────────────────┐ ┌─────────────┐
│ Medius box │ catch_events(filters) ─▶│ EventStream │ ─▶ recv() ─▶ CatchEvent
│ │ ├─────────────┤
│ │ input_events(filters) ─▶│ InputStream │ ─▶ recv() ─▶ InputEvent
│ │ ├─────────────┤
│ │ logs() ─▶│ LogStream │ ─▶ recv() ─▶ LogLine
└─────────────────┘ └─────────────┘catch_events returns the box's held-usage snapshots. input_events reads the same subscription and diffs the snapshots into press and release edges.
Every stream is a context manager and an iterable. with releases the subscription on exit, and for item in stream: drains it until the link drops.
SubscribeOpen an event, input, or log stream
All three are Device calls that send the box a subscribe request.
| Call | Returns | Channel |
|---|---|---|
dev.catch_events(filters) | EventStream | the subscribed traffic: input, raw HID, vendor endpoints, control transactions, bus events (see Catch) |
dev.input_events(filters) | InputStream | the same input, decoded into press and release edges (see below) |
dev.logs() | LogStream | device log lines (see Logs & counters) |
filters is one CatchFilter or an iterable of them, each an address (a CatchClass plus an id in it) with an optional direction and capture. The box holds a 32-entry table.
CatchFilter.watch(usage) # one button, key, or media usage CatchFilter.watch_axis(axis) # one Axis CatchFilter.watch_class(input_class) # every usage in one Class CatchFilter.watch_axes() # X, Y, the wheel and AC Pan CatchFilter.all_input() # a list: all four input classes CatchFilter.traffic(traffic_class, id) # one endpoint, interface, or EP number CatchFilter.traffic_class(tc) # every id in one TrafficClass CatchFilter.everything() # every class, every id, one table entry .with_direction(direction) # a Direction, default BOTH .with_capture(n) # bytes kept per event, default 0 = whole packet .on_press() / .on_release() # one edge of an input filter .inbound() / .outbound() # one flow of a traffic filter
Classes and their ids are on CatchClass; matching order on The table.
Subscribing checks the filters locally first. It raises CatchTableFullError when this process's subscriptions together need >32 entries (the box's table), CaptureNotApplicableError for a capture on an input class, and EmptySubscriptionError for no filters.
A box-side refusal gets no reply (subscribing is fire-and-forget). dev.query_catch() reads back what the box holds in CatchState.entries.
Every class at full length is more than the control link carries: under a busy mouse, a bulk trace goes undrained. Queue ranking and link budget: Delivery.
ReceiveBlocking, polling, timed, and iterated reads
Every stream has these four reads plus is_connected and close(). Shown for EventStream (yielding CatchEvent); InputStream and LogStream yield InputEvent and LogLine.
| Method | Returns | Behaviour |
|---|---|---|
recv() | CatchEvent | Blocks for the next item. Raises DisconnectedError when the link drops or the stream is closed. |
try_recv() | Optional[CatchEvent] | Returns immediately; None if nothing is queued. |
recv_timeout(ms) | Optional[CatchEvent] | Waits up to ms milliseconds; None on timeout. |
for ev in stream: | yields each item | Loops on recv(); ends when the link drops or the stream closes (no exception). |
clone() | EventStream | A second handle to the same subscription; the queue is shared. EventStream and LogStream only. |
is_connected | bool | Whether the box still delivers. A None from try_recv() or recv_timeout() can't tell a closed stream from an empty one. |
close() / with stream: | none | End the stream for every handle on it and free this one. A recv() blocked in another thread raises DisconnectedError at once. Automatic on with exit; garbage collection frees one handle and leaves the stream open for the others. |
import threading
from medius import CatchFilter, Device
def read(events):
for ev in events: # ends when the stream closes
print(ev)
with Device.find() as dev:
events = dev.catch_events(CatchFilter.everything())
reader = threading.Thread(target=read, args=(events,))
reader.start()
...
events.close() # the loop ends at once
reader.join()InputStream has no clone(); open a second one instead.
Event objects Dataclasses recv() returns
Every object here is a dataclass.
CatchEvent
├─ kind : CatchEventKind MOTION = 0 · USAGES = 1 · TRAFFIC = 2
├─ ts_us : int box microseconds; wraps every ~71.6 min
├─ clock : ClockDomain HOST_CHIP = 0 (real device side) · DEVICE_CHIP = 1 (clone side)
├─ payload : MotionEvent | UsageSnapshot | TrafficEvent
│
├─ .motion → MotionEvent | None None unless kind == MOTION
│ dx, dy, dz, pan
├─ .usages → UsageSnapshot | None None unless kind == USAGES
│ usages[], cls, direction, is_held(usage)
└─ .traffic → TrafficEvent | None None unless kind == TRAFFIC
catch_class, id, direction, flags,
true_len, bytes, truncated()| Payload | Fields | Methods |
|---|---|---|
MotionEvent | dx: int, dy: int, dz: int, pan: int (the relative deltas at the merge point) | none |
UsageSnapshot | usages: List[Usage] (buttons, keys, and media, one shape), cls: Class, direction: Direction | is_held(usage): the built Usage is in the snapshot |
TrafficEvent | catch_class: CatchClass, id: int, direction: Direction, flags: int, true_len: int, bytes: bytes | truncated(), setup(), data(), control_status(), transfer_status(), bus_event(), bulk_end_of_transfer(), zlp() |
InputEvent | kind: InputKind, usage: Optional[Usage], dx/dy/dz/pan, ts_us, clock | is_press, is_release |
LogLine | level: LogLevel, text: str | none |
Field meanings are on Types & errors; held usage ids come from the HID usage tables. flags is class-specific, read by one accessor per meaning: zlp() on the packet classes, bulk_end_of_transfer() on VENDOR_BULK, control_status() on CONTROL, transfer_status() on CLIP_TRANSFER, bus_event() on BUS.
Subtract two stamps only when their clock domains match; a Timeline puts them on this machine's clock.
EventStream and InputStream have a dropped property (int): events the host-side queue shed before you read them. The box-side count is on CatchState, box-wide and per entry. LogStream has none.
Consume loop Subscribe, iterate, react
from medius import (Device, CatchFilter, CatchEventKind, Usage, Button)
with Device.find() as dev:
filters = [
CatchFilter.watch_axes(), # cursor and wheel
CatchFilter.watch(Button.LEFT), # one button only
]
with dev.catch_events(filters) as events:
for ev in events: # ends when the link drops
if ev.kind == CatchEventKind.MOTION:
m = ev.motion
print(f"moved {m.dx},{m.dy} wheel {m.dz}")
elif ev.kind == CatchEventKind.USAGES:
if ev.usages.is_held(Usage.button(Button.LEFT)):
print("left held")
if events.dropped:
print("fell behind:", events.dropped, "dropped")from medius import Device, CatchFilter, CatchEventKind, TrafficClass
VI = TrafficClass.VENDOR_INTERRUPT
filters = [
CatchFilter.traffic_class(VI).with_capture(16), # the rest, 16 bytes
CatchFilter.traffic(VI, 0x83), # this one, whole packets
CatchFilter.traffic_class(TrafficClass.BUS), # resets, configures, detach
]
with Device.find() as dev:
with dev.catch_events(filters) as events:
for ev in events:
if ev.kind != CatchEventKind.TRAFFIC:
continue
t = ev.traffic
cut = " (cut)" if t.truncated() else ""
print(f"{ev.ts_us:>10} {ev.clock.name:<11} {t.catch_class.name:<16} "
f"ep={t.id:#04x} {t.direction.name:<8} "
f"{len(t.bytes)}/{t.true_len} bytes{cut} {t.bytes.hex(' ')}")truncated() separates a clipped capture from a short packet: a 16-byte capture of a 64-byte report and a real 16-byte report differ only in true_len.
events = dev.catch_events(CatchFilter.everything().with_capture(16))
while running:
ev = events.recv_timeout(50) # wake every 50 ms to do other work
if ev is None:
continue
handle(ev)st = dev.query_catch()
if st.table_full:
print("a filter was refused: the box's 32-entry table is full")
for e in st.entries:
print(f"{e.filter!r} dropped={e.dropped}")
print("box-wide dropped:", st.dropped, " clock age:", st.clock.age_ms)Decoded input Press and release edges, not snapshots
The box reports a held-usage snapshot per report; dev.input_events(filters) diffs each against the stream's held set and yields the edges.
| Member | Returns | Does |
|---|---|---|
recv() / try_recv() / recv_timeout(ms) | InputEvent | as on EventStream. Receives run one at a time; try_recv() returns None while another thread receives. |
held(input_class) | List[Usage] | Which usages of one Class this stream currently holds. |
dropped | int | Events the queue shed before you read them. |
Every filter must name an input class and cover both edges. A traffic class raises NotAnInputFilterError, everything() raises WildcardNotInputError, and one narrowed with on_press() raises HalfEdgeInputFilterError.
from medius import Class, CatchFilter, Device, InputKind
with Device.find() as dev:
with dev.input_events(CatchFilter.all_input()) as inputs:
for ev in inputs:
if ev.kind == InputKind.MOTION:
print(f"{ev.ts_us:>10} move {ev.dx},{ev.dy} wheel {ev.dz}")
else:
edge = "down" if ev.is_press else "up"
print(f"{ev.ts_us:>10} {ev.usage!r} {edge}")
print(" still held:", inputs.held(Class.KEY))TimelinePut box stamps on this machine's clock
A catch stamp is microseconds on a chip's clock. It wraps every ~71.6 minutes and relates to no clock on this machine. Timeline maps it onto this machine's clock.
| Member | Returns | Does |
|---|---|---|
observe(event, now_ns=None) | Stamped | Place one CatchEvent or InputEvent on this machine's clock; both kinds order on one timeline. now_ns defaults to time.monotonic_ns(). |
reset(domain) | none | Forget one ClockDomain's rollover count and measured floor, for a chip that rebooted. |
samples(domain) | int | Events observed for a domain; the floor is a minimum over these. |
Feed every event in arrival order. Each domain is tracked separately; the mapping improves as it runs.
from medius import CatchFilter, Device, Timeline
with Device.find() as dev:
with dev.catch_events(CatchFilter.watch_axes()) as events, Timeline() as time:
for ev in events:
at = time.observe(ev)
print(f"{at.host_ns:>16} ns box {at.box_us} us +{at.excess_ns} jitter")excess_ns is how much later than the measured floor the event arrived: jitter, not latency.
No async Build async on the non-blocking reads
The streams are synchronous, with no async def or await methods. To feed an event loop, run recv_timeout(ms) or try_recv() on a worker thread (or in run_in_executor). See Async.