Connecting & lifecycleOpen, share, and release the link

See Connection and Lifecycle.

CallDoes
Device.open(path)Open a serial path and handshake.
Device.find()Open the first box found, or raise NotFoundError.
dev.clone()Another handle to the same link.
dev.close()Free the handle once no call on it is running (a call in another thread finishes first). A with block and GC call it automatically.
with Device.find() as dev:Context manager that closes the link on block exit.

DiscoveryEnumerate boxes and open one by identity

See Discovery.

CallDoes
medius.list_boxes(cap=16)Enumerate every connected box as a BoxInfo. Reads each box's version, and its device info if it speaks this protocol (device is None for a box on another protocol).
Device.open_by_id(id)Open the box whose identity matches id (device MAC hex or CH343 serial) and handshake. Raises BadProtoVerError when that box speaks another protocol, NotFoundError when no box matches.
Device.find_mouse_box()Open the first box whose clone is a mouse. If none is found and a connected box is on another protocol, raises BadProtoVerError (that box's clone can't be read).
Device.find_keyboard_box()Open the first box whose clone is a keyboard, with the same errors.

MovementRelative cursor and wheel

See Move. +x right, +y down.

CallDoes
dev.move_rel(dx, dy)Move the cursor by a signed 16-bit delta.
dev.wheel(delta)Scroll the wheel.
dev.move_rel_now(dx, dy)The same, bypassing movement riding.
dev.wheel_now(delta)Scroll, bypassing movement riding.
dev.pan(delta)AC Pan (horizontal scroll), a full peer of the wheel.
dev.pan_now(delta)Pan, bypassing movement riding.
dev.flush_motion()Emit the motion held by movement riding, now.
dev.discard_motion()Drop the motion held by movement riding.
dev.move_axis(motion, timing, pending)Drive one axis from a Motion.cursor(dx, dy), Motion.wheel(delta), or Motion.pan(delta), with a MoveTiming and a PendingMotion.

InjectDrive any usage: button, key, or media

See Inject and the injection model (press / soft-release / force-release). Every verb takes a Usage built with Usage.button / key / media; ids are on Usage IDs.

CallDoes
dev.inject(input, action)Apply an Action to a built Usage (button, key, or media usage).
dev.press(input)Sends Action.PRESS.
dev.soft_release(input)Sends Action.SOFT_RELEASE.
dev.force_release(input)Sends Action.FORCE_RELEASE.

The same call for all three: dev.press(Usage.button(Button.LEFT)), dev.press(Usage.key(Key.W)), dev.press(Usage.media(MediaKey.MUTE)).

LocksWeigh native input

See Lock. Build axis/usage targets with LockTarget.x/y/wheel/pan/usage (or the button/key/media shortcuts); a Direction picks a direction, and scale takes one of the scale constants.

CallDoes
dev.scale(target, direction, scale)Keep scale percent of an axis or usage (e.g. LockTarget.x(), LockTarget.key(Key.W)). Signed: a negative (axes only) reverses what it keeps, so -100 inverts an axis.
dev.scale_all(what, direction, scale)The same over a Blanket class (buttons, keys, media, aim, wheel).
dev.lock(target, direction)Block an axis or usage: scale 0.
dev.unlock(target, direction)Back to passing untouched: scale 100.
dev.lock_all(what, direction) / unlock_allBlanket block / release a whole class.

A scale auto-clears unless the keepalive holds it. Direction.WITH and Direction.AGAINST need a live bearing (dev.set_bearing(window_ms, mode)); their refusal rules are on Direction.

LED, admin & optionsStatus light, resets, persistent settings

CallDoes
dev.led(target, mode, level)Drive the status LED. See LED.
dev.reset()Clear all overrides. See Admin.
dev.factory_reset()Clear all overrides, then the box erases its stored name, options and learned devices and reboots. See factory_reset.
dev.reapply()Re-send the active settings.
dev.reconnect()Rescan, reopen this box, and re-apply held state (reconnect). Raises BadProtoVerError if the box replies on another protocol, and the handle stays disconnected.
dev.reboot(target)Reboot a chip to run or download mode.
dev.allow_imperfect_clones(allow)Opt in to cloning a device the box can't clone faithfully; this also opens the advanced control layer. If the toggle re-presents the clone, the session is released, and the library re-sends what it holds once the new clone is up. See Options.
dev.set_movement_riding(window_ms)Set the riding window in ms, or None to turn it off.
dev.set_emit_pace(pace, force_hz=None)Pick the pace (EmitPace.learned() / .interval() / .fixed(hz)) and the advertised rate (force_hz, None = native). See Options.
dev.set_name(name)Set the box's human-readable name (1-32 printable ASCII characters). See Name.
dev.clear_name()Clear the name, back to the synthesised default. Read it back on Version.name.
dev.set_bearing(window_ms, mode)Set what Direction.WITH / AGAINST are measured against; None turns the bearing off. mode is a BearingMode.
dev.set_spread(percent)Spread each injected delta over percent of the command interval: 0 puts the whole delta on the next report, 100 spreads it over one interval, >100 overlaps. The box boots at 100. See Options.
dev.set_render(mode, full)Pick the texture (RenderMode) and whether native motion is rendered by the model rather than relayed. Both go in one frame, so full is required, not defaulted. A mode outside RenderMode raises ValueError and sends nothing. full is off on a box that has not been set. See Options.

QueriesRead box state; each blocks for one reply

See Requests. Each returns a dataclass from Types & errors.

CallReturns
dev.query_version()Version: protocol + firmware version.
dev.query_health()Health: link, mouse, clone, injection flags.
dev.device_info()DeviceInfo: the cloned device's USB identity, kind, and product.
dev.caps()Caps: mouse/keyboard capabilities.
dev.query_rate()Rate: native report rate and poll period.
dev.query_stats()Stats: box-side telemetry.
dev.query_locks()Locks: every weighed direction (.entries, .scale_of(...), .is_locked(...)).
dev.query_catch()CatchState: the live filter table (.entries, .table_full), drop counts, and the two chips' ClockEstimate.
dev.query_imperfect()ImperfectStatus: imperfect-clone state.
dev.query_movement_riding()int ms, or None when off.
dev.query_emit_pace()EmitPaceStatus: pacing mode, rate in effect, and the rate the clone advertises.
dev.query_bearing()Bearing: the bearing window and geometry.
dev.query_render()RenderStatus: the texture, whether native motion goes through it, and whether a profile has armed.
dev.query_spread()SpreadStatus: how far an injected delta is spread, and the interval in effect.
dev.firmware_info()FirmwareInfo: both chips' versions, slots, and what is staged.
dev.counters()Counters: host-side wire counters.

Firmware updateWrite either chip over the open connection

See Firmware update. Staging blocks for the whole transfer and calls progress(sent, total) as windows are acknowledged. A refusal raises UpdateError.

CallDoes
dev.stage_firmware(target, image, progress=None)Write one image into that chip's spare slot, without booting it. target is an UpdateTarget.
dev.activate_firmware()Commit everything staged and boot into it, host chip first.
dev.abort_update(target)Throw a staged or in-flight transfer away.
dev.update_firmware(target, image, progress=None)Stage one image and activate it in a single call. A refused activate aborts the staged image before UpdateError is raised.
dev.wait_firmware_confirmed()FirmwareInfo once neither chip is on probation, polling the box through the reboot after an activate. After 55 s it raises UpdateError if a chip is still on probation, QueryTimeoutError if the box has not replied.

StreamsSubscribe to live input and logs

Consuming events is on Streams, the catch feature on Catch, and logs on Logs & counters.

CallReturns
dev.catch_events(filters)EventStream of the subscribed traffic: input, raw HID, vendor endpoints, control transactions, bus events.
dev.input_events(filters)InputStream of decoded press and release edges, and motion. Every filter must name an input class and cover both edges.
dev.logs()LogStream of device log lines.

filters takes one CatchFilter or an iterable of them, each naming a CatchClass and an id inside it, with an optional Direction and Capture.

Box-side refusals get no reply; dev.query_catch() reads what the box holds.

Buffered clip playbackPreload a per-frame stream, box-clocked

Build a stream with ClipBuilder, then drive it with the ClipHandle from dev.clip(). See Clip.

CLIPBUILDER
CallAppends
ClipBuilder() / .clear()A new builder (chainable); reset for reuse.
.byte_len()Ring bytes the entries take; compare with ClipStatus.free before an append.
.gap(frames)A gap run (0 = no-op).
.move(dx, dy) / .wheel(dz) / .pan(dpan)A cursor / wheel / pan (horizontal scroll) motion frame.
.press(usage) / .release(usage) / .force_release(usage)A one-edge press / soft-release / force-release frame; usage is a Usage (button, key, or media).
.edge(usage, action)A one-edge frame for any Usage with an explicit Action (default press).
.raw(ep, direction, data)A frame carrying one raw report, as dev.raw sends one; played only with the imperfect-clone opt-in on.
.transfer(ep, setup, out=b"")A frame carrying one control transfer, as dev.transfer runs one: out is setup.length bytes for an OUT request, empty for an IN one. The reply arrives as a TrafficClass.CLIP_TRANSFER event. The box runs it only while the opt-in is on.
.frame(dx=0, dy=0, wheel=0, pan=0, edges=(), raw=(), transfers=())One frame carrying the motion deltas plus (usage, action) edges, (ep, direction, data) raw reports, and (ep, setup, out) transfers, within the clip constants.
EXAMPLE
from medius import Action, Button, ClipBuilder, Setup, Usage

b = ClipBuilder()

# move (+10, -4) and press Left on the same frame
b.frame(dx=10, dy=-4, edges=[(Usage.button(Button.LEFT), Action.PRESS)])

# the next tick queues a SET_REPORT to the real device
b.transfer(0, Setup(0x21, 0x09, 0x0300, 0, 2), bytes([0x04, 0x01]))
CLIPHANDLE
CallEffect
dev.clip()A ClipHandle (owns the append-seq counter).
clip.append(builder)Append the builder's entries to the ring. Every entry is checked first, so a refusal sends nothing: ClipFrameCountError, ClipFrameTooLongError, ClipTransferDataError, RawDirectionError, or RelativeDirectionError.
clip.set_autolock(blankets)Set the auto-lock scope: a list of Blanket classes locked while the clip plays.
clip.set_loop(on) / clip.set_retain(on)Loop the ring on completion; retain entries after playback instead of flushing.
clip.set_ride(on)Run the clip's motion under movement riding (off = the box's clock, the default). While rendering is on with a profile armed, only the clip's wheel and pan ride.
clip.finalize()Fix a retained clip's end so it can replay and loop.
clip.bind(trigger)Bind a ClipTrigger: a native Usage + Edge fires a ClipAction (up to 8).
clip.unbind(usage, edge)Remove one input trigger by usage + edge.
clip.bind_packet(trigger)Bind a ClipPacketTrigger. A matched packet runs its ClipAction on the frame clock's next tick; only the most specific matching trigger acts. A trigger the box would refuse raises ClipPacketTriggerError before anything is sent: the crate's refusals, or a selector_len without once_per_run. A bind the box itself refuses leaves the set unchanged: compare clip.query_config()'s readback with what was bound.
clip.unbind_packet(trigger)Remove the packet trigger with that trigger's (traffic_class, id, direction, match_bytes, mask). Its other fields are ignored. A key the box cannot hold is refused, as by bind_packet().
clip.clear_triggers()Remove every trigger of both kinds.
clip.start() / clip.stop()Begin playback; stop and flush the ring, releasing the auto-lock.
clip.pause() / clip.resume()Halt playback in place; carry on from where it paused.
clip.restart() / clip.toggle()Replay from the first frame; start if idle else stop.
clip.clear()Drop the ring's entries.
clip.lost()bool: the box dropped the clip appended since the last clear (its device chip restarted, the box released the session, or a reconnect found the ring empty). Set once the box can take a reload; reset by the next append or clear. No wire traffic. See lost.
clip.query_status()ClipStatus: ring depth, playback state, held usages, counters.
clip.query_config()ClipSettings: auto-lock, loop, retain, finalized, and both kinds of trigger, each packet trigger with its hits.

MockBox scripts the clip queries and runs a packet through the packet triggers.

Advanced control layerRaw injection, control transfers, rewrite rules, descriptor patches

Gated on the imperfect-clone opt-in, dev.allow_imperfect_clones(True). See Raw injection, Control transfers, Rewrite rules, and Descriptor patches. With it off: set_rewrite and apply_patch raise ImperfectRequiredError; the box drops dev.raw (the library sends it unchecked); a transfer returns TransferStatus.REFUSED without raising. Queries, removes, clears and set_patch need no opt-in.

CallDoes
dev.raw(ep, direction, data)Write data verbatim on cloned endpoint number ep. direction is Direction.IN (toward the game PC) or OUT (to the real device).
dev.transfer(ep, setup, out=b"", timeout_ms=None)Run one control transfer; returns a TransferOutcome. timeout_ms replaces the default reply wait.
dev.set_rewrite(rule)Install or overwrite one RewriteRule. A payload larger than the room the held rules leave in the 2048-byte pool raises RewritePoolFullError. A new rule past 32 raises RewriteTableFullError.
dev.remove_rewrite(rule)Drop the rule with this rule's key.
dev.clear_rewrite()Drop the whole rewrite table.
dev.query_rewrite()The RewriteTable summary.
dev.query_rewrite_entry(index)One rule in full, in the shape set_rewrite takes.
dev.set_patch(patch)Store one Patch (empty bytes removes it).
dev.apply_patch()If the stored patch set differs from the one the clone serves, re-present the clone with it (one replug). The re-clone releases the session, and the library re-sends what it holds once the new clone is up (session recovery). See apply_patch.
dev.clear_patch()Erase the real device's stored set. A clone serving patches re-presents unpatched (one replug), releasing the session as apply_patch does.
dev.query_patches()The PatchSet and its apply state.
dev.query_patch_entry(index)One patch in full.

TransformsSwap or remap a field on the wire

Faithful field transforms, no opt-in. See Transform. An axis argument is an Axis; remap takes LockTargets (or a Usage), so it can move a button onto a key or media usage. To weigh or reverse a field, use dev.scale (signed percent).

CallDoes
dev.transform(t)Install or overwrite one Transform.
dev.untransform(t)Drop the transform with the same (source, dest) key.
dev.clear_transforms()Drop the whole transform table.
dev.transform_swap(a, b)Exchange two axes.
dev.transform_remap(source, dest)Move a source field into a destination.
dev.query_transforms()The Transforms table, in the order the box applies it.

Mock boxIn-process fake box for tests, feature-gated

MockBox needs a library built with the mock feature. Without it, MockBox() raises RuntimeError; check medius.HAS_MOCK. Building it is on Build & features, the concept on Mock.

OPEN
CallDoes
MockBox()A fresh mock that records every frame and replies to queries. A context manager: with MockBox() as mock: frees it on exit, as mock.close() does.
mock.open()A Device over the mock, after the handshake.
mock.with_device()A Device over the mock, with no handshake.
Device.open_mock(mock) / Device.with_mock(mock)The same two, named as in Rust.
mock.clone()Another handle to the same mock state.
SCRIPT
CallDoes
mock.set_version, set_health, set_device_info, set_caps, set_mouse_caps, set_kbd_caps, set_rate, set_stats, set_locks, set_catch_stateSet each query's reply.
mock.set_imperfect_status(status)Set the ImperfectStatus dev.query_imperfect() returns. With allowed false the mock drops its consuming packet triggers, as the box does.
mock.set_transfer_reply(status, data=b"")Set a transfer's reply status and IN data while the opt-in is on. With it off, the mock replies REFUSED.
mock.set_movement_riding, set_bearing, set_emit_pace, set_spread_learned, set_render, set_advertised_hzSet the option queries' replies.
mock.set_clip_status(status)Set the ClipStatus clip.query_status() returns.
mock.set_clip_settings(settings)Set the ClipSettings clip.query_config() returns. Its packet triggers bind in order, as clip.bind_packet() binds them, under the opt-in state that set_imperfect_status scripted. For a consuming trigger, script that first. The reply is these settings plus the triggers bound on the mock. set_retain, finalize, input binds and playback are recorded as frames and leave the reply as scripted.
mock.clip_packet(traffic_class, id, direction, head)Tuple[Optional[ClipAction], bool]: run one packet through the mock's packet triggers, as the box does. The most specific matching trigger counts the packet in its hits. The action is None when no trigger matches, or when the trigger is once_per_run and the packet continues a run. The bool is whether the trigger consumes the packet.
mock.restart()Simulate a device-chip restart: the mock drops its session state, keeps what it stores, and sends its hello now and again on the next frame it receives. See restart.
mock.link_lost()Simulate the inter-chip link dropping and coming back. The mock releases the session a host set (counted in Stats.session); the clone stays up.
mock.detach(back_within_grace=False)Simulate the real device detaching: the mock releases the session. With back_within_grace the real device re-attaches inside the 250 ms grace and the clone stays up; without, the clone is down until attach().
mock.attach()Simulate the real device attaching: a clone that was up is cloned again, which releases the session. See restart and release.
mock.silent()Stop replying to queries, for timeout tests. One-way; frames are still recorded.
mock.refuse_next_activate(status)Refuse the next ACTIVATE with update status status, keeping what is staged.
mock.reboot_after_activate(unanswered, pending)After each accepted activate, leave unanswered firmware queries with no reply, then report the device chip on probation for the next pending queries.
mock.push_raw(data), push_log(level, text), push_motion, push_usages, push_trafficPut bytes, a log line or a catch event on the inbound stream, as the box sends them.
INSPECT
CallDoes
mock.recorded()int: how many frames the host has sent.
mock.saw(frame_type)bool: at least one frame of that FrameType was sent.
mock.recorded_frame(idx)Optional[RecordedFrame]: frame idx's type, SEQ and payload; None past the end.
mock.clear_recorded()Empty the record.
EXAMPLE
from medius import (ClipAction, ClipPacketTrigger, Direction, FrameType, ImperfectStatus,
                    MockBox, TrafficClass)

with MockBox() as mock:
    dev = mock.open()                 # a Device over the mock, after the handshake
    mock.set_imperfect_status(ImperfectStatus(allowed=True, over_capacity=False,
                                              clone_imperfect=False))
    clip = dev.clip()
    clip.bind_packet(ClipPacketTrigger(TrafficClass.HID_IN, 2, Direction.IN, ClipAction.START,
                                       match_bytes=b"\x07\x20", mask=b"\xFF\x20",
                                       once_per_run=True, selector_len=1, consume=True))

    head = b"\x07\x20\x00"
    assert mock.clip_packet(TrafficClass.HID_IN, 2, Direction.IN, head) == (ClipAction.START, True)
    assert mock.clip_packet(TrafficClass.HID_IN, 2, Direction.IN, head) == (None, True)  # same run

    mock.set_imperfect_status(ImperfectStatus(allowed=False, over_capacity=False,
                                              clone_imperfect=False))
    assert clip.query_config().packet_triggers == []   # the consuming trigger went with the opt-in

    assert mock.saw(FrameType.CLIP_TRIGGER)
    frames = [mock.recorded_frame(i) for i in range(mock.recorded())]
    bind = next(f for f in frames if f.type == FrameType.CLIP_TRIGGER)
    print(bind.payload.hex(" "))      # 04 02 00 01 00 07 01 02 07 20 ff 20

Library functionsTop-level helpers on medius

CallDoes
medius.find_ports(cap=16)List present medius ports as PortInfo, including the CH343 serial.
medius.list_boxes(cap=16)Enumerate every connected box as a BoxInfo. See Discovery.
medius.default_query_timeout_ms()The default query reply wait (1000 ms).
medius.default_transfer_timeout_ms()The default control-transfer reply wait (1500 ms).
medius.default_keepalive_cadence_ms()The default keepalive interval (500 ms).
medius.abi_version()The C ABI version the library exposes. import medius raises ImportError when it isn't the ABI the package was built for.
medius.version_string()The library version string.