From d989fac8da9b66dc5835552e30c4625422120ac2 Mon Sep 17 00:00:00 2001 From: Tzu-Chieh Huang Date: Fri, 17 Jul 2026 10:58:44 -0700 Subject: [PATCH 1/2] test(phase-3): prepare Pi hardware checks --- AGENTS.md | 5 + crates/focusrited/Cargo.toml | 3 + crates/focusrited/tests/scarlett2_alsa.rs | 32 +++-- docs/phases/phase-3-pi-compatibility.md | 165 ++++++++++++++++++++++ docs/roadmap.md | 2 + 5 files changed, 196 insertions(+), 11 deletions(-) create mode 100644 docs/phases/phase-3-pi-compatibility.md diff --git a/AGENTS.md b/AGENTS.md index 61e505f..d828f87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,11 @@ ## Engineering workflow +- Start every planned phase with a detailed execution plan in `docs/phases/`. +- Divide implementation into small, independently reviewable merge requests; + each MR states scope, verification, and any hardware action needed. +- Never edit, commit, or push `main` directly. Work on a dedicated branch and + merge through a reviewed MR. Do not push any branch unless explicitly asked. - Run mock/unit tests before hardware tests. - Hardware validation runs on target Linux hardware; QEMU cannot validate USB control behavior. diff --git a/crates/focusrited/Cargo.toml b/crates/focusrited/Cargo.toml index b791a35..77138a7 100644 --- a/crates/focusrited/Cargo.toml +++ b/crates/focusrited/Cargo.toml @@ -5,5 +5,8 @@ edition.workspace = true rust-version.workspace = true license.workspace = true +[features] +hardware-write-tests = [] + [dependencies] alsa = "0.12.0" diff --git a/crates/focusrited/tests/scarlett2_alsa.rs b/crates/focusrited/tests/scarlett2_alsa.rs index 673edc9..12ee30a 100644 --- a/crates/focusrited/tests/scarlett2_alsa.rs +++ b/crates/focusrited/tests/scarlett2_alsa.rs @@ -1,11 +1,18 @@ +#[cfg(feature = "hardware-write-tests")] use std::collections::BTreeSet; +#[cfg(feature = "hardware-write-tests")] +use focusrited::Device; use focusrited::{ - ControlId, Device, DeviceError, ServiceError, Value, + ControlId, DeviceError, ServiceError, Value, scarlett2_alsa::{Scarlett2Alsa, ValueType, discover}, worker::{DeviceWorker, WorkerError}, }; +fn hardware_card() -> String { + std::env::var("FOCUSRITED_HARDWARE_CARD").unwrap_or_else(|_| "0".into()) +} + #[test] fn fixture_records_solo_controls() { let fixture = include_str!("fixtures/scarlett-solo-4th-gen.md"); @@ -17,14 +24,15 @@ fn fixture_records_solo_controls() { #[test] #[ignore = "requires an attached Scarlett Solo ALSA card"] fn discovers_attached_solo() { - let discovery = discover("0").unwrap(); + let card = hardware_card(); + let discovery = discover(&card).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 worker = DeviceWorker::start(Scarlett2Alsa::new(card)).unwrap(); let snapshot = worker.state().unwrap().snapshot; assert_eq!(snapshot.capabilities.len(), discovery.controls.len()); assert!( @@ -51,7 +59,7 @@ fn discovers_attached_solo() { #[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 worker = DeviceWorker::start(Scarlett2Alsa::new(hardware_card())).unwrap(); let before = worker.state().unwrap(); let previous = before.snapshot.values[&control].clone(); println!("Direct Monitor before: {previous:?}. Toggle Direct now."); @@ -80,12 +88,12 @@ fn reconciles_external_direct_monitor_change() { } #[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(); +#[ignore = "disconnect then reconnect the Solo during this 60-second read-only check"] +fn reconnects_after_solo_disconnect() { + let worker = DeviceWorker::start(Scarlett2Alsa::new(hardware_card())).unwrap(); let initial = worker.state().unwrap(); let mut saw_offline = false; - println!("Detach Solo with usbipd now, then attach it again."); + println!("Disconnect Solo now, then reconnect it."); for _ in 0..240 { std::thread::sleep(std::time::Duration::from_millis(250)); @@ -113,13 +121,15 @@ fn reconnects_after_usbip_detach() { } worker.stop().unwrap(); - panic!("did not observe both USB/IP detach and reconnect within 60 seconds"); + panic!("did not observe both Solo disconnect and reconnect within 60 seconds"); } +#[cfg(feature = "hardware-write-tests")] #[test] #[ignore = "requires explicit approval; toggles Direct Monitor once and restores it"] fn toggles_direct_monitor_and_restores_it() { - let control = discover("0") + let card = hardware_card(); + let control = discover(&card) .unwrap() .controls .into_iter() @@ -132,7 +142,7 @@ fn toggles_direct_monitor_and_restores_it() { 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 mut device = Scarlett2Alsa::with_writable_controls(card, BTreeSet::from([id.clone()])); let target = !before; let changed = device.write(&id, Value::Bool(target)); diff --git a/docs/phases/phase-3-pi-compatibility.md b/docs/phases/phase-3-pi-compatibility.md new file mode 100644 index 0000000..d1bb846 --- /dev/null +++ b/docs/phases/phase-3-pi-compatibility.md @@ -0,0 +1,165 @@ +# Phase 3: Pi Compatibility Verification + +## Status + +Planned. Target is prepared Pi OS ARM64 with Scarlett Solo 4th Gen connected +directly by USB. Phase 2 WSL evidence remains valid only for its WSL scope. + +## Goal + +Prove current Solo service works on target Pi Linux without changing device +state by default. Record target prerequisites and fix only Pi-specific defects +found by that evidence. + +## Guardrails + +- Run mock/unit checks before every hardware check. +- Begin every hardware session with bounded, read-only discovery. +- Hardware writes, routing/clock changes, resets, firmware work, profile apply, + and physical unplug/replug require explicit approval for that session. +- Redact serials, usernames, LAN addresses, tokens, and unrelated logs before + adding fixtures or documentation. +- Use a named ALSA card or stable card selector; never assume Solo is card 0. +- Each MR uses its own branch, is independently reviewable, and never directly + edits, commits, or pushes `main`. + +## Merge-request plan + +### MR 1: Safe native hardware-test entry points + +**Scope** + +- Separate existing Solo tests into read-only discovery/reconciliation/reconnect + coverage and write-capable coverage. +- Gate write-capable coverage behind an explicit Cargo feature. +- Make the selected hardware card explicit for hardware tests and commands. +- Document exact read-only and write-capable invocation commands. + +**Verification** + +- `cargo fmt --check` +- `cargo clippy --workspace --all-targets -- -D warnings` +- `cargo test --workspace` +- Confirm an ignored-test run without the write feature cannot compile or run a + device-writing test. + +**Hardware action** + +None. Do not run ignored tests in this MR. + +**Commands after MR 1** + +Pi Solo is currently ALSA card 2. Select it explicitly rather than relying on +card order: + +```text +FOCUSRITED_HARDWARE_CARD=2 cargo test -p focusrited --test scarlett2_alsa -- --ignored +``` + +That command runs only ignored read-only hardware tests. A write-capable test +is excluded unless `--features hardware-write-tests` is passed. Even then, run +only after explicit session approval and filter to the intended test: + +```text +FOCUSRITED_HARDWARE_CARD=2 cargo test -p focusrited --test scarlett2_alsa \ + --features hardware-write-tests toggles_direct_monitor_and_restores_it -- --ignored +``` + +### MR 2: Pi read-only discovery and capability evidence + +**Scope** + +- Run bounded Pi read-only probes: kernel, USB identity, ALSA card/device list, + controls, and bounded control contents. +- Run Solo discovery against selected Pi ALSA card. +- Compare discovery shape with Phase 2 fixture; update sanitized fixture or + adapter parsing only when Pi evidence proves a difference. +- Record Pi OS/kernel, required packages, device access/group requirements, and + stable card-selection guidance. + +**Verification** + +- MR 1 checks. +- Read-only hardware discovery test. +- Read-only daemon startup using `focusrited --card CARD`, then clean stop. + +**Hardware action** + +Read-only only. No control write or stored-profile apply. + +### MR 3: Native reconciliation and controlled write metadata + +**Scope** + +- Verify external Direct Monitor/front-panel change reaches service state and + advances revision without daemon write. +- Capture integer ranges and enum items exposed by Pi ALSA. +- Make only evidence-backed metadata/validation changes; unsupported domains + remain explicit and non-writable. + +**Verification** + +- MR 1 checks. +- Read-only external-change test with operator interaction. +- Add or update mock tests for each parsing/validation defect found. + +**Hardware action** + +Read-only daemon behavior. Operator may change front-panel Direct Monitor; +daemon must not write any control. + +### MR 4: Native lifecycle recovery + +**Scope** + +- Verify service behavior through physical Solo unplug/replug and Pi reboot. +- Diagnose USB power, ALSA-node, service-startup, and recovery ordering issues. +- Add the smallest code or deployment change proven necessary. +- Record sanitized lifecycle evidence and known limits. + +**Verification** + +- MR 1 checks. +- Read-only physical disconnect/reconnect test. +- Read-only daemon start after reboot and recovery after reattach. + +**Hardware action** + +Physical cable unplug/replug and Pi reboot require session approval. No ALSA +write, routing, clock, reset, firmware, or profile apply. + +### MR 5: Phase 3 closeout + +**Scope** + +- Consolidate Pi prerequisites and verified commands in deployment/hardware + documentation. +- Mark Phase 3 evidence and unresolved limits. +- Add only regression tests for defects actually found in MRs 1–4. + +**Verification** + +- Full Rust verification: format, Clippy with warnings denied, and workspace + tests. +- Repeat one bounded read-only discovery and daemon-start check on Pi. + +**Hardware action** + +Read-only only. + +## Exit checks + +- [ ] Read-only and write-capable hardware tests are structurally separated; + write tests require explicit feature plus session approval. +- [ ] Solo service builds and starts natively on Pi against selected ALSA card. +- [ ] Sanitized Pi discovery proves capabilities and documented prerequisites. +- [ ] External/front-panel change reconciles into authoritative service state. +- [ ] Physical unplug/replug recovers to a fresh online snapshot. +- [ ] Pi reboot returns daemon and Solo readiness without device-state mutation. +- [ ] Target-specific limits, commands, and unresolved issues are documented. + +## Update rule + +After every MR, record completed checks, operator approvals, sanitized evidence, +defects found, and any changed prerequisite. Do not expand scope into touchscreen, +LAN, packaging, or 16i16/FCP work; those belong to later phases. diff --git a/docs/roadmap.md b/docs/roadmap.md index ca4331d..a8b6f60 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -44,6 +44,8 @@ disconnect/reconnect, and persistence; unsupported controls are explicit. ## Phase 3: Pi compatibility verification — planned +Execution plan: [Phase 3 Pi compatibility plan](phases/phase-3-pi-compatibility.md). + Develop directly on the Pi over SSH, including Zed Remote Development from a laptop, then validate current Solo service natively on Pi Linux. Find and fix target-only build, ALSA, USB, system-service, reboot, and unplug/replug issues. From 33da501eb456f696a13e3d7b7ebbb0da7bdf3e3c Mon Sep 17 00:00:00 2001 From: Tzu-Chieh Huang Date: Fri, 17 Jul 2026 11:03:11 -0700 Subject: [PATCH 2/2] fix(phase-3): serialize Solo hardware tests --- crates/focusrited/tests/scarlett2_alsa.rs | 18 +++++++++++++++++- docs/phases/phase-3-pi-compatibility.md | 20 +++++++++++--------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/crates/focusrited/tests/scarlett2_alsa.rs b/crates/focusrited/tests/scarlett2_alsa.rs index 12ee30a..e293709 100644 --- a/crates/focusrited/tests/scarlett2_alsa.rs +++ b/crates/focusrited/tests/scarlett2_alsa.rs @@ -1,5 +1,6 @@ #[cfg(feature = "hardware-write-tests")] use std::collections::BTreeSet; +use std::sync::Mutex; #[cfg(feature = "hardware-write-tests")] use focusrited::Device; @@ -9,8 +10,11 @@ use focusrited::{ worker::{DeviceWorker, WorkerError}, }; +static HARDWARE_LOCK: Mutex<()> = Mutex::new(()); + fn hardware_card() -> String { - std::env::var("FOCUSRITED_HARDWARE_CARD").unwrap_or_else(|_| "0".into()) + std::env::var("FOCUSRITED_HARDWARE_CARD") + .expect("FOCUSRITED_HARDWARE_CARD must identify the attached Solo") } #[test] @@ -24,6 +28,9 @@ fn fixture_records_solo_controls() { #[test] #[ignore = "requires an attached Scarlett Solo ALSA card"] fn discovers_attached_solo() { + let _hardware_guard = HARDWARE_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let card = hardware_card(); let discovery = discover(&card).unwrap(); @@ -58,6 +65,9 @@ fn discovers_attached_solo() { #[test] #[ignore = "toggle the Solo Direct Monitor control during this 30-second read-only check"] fn reconciles_external_direct_monitor_change() { + let _hardware_guard = HARDWARE_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let control = ControlId("alsa-numid:48".into()); let worker = DeviceWorker::start(Scarlett2Alsa::new(hardware_card())).unwrap(); let before = worker.state().unwrap(); @@ -90,6 +100,9 @@ fn reconciles_external_direct_monitor_change() { #[test] #[ignore = "disconnect then reconnect the Solo during this 60-second read-only check"] fn reconnects_after_solo_disconnect() { + let _hardware_guard = HARDWARE_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let worker = DeviceWorker::start(Scarlett2Alsa::new(hardware_card())).unwrap(); let initial = worker.state().unwrap(); let mut saw_offline = false; @@ -128,6 +141,9 @@ fn reconnects_after_solo_disconnect() { #[test] #[ignore = "requires explicit approval; toggles Direct Monitor once and restores it"] fn toggles_direct_monitor_and_restores_it() { + let _hardware_guard = HARDWARE_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let card = hardware_card(); let control = discover(&card) .unwrap() diff --git a/docs/phases/phase-3-pi-compatibility.md b/docs/phases/phase-3-pi-compatibility.md index d1bb846..22f9afe 100644 --- a/docs/phases/phase-3-pi-compatibility.md +++ b/docs/phases/phase-3-pi-compatibility.md @@ -2,8 +2,9 @@ ## Status -Planned. Target is prepared Pi OS ARM64 with Scarlett Solo 4th Gen connected -directly by USB. Phase 2 WSL evidence remains valid only for its WSL scope. +In progress. MR 1 complete; MR 2 is next. Target is prepared Pi OS ARM64 with +Scarlett Solo 4th Gen connected directly by USB. Phase 2 WSL evidence remains +valid only for its WSL scope. ## Goal @@ -25,7 +26,7 @@ found by that evidence. ## Merge-request plan -### MR 1: Safe native hardware-test entry points +### MR 1: Safe native hardware-test entry points — complete **Scope** @@ -49,11 +50,11 @@ None. Do not run ignored tests in this MR. **Commands after MR 1** -Pi Solo is currently ALSA card 2. Select it explicitly rather than relying on -card order: +Select Solo explicitly with its ALSA card ID rather than relying on card order: ```text -FOCUSRITED_HARDWARE_CARD=2 cargo test -p focusrited --test scarlett2_alsa -- --ignored +FOCUSRITED_HARDWARE_CARD=Gen cargo test -p focusrited --test scarlett2_alsa -- \ + --ignored --test-threads=1 ``` That command runs only ignored read-only hardware tests. A write-capable test @@ -61,8 +62,9 @@ is excluded unless `--features hardware-write-tests` is passed. Even then, run only after explicit session approval and filter to the intended test: ```text -FOCUSRITED_HARDWARE_CARD=2 cargo test -p focusrited --test scarlett2_alsa \ - --features hardware-write-tests toggles_direct_monitor_and_restores_it -- --ignored +FOCUSRITED_HARDWARE_CARD=Gen cargo test -p focusrited --test scarlett2_alsa \ + --features hardware-write-tests toggles_direct_monitor_and_restores_it -- \ + --ignored --test-threads=1 ``` ### MR 2: Pi read-only discovery and capability evidence @@ -149,7 +151,7 @@ Read-only only. ## Exit checks -- [ ] Read-only and write-capable hardware tests are structurally separated; +- [x] Read-only and write-capable hardware tests are structurally separated; write tests require explicit feature plus session approval. - [ ] Solo service builds and starts natively on Pi against selected ALSA card. - [ ] Sanitized Pi discovery proves capabilities and documented prerequisites.