Terminal UI configurator for Shure USB audio interfaces (MVX2U, MV6) on Linux and macOS. Replaces the Windows/Mac-only ShurePlus MOTIV Desktop app by talking to the device directly over USB HID. Single-crate Rust binary. Prefer the simple, obvious solution over clever abstractions.
Before considering any change complete:
cargo clippy --features probe -- -D warnings && cargo fmt --check && cargo test
--features probe is required — shurectl-probe is feature-gated and clippy skips it otherwise.
For non-trivial features, explore the relevant code and confirm a plan before implementing.
src/
main.rs # Entry point, CLI args (--demo, --list, --mute), event loop, apply_action()
app.rs # App state: Tab, Focus, DeviceState, DeviceAction events
device.rs # hidapi wrapper: open device, send/receive HID reports
meter.rs # cpal capture: dBFS metering, RollingWindow, PeakWindow
presets.rs # Host-side presets: TOML load/save/delete, PresetSlot
protocol.rs # Packet encoding, CRC-16/ANSI, command constructors, apply_response()
ui.rs # ratatui rendering: 5 tabs (Main | EQ | Dynamics | Presets | Info) + help overlay
bin/probe.rs # Maintainer-only HID address sweeper — builds only under `--features probe`
Control flow: key event → handle_key() → DeviceAction → apply_action() (main.rs) → device.rs → protocol.rs packet.
Meter flow: cpal callback → meter_level (AtomicI32) + peak_window (Mutex) → read by ui.rs each render tick.
Layering rules (strict):
apply_action()inmain.rsis the only place that writes to the device. Never calldevice.rsfromui.rsorapp.rs.- Raw protocol byte values live only in
protocol.rsas named constants. Never hardcode bytes elsewhere. src/bin/probe.rsbuilds asshurectl-probeonly under--features probe. It is a maintainer tool and must never ship to end users viacargo installor Homebrew.
Every packet is exactly 64 bytes (65 with hidapi's report-ID byte 0), sent via hid_write()
/ hid_read() on /dev/hidrawN — not the USB audio class interface, and not
HIDIOCSFEATURE/HIDIOCGFEATURE.
[0x01] [0x11] [0x22] [seq] [0x03] [0x08] [len] [0x70] [len] [cmd0..cmd2] [feat_addr..] [value..] [crc_hi] [crc_lo] [pad..]
^report ID ^magic, never changes CRC-16/ANSI covers 0x11 onward
- USB IDs: VID
0x14ED, PID0x1013 - CRC: CRC-16/ANSI — poly
0x8005, init0x0000, reflected in/out (NOT CCITT-FALSE) - SET + CONFIRM: every SET must be immediately followed by a
CMD_CONFIRMpacket or the device won't apply the change - State readback: no monolithic GET_STATE.
device.rs::get_state()issues individualcmd_get_*packets;apply_response()dispatches on the 2-byte feature address and writes intoDeviceState. Feature address → field mapping is documented inline inprotocol.rs.
When a command misbehaves on real hardware, capture packets before guessing:
sudo modprobe usbmon
lsusb | grep -i shure # find bus number
sudo wireshark -i usbmonN # filter: usb.transfer_type == 0x01
Compare captures against cmd_* constructor output. Firmware-version differences almost
always show up as FEAT_* addresses or value encoding — fix in protocol.rs only. If a
byte offset or command value is uncertain, say so and propose verifying with usbmon rather
than assuming.
Follow this sequence, no skipped steps:
protocol.rs—FEAT_*constant,cmd_get_*/cmd_set_*constructors,apply_response()branch decoding intoDeviceStatedevice.rs— typedget_*/set_*methods onMvx2u; add getter to thegettersslice inget_state()if part of full readbackapp.rs—DeviceActionvariant if user-triggerable; wire intoadjust_focused()ortoggle_focused()main.rs— handle the variant inapply_action()ui.rs— UI element if neededprotocol.rs— roundtrip test for the new packetREADME.md— update protocol table and keyboard shortcuts
Tabselects the visible panel;Focusselects the active control within itadjust_focused()handles ←/→ for sliders;toggle_focused()handles Enter/Space for booleans and enum cycling- Both return
Option<DeviceAction>—Nonemeans UI-only change, no HID write - Preset name editing lives in
main.rs::handle_key()(nottoggle_focused()): whenediting_preset_nameis true, chars append, Enter commits (PersistPresetName), Esc cancels
meter.rs runs a cpal capture stream on a background thread, publishing via Arc:
meter_level: Arc<AtomicI32>— instantaneous peak dBFS × 10, lock-freepeak_window: Arc<Mutex<PeakWindow>>—short(0.3 s) window drives bar height;long(3.0 s) drives peak-hold marker
start_meter() returns MeterStatus; the caller must keep the Stream in
MeterStatus::Running alive — dropping it stops capture. The meter does not start in demo mode.
TOML files in ~/.config/shurectl/presets/, 4 fixed slots (preset_1.toml–preset_4.toml).
- Mirror types:
presets.rsdefinesSer*enums with serde derives soprotocol.rstypes stay serde-free — on-disk format is decoupled from internal enum evolution PresetSlotcaptures all DSP settings fromDeviceState; identity fields (serial_number,firmware_version) are excluded and preserved on applyload_all_presets()runs at startup; missing files →NoneDeviceActionvariants:SavePreset,LoadPreset(applies then sends all SETs),DeletePreset,PersistPresetName
--demo runs with device: None; send_if_connected() silently succeeds. All app state
changes still apply — only HID writes are skipped. Demo mode must always remain fully navigable.
See Cargo.toml for versions. Usage notes that matter:
ratatui— useFrame::render_widget(), not direct buffer writescrossterm— handleKeyEventKind::Pressonlyhidapi—linux-nativefeature,/dev/hidrawNaccesscpal— default input device onlylibc— stderr suppression (dup/dup2) during cpal ALSA/JACK probinganyhow— all fallible functions returnanyhow::Result<T>tempfile(dev) — hermetic temp dirs inpresets.rstests
- No
unwrap()/expect()in production paths; nopanic!()outside tests; notodo!()/unimplemented!()in final code - No
println!()—eprintln!()only at startup; use the TUI status bar otherwise - Prefer borrowing; justify every
.clone() - Exhaustive match arms — avoid wildcard
_that silently swallows variants - Meaningful names (
gain_dbnotg); delete replaced code, no versioned function names - Validate packet arguments before encoding (clamp, don't panic)
- Never write firmware-update packets — those byte sequences are intentionally omitted (see readme legal section)
| Situation | Approach |
|---|---|
| New protocol command | Roundtrip test in protocol.rs first |
| Packet encoding changes | Test CRC correctness and 64-byte length invariant |
| State decode changes | Test apply_response() with hand-crafted response buffers |
| Focus/navigation changes | Manual test in --demo mode |
main() / CLI args |
No tests |
Performance is not a concern (~100 ms input-driven tick rate) — no benchmarks unless a specific bottleneck is identified.