Streams
Consume live input and device logsThe box has two live channels: the traffic it carries (Catch: physical input, and the USB bytes behind it) and its own log lines (Logs & counters). What an event means lives on those pages.
physical mouse / keyboard the traffic the box carries
│ (also forwarded (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
└─────────────────┘ └─────────────┘The first two read the same subscription. catch_events hands you the box's own held-usage snapshots; input_events diffs them into press and release edges first.
Every stream is a context manager and iterable. Use with so the subscription is released on exit, and for item in stream: to drain it until the link drops.
Subscribe
Open a stream from a DeviceAll three calls live on the Device and send a subscribe request to the box.
| 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 names an address, a CatchClass plus an id inside that class, with an optional direction and capture. The box holds them as 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 and the wheel 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
| CatchClass | Value | id addresses | Yields |
|---|---|---|---|
BUTTON / KEY / MEDIA | 0 / 1 / 2 | a button slot, key usage, or Consumer usage | UsageSnapshot |
AXIS | 3 | X, Y, or the wheel | MotionEvent |
HID_IN | 4 | a HID interface number | TrafficEvent |
HID_OUT | 5 | an interrupt-OUT endpoint address | |
VENDOR_INTERRUPT | 6 | a vendor interrupt endpoint address | |
VENDOR_BULK | 7 | a vendor bulk endpoint address | |
CONTROL | 8 | a control endpoint number (0 = EP0) | |
EMIT | 9 | an emitting endpoint address | |
BUS | 10 | nothing; the bus lifecycle |
Matching is most-specific-first: an exact (class, id) is matched before a blanket, and a blanket before everything(). Full semantics on Types and Catch.
Subscribing checks the filters here and raises: CatchTableFullError past 32 entries, CaptureNotApplicableError for a capture on an input class, EmptySubscriptionError for none.
What the box then refuses is fire-and-forget and gets no reply: read it back with dev.query_catch(), whose CatchState.entries is what it holds.
The control link runs at 4 Mbaud and vendor bulk alone measures 250 KiB/s through the box. Events drain in four strict-priority queues (input and bus, then byte-oriented classes, then control, then vendor bulk), so a busy mouse can starve a bulk trace.
Receive
Block, poll, time out, or iterateEvery stream has the same four read methods plus close(). The table shows EventStream (yielding CatchEvent); InputStream and LogStream are identical with InputEvent or LogLine in place of it.
| Method | Returns | Behaviour |
|---|---|---|
recv() | CatchEvent | Blocks for the next item. Raises DisconnectedError when the link drops. |
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 cleanly when the link drops (no exception). |
clone() | EventStream | A second handle to the same subscription; the queue is shared. EventStream and LogStream only. |
close() / with stream: | none | Release the subscription. Automatic on with exit and GC. |
InputStream has no clone(); open a second one instead.
Event objects
What recv() hands backEvery 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
├─ .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 (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(), bus_event(), bulk_end_of_transfer(), bulk_zlp() |
InputEvent | kind: InputKind, usage: Optional[Usage], dx/dy/dz, 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, and each class has an accessor that reads it: bulk_end_of_transfer() / bulk_zlp() on VENDOR_BULK, control_status() on CONTROL, bus_event() on BUS.
Subtract two stamps only when their clock domains match. To put them on this machine's clock, use a Timeline.
EventStream and InputStream both have a dropped property (an int): events the queue shed before you read them. That is the host-side count; the box-side one is on CatchState, both box-wide and per entry. LogStream has no such counter.
Consume loop
Subscribe, iterate, reactfrom 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 genuinely 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 snapshotsThe box reports held usages as a snapshot per report. dev.input_events(filters) diffs those against what it holds and yields the edges they represent.
| Member | Returns | Does |
|---|---|---|
recv() / try_recv() / recv_timeout(ms) | InputEvent | as on EventStream. |
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))Timeline
Put box stamps on this machine's clockA catch stamp is microseconds on a chip that booted before this process did. It wraps every ~71.6 minutes and relates to no clock here. Timeline maps it onto one.
| Member | Returns | Does |
|---|---|---|
observe(event, now_ns=None) | Stamped | Place one CatchEvent on this machine's clock. 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 as it arrives, in order. Each domain is tracked separately, and 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 it on the timeout / non-blocking readsThe streams are synchronous: there are 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.