Imported from Humblemonk/shurectl (
AGENTS.md). Install upstream withnpx skills add Humblemonk/shurectl. Copyright stays with the author.
shurectl
Terminal UI configurator for Shure USB audio interfaces (MVX2U Gen 1/Gen 2, MV6, MV7+) on Linux and macOS. Talks to the device directly over USB HID, replacing the Windows/Mac-only ShurePlus MOTIV Desktop app. Single-crate Rust binary. Prefer the simple, obvious solution over clever abstractions.
File-specific detail lives in .claude/rules/. Read the matching file before editing:
protocol.md for protocol.rs/device.rs/probe.rs (HID packet format, usbmon
debugging), meter.md for meter.rs/main.rs, presets.md for presets.rs. Claude Code
loads these automatically.
Commands
cargo run -- --demo mv7plus # no hardware; also mvx2u, mvx2u-gen2, mv6
cargo run -- --list # list connected devices
Verification gate — run before calling any change complete:
cargo clippy --features probe -- -D warnings && cargo build --all-targets --features probe \
&& cargo fmt --check && cargo test
--features probeis required; otherwise clippy skipsshurectl-probe.cargo build --all-targetscatches dead imports in#[cfg(test)]code that clippy never compiles. Read thecargo testoutput for warnings; don't just grep fortest result.- Clippy isn't run with
--all-targetsyet because of pre-existingfield_reassign_with_defaultwarnings inapp.rstests. Fold them into struct literals before tightening. - CI runs jscpd (budget 7% in
.github/linters/.jscpd.json, ~5.5% actual). Check withnpx jscpd -c .github/linters/.jscpd.json .. Fix duplication by extending a shared helper; don't raise the threshold.
Architecture
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, built only under `--features probe`
Control flow: key event → handle_key() → DeviceAction → apply_action() (main.rs) →
device.rs → protocol.rs packet.
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. shurectl-probemust never ship to end users viacargo installor Homebrew.- Never write firmware-update packets. Those byte sequences are intentionally omitted (see the readme legal section).
Demo mode: --demo runs with device: None, and send_if_connected() silently succeeds.
State changes still apply; only HID writes are skipped. Demo mode must stay fully navigable.
Adding a New Command
Follow this sequence without skipping steps:
protocol.rs:FEAT_*constant,cmd_get_*/cmd_set_*constructors, and anapply_response()branch decoding intoDeviceStatedevice.rs: typedget_*/set_*methods onShureDevice. If it's part of full readback, add the getter to thegettersslice in theget_state_*()function of every model that supports it (get_state()dispatches per model)app.rs:DeviceActionvariant if user-triggerable; wire it intoadjust_focused()ortoggle_focused()main.rs: handle the variant inapply_action()ui.rs: UI element if needed. Follow Cross-Device UI Consistency belowpresets.rs: if it's a DSP setting, add it toPresetSlot,from_device_state(), andapply_to_device_state()so presets capture itprotocol.rs: roundtrip test for the new packetREADME.md: update the protocol table and keyboard shortcuts, noting which models support the command if it isn't universal
TUI / Focus Model
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 a UI-only change with no HID write - Preset name editing lives in
main.rs::handle_key(), nottoggle_focused(). Whileediting_preset_nameis true, chars append, Enter commits (PersistPresetName), and Esc cancels
Cross-Device UI Consistency
A user owns one device but reads one readme, one help overlay, and one set of screenshots.
If "Gain Lock" on the Gen 2 is "Lock" on the MV6, the docs stop matching reality. Divergence
is also the largest maintenance cost here: ui.rs has eight draw_main_left_* variants and
four-way DeviceModel matches in draw_main_right() and draw_info_tab(), and app.rs
matches on DeviceModel in reset_focus_for_tab(), focus_next(), and focus_prev().
The rule: any control that exists on more than one model has identical label, units, value formatting, keybinding, and position relative to its neighbours on all of them. Only genuinely device-exclusive hardware features may differ.
Shared across all models unless the hardware makes it impossible:
- Tab set and order:
Tab::ALLis the source of truth. Models filter it, never reorder it - Keybindings: Tab/Shift-Tab cycles tabs, ↑/↓ moves focus, ←/→ adjusts, Enter/Space
toggles,
?help. No model-specific keys - Focus traversal: mode → mute → gain → monitor mix → device extras. Models skip entries they lack; they don't reshuffle
- Labels, units, formatting: "Gain", "Monitor Mix", "Denoiser" are spelled the same everywhere. dB values, percentages, and enum names render through the same code path
- Drawing helpers:
draw_mode_block(),draw_mute_block(),draw_monitor_mix_gauge(),draw_gain_lock_block(),draw_phantom_block(),segmented_span(),draw_main_shared(). Extend a helper with a parameter rather than forking a near-copy - Status and error wording: same phrasing for the same condition on every model
Hiding vs. locking (the convention from draw_tabs(), applied to controls too):
- Permanently unsupported on this hardware → hide it (Reverb/LED on non-MV7+)
- Supported but currently unavailable → show it with 🔒 and a notice (EQ/Dynamics on Gen 1
in Auto mode, via
draw_tab_locked_notice()) - Never leave a control visible and focusable but inert. It reads as a bug
Before adding anything model-specific, in order:
- Do other models have this capability under a different vendor name? Use the existing
shurectl name and control. Vendor naming stops at
protocol.rs. - Can an existing shared helper render it? Extend the helper.
- If a per-model
draw_*fork is genuinely required, keep block order, borders, and spacing identical to its siblings. - Update every
DeviceModelmatch: focus fns, bothdraw_main_*,draw_info_tab(), and preset serialization. Wherever_was used, a missing arm is a silent UX divergence rather than a compile error. - Verify with
--demo mvx2u,--demo mvx2u-gen2,--demo mv6, and--demo mv7plus.
Antipatterns:
- Copying
draw_main_left_gen2_manual()to bootstrap a new model and tweaking strings - A keybinding only one model responds to
- Reordering tabs or focus for one model because it "reads better" there
- Hardcoding a model name in a shared helper instead of passing behaviour in
- Documenting a shortcut in
README.mdthat only works on some devices without saying so
Rust Rules
- No
unwrap()/expect()in production paths; nopanic!()outside tests; notodo!()/unimplemented!()in final code - No
println!(). Useeprintln!()only at startup, and the TUI status bar after that anyhow::Result<T>for all fallible functions- Prefer borrowing; justify every
.clone() - Exhaustive match arms; no 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)
ratatui: useFrame::render_widget(), not direct buffer writes.crossterm: handleKeyEventKind::Pressonly
Testing
| 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 for all four models |
main() / CLI args |
No tests |
Performance is not a concern (~100 ms input-driven tick). No benchmarks unless a specific bottleneck has been identified.
Workflow
- For non-trivial features, explore the relevant code and confirm a plan before implementing.
- If a byte offset or command value is uncertain, say so and propose a usbmon capture rather than guessing.
- When a cross-model UI divergence is unavoidable, name the affected models in the commit/PR body.