SubscribeOpen an event, input, or log stream

All three are Device calls that send the box a subscribe request.

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

MethodReturnsBehaviour
recv()CatchEventBlocks 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 itemLoops on recv(); ends when the link drops or the stream closes (no exception).
clone()EventStreamA second handle to the same subscription; the queue is shared. EventStream and LogStream only.
is_connectedboolWhether 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:noneEnd 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.
CLOSE FROM ANOTHER THREAD
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 objectsDataclasses 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()
PayloadFieldsMethods
MotionEventdx: int, dy: int, dz: int, pan: 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(), transfer_status(), bus_event(), bulk_end_of_transfer(), zlp()
InputEventkind: InputKind, usage: Optional[Usage], dx/dy/dz/pan, 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, 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 loopSubscribe, 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")
TRAFFIC EVENTS
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.

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)
READBACK
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 inputPress 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.

MemberReturnsDoes
recv() / try_recv() / recv_timeout(ms)InputEventas 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.
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))

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.

MemberReturnsDoes
observe(event, now_ns=None)StampedPlace one CatchEvent or InputEvent on this machine's clock; both kinds order on one timeline. 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 arrival order. Each domain is tracked separately; 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 asyncBuild 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.