diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b45194..291ecfb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,7 @@ name: CI on: pull_request: push: + branches: [main] permissions: contents: read @@ -12,6 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - run: sudo apt-get update && sudo apt-get install -y libasound2-dev pkg-config - run: cargo fmt --check - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo test --workspace diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ce14de..100d5e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,12 +15,96 @@ sudo apt-get install -y build-essential On Fedora, install `gcc` and `make`. +Phase 2’s direct ALSA adapter also needs development headers and `pkg-config`. +On Debian/Ubuntu/WSL: + +```sh +sudo apt-get install -y pkg-config libasound2-dev +``` + +On Fedora, install `pkgconf-pkg-config` and `alsa-lib-devel`. + +Phase 2 read-only hardware discovery also needs command-line tools. On +Debian/Ubuntu/WSL: + +```sh +sudo apt-get install -y usbutils alsa-utils +``` + +`usbutils` provides `lsusb`; `alsa-utils` provides `amixer`. `libasound2-dev` +is for compiling the Rust ALSA binding, not for the `amixer` command. On Fedora, +install `usbutils` and `alsa-utils`. + ## Setup ```sh rustup show ``` +## Route a Scarlett Solo into WSL2 + +Do this only when ready for Phase 2 read-only discovery. While attached, the +Solo is unavailable to Windows applications. This grants WSL direct USB access; +it does not write device state. + +1. In an elevated PowerShell window, install/update WSL and `usbipd-win`: + + ```powershell + wsl --update + winget install usbipd + ``` + +2. Plug in the Solo, then list USB devices from elevated PowerShell. Identify + its `BUSID` by its Focusrite description; do not copy serial numbers into + issues, fixtures, or commits. + + ```powershell + usbipd list + usbipd bind --busid + ``` + + `bind` shares that USB port persistently. It needs elevation; attaching does + not. If the device is already shared, skip `bind`. + +3. Start the intended WSL2 distro, then attach from a normal PowerShell window: + + ```powershell + usbipd attach --wsl --busid + ``` + +4. In WSL, verify Linux sees the Solo, then run only bounded read-only probes + from [hardware support](docs/hardware-support.md#discovery-procedure): + + ```sh + lsusb + cat /proc/asound/cards + amixer -c controls + ``` + + If `amixer -c controls` fails but its `sudo` form works, grant the + current WSL user read/write access to ALSA controls, then open a new shell: + + ```sh + sudo usermod -aG audio "$USER" + exit + ``` + + Reopen the WSL distro and confirm `id` lists `audio`. This only changes + local device-node permission; it does not change Solo state. Do not use + `sudo` for later discovery once normal access works. + +5. When finished, return it to Windows: + + ```powershell + usbipd detach --busid + ``` + +Attach is not persistent: repeat it after WSL restart or USB unplug/replug. +Do not run `alsactl store`, control writes, routing/clock changes, firmware +updates, or resets without explicit approval. See Microsoft’s current +[WSL USB guide](https://learn.microsoft.com/windows/wsl/connect-usb) and +[usbipd-win instructions](https://github.com/dorssel/usbipd-win). + ## Checks ```sh diff --git a/Cargo.lock b/Cargo.lock index 7d51512..12fd136 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,78 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "alsa" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "028c6c5c84085c8cca7486f62d004f538e63eeb8c7f9c27fe735559ef4cb39fa" +dependencies = [ + "alsa-sys", + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ddaba18811a1d08b70bc74fe310fa090e0a3f1dfe59a86c8680f735ccda37b" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "focusrited" version = "0.1.0" +dependencies = [ + "alsa", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" diff --git a/crates/focusrited/Cargo.toml b/crates/focusrited/Cargo.toml index aed1c4c..b791a35 100644 --- a/crates/focusrited/Cargo.toml +++ b/crates/focusrited/Cargo.toml @@ -6,3 +6,4 @@ rust-version.workspace = true license.workspace = true [dependencies] +alsa = "0.12.0" diff --git a/crates/focusrited/src/lib.rs b/crates/focusrited/src/lib.rs new file mode 100644 index 0000000..b843b9d --- /dev/null +++ b/crates/focusrited/src/lib.rs @@ -0,0 +1,388 @@ +//! Device-independent policy core for `focusrited`. +//! +//! Linux/ALSA adapters and client transports sit outside this module. Keeping +//! the policy here makes discovery and state rules testable without hardware. + +pub mod profile_store; +pub mod scarlett2_alsa; +pub mod startup; +pub mod worker; + +use std::collections::BTreeMap; + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct ControlId(pub String); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Value { + Bool(bool), + Integer(i32), + Integer64(i64), + Array(Vec), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ValueDomain { + Boolean, + Integer, + Integer64, + Unsupported, + Array, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ControlCapability { + pub id: ControlId, + pub domain: ValueDomain, + pub writable: bool, + pub available: bool, + pub minimum: Option, + pub maximum: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeviceSnapshot { + /// Opaque adapter-provided identity. Profiles never cross this boundary. + pub device_id: String, + /// Adapter capability contract used to interpret profile control IDs. + pub capability_schema: String, + pub capabilities: Vec, + pub values: BTreeMap, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Profile { + pub device_id: String, + pub capability_schema: String, + pub values: BTreeMap, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DeviceError { + Offline, + Failed, + WriteDisabled, +} + +/// Hardware boundary. Implementations own Linux/ALSA details; policy never +/// assumes a device-specific control name. +pub trait Device { + fn snapshot(&mut self) -> Result; + fn write(&mut self, control: &ControlId, value: Value) -> Result<(), DeviceError>; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ServiceError { + Device(DeviceError), + UnknownControl, + Unavailable, + ReadOnly, + InvalidValue, + UnconfirmedWrite, + UnknownProfile, + ProfileBindingMismatch, +} + +pub struct Service { + device: D, + snapshot: DeviceSnapshot, + online: bool, + revision: u64, + profiles: BTreeMap, +} + +impl Service { + pub fn connect(mut device: D) -> Result { + let snapshot = device.snapshot().map_err(ServiceError::Device)?; + Ok(Self { + device, + snapshot, + online: true, + revision: 1, + profiles: BTreeMap::new(), + }) + } + + pub fn snapshot(&self) -> &DeviceSnapshot { + &self.snapshot + } + + pub fn revision(&self) -> u64 { + self.revision + } + + pub fn is_online(&self) -> bool { + self.online + } + + /// Validate, write, then resnapshot. State is never advanced optimistically. + pub fn command(&mut self, control: &ControlId, value: Value) -> Result<(), ServiceError> { + let capability = self + .snapshot + .capabilities + .iter() + .find(|capability| &capability.id == control) + .ok_or(ServiceError::UnknownControl)?; + if !capability.available { + return Err(ServiceError::Unavailable); + } + if !capability.writable { + return Err(ServiceError::ReadOnly); + } + if !matches_domain(&value, &capability.domain) { + return Err(ServiceError::InvalidValue); + } + if let Value::Integer(number) = value + && (capability.minimum.is_some_and(|minimum| number < minimum) + || capability.maximum.is_some_and(|maximum| number > maximum)) + { + return Err(ServiceError::InvalidValue); + } + self.device + .write(control, value.clone()) + .map_err(ServiceError::Device)?; + self.refresh()?; + if self.snapshot.values.get(control) != Some(&value) { + return Err(ServiceError::UnconfirmedWrite); + } + Ok(()) + } + + /// Reconcile ALSA/front-panel changes with authoritative hardware state. + pub fn refresh(&mut self) -> Result<(), ServiceError> { + match self.device.snapshot() { + Ok(snapshot) => { + let changed = snapshot != self.snapshot; + let came_online = !self.online; + self.snapshot = snapshot; + self.online = true; + if changed || came_online { + self.revision += 1; + } + Ok(()) + } + Err(error) => { + self.mark_offline(); + Err(ServiceError::Device(error)) + } + } + } + + pub fn mark_offline(&mut self) { + if self.online { + self.online = false; + self.revision += 1; + } + } + + pub fn reconnect(&mut self) -> Result<(), ServiceError> { + self.refresh() + } + + /// Saving is explicit. Nothing is applied during connect or reconnect. + pub fn save_profile(&mut self, name: String) { + let values = self + .snapshot + .capabilities + .iter() + .filter(|capability| capability.available && capability.writable) + .filter_map(|capability| { + self.snapshot + .values + .get(&capability.id) + .map(|value| (capability.id.clone(), value.clone())) + }) + .collect(); + self.profiles.insert( + name, + Profile { + device_id: self.snapshot.device_id.clone(), + capability_schema: self.snapshot.capability_schema.clone(), + values, + }, + ); + } + + pub fn profiles(&self) -> &BTreeMap { + &self.profiles + } + + /// Loads stored profiles only. It never applies hardware state. + pub fn set_profiles(&mut self, profiles: BTreeMap) { + self.profiles = profiles; + } + + /// Profile writes are ordered by stable control ID. Hardware cannot make a + /// multi-control apply atomic; later persistence adds reviewed dry-runs. + pub fn apply_profile(&mut self, name: &str) -> Result<(), ServiceError> { + let profile = self + .profiles + .get(name) + .cloned() + .ok_or(ServiceError::UnknownProfile)?; + if profile.device_id != self.snapshot.device_id + || profile.capability_schema != self.snapshot.capability_schema + { + return Err(ServiceError::ProfileBindingMismatch); + } + for (control, value) in profile.values { + self.command(&control, value)?; + } + Ok(()) + } +} + +fn matches_domain(value: &Value, domain: &ValueDomain) -> bool { + matches!( + (value, domain), + (Value::Bool(_), ValueDomain::Boolean) + | (Value::Integer(_), ValueDomain::Integer) + | (Value::Integer64(_), ValueDomain::Integer64) + | (Value::Array(_), ValueDomain::Array) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct MockDevice { + snapshot: DeviceSnapshot, + fail_write: bool, + ignore_write: bool, + } + + impl Device for MockDevice { + fn snapshot(&mut self) -> Result { + Ok(self.snapshot.clone()) + } + + fn write(&mut self, control: &ControlId, value: Value) -> Result<(), DeviceError> { + if self.fail_write { + return Err(DeviceError::Failed); + } + if !self.ignore_write { + self.snapshot.values.insert(control.clone(), value); + } + Ok(()) + } + } + + fn mock() -> MockDevice { + let volume = ControlId("output.volume".into()); + MockDevice { + snapshot: DeviceSnapshot { + device_id: "mock-device".into(), + capability_schema: "mock-v1".into(), + capabilities: vec![ControlCapability { + id: volume.clone(), + domain: ValueDomain::Integer, + writable: true, + available: true, + minimum: Some(0), + maximum: Some(100), + }], + values: BTreeMap::from([(volume, Value::Integer(50))]), + }, + fail_write: false, + ignore_write: false, + } + } + + #[test] + fn command_confirms_hardware_state() { + let volume = ControlId("output.volume".into()); + let mut service = Service::connect(mock()).unwrap(); + + service.command(&volume, Value::Integer(75)).unwrap(); + + assert_eq!(service.snapshot().values[&volume], Value::Integer(75)); + assert_eq!(service.revision(), 2); + } + + #[test] + fn external_change_reconciles_and_disconnect_reconnect_changes_state() { + let mut service = Service::connect(mock()).unwrap(); + service + .device + .snapshot + .values + .insert(ControlId("output.volume".into()), Value::Integer(60)); + + service.refresh().unwrap(); + service.mark_offline(); + service.reconnect().unwrap(); + + assert!(service.is_online()); + assert_eq!(service.revision(), 4); + } + + #[test] + fn failed_write_does_not_change_state() { + let volume = ControlId("output.volume".into()); + let mut device = mock(); + device.fail_write = true; + let mut service = Service::connect(device).unwrap(); + + assert_eq!( + service.command(&volume, Value::Integer(75)), + Err(ServiceError::Device(DeviceError::Failed)) + ); + assert_eq!(service.snapshot().values[&volume], Value::Integer(50)); + } + + #[test] + fn ignored_write_is_not_reported_as_confirmed() { + let volume = ControlId("output.volume".into()); + let mut device = mock(); + device.ignore_write = true; + let mut service = Service::connect(device).unwrap(); + + assert_eq!( + service.command(&volume, Value::Integer(75)), + Err(ServiceError::UnconfirmedWrite) + ); + assert_eq!(service.snapshot().values[&volume], Value::Integer(50)); + } + + #[test] + fn command_rejects_value_with_wrong_domain() { + let volume = ControlId("output.volume".into()); + let mut service = Service::connect(mock()).unwrap(); + + assert_eq!( + service.command(&volume, Value::Bool(true)), + Err(ServiceError::InvalidValue) + ); + } + + #[test] + fn profile_needs_explicit_apply() { + let volume = ControlId("output.volume".into()); + let mut service = Service::connect(mock()).unwrap(); + service.save_profile("desk".into()); + service.command(&volume, Value::Integer(75)).unwrap(); + + service.apply_profile("desk").unwrap(); + + assert_eq!(service.snapshot().values[&volume], Value::Integer(50)); + } + + #[test] + fn profile_does_not_apply_to_different_device_or_schema() { + let mut service = Service::connect(mock()).unwrap(); + service.save_profile("desk".into()); + service.snapshot.device_id = "other-device".into(); + + assert_eq!( + service.apply_profile("desk"), + Err(ServiceError::ProfileBindingMismatch) + ); + service.snapshot.device_id = "mock-device".into(); + service.snapshot.capability_schema = "mock-v2".into(); + assert_eq!( + service.apply_profile("desk"), + Err(ServiceError::ProfileBindingMismatch) + ); + } +} diff --git a/crates/focusrited/src/main.rs b/crates/focusrited/src/main.rs index 99e6aa6..4f5df09 100644 --- a/crates/focusrited/src/main.rs +++ b/crates/focusrited/src/main.rs @@ -1,5 +1,52 @@ -//! Focusrite controller policy daemon. -//! -//! Hardware and API implementation begin in later phases. +//! Read-only Focusrite controller daemon bootstrap. -fn main() {} +use std::{env, process, thread, time::Duration}; + +use focusrited::{ + scarlett2_alsa::Scarlett2Alsa, + startup::{Config, ConfigError, load_profiles}, + worker::DeviceWorker, +}; + +fn main() { + let config = match Config::from_args(env::args().skip(1)) { + Ok(config) => config, + Err(ConfigError::Help) => { + println!("{}", ConfigError::Help); + return; + } + Err(error) => fail(error), + }; + let card = config + .card + .as_deref() + .unwrap_or_else(|| fail("--card is required")); + let profiles = load_profiles(&config).unwrap_or_else(|error| fail(error)); + let worker = DeviceWorker::start_with_profiles(Scarlett2Alsa::new(card), profiles) + .unwrap_or_else(|error| fail(error)); + let mut online = true; + + loop { + thread::sleep(Duration::from_secs(1)); + match worker.refresh() { + Ok(state) if !online => { + eprintln!( + "focusrited: device recovered at revision {}", + state.revision + ); + online = true; + } + Ok(_) => {} + Err(error) if online => { + eprintln!("focusrited: device offline: {error}"); + online = false; + } + Err(_) => {} + } + } +} + +fn fail(error: impl std::fmt::Display) -> ! { + eprintln!("focusrited: {error}"); + process::exit(2); +} diff --git a/crates/focusrited/src/profile_store.rs b/crates/focusrited/src/profile_store.rs new file mode 100644 index 0000000..f715a45 --- /dev/null +++ b/crates/focusrited/src/profile_store.rs @@ -0,0 +1,321 @@ +//! Atomic, explicit profile persistence. + +use std::{ + collections::BTreeMap, + fs::{self, File}, + io::{self, Write}, + path::PathBuf, +}; + +use crate::{ControlId, Device, Profile, Service, Value}; + +const HEADER: &str = "focusrited-profiles-v2"; + +pub type Profiles = BTreeMap; + +pub struct ProfileStore { + path: PathBuf, +} + +impl ProfileStore { + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + pub fn load(&self) -> io::Result { + match fs::read_to_string(&self.path) { + Ok(contents) => parse(&contents), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Profiles::new()), + Err(error) => Err(error), + } + } + + /// Writes a complete replacement through a same-directory temporary file. + pub fn save(&self, profiles: &Profiles) -> io::Result<()> { + if let Some(parent) = self.path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent)?; + } + let temporary = self.path.with_extension("tmp"); + let mut file = File::create(&temporary)?; + file.write_all(encode(profiles).as_bytes())?; + file.sync_all()?; + drop(file); + fs::rename(temporary, &self.path) + } + + pub fn load_into(&self, service: &mut Service) -> io::Result<()> { + service.set_profiles(self.load()?); + Ok(()) + } + + pub fn save_service(&self, service: &Service) -> io::Result<()> { + self.save(service.profiles()) + } +} + +fn encode(profiles: &Profiles) -> String { + let mut output = format!("{HEADER}\n"); + for (name, profile) in profiles { + output.push_str("P "); + output.push_str(&hex(name.as_bytes())); + output.push('\n'); + output.push_str("D "); + output.push_str(&hex(profile.device_id.as_bytes())); + output.push('\n'); + output.push_str("S "); + output.push_str(&hex(profile.capability_schema.as_bytes())); + output.push('\n'); + for (control, value) in &profile.values { + output.push_str("C "); + output.push_str(&hex(control.0.as_bytes())); + output.push(' '); + output.push_str(&encode_value(value)); + output.push('\n'); + } + output.push_str("E\n"); + } + output +} + +fn encode_value(value: &Value) -> String { + match value { + Value::Bool(value) => format!("B {}", u8::from(*value)), + Value::Integer(value) => format!("I {value}"), + Value::Integer64(value) => format!("L {value}"), + // Profiles capture writable controls only; arrays are not writable in + // current adapters and must not silently serialize as a scalar. + Value::Array(_) => "A".into(), + } +} + +fn parse(contents: &str) -> io::Result { + let mut lines = contents.lines(); + if lines.next() != Some(HEADER) { + return invalid("unknown profile store version"); + } + + let mut profiles = Profiles::new(); + let mut current = None; + for line in lines { + let mut fields = line.split_ascii_whitespace(); + match fields.next() { + Some("P") => { + if current.is_some() || fields.clone().count() != 1 { + return invalid("invalid profile header"); + } + current = Some(( + unhex(fields.next().expect("one field"))?, + None, + None, + BTreeMap::new(), + )); + } + Some("D") => { + let Some((_, device_id, _, _)) = current.as_mut() else { + return invalid("device outside profile"); + }; + if device_id.is_some() || fields.clone().count() != 1 { + return invalid("invalid device binding"); + } + *device_id = Some(unhex(fields.next().expect("one field"))?); + } + Some("S") => { + let Some((_, _, schema, _)) = current.as_mut() else { + return invalid("schema outside profile"); + }; + if schema.is_some() || fields.clone().count() != 1 { + return invalid("invalid schema binding"); + } + *schema = Some(unhex(fields.next().expect("one field"))?); + } + Some("C") => { + let Some((_, device_id, schema, values)) = current.as_mut() else { + return invalid("control outside profile"); + }; + if device_id.is_none() || schema.is_none() { + return invalid("control before profile binding"); + } + let control = fields + .next() + .ok_or_else(|| invalid_error("missing control id"))?; + let value = parse_value(&mut fields)?; + if fields.next().is_some() { + return invalid("unexpected control data"); + } + values.insert(ControlId(unhex(control)?), value); + } + Some("E") if fields.next().is_none() => { + let Some((name, Some(device_id), Some(capability_schema), values)) = current.take() + else { + return invalid("profile end without profile"); + }; + profiles.insert( + name, + Profile { + device_id, + capability_schema, + values, + }, + ); + } + _ => return invalid("invalid profile record"), + } + } + if current.is_some() { + return invalid("unterminated profile"); + } + Ok(profiles) +} + +fn parse_value<'a>(fields: &mut impl Iterator) -> io::Result { + let value = fields + .next() + .ok_or_else(|| invalid_error("missing value type"))?; + let mut number = || fields.next().ok_or_else(|| invalid_error("missing value")); + match value { + "B" => match number()? { + "0" => Ok(Value::Bool(false)), + "1" => Ok(Value::Bool(true)), + _ => invalid("invalid boolean"), + }, + "I" => number()? + .parse() + .map(Value::Integer) + .map_err(|_| invalid_error("invalid integer")), + "L" => number()? + .parse() + .map(Value::Integer64) + .map_err(|_| invalid_error("invalid integer64")), + "A" => invalid("array profiles are unsupported"), + _ => invalid("unknown value type"), + } +} + +fn hex(bytes: &[u8]) -> String { + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write; + write!(output, "{byte:02x}").expect("writing String cannot fail"); + } + output +} + +fn unhex(input: &str) -> io::Result { + if !input.len().is_multiple_of(2) { + return invalid("invalid hex length"); + } + let bytes = input + .as_bytes() + .chunks_exact(2) + .map(|pair| { + std::str::from_utf8(pair) + .ok() + .and_then(|pair| u8::from_str_radix(pair, 16).ok()) + .ok_or_else(|| invalid_error("invalid hex")) + }) + .collect::>>()?; + String::from_utf8(bytes).map_err(|_| invalid_error("profile text is not UTF-8")) +} + +fn invalid(message: &str) -> io::Result { + Err(invalid_error(message)) +} + +fn invalid_error(message: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ControlCapability, DeviceError, DeviceSnapshot, ValueDomain}; + + #[test] + fn save_load_round_trip_is_atomic_format() { + let directory = + std::env::temp_dir().join(format!("focusrited-profile-test-{}", std::process::id())); + let path = directory.join("profiles"); + let store = ProfileStore::new(&path); + let profiles = BTreeMap::from([( + "desk\nprofile".into(), + Profile { + device_id: "mock-device".into(), + capability_schema: "mock-v1".into(), + values: BTreeMap::from([ + (ControlId("output.mute".into()), Value::Bool(false)), + (ControlId("output.level".into()), Value::Integer(75)), + ]), + }, + )]); + + store.save(&profiles).unwrap(); + + assert_eq!(store.load().unwrap(), profiles); + assert!(!path.with_extension("tmp").exists()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn reject_corrupt_profile_store() { + assert!(parse("focusrited-profiles-v1\nC 00 B 1\n").is_err()); + } + + struct MockDevice(DeviceSnapshot); + + impl Device for MockDevice { + fn snapshot(&mut self) -> Result { + Ok(self.0.clone()) + } + + fn write(&mut self, control: &ControlId, value: Value) -> Result<(), DeviceError> { + self.0.values.insert(control.clone(), value); + Ok(()) + } + } + + fn service(value: i32) -> Service { + let control = ControlId("output.level".into()); + Service::connect(MockDevice(DeviceSnapshot { + device_id: "mock-device".into(), + capability_schema: "mock-v1".into(), + capabilities: vec![ControlCapability { + id: control.clone(), + domain: ValueDomain::Integer, + writable: true, + available: true, + minimum: Some(0), + maximum: Some(100), + }], + values: BTreeMap::from([(control, Value::Integer(value))]), + })) + .unwrap() + } + + #[test] + fn profiles_load_without_applying_hardware_state() { + let directory = std::env::temp_dir().join(format!( + "focusrited-profile-service-test-{}", + std::process::id() + )); + let store = ProfileStore::new(directory.join("profiles")); + let mut original = service(50); + original.save_profile("desk".into()); + store.save_service(&original).unwrap(); + + let mut restored = service(75); + store.load_into(&mut restored).unwrap(); + + assert_eq!( + restored.snapshot().values[&ControlId("output.level".into())], + Value::Integer(75) + ); + restored.apply_profile("desk").unwrap(); + assert_eq!( + restored.snapshot().values[&ControlId("output.level".into())], + Value::Integer(50) + ); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/crates/focusrited/src/scarlett2_alsa.rs b/crates/focusrited/src/scarlett2_alsa.rs new file mode 100644 index 0000000..780d6c1 --- /dev/null +++ b/crates/focusrited/src/scarlett2_alsa.rs @@ -0,0 +1,427 @@ +//! ALSA control discovery for Scarlett2-family devices. +//! +//! Instances stay read-only unless constructed with an explicitly approved +//! discovered boolean control for a bounded hardware test. + +use std::collections::BTreeSet; + +use alsa::{ + ctl::{Ctl, ElemType, ElemValue}, + hctl::HCtl, +}; + +use crate::{ + ControlCapability, ControlId, Device, DeviceError, DeviceSnapshot, Value, ValueDomain, +}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Discovery { + pub device_id: String, + pub controls: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Control { + /// Stable only within this discovered device capability set. + pub id: ControlId, + /// ALSA display metadata; never use it as a shared-model identifier. + pub name: String, + pub numid: u32, + pub value_type: ValueType, + pub count: u32, + pub values: Vec, + /// A single unreadable control must not make the whole card offline. + pub available: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ValueType { + Boolean, + Integer, + Integer64, + Enumerated, + Bytes, + Iec958, + None, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ObservedValue { + Boolean(bool), + Integer(i32), + Integer64(i64), + Enumerated(u32), + Byte(u8), +} + +#[derive(Debug)] +pub struct DiscoveryError(String); + +impl std::fmt::Display for DiscoveryError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::error::Error for DiscoveryError {} + +pub struct Scarlett2Alsa { + card: String, + writable_controls: BTreeSet, +} + +impl Scarlett2Alsa { + pub fn new(card: impl Into) -> Self { + Self { + card: card.into(), + writable_controls: BTreeSet::new(), + } + } + + /// Enables only specified discovered boolean controls. + pub fn with_writable_controls( + card: impl Into, + writable_controls: BTreeSet, + ) -> Self { + Self { + card: card.into(), + writable_controls, + } + } +} + +impl Device for Scarlett2Alsa { + fn snapshot(&mut self) -> Result { + discover(&self.card) + .map(|discovery| snapshot_from(discovery, &self.writable_controls)) + .map_err(|_| DeviceError::Offline) + } + + fn write(&mut self, control: &ControlId, value: Value) -> Result<(), DeviceError> { + if !self.writable_controls.contains(control) { + return Err(DeviceError::WriteDisabled); + } + let Value::Bool(value) = value else { + return Err(DeviceError::WriteDisabled); + }; + write_boolean(&self.card, control, value) + } +} + +/// Opens `hw:` and reads every ALSA control once. +pub fn discover(card: &str) -> Result { + let control = HCtl::new(&format!("hw:{card}"), false) + .map_err(|error| DiscoveryError(error.to_string()))?; + control + .load() + .map_err(|error| DiscoveryError(error.to_string()))?; + + let controls = control + .elem_iter() + .filter_map(|element| { + let id = element.get_id().ok()?; + let numid = id.get_numid(); + let name = id + .get_name() + .map(str::to_owned) + .unwrap_or_else(|_| format!("alsa-numid:{numid}")); + let Ok(info) = element.info() else { + return Some(Control { + id: control_id(numid), + name, + numid, + value_type: ValueType::None, + count: 0, + values: Vec::new(), + available: false, + }); + }; + let value_type = value_type(info.get_type()); + let count = info.get_count(); + match element.read() { + Ok(value) => Some(Control { + id: control_id(numid), + name, + numid, + value_type, + count, + values: observed_values(&value, info.get_type(), count), + available: true, + }), + Err(_) => Some(Control { + id: control_id(numid), + name, + numid, + value_type, + count, + values: Vec::new(), + available: false, + }), + } + }) + .collect(); + + Ok(Discovery { + device_id: device_id(card), + controls, + }) +} + +fn device_id(card: &str) -> String { + let Ok(control) = Ctl::new(&format!("hw:{card}"), false) else { + return format!("alsa-card:{card}"); + }; + let Ok(info) = control.card_info() else { + return format!("alsa-card:{card}"); + }; + let index = info.get_card().get_index(); + let serial = std::fs::read_to_string(format!("/sys/class/sound/card{index}/device/serial")) + .ok() + .map(|serial| serial.trim().to_owned()) + .filter(|serial| !serial.is_empty()); + let driver = info.get_driver().unwrap_or("unknown"); + let id = info.get_id().unwrap_or("unknown"); + match serial { + Some(serial) => format!("alsa-usb:{driver}:{id}:{serial}"), + None => format!("alsa-card:{driver}:{id}"), + } +} + +fn control_id(numid: u32) -> ControlId { + ControlId(format!("alsa-numid:{numid}")) +} + +fn snapshot_from(discovery: Discovery, writable_controls: &BTreeSet) -> DeviceSnapshot { + let mut capabilities = Vec::with_capacity(discovery.controls.len()); + let mut values = std::collections::BTreeMap::new(); + let capability_schema = schema_fingerprint(&discovery.controls); + for control in discovery.controls { + capabilities.push(ControlCapability { + id: control.id.clone(), + domain: value_domain(control.value_type, control.count), + writable: control.value_type == ValueType::Boolean + && control.values.len() == 1 + && writable_controls.contains(&control.id), + available: control.available, + minimum: None, + maximum: None, + }); + if control.available { + values.insert(control.id, control_value(&control.values)); + } + } + DeviceSnapshot { + device_id: discovery.device_id, + capability_schema, + capabilities, + values, + } +} + +fn value_domain(value_type: ValueType, count: u32) -> ValueDomain { + match value_type { + ValueType::Enumerated | ValueType::Bytes | ValueType::Iec958 | ValueType::None => { + ValueDomain::Unsupported + } + ValueType::Boolean | ValueType::Integer | ValueType::Integer64 if count != 1 => { + ValueDomain::Array + } + ValueType::Boolean => ValueDomain::Boolean, + ValueType::Integer => ValueDomain::Integer, + ValueType::Integer64 => ValueDomain::Integer64, + } +} + +fn schema_fingerprint(controls: &[Control]) -> String { + let mut hash = 0xcbf29ce484222325u64; + for control in controls { + for byte in format!( + "{}:{:?}:{};", + control.id.0, control.value_type, control.count + ) + .bytes() + { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x100000001b3); + } + } + format!("scarlett2-alsa-{hash:016x}") +} + +fn write_boolean(card: &str, control: &ControlId, value: bool) -> Result<(), DeviceError> { + let numid = control + .0 + .strip_prefix("alsa-numid:") + .and_then(|number| number.parse().ok()) + .ok_or(DeviceError::WriteDisabled)?; + let hctl = HCtl::new(&format!("hw:{card}"), false).map_err(|_| DeviceError::Offline)?; + hctl.load().map_err(|_| DeviceError::Offline)?; + let element = hctl + .elem_iter() + .find(|element| element.get_id().is_ok_and(|id| id.get_numid() == numid)) + .ok_or(DeviceError::WriteDisabled)?; + let info = element.info().map_err(|_| DeviceError::Failed)?; + if info.get_type() != ElemType::Boolean || info.get_count() != 1 { + return Err(DeviceError::WriteDisabled); + } + let mut current = element.read().map_err(|_| DeviceError::Failed)?; + current + .set_boolean(0, value) + .ok_or(DeviceError::WriteDisabled)?; + element.write(¤t).map_err(|_| DeviceError::Failed)?; + Ok(()) +} + +fn control_value(values: &[ObservedValue]) -> Value { + let mut values = values.iter().map(observed_value).collect::>(); + if values.len() == 1 { + values.pop().expect("one value") + } else { + Value::Array(values) + } +} + +fn observed_value(value: &ObservedValue) -> Value { + match value { + ObservedValue::Boolean(value) => Value::Bool(*value), + ObservedValue::Integer(value) => Value::Integer(*value), + ObservedValue::Integer64(value) => Value::Integer64(*value), + ObservedValue::Enumerated(value) => Value::Integer(*value as i32), + ObservedValue::Byte(value) => Value::Integer((*value).into()), + } +} + +fn value_type(value_type: ElemType) -> ValueType { + match value_type { + ElemType::Boolean => ValueType::Boolean, + ElemType::Integer => ValueType::Integer, + ElemType::Integer64 => ValueType::Integer64, + ElemType::Enumerated => ValueType::Enumerated, + ElemType::Bytes => ValueType::Bytes, + ElemType::IEC958 => ValueType::Iec958, + ElemType::None => ValueType::None, + } +} + +fn observed_values(value: &ElemValue, value_type: ElemType, count: u32) -> Vec { + (0..count) + .filter_map(|index| match value_type { + ElemType::Boolean => value.get_boolean(index).map(ObservedValue::Boolean), + ElemType::Integer => value.get_integer(index).map(ObservedValue::Integer), + ElemType::Integer64 => value.get_integer64(index).map(ObservedValue::Integer64), + ElemType::Enumerated => value.get_enumerated(index).map(ObservedValue::Enumerated), + ElemType::Bytes => value.get_byte(index).map(ObservedValue::Byte), + ElemType::IEC958 | ElemType::None => None, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn control_ids_do_not_depend_on_display_labels() { + assert_eq!(control_id(48), ControlId("alsa-numid:48".into())); + } + + #[test] + fn snapshots_are_read_only() { + let snapshot = snapshot_from( + Discovery { + device_id: "mock-device".into(), + controls: vec![Control { + id: control_id(48), + name: "Direct Monitor Playback Switch".into(), + numid: 48, + value_type: ValueType::Boolean, + count: 1, + values: vec![ObservedValue::Boolean(false)], + available: true, + }], + }, + &BTreeSet::new(), + ); + + assert!(!snapshot.capabilities[0].writable); + assert_eq!(snapshot.values[&control_id(48)], Value::Bool(false)); + } + + #[test] + fn approved_boolean_control_is_writable() { + let control = control_id(48); + let snapshot = snapshot_from( + Discovery { + device_id: "mock-device".into(), + controls: vec![Control { + id: control.clone(), + name: "Direct Monitor Playback Switch".into(), + numid: 48, + value_type: ValueType::Boolean, + count: 1, + values: vec![ObservedValue::Boolean(false)], + available: true, + }], + }, + &BTreeSet::from([control]), + ); + + assert!(snapshot.capabilities[0].writable); + } + + #[test] + fn schema_fingerprint_ignores_availability_but_tracks_shape() { + let mut controls = vec![Control { + id: control_id(48), + name: "Direct Monitor Playback Switch".into(), + numid: 48, + value_type: ValueType::Boolean, + count: 1, + values: vec![ObservedValue::Boolean(false)], + available: true, + }]; + let original = schema_fingerprint(&controls); + controls[0].available = false; + + assert_eq!(original, schema_fingerprint(&controls)); + controls[0].count = 2; + + assert_ne!(original, schema_fingerprint(&controls)); + } + + #[test] + fn multi_value_unsupported_types_stay_unsupported() { + for value_type in [ + ValueType::Enumerated, + ValueType::Bytes, + ValueType::Iec958, + ValueType::None, + ] { + assert_eq!(value_domain(value_type, 2), ValueDomain::Unsupported); + } + } + + #[test] + fn unavailable_control_keeps_metadata_without_a_value() { + let id = control_id(48); + let snapshot = snapshot_from( + Discovery { + device_id: "mock-device".into(), + controls: vec![Control { + id: id.clone(), + name: "Direct Monitor Playback Switch".into(), + numid: 48, + value_type: ValueType::Boolean, + count: 1, + values: Vec::new(), + available: false, + }], + }, + &BTreeSet::new(), + ); + + assert_eq!(snapshot.capabilities[0].domain, ValueDomain::Boolean); + assert!(!snapshot.capabilities[0].available); + assert!(!snapshot.values.contains_key(&id)); + } +} diff --git a/crates/focusrited/src/startup.rs b/crates/focusrited/src/startup.rs new file mode 100644 index 0000000..a8d87d5 --- /dev/null +++ b/crates/focusrited/src/startup.rs @@ -0,0 +1,178 @@ +//! Startup configuration and profile loading. + +use std::{io, path::PathBuf}; + +use crate::{ + Device, Service, ServiceError, + profile_store::{ProfileStore, Profiles}, +}; + +pub const DEFAULT_PROFILE_STORE_PATH: &str = "/var/lib/focusrited/profiles"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Config { + pub card: Option, + pub profile_store_path: PathBuf, +} + +impl Default for Config { + fn default() -> Self { + Self { + card: None, + profile_store_path: DEFAULT_PROFILE_STORE_PATH.into(), + } + } +} + +impl Config { + /// Parses arguments after the program name. + pub fn from_args(arguments: impl IntoIterator) -> Result { + let mut config = Self::default(); + let mut arguments = arguments.into_iter(); + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--card" => { + config.card = Some(arguments.next().ok_or(ConfigError::MissingCard)?); + } + "--profile-store" => { + config.profile_store_path = arguments + .next() + .map(PathBuf::from) + .ok_or(ConfigError::MissingProfileStorePath)?; + } + "--help" | "-h" => return Err(ConfigError::Help), + _ => return Err(ConfigError::UnknownArgument(argument)), + } + } + Ok(config) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ConfigError { + Help, + MissingCard, + MissingProfileStorePath, + UnknownArgument(String), +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Help => { + formatter.write_str("usage: focusrited --card CARD [--profile-store PATH]") + } + Self::MissingCard => formatter.write_str("--card requires an ALSA card name"), + Self::MissingProfileStorePath => formatter.write_str("--profile-store requires a path"), + Self::UnknownArgument(argument) => write!(formatter, "unknown argument: {argument}"), + } + } +} + +impl std::error::Error for ConfigError {} + +#[derive(Debug)] +pub enum StartupError { + Device(ServiceError), + ProfileStore(io::Error), +} + +impl std::fmt::Display for StartupError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Device(error) => write!(formatter, "device startup failed: {error:?}"), + Self::ProfileStore(error) => write!(formatter, "profile store load failed: {error}"), + } + } +} + +impl std::error::Error for StartupError {} + +/// Connects device, then loads stored profiles without applying hardware state. +pub fn connect(device: D, config: &Config) -> Result, StartupError> { + let mut service = Service::connect(device).map_err(StartupError::Device)?; + service.set_profiles(load_profiles(config).map_err(StartupError::ProfileStore)?); + Ok(service) +} + +pub fn load_profiles(config: &Config) -> io::Result { + ProfileStore::new(&config.profile_store_path).load() +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::{ControlCapability, ControlId, DeviceError, DeviceSnapshot, Value, ValueDomain}; + + #[test] + fn profile_store_path_defaults_and_overrides() { + assert_eq!( + Config::from_args([]).unwrap().profile_store_path, + PathBuf::from(DEFAULT_PROFILE_STORE_PATH) + ); + let config = Config::from_args([ + "--card".into(), + "Solo".into(), + "--profile-store".into(), + "/tmp/profiles".into(), + ]) + .unwrap(); + assert_eq!(config.card.as_deref(), Some("Solo")); + assert_eq!(config.profile_store_path, PathBuf::from("/tmp/profiles")); + } + + struct MockDevice(DeviceSnapshot); + + impl Device for MockDevice { + fn snapshot(&mut self) -> Result { + Ok(self.0.clone()) + } + + fn write(&mut self, control: &ControlId, value: Value) -> Result<(), DeviceError> { + self.0.values.insert(control.clone(), value); + Ok(()) + } + } + + fn device(value: i32) -> MockDevice { + let control = ControlId("output.level".into()); + MockDevice(DeviceSnapshot { + device_id: "mock-device".into(), + capability_schema: "mock-v1".into(), + capabilities: vec![ControlCapability { + id: control.clone(), + domain: ValueDomain::Integer, + writable: true, + available: true, + minimum: Some(0), + maximum: Some(100), + }], + values: BTreeMap::from([(control, Value::Integer(value))]), + }) + } + + #[test] + fn startup_loads_profiles_without_applying_them() { + let path = + std::env::temp_dir().join(format!("focusrited-startup-test-{}", std::process::id())); + let config = Config { + card: None, + profile_store_path: path.clone(), + }; + let store = ProfileStore::new(&path); + let mut saved = Service::connect(device(50)).unwrap(); + saved.save_profile("desk".into()); + store.save_service(&saved).unwrap(); + + let restored = connect(device(75), &config).unwrap(); + + assert_eq!( + restored.snapshot().values[&ControlId("output.level".into())], + Value::Integer(75) + ); + assert!(restored.profiles().contains_key("desk")); + std::fs::remove_file(path).unwrap(); + } +} diff --git a/crates/focusrited/src/worker.rs b/crates/focusrited/src/worker.rs new file mode 100644 index 0000000..4874321 --- /dev/null +++ b/crates/focusrited/src/worker.rs @@ -0,0 +1,216 @@ +//! Single-worker boundary for all blocking device operations. + +use std::{ + sync::{ + Mutex, + mpsc::{Receiver, SyncSender, sync_channel}, + }, + thread::{self, JoinHandle}, +}; + +use crate::{ + ControlId, Device, DeviceSnapshot, Service, ServiceError, Value, profile_store::Profiles, +}; + +const QUEUE_LIMIT: usize = 32; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct State { + pub snapshot: DeviceSnapshot, + pub revision: u64, + pub online: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum WorkerError { + Service(ServiceError), + Stopped, +} + +impl std::fmt::Display for WorkerError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Service(error) => write!(formatter, "service error: {error:?}"), + Self::Stopped => formatter.write_str("device worker stopped"), + } + } +} + +impl std::error::Error for WorkerError {} + +pub struct DeviceWorker { + sender: SyncSender, + thread: Mutex>>, +} + +enum Request { + State(std::sync::mpsc::Sender), + Refresh(std::sync::mpsc::Sender>), + Command { + control: ControlId, + value: Value, + reply: std::sync::mpsc::Sender>, + }, + Stop(std::sync::mpsc::Sender<()>), +} + +impl DeviceWorker { + /// Starts one bounded queue and one serial device-owning thread. + pub fn start(device: D) -> Result { + Self::start_with_profiles(device, Profiles::new()) + } + + /// Starts with stored profiles. Loading profiles never applies a write. + pub fn start_with_profiles( + device: D, + profiles: Profiles, + ) -> Result { + let (sender, receiver) = sync_channel(QUEUE_LIMIT); + let (ready_sender, ready_receiver) = sync_channel(1); + let thread = thread::spawn(move || match Service::connect(device) { + Ok(mut service) => { + service.set_profiles(profiles); + let _ = ready_sender.send(Ok(())); + run(service, receiver); + } + Err(error) => { + let _ = ready_sender.send(Err(error)); + } + }); + + match ready_receiver.recv().map_err(|_| WorkerError::Stopped)? { + Ok(()) => Ok(Self { + sender, + thread: Mutex::new(Some(thread)), + }), + Err(error) => { + let _ = thread.join(); + Err(WorkerError::Service(error)) + } + } + } + + pub fn state(&self) -> Result { + let (sender, receiver) = std::sync::mpsc::channel(); + self.sender + .send(Request::State(sender)) + .map_err(|_| WorkerError::Stopped)?; + receiver.recv().map_err(|_| WorkerError::Stopped) + } + + pub fn refresh(&self) -> Result { + let (sender, receiver) = std::sync::mpsc::channel(); + self.sender + .send(Request::Refresh(sender)) + .map_err(|_| WorkerError::Stopped)?; + receiver + .recv() + .map_err(|_| WorkerError::Stopped)? + .map_err(WorkerError::Service) + } + + pub fn command(&self, control: ControlId, value: Value) -> Result { + let (sender, receiver) = std::sync::mpsc::channel(); + self.sender + .send(Request::Command { + control, + value, + reply: sender, + }) + .map_err(|_| WorkerError::Stopped)?; + receiver + .recv() + .map_err(|_| WorkerError::Stopped)? + .map_err(WorkerError::Service) + } + + pub fn stop(&self) -> Result<(), WorkerError> { + let (sender, receiver) = std::sync::mpsc::channel(); + self.sender + .send(Request::Stop(sender)) + .map_err(|_| WorkerError::Stopped)?; + receiver.recv().map_err(|_| WorkerError::Stopped)?; + if let Some(thread) = self.thread.lock().map_err(|_| WorkerError::Stopped)?.take() { + let _ = thread.join(); + } + Ok(()) + } +} + +fn run(mut service: Service, receiver: Receiver) { + while let Ok(request) = receiver.recv() { + match request { + Request::State(reply) => { + let _ = reply.send(state(&service)); + } + Request::Refresh(reply) => { + let _ = reply.send(service.refresh().map(|_| state(&service))); + } + Request::Command { + control, + value, + reply, + } => { + let _ = reply.send(service.command(&control, value).map(|_| state(&service))); + } + Request::Stop(reply) => { + let _ = reply.send(()); + return; + } + } + } +} + +fn state(service: &Service) -> State { + State { + snapshot: service.snapshot().clone(), + revision: service.revision(), + online: service.is_online(), + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::{ControlCapability, DeviceError, ValueDomain}; + + struct MockDevice(DeviceSnapshot); + + impl Device for MockDevice { + fn snapshot(&mut self) -> Result { + Ok(self.0.clone()) + } + + fn write(&mut self, control: &ControlId, value: Value) -> Result<(), DeviceError> { + self.0.values.insert(control.clone(), value); + Ok(()) + } + } + + #[test] + fn serial_worker_confirms_command_before_returning_state() { + let volume = ControlId("output.volume".into()); + let worker = DeviceWorker::start(MockDevice(DeviceSnapshot { + device_id: "mock-device".into(), + capability_schema: "mock-v1".into(), + capabilities: vec![ControlCapability { + id: volume.clone(), + domain: ValueDomain::Integer, + writable: true, + available: true, + minimum: Some(0), + maximum: Some(100), + }], + values: BTreeMap::from([(volume.clone(), Value::Integer(50))]), + })) + .unwrap(); + + let state = worker.command(volume.clone(), Value::Integer(75)).unwrap(); + + assert_eq!(state.snapshot.values[&volume], Value::Integer(75)); + assert_eq!(state.revision, 2); + worker.stop().unwrap(); + } +} diff --git a/crates/focusrited/tests/fixtures/scarlett-solo-4th-gen.md b/crates/focusrited/tests/fixtures/scarlett-solo-4th-gen.md new file mode 100644 index 0000000..c943c61 --- /dev/null +++ b/crates/focusrited/tests/fixtures/scarlett-solo-4th-gen.md @@ -0,0 +1,44 @@ +# Scarlett Solo 4th Gen ALSA control fixture + +Captured through USB/IP on WSL2 on 2026-07-15 with bounded read-only +`amixer -c 0 contents`. Card index is runtime-assigned and must not be assumed +by production code. Serial numbers and mutable current values are omitted. + +Firmware control reports version `2417`; minimum version reports `2115`. +`USB Internal Validity` and `Sync Status` are read-only status controls. + +## Discovered writable controls + +| Family | ALSA control pattern | Type | Domain | +| --- | --- | --- | --- | +| PCM capture route | `PCM 0[1-4] Capture Enum` | enum | Off, Analogue 1/2, Mix A-F, DSP 1/2, PCM 1/2 | +| PCM source | `PCM Input Capture Switch` | enum | Direct, Mixer | +| DSP input route | `DSP Input [1-2] Capture Enum` | enum | same route domain | +| Input 1 mode | `Line In 1 Level Capture Enum` | enum | Line, Inst | +| Input 2 Air | `Line In 2 Air Capture Enum` | enum | Off, Presence, Presence + Drive | +| Input 2 phantom | `Line In 2 Phantom Power Capture Switch` | boolean | off/on | +| Analogue output route | `Analogue Output 0[1-2] Playback Enum` | enum | same route domain | +| Direct monitor | `Direct Monitor Playback Switch` | boolean | off/on | +| Mixer input level | `Mix [A-F] Input 0[1-4] Playback Volume` | integer | 0–184, -80.00 to +12.00 dB | +| Mixer input route | `Mixer Input 0[1-4] Capture Enum` | enum | same route domain | +| Monitor-mix level | `Monitor Mix [A-B] Input 0[1-4] Playback Volume` | integer | 0–184, -80.00 to +12.00 dB | + +`Mix` levels (24 controls) and `Monitor Mix` levels (8 controls) report ALSA +readback events. Production discovery must retain the actual ALSA identifier, +type, range, enum items, access mode, and availability; display labels are not +stable IDs. + +## Discovered read-only controls + +| ALSA control | Type | Domain | +| --- | --- | --- | +| `Firmware Version` | integer | driver-reported version | +| `Minimum Firmware Version` | integer | driver-reported version | +| `USB Internal Validity` | boolean | validity status | +| `Sync Status` | enum | Unlocked, Locked | +| `Capture Channel Map` | integer array | four fixed channels | +| `Playback Channel Map` | integer array | two fixed channels | +| `Level Meter` | integer array | 12 values, 0–4095 | + +No control was changed for this capture. This proves Linux-exposed controls on +this Solo only; it does not establish Pi or FCP-device support. diff --git a/crates/focusrited/tests/scarlett2_alsa.rs b/crates/focusrited/tests/scarlett2_alsa.rs new file mode 100644 index 0000000..673edc9 --- /dev/null +++ b/crates/focusrited/tests/scarlett2_alsa.rs @@ -0,0 +1,147 @@ +use std::collections::BTreeSet; + +use focusrited::{ + ControlId, Device, DeviceError, ServiceError, Value, + scarlett2_alsa::{Scarlett2Alsa, ValueType, discover}, + worker::{DeviceWorker, WorkerError}, +}; + +#[test] +fn fixture_records_solo_controls() { + let fixture = include_str!("fixtures/scarlett-solo-4th-gen.md"); + + assert!(fixture.contains("Direct Monitor Playback Switch")); + assert!(fixture.contains("0–184")); +} + +#[test] +#[ignore = "requires an attached Scarlett Solo ALSA card"] +fn discovers_attached_solo() { + let discovery = discover("0").unwrap(); + + assert!(discovery.controls.len() >= 56); + assert!(discovery.controls.iter().any(|control| { + control.name == "Level Meter" && control.value_type == ValueType::Integer + })); + + let worker = DeviceWorker::start(Scarlett2Alsa::new("0")).unwrap(); + let snapshot = worker.state().unwrap().snapshot; + assert_eq!(snapshot.capabilities.len(), discovery.controls.len()); + assert!( + snapshot + .capabilities + .iter() + .all(|control| !control.writable) + ); + + assert_eq!( + worker.command( + ControlId("alsa-numid:48".into()), + focusrited::Value::Bool(true) + ), + Err(focusrited::worker::WorkerError::Service( + focusrited::ServiceError::ReadOnly + )) + ); + assert_eq!(worker.state().unwrap().revision, 1); + worker.stop().unwrap(); +} + +#[test] +#[ignore = "toggle the Solo Direct Monitor control during this 30-second read-only check"] +fn reconciles_external_direct_monitor_change() { + let control = ControlId("alsa-numid:48".into()); + let worker = DeviceWorker::start(Scarlett2Alsa::new("0")).unwrap(); + let before = worker.state().unwrap(); + let previous = before.snapshot.values[&control].clone(); + println!("Direct Monitor before: {previous:?}. Toggle Direct now."); + + for _ in 0..120 { + std::thread::sleep(std::time::Duration::from_millis(250)); + let current = worker.refresh().unwrap(); + if current.snapshot.values[&control] != previous { + println!( + "Direct Monitor after: {:?}; revision {} to {}.", + current.snapshot.values[&control], before.revision, current.revision + ); + assert!(current.revision > before.revision); + assert!(matches!(current.snapshot.values[&control], Value::Bool(_))); + worker.stop().unwrap(); + return; + } + } + + let after = worker.state().unwrap(); + worker.stop().unwrap(); + panic!( + "Direct Monitor did not change within 30 seconds: before {previous:?}, after {:?}", + after.snapshot.values[&control] + ); +} + +#[test] +#[ignore = "detach then re-attach the Solo with usbipd during this 60-second read-only check"] +fn reconnects_after_usbip_detach() { + let worker = DeviceWorker::start(Scarlett2Alsa::new("0")).unwrap(); + let initial = worker.state().unwrap(); + let mut saw_offline = false; + println!("Detach Solo with usbipd now, then attach it again."); + + for _ in 0..240 { + std::thread::sleep(std::time::Duration::from_millis(250)); + match worker.refresh() { + Ok(state) if saw_offline && state.online => { + println!( + "Solo reconnected; revision {} to {}.", + initial.revision, state.revision + ); + assert!(state.revision > initial.revision); + worker.stop().unwrap(); + return; + } + Ok(_) => {} + Err(WorkerError::Service(ServiceError::Device(DeviceError::Offline))) => { + if !saw_offline { + let offline = worker.state().unwrap(); + println!("Solo offline at revision {}.", offline.revision); + assert!(!offline.online); + saw_offline = true; + } + } + Err(error) => panic!("unexpected worker error: {error:?}"), + } + } + + worker.stop().unwrap(); + panic!("did not observe both USB/IP detach and reconnect within 60 seconds"); +} + +#[test] +#[ignore = "requires explicit approval; toggles Direct Monitor once and restores it"] +fn toggles_direct_monitor_and_restores_it() { + let control = discover("0") + .unwrap() + .controls + .into_iter() + .find(|control| control.name == "Direct Monitor Playback Switch") + .expect("attached Solo must expose Direct Monitor Playback Switch"); + assert_eq!(control.value_type, ValueType::Boolean); + assert_eq!(control.values.len(), 1); + let id = control.id; + let before = match control.values[0] { + focusrited::scarlett2_alsa::ObservedValue::Boolean(value) => value, + _ => unreachable!("Direct Monitor must be boolean"), + }; + let mut device = Scarlett2Alsa::with_writable_controls("0", BTreeSet::from([id.clone()])); + let target = !before; + + let changed = device.write(&id, Value::Bool(target)); + let after_change = device.snapshot(); + let restored = device.write(&id, Value::Bool(before)); + let after_restore = device.snapshot(); + + assert!(changed.is_ok(), "change failed: {changed:?}"); + assert_eq!(after_change.unwrap().values[&id], Value::Bool(target)); + assert!(restored.is_ok(), "restore failed: {restored:?}"); + assert_eq!(after_restore.unwrap().values[&id], Value::Bool(before)); +} diff --git a/docs/phases/phase-2-solo-wsl.md b/docs/phases/phase-2-solo-wsl.md index 7cfa89b..b165231 100644 --- a/docs/phases/phase-2-solo-wsl.md +++ b/docs/phases/phase-2-solo-wsl.md @@ -1,5 +1,47 @@ # Phase 2: Solo Service Development in WSL +## Status + +Complete for WSL scope on branch `phase-2-solo-wsl`. Native-Linux/Pi hardware +validation moves to Phase 3. + +Completed: WSL2 USB handoff documented; sanitized read-only Solo control +[fixture](../../crates/focusrited/tests/fixtures/scarlett-solo-4th-gen.md) +captured; `Scarlett2Alsa` read-only discovery passes against the attached Solo; +device-independent service policy core has mock coverage for confirmed writes, +hardware failure, reconciliation, disconnect/reconnect, and explicit in-memory +profile apply; a bounded serial device worker owns all blocking device calls; +external front-panel Direct Monitor state change is reconciled through WSL2 +without a daemon write. +USB/IP detach/re-attach recovery also passed: offline state advanced its +revision, then recovery resnapshotted the Solo. This validates WSL2 device loss +and recovery only. Physical unplug/replug validation is deferred until native +Linux development hardware or Pi validation is available. + +Profile startup uses service-owned `/var/lib/focusrited/profiles` by default; +`--profile-store PATH` overrides it. Read-only daemon startup requires +`--card CARD`, and polls state reconciliation through serial device worker. +Loading never applies hardware state. +Initial executable startup validation was blocked because WSL had Solo listed +by `/proc/asound` but no `/dev/snd` nodes. USB/IP/WSL device nodes were restored +without a daemon write. +Device nodes were restored and read-only `focusrited --card 0` ran against the +attached Solo on 2026-07-15 until Ctrl-C, with no daemon output or write. +With explicit approval, one bounded Direct Monitor test discovered its boolean +ALSA control, wrote its opposite value, confirmed it, restored original value, +and confirmed restoration on 2026-07-15. No other control was written. +Profiles bind to adapter-provided device identity and capability-schema version; +they reject mismatches before any command. Phase 2 stops at persistence and +bounded adapter-write validation; local profile save/list/dry-run/apply begins +in Phase 4a, LAN profile operations in Phase 5, and profile-safety acceptance +in Phase 8. +Integer-range and enum-item metadata are deferred to Phase 3 native-Linux +validation; those controls stay explicit but non-writable in Phase 2. + +Dependency decision: direct ALSA access uses `alsa` 0.12.0 (MIT OR Apache-2.0), +reviewed compatible with this project's MIT distribution. It needs `pkg-config` +and ALSA development headers in the development environment. + ## Goal Develop and test the Rust service with mocks and a Scarlett Solo attached @@ -23,10 +65,15 @@ directly to WSL2. This is development evidence, not Pi compatibility proof. ## Exit checks -- [ ] Mock tests cover writes, failures, disconnect/reconnect, and persistence. -- [ ] Sanitized Solo discovery fixture records supported and unsupported controls. -- [ ] Solo behavior is verified through WSL2 without unsafe state changes. -- [ ] Pi and 16i16 work remain deferred to Phases 3 and 7. +- [x] Mock tests cover writes, failures, disconnect/reconnect, and persistence. +- [x] Sanitized Solo discovery fixture records supported controls. +- [x] Unsupported/unreadable controls remain explicit without taking device offline. +- [x] Solo external-control reconciliation is verified through WSL2 without a + daemon write. +- [x] Solo USB/IP disconnect/reconnect is verified through WSL2 without a + daemon write. +- Physical Solo unplug/replug is deferred to native Linux or Pi validation. +- Pi and 16i16 work remain deferred to Phases 3 and 7. ## Update rule diff --git a/docs/roadmap.md b/docs/roadmap.md index f777d34..53edeaa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -22,7 +22,7 @@ Execution record: [Phase 1 foundation plan](phases/phase-1-foundation.md). Exit: WSL reliably formats, lints, and tests Rust workspace; no hardware control or cross-compilation implementation. -## Phase 2: Solo service development in WSL — planned +## Phase 2: Solo service development in WSL — complete (WSL scope) Execution record: [Phase 2 Solo WSL plan](phases/phase-2-solo-wsl.md). @@ -32,6 +32,13 @@ reconnect, validation, and explicit profile persistence. Validate Solo controls and external/front-panel changes through WSL2, while treating it as development evidence only. +Phase 2 owns profile storage, device/schema binding, and bounded adapter write +validation. It does not add user-facing save/list/dry-run/apply operations or +general multi-control hardware application. +Integer-range and enum-item discovery, plus controlled writes for those +domains, are deferred to Phase 3 native-Linux validation. Until then, those +domains remain explicit but non-writable. + Exit: mock and Solo-on-WSL tests cover supported control behavior, failure, disconnect/reconnect, and persistence; unsupported controls are explicit. @@ -50,7 +57,8 @@ deployment prerequisites are recorded. Implement versioned Unix-socket snapshot, command, and event messages, then build fullscreen Rust touch UI using only that local API. Start with main monitor/output controls; add capability groups after hardware and screen-fit -validation. +validation. Add local profile save/list, binding/diff dry-run, reviewed apply, +and per-control applied/skipped/failed results. Exit: hardware controller works from Pi display; touchscreen-client crash does not affect daemon; mock IPC tests cover command ordering, reconnect, and @@ -76,7 +84,8 @@ designated main volume and mute controls. Establish the web toolchain here: pin Node through `.nvmrc`/fnm and pnpm through Corepack, then add a verified Fict release with Vite static builds, compatible TypeScript, Biome, and Vitest. `focusrited` serves the resulting static assets; Vite is not a production -server dependency. +server dependency. Extend profile operations to LAN clients using normal +idempotency and confirmation rules. Exit: phone browser controls hardware on accepted LAN security model; two mock clients converge after concurrent updates, restart/resync, revision gap, and