valentine

Terminal control panel for the Focusrite Scarlett 18i20 — a from-scratch replacement for Focusrite Control.
Log | Files | Refs | README | LICENSE

main.rs (11794B)


      1 //! Phase 0 feasibility spike for scarlett-tui.
      2 //!
      3 //! Goal: prove that on macOS we can speak the Focusrite "scarlett2" control
      4 //! protocol to a Scarlett 18i20 3rd Gen over endpoint 0, WITHOUT taking the
      5 //! audio streaming interfaces away from Core Audio — i.e. replicate what
      6 //! Focusrite Control does, from userspace, in Rust.
      7 //!
      8 //! It is deliberately chatty and defensive: every step prints what happened so
      9 //! we can read the real macOS behaviour rather than guess it.
     10 //!
     11 //! Run with Focusrite Control QUIT (only one process may own the device):
     12 //!     cargo run -p spike
     13 //!
     14 //! Protocol facts (from the GPL Linux driver sound/usb/mixer_scarlett2.c):
     15 //!   * control transfers on EP0, recipient = interface
     16 //!       TX: bmRequestType 0x21 (Class|Interface|OUT), bRequest 2 (CMD_REQ)
     17 //!       RX: bmRequestType 0xA1 (Class|Interface|IN),  bRequest 3 (CMD_RESP)
     18 //!   * 16-byte little-endian header: cmd u32, size u16, seq u16, error u32, pad u32
     19 //!   * seq increments by 1 per request; response echoes cmd/seq, error must be 0
     20 
     21 use std::time::Duration;
     22 
     23 use anyhow::{anyhow, bail, Context, Result};
     24 use rusb::{Direction, Recipient, RequestType, TransferType, UsbContext};
     25 
     26 const VID: u16 = 0x1235; // Focusrite / Novation
     27 const PID: u16 = 0x8215; // Scarlett 18i20 3rd Gen
     28 
     29 // scarlett2 opcodes
     30 const CMD_INIT_1: u32 = 0x0000_0000;
     31 const CMD_INIT_2: u32 = 0x0000_0002;
     32 const CMD_GET_SYNC: u32 = 0x0000_6004;
     33 const CMD_GET_DATA: u32 = 0x0080_0000;
     34 
     35 const REQ: u8 = 2; // CMD_REQ  (host -> device)
     36 const RESP: u8 = 3; // CMD_RESP (device -> host)
     37 const CMD_INIT: u8 = 0; // bRequest for the raw "step 0" priming read
     38 const HDR: usize = 16;
     39 const TIMEOUT: Duration = Duration::from_millis(1000);
     40 
     41 fn main() {
     42     if let Err(e) = run() {
     43         eprintln!("\n\x1b[31mSPIKE FAILED:\x1b[0m {e:#}");
     44         eprintln!("\nIf this is an access/ownership error, make sure Focusrite Control");
     45         eprintln!("is fully quit (it holds the device exclusively).");
     46         std::process::exit(1);
     47     }
     48 }
     49 
     50 fn run() -> Result<()> {
     51     let ctx = rusb::Context::new().context("create libusb context")?;
     52 
     53     let device = ctx
     54         .devices()?
     55         .iter()
     56         .find(|d| {
     57             d.device_descriptor()
     58                 .map(|x| x.vendor_id() == VID && x.product_id() == PID)
     59                 .unwrap_or(false)
     60         })
     61         .ok_or_else(|| anyhow!("Scarlett 18i20 3rd Gen ({VID:04x}:{PID:04x}) not found"))?;
     62 
     63     let desc = device.device_descriptor()?;
     64     println!("== Device ==");
     65     println!(
     66         "  {:04x}:{:04x}  bDeviceClass={:#04x} subclass={:#04x} configs={}",
     67         desc.vendor_id(),
     68         desc.product_id(),
     69         desc.class_code(),
     70         desc.sub_class_code(),
     71         desc.num_configurations()
     72     );
     73 
     74     // --- 1. Enumerate interfaces + endpoints; find the control interface and a
     75     //        candidate interrupt-in (notification) endpoint. ----------------
     76     let config = device
     77         .active_config_descriptor()
     78         .context("read active config descriptor")?;
     79 
     80     let mut control_iface: Option<u8> = None;
     81     let mut notify: Option<(u8, u8)> = None; // (interface number, endpoint address)
     82 
     83     println!("\n== Interfaces ==");
     84     for iface in config.interfaces() {
     85         for d in iface.descriptors() {
     86             let ifn = d.interface_number();
     87             let class = d.class_code();
     88             let sub = d.sub_class_code();
     89             println!(
     90                 "  iface {ifn} alt {}  class={class:#04x} subclass={sub:#04x} endpoints={}",
     91                 d.setting_number(),
     92                 d.num_endpoints()
     93             );
     94             // The scarlett2 control protocol (Gen3+) lives on the VENDOR-SPECIFIC
     95             // interface (class 0xFF) that carries the interrupt notify endpoint —
     96             // NOT the USB-Audio control interface. wIndex must be this interface.
     97             if class == 0xFF {
     98                 control_iface.get_or_insert(ifn);
     99             }
    100             for ep in d.endpoint_descriptors() {
    101                 let addr = ep.address();
    102                 let is_int = ep.transfer_type() == TransferType::Interrupt;
    103                 let is_in = ep.direction() == Direction::In;
    104                 println!(
    105                     "      ep {addr:#04x} {} {}",
    106                     if is_in { "IN " } else { "OUT" },
    107                     transfer_type_name(ep.transfer_type())
    108                 );
    109                 if is_int && is_in {
    110                     notify.get_or_insert((ifn, addr));
    111                 }
    112             }
    113         }
    114     }
    115 
    116     // Prefer the vendor-specific interface; otherwise whichever carries the
    117     // interrupt-in endpoint; last resort interface 0.
    118     let ctl_iface = control_iface.or(notify.map(|(i, _)| i)).unwrap_or(0);
    119     println!("\n  -> using control interface {ctl_iface}");
    120     if let Some((ifn, ep)) = notify {
    121         println!("  -> candidate notify endpoint {ep:#04x} on interface {ifn}");
    122     } else {
    123         println!("  -> no interrupt-in endpoint found (will rely on polling)");
    124     }
    125 
    126     // --- 2. Open the device. This is the first macOS gate: can a userspace app
    127     //        open the device while Core Audio drives the audio interfaces? ---
    128     let handle = device.open().context(
    129         "open device — if this fails with 'access denied' or 'busy', quit Focusrite Control",
    130     )?;
    131     println!("\n\x1b[32mOK\x1b[0m device opened (Core Audio should still be streaming)");
    132 
    133     // --- 3. scarlett2 INIT handshake over EP0 (mirrors the kernel exactly). ---
    134     //
    135     //   step 0: raw control-IN, bRequest = CMD_INIT (0) — primes the device.
    136     //   INIT_1: packet cmd 0, seq reset to 1 (the "seq sent=1, response=0" quirk).
    137     //   INIT_2: packet cmd 2, seq reset to 1; firmware version is u32 LE at byte 8.
    138     println!("\n== INIT step 0 (raw read, bRequest=0) ==");
    139     {
    140         let rt_in = rusb::request_type(Direction::In, RequestType::Class, Recipient::Interface);
    141         let mut buf = [0u8; 24];
    142         match handle.read_control(rt_in, CMD_INIT, 0, ctl_iface as u16, &mut buf, TIMEOUT) {
    143             Ok(n) => println!("  ok, {n} bytes: {}", hex(&buf[..n])),
    144             Err(e) => eprintln!("  step 0 failed (continuing): {e}"),
    145         }
    146     }
    147 
    148     let mut seq: u16 = 1;
    149 
    150     println!("\n== INIT_1 ==");
    151     // seq already 1
    152     match cmd(&handle, ctl_iface, &mut seq, CMD_INIT_1, &[], 0) {
    153         Ok(data) => println!("  ok ({} payload bytes){}", data.len(), maybe_hex(&data)),
    154         Err(e) => return Err(e).context("INIT_1 — the handshake gate"),
    155     }
    156 
    157     println!("\n== INIT_2 (device info / firmware) ==");
    158     seq = 1; // reset per kernel
    159     match cmd(&handle, ctl_iface, &mut seq, CMD_INIT_2, &[], 32) {
    160         Ok(data) => {
    161             println!("  ok, {} bytes: {}", data.len(), hex(&data));
    162             if data.len() >= 12 {
    163                 let fw = u32::from_le_bytes(data[8..12].try_into().unwrap());
    164                 println!("  \x1b[36mfirmware version: {fw}\x1b[0m");
    165             }
    166         }
    167         Err(e) => eprintln!("  INIT_2 failed (non-fatal for the gate): {e:#}"),
    168     }
    169 
    170     println!("\n== GET_SYNC (clock lock status) ==");
    171     match cmd(&handle, ctl_iface, &mut seq, CMD_GET_SYNC, &[], 16) {
    172         Ok(data) => println!("  ok, {} bytes: {}  (nonzero usually = locked)", data.len(), hex(&data)),
    173         Err(e) => eprintln!("  GET_SYNC failed: {e:#}"),
    174     }
    175 
    176     println!("\n== GET_DATA (read config offset 0, 8 bytes) ==");
    177     // GET_DATA payload = { offset: u32 LE, size: u32 LE }
    178     let mut p = Vec::new();
    179     p.extend_from_slice(&0u32.to_le_bytes()); // offset 0
    180     p.extend_from_slice(&8u32.to_le_bytes()); // 8 bytes
    181     match cmd(&handle, ctl_iface, &mut seq, CMD_GET_DATA, &p, 8) {
    182         Ok(data) => println!("  ok, {} bytes: {}", data.len(), hex(&data)),
    183         Err(e) => eprintln!("  GET_DATA failed: {e:#}"),
    184     }
    185 
    186     // --- 4. Notification endpoint: try to claim the interface and read it.
    187     //        On macOS this likely fails (kernel owns the audio interface) — in
    188     //        which case we fall back to polling, which is fine. ---------------
    189     if let Some((ifn, ep)) = notify {
    190         println!("\n== Notify endpoint (claim + interrupt read, 500ms) ==");
    191         match handle.claim_interface(ifn) {
    192             Ok(()) => {
    193                 println!("  claimed interface {ifn}; waiting for a notification...");
    194                 let mut buf = [0u8; 64];
    195                 match handle.read_interrupt(ep, &mut buf, Duration::from_millis(500)) {
    196                     Ok(n) => println!("  got {n} bytes: {}", hex(&buf[..n])),
    197                     Err(rusb::Error::Timeout) => {
    198                         println!("  no notification within 500ms (expected when idle)")
    199                     }
    200                     Err(e) => eprintln!("  interrupt read error: {e}"),
    201                 }
    202                 let _ = handle.release_interface(ifn);
    203             }
    204             Err(e) => {
    205                 println!(
    206                     "  could not claim interface {ifn}: {e}\n  -> EXPECTED on macOS; we'll use polling for live updates."
    207                 );
    208             }
    209         }
    210     }
    211 
    212     println!("\n\x1b[32mSPIKE PASSED\x1b[0m — EP0 control works; build the TUI on this foundation.");
    213     println!("Confirm audio kept playing throughout, then hand the device back to Focusrite Control if needed.");
    214     Ok(())
    215 }
    216 
    217 /// One scarlett2 request/response round-trip over EP0.
    218 fn cmd(
    219     handle: &rusb::DeviceHandle<rusb::Context>,
    220     iface: u8,
    221     seq: &mut u16,
    222     cmd: u32,
    223     payload: &[u8],
    224     resp_payload_cap: usize,
    225 ) -> Result<Vec<u8>> {
    226     let this_seq = *seq;
    227     *seq = seq.wrapping_add(1);
    228 
    229     // request packet: 16-byte header + payload
    230     let mut req = Vec::with_capacity(HDR + payload.len());
    231     req.extend_from_slice(&cmd.to_le_bytes());
    232     req.extend_from_slice(&(payload.len() as u16).to_le_bytes());
    233     req.extend_from_slice(&this_seq.to_le_bytes());
    234     req.extend_from_slice(&0u32.to_le_bytes()); // error
    235     req.extend_from_slice(&0u32.to_le_bytes()); // pad
    236     req.extend_from_slice(payload);
    237 
    238     let rt_out = rusb::request_type(Direction::Out, RequestType::Class, Recipient::Interface);
    239     handle
    240         .write_control(rt_out, REQ, 0, iface as u16, &req, TIMEOUT)
    241         .context("control OUT (CMD_REQ)")?;
    242 
    243     let mut buf = vec![0u8; HDR + resp_payload_cap];
    244     let rt_in = rusb::request_type(Direction::In, RequestType::Class, Recipient::Interface);
    245     let n = handle
    246         .read_control(rt_in, RESP, 0, iface as u16, &mut buf, TIMEOUT)
    247         .context("control IN (CMD_RESP)")?;
    248     buf.truncate(n);
    249 
    250     if n < HDR {
    251         bail!("short response: {n} bytes (< {HDR}-byte header)");
    252     }
    253     let r_cmd = u32::from_le_bytes(buf[0..4].try_into().unwrap());
    254     let r_size = u16::from_le_bytes(buf[4..6].try_into().unwrap());
    255     let r_seq = u16::from_le_bytes(buf[6..8].try_into().unwrap());
    256     let r_err = u32::from_le_bytes(buf[8..12].try_into().unwrap());
    257 
    258     if r_err != 0 {
    259         bail!("device error code {r_err:#x} (cmd {cmd:#x}, seq {this_seq})");
    260     }
    261     if r_cmd != cmd {
    262         eprintln!("  warn: response cmd {r_cmd:#x} != request {cmd:#x}");
    263     }
    264     if r_seq != this_seq {
    265         eprintln!("  warn: response seq {r_seq} != request {this_seq}");
    266     }
    267 
    268     let end = (HDR + r_size as usize).min(buf.len());
    269     Ok(buf[HDR..end].to_vec())
    270 }
    271 
    272 fn transfer_type_name(t: rusb::TransferType) -> &'static str {
    273     match t {
    274         rusb::TransferType::Control => "control",
    275         rusb::TransferType::Isochronous => "isochronous",
    276         rusb::TransferType::Bulk => "bulk",
    277         rusb::TransferType::Interrupt => "interrupt",
    278     }
    279 }
    280 
    281 fn hex(b: &[u8]) -> String {
    282     b.iter().map(|x| format!("{x:02x} ")).collect()
    283 }
    284 
    285 fn maybe_hex(b: &[u8]) -> String {
    286     if b.is_empty() {
    287         String::new()
    288     } else {
    289         format!(": {}", hex(b))
    290     }
    291 }