clipOpen a clip handle

fn clip(&self) -> ClipHandle

No round-trip

Returns a ClipHandle bound to this box. Keep one per clip session: it owns the append-sequence counter the box uses to spot a dropped append.

A clip plays on any clone. The keepalive holds a loaded clip, its settings and both kinds of trigger past the silence window. If the link is down for longer, the box clears them, and the clip and config need reloading. A device-chip restart or a released session empties the ring. The library then re-sends the settings and triggers, and lost flags the clip for reload.

allow_imperfect_clones(false) causes a partial release. It drops consuming packet triggers and queued transfer items. Unless the toggle also re-presents the clone, it keeps the ring, its settings and every other trigger.

The ring is 64 KiB with PSRAM and 16 KiB without. ClipStatus::free on a cleared ring is the size.

ClipBuilderBuild the entry stream

fn new() -> ClipBuilder

No round-trip

Each method appends one per-frame entry. Motion is a relative delta. An edge is an Action that stays held until a later frame changes it. A gap NAKs like an idle mouse.

MethodAppends
gap(frames)N idle frames (0 is a no-op).
move_by(dx, dy)a cursor-motion frame.
wheel(dz)a wheel frame.
pan(dpan)a pan (horizontal scroll) frame.
press / release / force_release(usage)a one-frame press, soft-release, or force-release of any Usage (button, key, or media), like Device::press.
edge(usage, action)a one-edge frame for any Usage with an explicit Action.
raw(ep, direction, bytes)a frame carrying one raw report.
transfer(ep, setup, out)a frame carrying one control transfer.
frame(frame)one frame carrying whatever a ClipFrame holds.

They take &mut self and return &mut Self, for chaining or a loop. clear() reuses the allocation, and byte_len() is the ring space the entries take. Each one-field call wraps frame.

EXAMPLE
use medius::{ClipBuilder, Button, Key};

let mut clip = ClipBuilder::new();
for _ in 0..200 { clip.move_by(10, 0); }   // 200 box-timed frames of +10 dx
clip.press(Button::LEFT)                   // a click, held for 20 frames
    .gap(20)
    .release(Button::LEFT);
clip.press(Key::A)                         // then type 'a'
    .gap(3)
    .release(Key::A);

frame fills more than one field, so a move and an edge share one entry and one emitted report.

EXAMPLE
use medius::{Button, ClipBuilder, ClipFrame};

let mut clip = ClipBuilder::new();

// move (+10, -4) AND press Left on the same frame
clip.frame(ClipFrame::new().move_by(10, -4).press(Button::LEFT));

// press once, keep moving while held, then release
clip.frame(ClipFrame::new().move_by(8, -2).press(Button::LEFT));
for _ in 0..60 { clip.move_by(8, -2); }   // Left stays down (edges are sticky)
clip.release(Button::LEFT);

ClipFrameBuild one multi-field frame

fn new() -> ClipFrame

No round-trip

One frame carries any mix of motion, edges, raw reports, and control transfers, ≤512 encoded bytes (CLIP_ENTRY_MAX). Each method takes self and returns the frame.

MethodDoes
move_by(dx, dy)Set the cursor motion, a relative delta.
wheel(dz) / pan(dpan)Set the wheel or the pan (horizontal scroll) motion.
press / release / force_release(usage)Add a press, soft-release, or force-release edge of any Usage.
edge(usage, action)Add an edge with an explicit Action. A frame holds ≤8 (CLIP_EDGES_MAX).
raw(ep, direction, bytes)Add a raw report, as Device::raw sends one. A frame holds ≤8 (CLIP_RAW_MAX), sent in order ahead of the frame's report.
transfer(ep, setup, out)Add a control transfer, as Device::transfer runs one: out is setup.length bytes for an OUT request, empty for an IN one. Each reply is a TrafficClass::ClipTransfer catch event.
byte_len()The ring bytes the frame takes: ≤512 for a frame append accepts.

Raw reports and transfers need allow_imperfect_clones on as the frame plays. With it off, the box discards them and counts each in ClipStatus::gated.

EXAMPLE
use medius::{Button, ClipBuilder, ClipFrame, Direction, Setup};

let mut clip = ClipBuilder::new();
clip.frame(
    ClipFrame::new()
        .move_by(4, -2)
        .press(Button::LEFT)
        .raw(2, Direction::OUT, [0x10, 0xFF, 0x05])
        .transfer(0, Setup::new(0x21, 0x09, 0x0300, 0, 2), [0x04, 0x01]),
);
device.clip().append(&clip)?;

ClipHandleFill the ring, configure, and drive playback

From Device::clip(). Every method below is fire-and-forget. query_status reads ring depth and playback state. query_config reads the config back.

LOAD AND SETTINGS
MethodDoes
append(clip: &ClipBuilder)Send a ClipBuilder's entries to the ring. A large clip is split into whole-entry frames with contiguous append seqs. Every entry is checked first, so a refusal sends nothing.
set_autolock(scope: &[Blanket])Which input groups to lock while playing (clip-owned, released on stop).
set_loop(on: bool)Loop playback at the clip end (retained mode only).
set_retain(on: bool)Retain the clip so it can rewind and replay (false = streaming, the default). Set before the first append.
set_ride(on: bool)Make the clip's motion wait for native motion under movement riding (false = the box's clock, the default). Changeable mid-playback. With rendering on and a profile armed, it applies to wheel and pan only.
finalize()Close a retained clip: fix its end so it can replay and loop.
TRIGGERS
MethodDoes
bind(trigger: ClipTrigger)Add or overwrite an input trigger: a native edge drives an action on the box, no host round-trip.
unbind(usage, edge: Edge)Remove the input trigger on that usage and edge.
bind_packet(trigger: &ClipPacketTrigger)Add or overwrite a packet trigger: a matched packet drives an action on the frame clock's next tick.
unbind_packet(trigger: &ClipPacketTrigger)Remove the packet trigger with that trigger's key, ignoring its action, consume, once_per_run and selector_len. A key the box can't hold is refused, as bind_packet refuses it.
clear_triggers()Remove every trigger of both kinds.
ENGINE VERBS
MethodDoes
start()Rewind to the clip start and play (resume from a pause).
stop()Stop, and release held input and the clip lock. A streaming clip flushes; a retained clip rewinds and is kept.
pause() / resume()Halt mid-clip keeping the cursor and held input / continue from the paused cursor.
restart()Force a rewind and play, even mid-playback.
toggle()Play if idle/paused, stop if playing.
clear()Discard the loaded clip, free the ring, clear a fault.

A dropped append or an overflow faults the clip (ClipState::Faulted) and stops it. A faulted stream is missing entries: clear and rebuild.

lostWhether the box dropped the loaded clip

fn lost(&self) -> bool

No round-trip

RETURNS
ValueWhen
trueA clip appended since the last clear is gone from the box: its device chip restarted, the box released the session, or a reconnect found the ring empty. Goes true once the box can take a reload.
falseNothing appended, the clip still on the box, or an append or clear since.

A recovery restores the settings and triggers, not the ring's content. The caller rebuilds and appends the clip.

EXAMPLE
let handle = device.clip();
handle.append(&clip)?;

// later, from a loop that owns the clip
if handle.lost() {
    handle.append(&clip)?;   // reload; lost() reads false again
}

Streaming and retainedDrain-and-discard, or keep-and-replay

set_retain picks the mode before the first append.

AspectStreaming (default)Retained
Turn ondefaultset_retain(true) before the first append
Ringeach entry freed as it plays; unboundedentries kept after playing, up to the ring size
Top up mid-playyes, appends to the tail in real timeuntil finalize, then sealed
Replayno, plays onceyes, rewinds and replays
loopnot availableavailable once finalized
stopflushes the bufferrewinds and keeps the clip
Best foropen-ended or generated inputa fixed macro replayed on a trigger
streaming (drain-and-discard)
    append --> [ e4 e3 e2 ] --> play --> freed     (unbounded, top up forever)
                   box reclaims each entry once played; no replay

retained (keep-and-replay, up to the ring size)
    append --> [ e0 e1 e2 e3 e4 ] --> finalize --> sealed
               base |--> play cursor --> end
                    +---- start / loop rewinds to base <----+

An append after finalize is rejected. To reload a retained clip, clear it and build again.

EXAMPLE
// Streaming: preload, play with auto-lock, then top up in real time, pacing against free.
use medius::{Blanket, ClipState};
use std::time::Duration;

let handle = device.clip();       // device: an open Device
handle.set_autolock(&[Blanket::Aim])?;   // lock only X and Y while playing
handle.append(&clip)?;                    // preload (clip, next_chunk: built ClipBuilders)
handle.start()?;

loop {
    let s = handle.query_status()?;
    if s.state == ClipState::Idle { break; }           // done, or stopped
    if s.free as usize > next_chunk.byte_len() {
        handle.append(&next_chunk)?;                   // stream more while there's room
    }
    std::thread::sleep(Duration::from_millis(5));
}
handle.stop()?;

TriggersRun a clip action on a native edge or a matched packet

A trigger runs one ClipAction on the box, with no host round-trip. One set holds both kinds.

  a native edge                   a packet at a traffic surface
  button, key, media              HidIn, HidOut, VendorInterrupt,
        |                         VendorBulk, Control, Emit
        v                               |
  [ input triggers ]  8                 v
    bind, unbind                  [ packet triggers ]  8
        |                           bind_packet, unbind_packet
        | action                        | action       | packet
        v                               v              v
        +-----> ClipAction <------------+        [ rewrite table ]
                run on the box                     reads it next, unless
                                                   the trigger consumed it
                                                       |
                                                       v
                                                   delivered
AspectInput triggerPacket trigger
TypeClipTriggerClipPacketTrigger
Fires ona press or release edge of a button, key, or media usagea packet on a traffic surface, by a masked head compare
Keyusage and edgeclass, id, direction, match and mask
Box capacity88, with 112 match bytes between them
.consume()locks the usage for the holddrops every packet the trigger matches as the top-ranked trigger. Needs the imperfect-clone opt-in.
Callsbind, unbindbind_packet, unbind_packet
Reads back intriggerspacket_triggers, each with its hits

The keepalive holds bound triggers past the silence window, and a reconnect reads the set back. A drop shorter than the window leaves every trigger bound; a longer one clears the set.

INPUT TRIGGERS
PartMeaningExample
usagethe button, key, or media the edge is on (or any of a class)Key::F1
edgewhich transition fires it: press, release, or bothEdge::Press
actionthe engine verb to runClipAction::Start

Input triggers are keyed by (usage, edge), like a lock. A native edge runs only the most specific match: Key::F1 before an any-key trigger.

GoalBind
Hold to playF1 Press -> Start and F1 Release -> Stop
Toggle play/stop on one keySide1 Press -> Toggle
Separate play and stop keysF1 Press -> Start and F2 Press -> Stop
Pause, then resumeF3 Press -> Pause and F4 Press -> Resume

On a press binding, .consume() locks the trigger usage while the trigger stays active. On an Edge::Release binding it has no effect.

EXAMPLE
use medius::{Button, ClipAction, ClipTrigger, Edge, Key};

let clip = device.clip();
clip.set_retain(true)?;      // set the mode before loading
clip.append(&recording)?;
clip.finalize()?;            // close it so it can replay

// Hold-to-play: F1 down starts, F1 up stops. The press binding's consume locks F1
// for the whole hold; the release binding needs none.
clip.bind(ClipTrigger::new(Key::F1, Edge::Press, ClipAction::Start).consume())?;
clip.bind(ClipTrigger::new(Key::F1, Edge::Release, ClipAction::Stop))?;

// Or one side-button that toggles play/stop:
clip.bind(ClipTrigger::new(Button::SIDE1, Edge::Press, ClipAction::Toggle))?;
PACKET TRIGGERS

A packet trigger is clip config. It shares the clip's lifetime (held by the keepalive, cleared with the clip on a hard stop) and its read-back. It sits beside the rewrite table and reads each packet as it arrived, independent of the table. One packet can both fire a trigger and have a rule act on it.

catch_events shows the address and bytes to match: a HidIn event's id is the interface number and its bytes are the report. The box's rules are on CLIP_TRIGGER.

PartMeaning
addressthe TrafficClass, the id within it (the interface number for HidIn, the endpoint number for the rest, or ANY_ID), and the Direction
actionthe engine verb to run, on the frame clock's next tick: within one frame of the matching packet, 1 ms at the default pace
.matching()head bytes and their mask, of one length, ≤16 bytes. Without it, every packet on the address matches.
.consume()drop every packet the trigger matches as the top-ranked trigger, whether or not the action runs on it. A consumed HidIn report is dropped whole, motion and buttons included. A release edge in it is emitted with the next report.
.once_per_run()run the action on the first packet of a run of matching ones. The first selector_len match bytes select the stream (a report ID, say), and the rest are the condition.
TriggerNeeds the opt-inReason
a trigger that watchesnoIt alters no byte the PC or the real device receives.
a trigger that consumesyesIt drops traffic, as a Drop rewrite rule does.
GoalBind
Hold a vendor button to playtwo once_per_run triggers: the pressed bytes to Start, the released bytes to Stop
Keep that button from the PC.consume() on the pressed trigger, under allow_imperfect_clones(true)
Restart when the PC writes a reportHidOut or Control, Direction::OUT, to Restart

A run starts on the first matching packet the trigger reads. If a trigger's condition already holds when it is bound, it fires on the next packet of its stream. So on a real device that reports every poll, a release trigger bound with the button up fires once. Bind the press trigger first and the release trigger while the button is held, or accept the one action.

RANK

Of the triggers a packet matches, only the most specific acts on it: an exact id over ANY_ID, more masked bits over fewer, IN or OUT over Both, then the one bound earlier. hits counts only packets matched as the top-ranked trigger. An outranked trigger's hits stays still, though it keeps tracking its run.

ReportTop-ranked triggerResult
07 20 ..[07 20]/[FF 20], 9 masked bitsits action runs and the report is relayed
07 00 ..a consuming [07]/[FF], 8 masked bitsits action runs and the report is dropped
08 20 ..neitherthe report is relayed
REFUSED

bind_packet returns Error::ClipPacketTrigger before anything is sent, and reason says which check failed. A With or Against direction returns Error::RelativeDirection.

TriggerReason
Bus or ClipTransfer as the classNeither is a surface packets cross.
a match and a mask of different lengths, or >16 bytes (PKT_MATCH_MAX)The box compares them byte for byte over the packet head.
a match bit outside its maskNo packet can match it.
Direction::OUT on HidIn or Emit, Direction::IN on HidOutThe class never carries that flow.
.consume() on ControlA control transfer always runs to completion.
.once_per_run() on Control, with ANY_ID, or with Direction::BothA run is over one stream.
.once_per_run() with a selector ≥ the match length, or condition bytes with no masked bitThe bytes past the selector are the condition. A condition every packet meets never ends its run.

The box makes three checks that bind_packet can't: a consuming trigger needs allow_imperfect_clones(true), the set holds 8 triggers (CLIP_PKT_TRIG_MAX), and their match bytes share a pool of 112 (CLIP_PKT_MATCH_POOL). A bind the box refuses leaves the set as it was: a new key isn't held, and a held key keeps its old trigger. Compare what query_config reads back with what was bound. Turning the opt-in off removes the consuming triggers.

EXAMPLE
use medius::{ClipAction, ClipPacketTrigger, Direction, TrafficClass};

device.allow_imperfect_clones(true)?;   // first: a consuming trigger needs it
let clip = device.clip();

// Interface 2 is a vendor-page HID interface. Report ID 7 carries a button in bit 5 of its
// second byte, repeated every poll while held, so the report ID is the selector.
let held = ClipPacketTrigger::new(TrafficClass::HidIn, 2, Direction::IN, ClipAction::Start)
    .matching([0x07, 0x20], [0xFF, 0x20])
    .once_per_run(1)
    .consume();                   // the press never reaches the PC: needs the opt-in
let let_go = ClipPacketTrigger::new(TrafficClass::HidIn, 2, Direction::IN, ClipAction::Stop)
    .matching([0x07, 0x00], [0xFF, 0x20])
    .once_per_run(1);
clip.bind_packet(&held)?;
clip.bind_packet(&let_go)?;       // bound with the button up: one Stop on the next report

// A SET_REPORT on EP0 restarts the clip. A trigger that only watches needs no opt-in.
let set_report = ClipPacketTrigger::new(TrafficClass::Control, 0, Direction::OUT, ClipAction::Restart)
    .matching([0x21, 0x09], [0xFF, 0xFF]);
clip.bind_packet(&set_report)?;

// A refused bind leaves the set unchanged: compare the read-back with what was bound.
let bound = clip.query_config()?.packet_triggers;
for t in [&held, &let_go, &set_report] {
    match bound.iter().find(|e| &e.trigger == t) {
        Some(e) => println!("{:?} id {} -> {:?}, {} hits", t.class, t.id, t.action, e.hits),
        None => println!("the box did not take {t:?}"),
    }
}

clip.unbind_packet(&let_go)?;   // by key: action, consume, once_per_run and selector_len are ignored
clip.clear_triggers()?;         // both kinds

On AsyncDeviceAsyncClipHandle: control fires, queries await

AsyncDevice::clip() returns an AsyncClipHandle that keeps append, the settings, both kinds of trigger, the engine verbs and lost synchronous. query_status().await and query_config().await are futures.

EXAMPLE
let device = Device::find()?.into_async();
let handle = device.clip();
handle.append(&clip)?;          // sync, no await
handle.start()?;                // sync
let s = handle.query_status().await?;   // the query awaits