Medius - BindingsStreams

Streams

Consume live input and device logs

The 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 Device

All three calls live on the Device and send a subscribe request to the box.

CallReturnsChannel
dev.catch_events(filters)EventStreamthe subscribed traffic: input, raw HID, vendor endpoints, control transactions, bus events (see Catch)
dev.input_events(filters)InputStreamthe same input, decoded into press and release edges (see below)
dev.logs()LogStreamdevice 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
CatchClassValueid addressesYields
BUTTON / KEY / MEDIA0 / 1 / 2a button slot, key usage, or Consumer usageUsageSnapshot
AXIS3X, Y, or the wheelMotionEvent
HID_IN4a HID interface numberTrafficEvent
HID_OUT5an interrupt-OUT endpoint address
VENDOR_INTERRUPT6a vendor interrupt endpoint address
VENDOR_BULK7a vendor bulk endpoint address
CONTROL8a control endpoint number (0 = EP0)
EMIT9an emitting endpoint address
BUS10nothing; 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 iterate

Every 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.

MethodReturnsBehaviour
recv()CatchEventBlocks 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 itemLoops on recv(); ends cleanly when the link drops (no exception).
clone()EventStreamA second handle to the same subscription; the queue is shared. EventStream and LogStream only.
close() / with stream:noneRelease the subscription. Automatic on with exit and GC.

InputStream has no clone(); open a second one instead.

Event objects

What recv() hands back

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
 ├─ .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()
PayloadFieldsMethods
MotionEventdx: int, dy: int, dz: int (the relative deltas at the merge point)none
UsageSnapshotusages: List[Usage] (buttons, keys, and media, one shape), cls: Class, direction: Directionis_held(usage): the built Usage is in the snapshot
TrafficEventcatch_class: CatchClass, id: int, direction: Direction, flags: int, true_len: int, bytes: bytestruncated(), setup(), data(), control_status(), bus_event(), bulk_end_of_transfer(), bulk_zlp()
InputEventkind: InputKind, usage: Optional[Usage], dx/dy/dz, ts_us, clockis_press, is_release
LogLinelevel: LogLevel, text: strnone

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, 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")
TRACE A VENDOR ENDPOINT AND THE BUS
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.

NON-BLOCKING POLL
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)
CONFIRM THE BOX TOOK THEM
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 held usages as a snapshot per report. dev.input_events(filters) diffs those against what it holds and yields the edges they represent.

MemberReturnsDoes
recv() / try_recv() / recv_timeout(ms)InputEventas on EventStream.
held(input_class)List[Usage]Which usages of one Class this stream currently holds.
droppedintEvents 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.

EXAMPLE
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 clock

A 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.

MemberReturnsDoes
observe(event, now_ns=None)StampedPlace one CatchEvent on this machine's clock. now_ns defaults to time.monotonic_ns().
reset(domain)noneForget one ClockDomain's rollover count and measured floor, for a chip that rebooted.
samples(domain)intEvents 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.

EXAMPLE
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 reads

The 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.