From 331bd32982b3e131fa668926c0d0d36f8bbbd3f3 Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Fri, 11 Sep 2026 13:32:23 -0400 Subject: [PATCH 01/22] feat(macos): add macOS RFCOMM backend via IOBluetooth - implement a macOS connection backend (lib/src/connection_backend/macos/rfcomm.rs) using objc2-io-bluetooth/IOBluetooth.framework, implementing the same RfcommBackend/RfcommConnection traits as the existing Windows/Linux backends - ensure a baseband connection before service selection, query SDP on-demand (bounded by a timeout) when the cached service list is empty, and fix ServiceClassIDList parsing to handle the single-UUID case that getArrayValue() never recognized - use openRFCOMMChannelAsync and treat its synchronous return as the success signal, since rfcommChannelOpenComplete is unreliable and never fires - have a single thread own the channel's entire lifecycle (open, write, run-loop pumping, close), and give the CLI's actual main thread a real run loop to pump instead of letting tokio's #[tokio::main] block it - IOBluetooth dispatches delegate callbacks through the process's main-thread run loop regardless of which thread issues the calls, so without this, RFCOMM reads never complete - retry writeSync on transient kIOReturnNotOpen, since the channel can take up to ~2s to finish opening after openRFCOMMChannelAsync returns - skip individual paired devices whose MAC address can't be read instead of failing the whole device list - add debug/trace logging across the connection lifecycle and the shared packet I/O handler - justfile: remove the prior binary before copying the new one in the build-cli/build-gui recipes, fixing a codesigning/page-cache staleness bug from overwriting in place; add a build-gui-bundle recipe - packaging/macos/{build.sh,Info.plist}: build the .app bundle, including the Bluetooth usage description required for the permission prompt - add macOS to CI (build.yml, test-macos.yml) and build docs (docs/build-macos.md) Co-Authored-By: Claude Co-Authored-By: opencode --- .github/workflows/build.yml | 47 ++ .github/workflows/test-macos.yml | 31 + Cargo.lock | 19 + Cargo.toml | 4 + README.md | 3 +- cli/Cargo.toml | 4 + cli/src/main.rs | 34 + docs/build-macos.md | 24 + justfile | 10 + lib/Cargo.toml | 14 + lib/src/connection_backend.rs | 6 + lib/src/connection_backend/macos.rs | 14 + lib/src/connection_backend/macos/rfcomm.rs | 626 ++++++++++++++++++ .../common/packet/packet_io_controller.rs | 5 + packaging/macos/Info.plist | 24 + packaging/macos/build.sh | 15 + 16 files changed, 879 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test-macos.yml create mode 100644 docs/build-macos.md create mode 100644 lib/src/connection_backend/macos.rs create mode 100644 lib/src/connection_backend/macos/rfcomm.rs create mode 100644 packaging/macos/Info.plist create mode 100755 packaging/macos/build.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a870ca070..c784466a0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,6 +18,11 @@ on: required: true default: true type: boolean + macos: + description: "Enable macOS build" + required: true + default: true + type: boolean permissions: contents: read @@ -141,6 +146,48 @@ jobs: name: windows path: build-output/* + build-macos: + runs-on: macos-latest + if: inputs.macos + permissions: + id-token: write + attestations: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install just + run: brew install just + - name: Install rust + run: rustup install --profile minimal stable + + - name: Build cli + run: just build-cli + - name: Build gui + run: just build-gui + - name: Build app bundle + run: just build-gui-bundle + + - name: Add platform to file name, zip app bundle + run: | + mv build-output/openscq30 build-output/openscq30-cli-macos-arm64 + rm build-output/openscq30-gui + cd build-output + zip -r openscq30-gui-macos-arm64.app.zip OpenSCQ30.app + rm -rf OpenSCQ30.app + + - name: Attest artifacts + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: build-output/* + + - name: Upload artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: macos + path: build-output/* + build-android: runs-on: ubuntu-latest if: inputs.android diff --git a/.github/workflows/test-macos.yml b/.github/workflows/test-macos.yml new file mode 100644 index 000000000..ef4ed6ada --- /dev/null +++ b/.github/workflows/test-macos.yml @@ -0,0 +1,31 @@ +name: test macos + +on: + push: + paths: &push_paths + - Cargo.lock + - lib/** + - .github/workflows/test-macos.yml + pull_request: + paths: *push_paths + +permissions: + contents: read + +jobs: + # Only lib is tested on macos since building the gui/cli would take + # a lot longer, and they don't contain any platform specific code + test-rust: + runs-on: macos-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install just + run: brew install just + - name: Install rust + run: rustup install --profile minimal stable + - name: Cache cargo dependencies + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - name: Run lib tests + run: just lib/ test diff --git a/Cargo.lock b/Cargo.lock index 264f578aa..adea63491 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3891,6 +3891,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.6.2", "dispatch2", + "libc", "objc2 0.6.4", ] @@ -3954,8 +3955,21 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.1", "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-bluetooth" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558b52e7b77f6a68e1e2cc700ff2266779ede47bacb260a33d363b9a623cc761" +dependencies = [ + "libc", "objc2 0.6.4", "objc2-core-foundation", + "objc2-foundation 0.3.2", ] [[package]] @@ -4083,6 +4097,7 @@ dependencies = [ "insta", "insta-cmd", "macaddr", + "objc2-core-foundation", "openscq30-i18n", "openscq30-lib", "serde", @@ -4156,6 +4171,10 @@ dependencies = [ "macaddr", "nom 8.0.0", "nom-language", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-io-bluetooth", "openscq30-i18n", "openscq30-i18n-macros", "openscq30-lib-has", diff --git a/Cargo.toml b/Cargo.toml index 6cfc19c59..5c70e1f27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -117,6 +117,10 @@ embed-resource = "3" pathfinding = "4" palette = "0.7" bitflags = "2" +objc2 = "0.6" +objc2-foundation = "0.3" +objc2-core-foundation = "0.3" +objc2-io-bluetooth = "0.3" [patch.'https://github.com/wash2/accesskit'] accesskit = { git = "https://github.com/Oppzippy/accesskit.git", branch = "iced-0.14" } diff --git a/README.md b/README.md index b43bc426b..3dc8dd4d9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ OpenSCQ30 is free software for controlling settings for Soundcore headphones and [x] Windows - Ready [x] Linux - Ready -[x] Android - Ready +[x] Android - Ready +[ ] MacOS - Beta ### Supported Devices diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 21b2dff2a..b91156cc8 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -33,6 +33,10 @@ serde = { workspace = true } serde_json = { workspace = true } indexmap = { workspace = true, features = ["serde"] } +[target.'cfg(target_os = "macos")'.dependencies] +tokio = { workspace = true, features = ["rt-multi-thread"] } +objc2-core-foundation = { workspace = true } + [dev-dependencies] insta = { workspace = true } insta-cmd = { workspace = true } diff --git a/cli/src/main.rs b/cli/src/main.rs index aa860593b..e7dfb6d57 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -11,8 +11,42 @@ use openscq30_lib::OpenSCQ30Session; use tracing::level_filters::LevelFilter; use tracing_subscriber::EnvFilter; +#[cfg(not(target_os = "macos"))] #[tokio::main] async fn main() -> ExitCode { + run().await +} + +// IOBluetooth delivers delegate callbacks (RFCOMM data, channel open/close) through the +// process's main thread run loop regardless of which thread issued the call, so tokio can't be +// left owning the real OS main thread here the way #[tokio::main] normally would: nothing would +// ever pump a run loop on it, and every callback would silently never arrive. +#[cfg(target_os = "macos")] +fn main() -> ExitCode { + let (exit_code_sender, exit_code_receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("failed to start tokio runtime"); + let _ = exit_code_sender.send(runtime.block_on(run())); + }); + + loop { + match exit_code_receiver.try_recv() { + Ok(code) => return code, + // the worker thread panicked before sending a result + Err(std::sync::mpsc::TryRecvError::Disconnected) => return ExitCode::FAILURE, + Err(std::sync::mpsc::TryRecvError::Empty) => {} + } + unsafe { + objc2_core_foundation::CFRunLoop::run_in_mode( + objc2_core_foundation::kCFRunLoopDefaultMode, + 0.1, + false, + ); + } + } +} + +async fn run() -> ExitCode { let matches = cli::build().get_matches(); if let Err(err) = initialize_logging(&matches) { diff --git a/docs/build-macos.md b/docs/build-macos.md new file mode 100644 index 000000000..bdb434d3e --- /dev/null +++ b/docs/build-macos.md @@ -0,0 +1,24 @@ +## Building openscq30-cli on macOS + +1. Install rust +2. Install the Xcode command line tools (`xcode-select --install`) +3. Run `cargo build --package openscq30-cli --profile release-fast` (or `cargo build --package openscq30-cli --release`, but it's very slow to build) +4. The compiled binary can be found at `target/release-fast/openscq30` + +## Building openscq30-gui on macOS + +### Just the executable + +1. Install rust +2. Install the Xcode command line tools (`xcode-select --install`) +3. Run `cargo build --package openscq30-gui --profile release-fast` (or `cargo build --package openscq30-gui --release`, but it's very slow to build) +4. The compiled binary can be found at `target/release-fast/openscq30-gui` + +### App bundle + +1. Install rust, the Xcode command line tools, and [just](https://github.com/casey/just) +2. Run `just build-gui-fast` (or `just build-gui`, but it's very slow to build) +3. Run `just build-gui-bundle` +4. The app bundle can be found at `build-output/OpenSCQ30.app` + +The app bundle isn't code signed or notarized, so macOS will refuse to open it with a plain double click. Right click the app and choose "Open" instead, then confirm in the dialog that appears. diff --git a/justfile b/justfile index a0219ed26..0e7eac9ac 100644 --- a/justfile +++ b/justfile @@ -18,12 +18,14 @@ list: [group("build")] build-gui features='': create-build-output-dir just gui::build release '{{ features }}' + rm -f '{{ build-output-dir }}/openscq30-gui' cp target/release/openscq30-gui '{{ build-output-dir }}/' [doc("Run a release build with excessively slow optimizations disabled")] [group("build")] build-gui-fast features='': create-build-output-dir just gui::build release-fast '{{ features }}' + rm -f '{{ build-output-dir }}/openscq30-gui' cp target/release-fast/openscq30-gui '{{ build-output-dir }}/' [doc("Build the windows installer. The gui must be built first.")] @@ -33,16 +35,24 @@ build-gui-installer: create-build-output-dir ./packaging/windows/build.sh cp packaging/windows/Output/openscq30-gui-installer.exe '{{ build-output-dir }}/' +[doc("Build the macos app bundle. The gui must be built first.")] +[group("build")] +[macos] +build-gui-bundle: create-build-output-dir + ./packaging/macos/build.sh + [doc("Run a fully optimized release build")] [group("build")] build-cli features='': create-build-output-dir just cli::build release '{{ features }}' + rm -f '{{ build-output-dir }}/openscq30' cp target/release/openscq30 '{{ build-output-dir }}/' [doc("Run a release build with excessively slow optimizations disabled")] [group("build")] build-cli-fast features='': create-build-output-dir just cli::build release-fast '{{ features }}' + rm -f '{{ build-output-dir }}/openscq30' cp target/release-fast/openscq30 '{{ build-output-dir }}/' android-apk-path := "./android/app/build/outputs/apk" diff --git a/lib/Cargo.toml b/lib/Cargo.toml index f4fba3202..54241e3af 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -68,6 +68,20 @@ windows = { workspace = true, features = [ rusqlite = { workspace = true, features = ["bundled"] } rust-embed = { workspace = true, features = ["debug-embed"] } +[target.'cfg(target_os = "macos")'.dependencies] +tokio = { workspace = true, features = ["rt-multi-thread"] } +objc2 = { workspace = true } +objc2-foundation = { workspace = true } +objc2-core-foundation = { workspace = true } +objc2-io-bluetooth = { workspace = true, features = [ + "Bluetooth", + "IOBluetoothDevice", + "IOBluetoothRFCOMMChannel", + "IOBluetoothSDPServiceRecord", + "IOBluetoothSDPUUID", + "IOBluetoothSDPDataElement", +] } + [dev-dependencies] tokio = { workspace = true, features = ["macros", "test-util"] } proptest = { workspace = true, default-features = false, features = ["std"] } diff --git a/lib/src/connection_backend.rs b/lib/src/connection_backend.rs index 94b562c06..3529982cb 100644 --- a/lib/src/connection_backend.rs +++ b/lib/src/connection_backend.rs @@ -8,6 +8,9 @@ cfg_select! { target_os = "windows" => { mod windows; } + target_os = "macos" => { + mod macos; + } _ => { mod none; } @@ -30,6 +33,9 @@ pub fn default_backends() -> Option { target_os = "windows" => { Some(windows::PlatformConnectionBackends) } + target_os = "macos" => { + Some(macos::PlatformConnectionBackends) + } _ => { None:: } diff --git a/lib/src/connection_backend/macos.rs b/lib/src/connection_backend/macos.rs new file mode 100644 index 000000000..cf043b2b9 --- /dev/null +++ b/lib/src/connection_backend/macos.rs @@ -0,0 +1,14 @@ +mod rfcomm; + +use crate::{api::connection, connection_backend::ConnectionBackends}; + +#[derive(Default)] +pub struct PlatformConnectionBackends; + +impl ConnectionBackends for PlatformConnectionBackends { + type Rfcomm = rfcomm::MacosRfcommBackend; + + async fn rfcomm(&self) -> connection::Result { + Ok(rfcomm::MacosRfcommBackend) + } +} diff --git a/lib/src/connection_backend/macos/rfcomm.rs b/lib/src/connection_backend/macos/rfcomm.rs new file mode 100644 index 000000000..d8205c57b --- /dev/null +++ b/lib/src/connection_backend/macos/rfcomm.rs @@ -0,0 +1,626 @@ +use std::{ + collections::HashSet, + ffi::{c_int, c_void}, + panic::Location, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread, +}; + +use async_trait::async_trait; +use macaddr::MacAddr6; +use objc2::{AnyThread, DefinedClass, define_class, rc::Retained, runtime::AnyObject}; +use objc2_core_foundation::{CFRunLoop, CFRunLoopRunResult, CFTimeInterval, kCFRunLoopDefaultMode}; +use objc2_foundation::{NSArray, NSObject, NSObjectProtocol, NSString}; +use objc2_io_bluetooth::{ + BluetoothRFCOMMChannelID, BluetoothSDPServiceAttributeID, IOBluetoothDevice, + IOBluetoothRFCOMMChannel, IOBluetoothSDPDataElement, IOBluetoothSDPServiceRecord, + IOBluetoothSDPUUID, +}; +use tokio::sync::{mpsc, watch}; +use tracing::{debug, debug_span, trace}; +use uuid::Uuid; + +use crate::{ + api::connection::{self, RfcommBackend, RfcommConnection}, + connection::RfcommServiceSelectionStrategy, +}; + +// Bluetooth SDP spec: the ServiceClassIDList attribute, which holds the UUIDs a service record advertises. +const SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID: BluetoothSDPServiceAttributeID = 0x0001; + +// A channel can briefly report "not open" right after connecting while IOBluetooth finishes the +// underlying L2CAP negotiation, even though openRFCOMMChannelAsync already returned success. How +// long this takes is inconsistent in practice (observed anywhere from ~200ms to over 2s), so the +// retry budget is generous rather than tight. +const IO_RETURN_NOT_OPEN: c_int = 0xE00002CDu32 as c_int; +const WRITE_NOT_OPEN_RETRIES: u32 = 50; +const WRITE_NOT_OPEN_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(200); + +const SDP_QUERY_TIMEOUT_SECONDS: CFTimeInterval = 10.0; + +// CFRunLoopRun() returns immediately if nothing is scheduled at the moment it's called, so the +// connection's event loop re-enters it in short bursts instead of once indefinitely. +const RFCOMM_EVENT_POLL_SECONDS: CFTimeInterval = 0.1; + +// How long the connection thread waits for a write request before falling back to pumping +// the run loop, so incoming delegate callbacks keep getting delivered between writes. +const WRITE_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(100); + +#[derive(Default)] +pub struct MacosRfcommBackend; + +#[async_trait] +impl RfcommBackend for MacosRfcommBackend { + async fn devices(&self) -> connection::Result> { + tokio::task::spawn_blocking(|| { + // Skip devices whose address can't be read rather than failing the whole list. + Ok(paired_devices() + .into_iter() + .filter_map(|device| { + let mac_address = mac_address_of(&device).ok()?; + Some(connection::ConnectionDescriptor { + name: unsafe { device.nameOrAddress() } + .map(|name| name.to_string()) + .unwrap_or_default(), + mac_address, + }) + }) + .collect()) + }) + .await + .unwrap() + } + + async fn connect( + &self, + mac_address: MacAddr6, + service_selection_strategy: RfcommServiceSelectionStrategy, + ) -> connection::Result> { + tokio::task::spawn_blocking( + move || -> connection::Result> { + let span = debug_span!( + "RfcommBackend::connect", + mac_address = tracing::field::display(mac_address), + ); + let _span_guard = span.enter(); + + debug!("finding device with desired mac address"); + let device = device_with_mac_address(mac_address)?; + + debug!("ensuring baseband connection"); + ensure_connected(&device)?; + + debug!("selecting RFCOMM service"); + let channel_id = select_channel_id(&device, &service_selection_strategy)?; + + debug!("opening RFCOMM channel"); + let connection = MacosRfcommConnection::open(device, channel_id)?; + Ok(Arc::new(connection)) + }, + ) + .await + .unwrap() + } +} + +fn paired_devices() -> Vec> { + unsafe { IOBluetoothDevice::pairedDevices() } + .map(|devices| downcast_all::(&devices)) + .unwrap_or_default() +} + +// pairedDevices()/services()/getArrayValue() are untyped NSArray, so elements need a runtime downcast. +fn downcast_all(array: &NSArray) -> Vec> { + array + .to_vec() + .into_iter() + .filter_map(|element| element.downcast::().ok()) + .collect() +} + +fn mac_address_of(device: &IOBluetoothDevice) -> connection::Result { + let address_string = + unsafe { device.addressString() }.ok_or_else(|| connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + })?; + address_string + .to_string() + .replace('-', ":") + .parse() + .map_err(|err: macaddr::ParseError| connection::Error::Other { + source: Box::new(err), + location: Location::caller(), + }) +} + +fn device_with_mac_address( + mac_address: MacAddr6, +) -> connection::Result> { + let address_string = NSString::from_str(&mac_address.to_string().replace(':', "-")); + unsafe { IOBluetoothDevice::deviceWithAddressString(Some(&address_string)) }.ok_or( + connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + }, + ) +} + +fn ensure_connected(device: &IOBluetoothDevice) -> connection::Result<()> { + if unsafe { device.isConnected() } { + return Ok(()); + } + debug!("device not connected, opening baseband connection"); + let status = unsafe { device.openConnection() }; + if status != 0 { + return Err(connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + }); + } + Ok(()) +} + +fn select_channel_id( + device: &IOBluetoothDevice, + service_selection_strategy: &RfcommServiceSelectionStrategy, +) -> connection::Result { + let uuid = match service_selection_strategy { + RfcommServiceSelectionStrategy::Constant(uuid) => *uuid, + RfcommServiceSelectionStrategy::Dynamic(select_uuid) => { + let uuids = service_uuids(device); + debug!("found RFCOMM services: {uuids:?}"); + let uuid = select_uuid(uuids); + debug!("using RFCOMM service: {uuid}"); + uuid + } + }; + + let record = service_record_for_uuid(device, uuid)?; + let mut channel_id: BluetoothRFCOMMChannelID = 0; + let status = unsafe { record.getRFCOMMChannelID(&mut channel_id) }; + if status != 0 { + return Err(connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + }); + } + debug!("resolved RFCOMM channel id: {channel_id}"); + Ok(channel_id) +} + +fn service_record_for_uuid( + device: &IOBluetoothDevice, + uuid: Uuid, +) -> connection::Result> { + let sdp_uuid = sdp_uuid_from_uuid(uuid).ok_or(connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + })?; + unsafe { device.getServiceRecordForUUID(Some(&sdp_uuid)) }.ok_or( + connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + }, + ) +} + +// macOS only populates the SDP cache once System Settings' device info pane has been opened, so query if empty. +// ponytail: only queries on a fully empty cache; forcing a fresh query on every connect was tried and reverted +// after it caused performSDPQuery to hang indefinitely when called repeatedly in quick succession. +fn service_uuids(device: &IOBluetoothDevice) -> HashSet { + let mut records = cached_service_records(device); + if records.is_empty() { + debug!("no cached SDP records, performing SDP query"); + if let Err(err) = perform_sdp_query(device) { + debug!("SDP query failed: {err}"); + return HashSet::new(); + } + records = cached_service_records(device); + } + debug!("found {} cached SDP service records", records.len()); + records + .into_iter() + .flat_map(|record| service_class_uuids(&record)) + .collect() +} + +fn cached_service_records( + device: &IOBluetoothDevice, +) -> Vec> { + unsafe { device.services() } + .map(|records| downcast_all::(&records)) + .unwrap_or_default() +} + +fn perform_sdp_query(device: &IOBluetoothDevice) -> connection::Result<()> { + let (done_sender, done_receiver) = std::sync::mpsc::channel(); + let delegate = SdpQueryDelegate::new(done_sender); + let delegate_object: &AnyObject = &delegate; + let status = unsafe { device.performSDPQuery(Some(delegate_object)) }; + if status != 0 { + return Err(connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + }); + } + + // Bounded so a delegate callback that never arrives can't hang the connection forever. + let run_result = + unsafe { CFRunLoop::run_in_mode(kCFRunLoopDefaultMode, SDP_QUERY_TIMEOUT_SECONDS, false) }; + if run_result == CFRunLoopRunResult::TimedOut { + return Err(connection::Error::TimedOut { + action: "SDP query", + }); + } + + match done_receiver.recv() { + Ok(0) => Ok(()), + _ => Err(connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + }), + } +} + +struct SdpQueryState { + done_sender: std::sync::mpsc::Sender, +} + +define_class!( + #[unsafe(super(NSObject))] + #[ivars = SdpQueryState] + struct SdpQueryDelegate; + + unsafe impl NSObjectProtocol for SdpQueryDelegate {} + + impl SdpQueryDelegate { + #[unsafe(method(sdpQueryComplete:status:))] + fn sdp_query_complete(&self, _device: Option<&IOBluetoothDevice>, status: c_int) { + let _ = self.ivars().done_sender.send(status); + if let Some(run_loop) = CFRunLoop::current() { + run_loop.stop(); + } + } + } +); + +impl SdpQueryDelegate { + fn new(done_sender: std::sync::mpsc::Sender) -> Retained { + let this = Self::alloc().set_ivars(SdpQueryState { done_sender }); + unsafe { objc2::msg_send![super(this), init] } + } +} + +fn service_class_uuids(record: &IOBluetoothSDPServiceRecord) -> Vec { + let Some(element) = + (unsafe { record.getAttributeDataElement(SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID) }) + else { + debug!("record has no ServiceClassIDList attribute"); + return Vec::new(); + }; + // IOBluetooth surfaces a single-UUID ServiceClassIDList as a bare UUID element, not a 1-item array. + if let Some(uuid) = unsafe { element.getUUIDValue() } { + return uuid_from_sdp_uuid(&uuid).into_iter().collect(); + } + + // getArrayValue() doesn't recognize this element even when it's an array; getValue() + downcast does. + let sub_elements = + unsafe { element.getValue() }.and_then(|value| value.downcast::>().ok()); + let Some(sub_elements) = sub_elements else { + debug!( + "ServiceClassIDList attribute value is not an array (type descriptor: {:?})", + unsafe { element.getTypeDescriptor() } + ); + return Vec::new(); + }; + let sub_elements = downcast_all::(&sub_elements); + debug!("ServiceClassIDList has {} entries", sub_elements.len()); + sub_elements + .into_iter() + .filter_map(|sub_element| { + let uuid = unsafe { sub_element.getUUIDValue() }; + if uuid.is_none() { + debug!("ServiceClassIDList entry is not a UUID"); + } + uuid.and_then(|uuid| uuid_from_sdp_uuid(&uuid)) + }) + .collect() +} + +fn sdp_uuid_from_uuid(uuid: Uuid) -> Option> { + let bytes = uuid.into_bytes(); + unsafe { IOBluetoothSDPUUID::uuidWithBytes_length(bytes.as_ptr().cast(), bytes.len() as _) } +} + +fn uuid_from_sdp_uuid(sdp_uuid: &IOBluetoothSDPUUID) -> Option { + let normalized = unsafe { sdp_uuid.getUUIDWithLength(16) }?; + let bytes: [u8; 16] = normalized.to_vec().try_into().ok()?; + Some(Uuid::from_bytes(bytes)) +} + +struct ConnectionState { + data_sender: mpsc::Sender>, + status_sender: watch::Sender, + running: Arc, +} + +define_class!( + #[unsafe(super(NSObject))] + #[ivars = ConnectionState] + struct RfcommDelegate; + + unsafe impl NSObjectProtocol for RfcommDelegate {} + + impl RfcommDelegate { + #[unsafe(method(rfcommChannelOpenComplete:status:))] + fn rfcomm_channel_open_complete( + &self, + _channel: Option<&IOBluetoothRFCOMMChannel>, + status: c_int, + ) { + debug!("rfcommChannelOpenComplete status: {status}"); + } + + #[unsafe(method(rfcommChannelData:data:length:))] + fn rfcomm_channel_data( + &self, + _channel: Option<&IOBluetoothRFCOMMChannel>, + data: *mut c_void, + length: usize, + ) { + let bytes = unsafe { std::slice::from_raw_parts(data.cast::(), length) }.to_vec(); + trace!("received packet: {bytes:?}"); + if self.ivars().data_sender.blocking_send(bytes).is_err() { + debug!("read_channel receiver is closed"); + } + } + + #[unsafe(method(rfcommChannelClosed:))] + fn rfcomm_channel_closed(&self, _channel: Option<&IOBluetoothRFCOMMChannel>) { + debug!("rfcomm channel closed"); + self.ivars().running.store(false, Ordering::Relaxed); + self.ivars() + .status_sender + .send_replace(connection::ConnectionStatus::Disconnected); + if let Some(run_loop) = CFRunLoop::current() { + run_loop.stop(); + } + } + } +); + +impl RfcommDelegate { + fn new( + data_sender: mpsc::Sender>, + status_sender: watch::Sender, + running: Arc, + ) -> Retained { + let this = Self::alloc().set_ivars(ConnectionState { + data_sender, + running, + status_sender, + }); + unsafe { objc2::msg_send![super(this), init] } + } +} + +struct SendableDevice(Retained); +unsafe impl Send for SendableDevice {} + +pub struct MacosRfcommConnection { + write_requests: + std::sync::mpsc::Sender<(Vec, std::sync::mpsc::Sender>)>, + read_channel: Mutex>>>, + connection_status_receiver: watch::Receiver, + running: Arc, +} + +impl MacosRfcommConnection { + fn open( + device: Retained, + channel_id: BluetoothRFCOMMChannelID, + ) -> connection::Result { + let (data_sender, data_receiver) = mpsc::channel(100); + let (status_sender, status_receiver) = + watch::channel(connection::ConnectionStatus::Connected); + let (open_result_sender, open_result_receiver) = std::sync::mpsc::channel(); + let (write_request_sender, write_request_receiver) = std::sync::mpsc::channel(); + let device = SendableDevice(device); + let running = Arc::new(AtomicBool::new(true)); + + thread::spawn({ + let running = running.clone(); + move || { + run_connection_thread( + device, + channel_id, + data_sender, + status_sender, + open_result_sender, + write_request_receiver, + running, + ) + } + }); + + open_result_receiver + .recv() + .map_err(|err| connection::Error::Other { + source: Box::new(err), + location: Location::caller(), + })??; + + Ok(Self { + write_requests: write_request_sender, + read_channel: Mutex::new(Some(data_receiver)), + connection_status_receiver: status_receiver, + running, + }) + } +} + +fn pump_run_loop() { + unsafe { + CFRunLoop::run_in_mode(kCFRunLoopDefaultMode, RFCOMM_EVENT_POLL_SECONDS, false); + } +} + +fn do_write(channel: &IOBluetoothRFCOMMChannel, data: &[u8]) -> connection::Result<()> { + let mut buffer = data.to_vec(); + let mut status; + let mut attempt = 0; + loop { + status = + unsafe { channel.writeSync_length(buffer.as_mut_ptr().cast(), buffer.len() as u16) }; + attempt += 1; + if status != IO_RETURN_NOT_OPEN || attempt >= WRITE_NOT_OPEN_RETRIES { + break; + } + debug!("channel not open yet, retrying write ({attempt})"); + // The port that delegate callbacks are dispatched through isn't registered until the + // first successful write, so pumping the run loop here has nothing to catch yet: it + // needs an actual wall-clock wait for the channel to finish opening, not a pump. + thread::sleep(WRITE_NOT_OPEN_RETRY_DELAY); + } + debug!("writeSync status: {status}, channel isOpen: {}", unsafe { + channel.isOpen() + }); + if status != 0 { + return Err(connection::Error::WriteError { + source: None, + location: Location::caller(), + }); + } + trace!("wrote packet: {buffer:?}"); + Ok(()) +} + +fn run_connection_thread( + device: SendableDevice, + channel_id: BluetoothRFCOMMChannelID, + data_sender: mpsc::Sender>, + status_sender: watch::Sender, + open_result_sender: std::sync::mpsc::Sender>, + write_request_receiver: std::sync::mpsc::Receiver<( + Vec, + std::sync::mpsc::Sender>, + )>, + running: Arc, +) { + let span = debug_span!("MacosRfcommConnection connection thread"); + let _span_guard = span.enter(); + + let delegate = RfcommDelegate::new(data_sender, status_sender, running.clone()); + + let mut channel: Option> = None; + let delegate_object: &AnyObject = &delegate; + let start_status = unsafe { + device.0.openRFCOMMChannelAsync_withChannelID_delegate( + Some(&mut channel), + channel_id, + Some(delegate_object), + ) + }; + + // openRFCOMMChannelAsync's return here (not the later rfcommChannelOpenComplete callback, + // which is unreliable and only useful for logging) is the actual success signal: the channel + // object is already valid and retained once this returns kIOReturnSuccess. + let Some(channel) = channel.filter(|_| start_status == 0) else { + debug!("openRFCOMMChannelAsync failed to start with status {start_status}"); + let _ = open_result_sender.send(Err(connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + })); + return; + }; + + let _ = open_result_sender.send(Ok(())); + + // The channel internally uses an NSPort (IOBluetoothRFCOMMChannel conforms to + // NSPortDelegate), and NSPort events are dispatched to the run loop of the thread that + // first performs I/O on the channel. So all writes and run-loop pumping must happen on this + // thread, which guarantees delegate callbacks (rfcommChannelData etc.) get dispatched here. + debug!("channel opened, running write/pump loop"); + + // Service any write requests queued while the channel was still opening. + for (data, response_sender) in write_request_receiver.try_iter() { + let _ = response_sender.send(do_write(&channel, &data)); + } + + while running.load(Ordering::Relaxed) { + match write_request_receiver.recv_timeout(WRITE_DRAIN_TIMEOUT) { + Ok((data, response_sender)) => { + let _ = response_sender.send(do_write(&channel, &data)); + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + } + if running.load(Ordering::Relaxed) { + pump_run_loop(); + } + } + + debug!("run loop stopped, draining pending write requests"); + // The caller's `write()` is blocked on recv() waiting for a status. If we exit without a + // response it would hang forever, so answer all still-queued writes with a clean failure + // before closing the channel. + for (_, response_sender) in write_request_receiver.try_iter() { + let _ = response_sender.send(Err(connection::Error::WriteError { + source: None, + location: Location::caller(), + })); + } + + debug!("cleaning up channel"); + unsafe { + channel.setDelegate(None); + channel.closeChannel(); + } + debug!("connection thread exiting"); +} + +#[async_trait] +impl RfcommConnection for MacosRfcommConnection { + async fn write(&self, data: &[u8]) -> connection::Result<()> { + let (response_sender, response_receiver) = std::sync::mpsc::channel(); + self.write_requests + .send((data.to_owned(), response_sender)) + .map_err(|err| connection::Error::Other { + source: Box::new(err), + location: Location::caller(), + })?; + let result = tokio::task::spawn_blocking(move || response_receiver.recv()) + .await + .expect("connection thread panicked while handling a write") + .map_err(|err| connection::Error::Other { + source: Box::new(err), + location: Location::caller(), + })?; + result + } + + fn read_channel(&self) -> mpsc::Receiver> { + self.read_channel + .lock() + .unwrap() + .take() + .expect("read_channel may only be called once per MacosRfcommConnection") + } + + fn connection_status(&self) -> watch::Receiver { + self.connection_status_receiver.clone() + } +} + +impl Drop for MacosRfcommConnection { + fn drop(&mut self) { + // The write-request sender is dropped with this struct, which unblocks the connection + // thread (it sees Disconnected, or running flips and it exits), and it cleans up the + // channel (clears the delegate, closes it) on its own thread. + self.running.store(false, Ordering::Relaxed); + } +} diff --git a/lib/src/devices/soundcore/common/packet/packet_io_controller.rs b/lib/src/devices/soundcore/common/packet/packet_io_controller.rs index eb10a6e00..ac27015dd 100644 --- a/lib/src/devices/soundcore/common/packet/packet_io_controller.rs +++ b/lib/src/devices/soundcore/common/packet/packet_io_controller.rs @@ -61,6 +61,7 @@ impl PacketIOController { ) -> (JoinHandle<()>, mpsc::Receiver) { let (outgoing_sender, outgoing_receiver) = mpsc::channel(100); let handle = tokio::spawn(async move { + tracing::debug!("packet handler started"); let mut buffer = Vec::::new(); 'receive_packet: @@ -98,6 +99,7 @@ impl PacketIOController { bytes.extend_from_slice(&buffer[start_index..]); buffer = bytes; } + tracing::debug!("packet handler stopped: incoming channel closed"); }.instrument(info_span!("packet handler"))); (handle, outgoing_receiver) } @@ -126,12 +128,15 @@ impl PacketIOController { .await .is_ok() { + tracing::debug!("got response on attempt {i}"); return Ok(handle.wait_for_value().await); } + tracing::debug!("no response within {}ms", 500 * i); } self.packet_queues.cancel(&queue_key, handle); + tracing::warn!("gave up after 3 attempts, no response received"); Err(device::Error::ActionTimedOut { action: "resending packet until ack received", }) diff --git a/packaging/macos/Info.plist b/packaging/macos/Info.plist new file mode 100644 index 000000000..7fc81fecb --- /dev/null +++ b/packaging/macos/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleName + OpenSCQ30 + CFBundleDisplayName + OpenSCQ30 + CFBundleIdentifier + com.oppzippy.OpenSCQ30 + CFBundleVersion + 2.12.0 + CFBundleShortVersionString + 2.12.0 + CFBundleExecutable + openscq30-gui + CFBundlePackageType + APPL + NSHighResolutionCapable + + NSBluetoothAlwaysUsageDescription + OpenSCQ30 connects to your headphones over Bluetooth to read and change their settings. + + diff --git a/packaging/macos/build.sh b/packaging/macos/build.sh new file mode 100755 index 000000000..f3cdf0378 --- /dev/null +++ b/packaging/macos/build.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_path="$(readlink -f -- "$0")" +script_dir="$(dirname -- "$script_path")" +project_root="$script_dir/../.." +input_binary="$project_root/build-output/openscq30-gui" +app_dir="$project_root/build-output/OpenSCQ30.app" + +rm -rf "$app_dir" +mkdir -p "$app_dir/Contents/MacOS" + +cp "$script_dir/Info.plist" "$app_dir/Contents/Info.plist" +cp "$input_binary" "$app_dir/Contents/MacOS/openscq30-gui" +chmod +x "$app_dir/Contents/MacOS/openscq30-gui" From 6c550c0449e03b2b36ec581aafa6d9620dbfdc40 Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Fri, 11 Sep 2026 13:56:56 -0400 Subject: [PATCH 02/22] Add .DS_Store (MacOS artifact) to .gitignore. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 93072327c..bf67e01a2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ /build-output .vscode .cargo +.DS_Store From 97a6643a3d5130e54ffda364f490f56fca8c87ee Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Fri, 11 Sep 2026 23:47:19 -0400 Subject: [PATCH 03/22] feat(lib): add basic support for the Soundcore Liberty 4 (A3953) - parse battery level/charging, dual firmware version, and serial number from the device's state-update packet, verified against a real capture - preserve everything past those fields verbatim in an unknown_suffix rather than guessing at layout - register SoundcoreA3953 in device_model.rs/soundcore.rs following the existing a3947/a3954 pattern, plus the README/translation entries required for it to compile and show up as a selectable model - equalizer, sound modes, buttons, and other flags are not yet implemented - they need more captures with individual settings toggled via the official app to identify field offsets Co-Authored-By: Claude --- README.md | 1 + lib/i18n/en/openscq30-lib.ftl | 1 + lib/src/devices/device_model.rs | 3 + lib/src/devices/soundcore.rs | 1 + lib/src/devices/soundcore/a3953.rs | 88 +++++++++++++++++ lib/src/devices/soundcore/a3953/packets.rs | 3 + .../soundcore/a3953/packets/state_update.rs | 98 +++++++++++++++++++ lib/src/devices/soundcore/a3953/state.rs | 33 +++++++ 8 files changed, 228 insertions(+) create mode 100644 lib/src/devices/soundcore/a3953.rs create mode 100644 lib/src/devices/soundcore/a3953/packets.rs create mode 100644 lib/src/devices/soundcore/a3953/packets/state_update.rs create mode 100644 lib/src/devices/soundcore/a3953/state.rs diff --git a/README.md b/README.md index 3dc8dd4d9..4c3d6e880 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ If there's a device not in this list that you want to see supported, check if th | A3949 | Soundcore P20i / P25i / R50i | | A3951 | Soundcore Liberty Air 2 Pro | | A3952 | Soundcore Liberty 3 Pro | +| A3953 | Soundcore Liberty 4 | | A3954 | Soundcore Liberty 4 Pro | | A3955 | Soundcore P40i | | A3957 | Soundcore Liberty 5 | diff --git a/lib/i18n/en/openscq30-lib.ftl b/lib/i18n/en/openscq30-lib.ftl index 2ed7f3d92..fdb03b8bc 100644 --- a/lib/i18n/en/openscq30-lib.ftl +++ b/lib/i18n/en/openscq30-lib.ftl @@ -25,6 +25,7 @@ soundcore-a3948 = Soundcore A20i soundcore-a3949 = Soundcore P20i / P25i / R50i soundcore-a3951 = Soundcore Liberty Air 2 Pro soundcore-a3952 = Soundcore Liberty 3 Pro +soundcore-a3953 = Soundcore Liberty 4 soundcore-a3939 = Soundcore Life P3 soundcore-a3935 = Soundcore Life A2 NC soundcore-a3959 = Soundcore P30i / R50i NC diff --git a/lib/src/devices/device_model.rs b/lib/src/devices/device_model.rs index 43b967e60..c43439e3f 100644 --- a/lib/src/devices/device_model.rs +++ b/lib/src/devices/device_model.rs @@ -54,6 +54,7 @@ pub enum DeviceModel { SoundcoreA3945, SoundcoreA3951, SoundcoreA3952, + SoundcoreA3953, SoundcoreA3939, SoundcoreA3935, SoundcoreA3954, @@ -120,6 +121,7 @@ impl DeviceModel { Self::SoundcoreA3949 => new_soundcore_device!(soundcore::a3949), Self::SoundcoreA3951 => new_soundcore_device!(soundcore::a3951), Self::SoundcoreA3952 => new_soundcore_device!(soundcore::a3952), + Self::SoundcoreA3953 => new_soundcore_device!(soundcore::a3953), Self::SoundcoreA3954 => new_soundcore_device!(soundcore::a3954), Self::SoundcoreA3955 => new_soundcore_device!(soundcore::a3955), Self::SoundcoreA3957 => new_soundcore_device!(soundcore::a3957), @@ -176,6 +178,7 @@ impl DeviceModel { Self::SoundcoreA3949 => new_soundcore_device!(soundcore::a3949), Self::SoundcoreA3951 => new_soundcore_device!(soundcore::a3951), Self::SoundcoreA3952 => new_soundcore_device!(soundcore::a3952), + Self::SoundcoreA3953 => new_soundcore_device!(soundcore::a3953), Self::SoundcoreA3954 => new_soundcore_device!(soundcore::a3954), Self::SoundcoreA3955 => new_soundcore_device!(soundcore::a3955), Self::SoundcoreA3957 => new_soundcore_device!(soundcore::a3957), diff --git a/lib/src/devices/soundcore.rs b/lib/src/devices/soundcore.rs index 19c770dc4..b3c4c5891 100644 --- a/lib/src/devices/soundcore.rs +++ b/lib/src/devices/soundcore.rs @@ -23,6 +23,7 @@ pub mod a3948; pub mod a3949; pub mod a3951; pub mod a3952; +pub mod a3953; pub mod a3954; pub mod a3955; pub mod a3957; diff --git a/lib/src/devices/soundcore/a3953.rs b/lib/src/devices/soundcore/a3953.rs new file mode 100644 index 000000000..3da2578db --- /dev/null +++ b/lib/src/devices/soundcore/a3953.rs @@ -0,0 +1,88 @@ +use std::collections::HashMap; + +use crate::devices::soundcore::{ + a3953::{packets::A3953StateUpdatePacket, state::A3953State}, + common::{ + device::fetch_state_from_state_update_packet, + macros::soundcore_device, + packet::outbound::{RequestState, ToPacket}, + }, +}; + +mod packets; +mod state; + +// Only battery, dual firmware version, and serial number have been reverse-engineered so far (see +// packets::state_update). Everything else (equalizer, sound modes, buttons, flags) needs more +// captures with individual settings toggled via the official app to identify field offsets. +soundcore_device!( + A3953State, + async |packet_io| { + fetch_state_from_state_update_packet::(packet_io).await + }, + async |builder| { + builder.module_collection().add_state_update(); + + builder.serial_number_and_dual_firmware_version(); + // Only ever observed at 5/5 (fully charged); assumed max_level 5 to match the structurally + // similar A3947 (Liberty 4 NC) until a partial-charge capture confirms or corrects this. + builder.dual_battery(5); + }, + { + HashMap::from([( + RequestState::COMMAND, + A3953StateUpdatePacket::default().to_packet(), + )]) + }, +); + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use crate::{ + DeviceModel, + devices::soundcore::common::{ + device::{SoundcoreDeviceConfig, test_utils::TestSoundcoreDevice}, + packet, + }, + settings::SettingId, + }; + + #[tokio::test(start_paused = true)] + async fn parses_known_packet() { + let device = TestSoundcoreDevice::new( + super::device_registry, + DeviceModel::SoundcoreA3953, + HashMap::from([( + packet::Command([1, 1]), + packet::Inbound::new( + packet::Command([1, 1]), + vec![ + 1, 1, 5, 5, 0, 0, 48, 51, 46, 50, 51, 48, 51, 46, 50, 51, 51, 57, 53, 51, + 53, 52, 67, 54, 51, 51, 67, 67, 69, 69, 69, 56, 0, 0, 120, 120, 120, 120, + 120, 120, 120, 120, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 0, 60, 60, 60, 60, 60, 60, 60, 60, 0, 0, 60, 60, 60, 60, 60, 60, 60, + 60, 0, 0, 0, 0, 0, 0, 0, 60, 60, 60, 60, 60, 60, 60, 60, 0, 0, 60, 60, 60, + 60, 60, 60, 60, 60, 0, 0, 0, 0, 18, 17, 102, 17, 102, 17, 52, 17, 52, 17, + 36, 17, 36, 17, 82, 17, 83, 3, 2, 48, 0, 0, 1, 1, 0, 0, 0, 0, 255, 255, 1, + 1, 0, 5, 0, 0, 0, 1, 2, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 120, 255, 50, 0, 255, + 255, + ], + ), + )]), + SoundcoreDeviceConfig::default(), + ) + .await; + + device.assert_setting_values([ + (SettingId::BatteryLevelLeft, "5/5".into()), + (SettingId::BatteryLevelRight, "5/5".into()), + (SettingId::IsChargingLeft, "No".into()), + (SettingId::IsChargingRight, "No".into()), + (SettingId::FirmwareVersionLeft, "03.23".into()), + (SettingId::FirmwareVersionRight, "03.23".into()), + (SettingId::SerialNumber, "395354C633CCEEE8".into()), + ]); + } +} diff --git a/lib/src/devices/soundcore/a3953/packets.rs b/lib/src/devices/soundcore/a3953/packets.rs new file mode 100644 index 000000000..3282c85d9 --- /dev/null +++ b/lib/src/devices/soundcore/a3953/packets.rs @@ -0,0 +1,3 @@ +mod state_update; + +pub use state_update::*; diff --git a/lib/src/devices/soundcore/a3953/packets/state_update.rs b/lib/src/devices/soundcore/a3953/packets/state_update.rs new file mode 100644 index 000000000..c029197a8 --- /dev/null +++ b/lib/src/devices/soundcore/a3953/packets/state_update.rs @@ -0,0 +1,98 @@ +use nom::{ + IResult, Parser, + combinator::{map, rest}, + error::{ContextError, ParseError, context}, +}; + +use crate::devices::soundcore::{ + a3953::state::A3953State, + common::{ + macros::state_update_packet_module, + packet::{self, inbound::FromPacketBody, outbound::ToPacket}, + structures::{DualBattery, DualFirmwareVersion, SerialNumber, TwsStatus}, + }, +}; + +/// Only `tws_status`, `battery`, `dual_firmware_version`, and `serial_number` have been +/// reverse-engineered so far (confirmed against a real capture from the device: the serial number +/// decodes to "3953" + the device's own MAC address, byte-reversed). Everything after that is +/// captured verbatim in `unknown_suffix` rather than guessed at, so round-tripping stays exact +/// until more fields (equalizer, sound modes, buttons, etc.) are identified from real captures. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct A3953StateUpdatePacket { + pub tws_status: TwsStatus, + pub battery: DualBattery, + pub dual_firmware_version: DualFirmwareVersion, + pub serial_number: SerialNumber, + pub unknown_suffix: Vec, +} + +impl FromPacketBody for A3953StateUpdatePacket { + type DirectionMarker = packet::InboundMarker; + + fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( + input: &'a [u8], + ) -> IResult<&'a [u8], Self, E> { + context( + "a3953 state update packet", + map( + ( + TwsStatus::take, + DualBattery::take, + DualFirmwareVersion::take, + SerialNumber::take, + map(rest, |suffix: &[u8]| suffix.to_vec()), + ), + |(tws_status, battery, dual_firmware_version, serial_number, unknown_suffix)| { + Self { + tws_status, + battery, + dual_firmware_version, + serial_number, + unknown_suffix, + } + }, + ), + ) + .parse_complete(input) + } +} + +impl ToPacket for A3953StateUpdatePacket { + type DirectionMarker = packet::InboundMarker; + + fn command(&self) -> packet::Command { + packet::inbound::STATE_COMMAND + } + + fn body(&self) -> Vec { + self.tws_status + .bytes() + .into_iter() + .chain(self.battery.bytes()) + .chain(self.dual_firmware_version.bytes()) + .chain(self.serial_number.to_string().into_bytes()) + .chain(self.unknown_suffix.iter().copied()) + .collect() + } +} + +state_update_packet_module!(A3953State, A3953StateUpdatePacket); + +#[cfg(test)] +mod tests { + use nom_language::error::VerboseError; + + use crate::devices::soundcore::common::packet::inbound::TryToPacket; + + use super::*; + + #[test] + fn serialize_and_deserialize() { + let bytes = A3953StateUpdatePacket::default() + .to_packet() + .bytes_with_checksum(); + let (_, packet) = packet::Inbound::take_with_checksum::>(&bytes).unwrap(); + let _: A3953StateUpdatePacket = packet.try_to_packet().unwrap(); + } +} diff --git a/lib/src/devices/soundcore/a3953/state.rs b/lib/src/devices/soundcore/a3953/state.rs new file mode 100644 index 000000000..ae6937316 --- /dev/null +++ b/lib/src/devices/soundcore/a3953/state.rs @@ -0,0 +1,33 @@ +use openscq30_lib_macros::Has; + +use crate::devices::soundcore::{ + a3953, + common::structures::{DualBattery, DualFirmwareVersion, SerialNumber, TwsStatus}, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Has)] +pub struct A3953State { + tws_status: TwsStatus, + battery: DualBattery, + dual_firmware_version: DualFirmwareVersion, + serial_number: SerialNumber, +} + +impl From for A3953State { + fn from(packet: a3953::packets::A3953StateUpdatePacket) -> Self { + let a3953::packets::A3953StateUpdatePacket { + tws_status, + battery, + dual_firmware_version, + serial_number, + unknown_suffix: _, + } = packet; + + Self { + tws_status, + battery, + dual_firmware_version, + serial_number, + } + } +} From 777926f53e0cc77796cdcf9cfd587350fbf40d10 Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Sat, 12 Sep 2026 12:57:47 -0400 Subject: [PATCH 04/22] feat(lib): add ambient sound mode support for the Soundcore Liberty 4 (A3953) - add a device-specific SoundModes struct and wire it through the shared sound_modes_v2 module, exposing AmbientSoundMode as a real read/write setting (command 0x06/0x81) - derive the field's packet offset dynamically from a length byte read earlier in the packet, matching the official app's own offset math, rather than hardcoding the position observed in one capture - preserve button config, the ambient-sound-mode cycle bits, and every other still-unimplemented field verbatim for round-trip correctness; the cycle bits are parsed but not exposed since no outbound command for them was found - verify against three real captures of the same device (Normal, Noise Canceling, Transparency) Co-Authored-By: Claude --- lib/src/devices/soundcore/a3953.rs | 72 +++++++++- lib/src/devices/soundcore/a3953/modules.rs | 18 +++ .../soundcore/a3953/modules/sound_modes.rs | 35 +++++ .../modules/sound_modes/setting_handler.rs | 54 ++++++++ .../soundcore/a3953/packets/state_update.rs | 128 ++++++++++++++---- lib/src/devices/soundcore/a3953/state.rs | 8 ++ lib/src/devices/soundcore/a3953/structures.rs | 91 +++++++++++++ 7 files changed, 375 insertions(+), 31 deletions(-) create mode 100644 lib/src/devices/soundcore/a3953/modules.rs create mode 100644 lib/src/devices/soundcore/a3953/modules/sound_modes.rs create mode 100644 lib/src/devices/soundcore/a3953/modules/sound_modes/setting_handler.rs create mode 100644 lib/src/devices/soundcore/a3953/structures.rs diff --git a/lib/src/devices/soundcore/a3953.rs b/lib/src/devices/soundcore/a3953.rs index 3da2578db..5733647cc 100644 --- a/lib/src/devices/soundcore/a3953.rs +++ b/lib/src/devices/soundcore/a3953.rs @@ -9,12 +9,15 @@ use crate::devices::soundcore::{ }, }; +mod modules; mod packets; mod state; +pub mod structures; -// Only battery, dual firmware version, and serial number have been reverse-engineered so far (see -// packets::state_update). Everything else (equalizer, sound modes, buttons, flags) needs more -// captures with individual settings toggled via the official app to identify field offsets. +// Battery, dual firmware version, serial number, and ambient sound mode have been +// reverse-engineered (see packets::state_update and structures::SoundModes, both cited against the +// official app's decompiled source). Equalizer, buttons, and the remaining flags still need more +// captures (or further decompilation) to identify field offsets. soundcore_device!( A3953State, async |packet_io| { @@ -27,6 +30,7 @@ soundcore_device!( // Only ever observed at 5/5 (fully charged); assumed max_level 5 to match the structurally // similar A3947 (Liberty 4 NC) until a partial-charge capture confirms or corrects this. builder.dual_battery(5); + builder.a3953_sound_modes(); }, { HashMap::from([( @@ -83,6 +87,68 @@ mod tests { (SettingId::FirmwareVersionLeft, "03.23".into()), (SettingId::FirmwareVersionRight, "03.23".into()), (SettingId::SerialNumber, "395354C633CCEEE8".into()), + (SettingId::AmbientSoundMode, "Normal".into()), ]); } + + // The three tests below are real captures of the same device, differing only in byte 121 (the + // ambient sound mode toggled in the official app between captures): 2 (Normal), 0 (Noise + // Canceling), and 1 (Transparency). See a3953/structures.rs for the byte citation. + #[tokio::test(start_paused = true)] + async fn parses_noise_canceling_mode() { + let device = TestSoundcoreDevice::new( + super::device_registry, + DeviceModel::SoundcoreA3953, + HashMap::from([( + packet::Command([1, 1]), + packet::Inbound::new( + packet::Command([1, 1]), + vec![ + 1, 1, 5, 5, 0, 0, 48, 51, 46, 50, 51, 48, 51, 46, 50, 51, 51, 57, 53, 51, + 53, 52, 67, 54, 51, 51, 67, 67, 69, 69, 69, 56, 0, 0, 120, 120, 120, 120, + 120, 120, 120, 120, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 0, 60, 60, 60, 60, 60, 60, 60, 60, 0, 0, 60, 60, 60, 60, 60, 60, 60, + 60, 0, 0, 0, 0, 0, 0, 0, 60, 60, 60, 60, 60, 60, 60, 60, 0, 0, 60, 60, 60, + 60, 60, 60, 60, 60, 0, 0, 0, 0, 18, 17, 102, 17, 102, 17, 52, 17, 52, 17, + 36, 17, 36, 17, 82, 17, 83, 3, 0, 48, 0, 0, 1, 1, 0, 0, 0, 0, 255, 255, 1, + 1, 0, 5, 0, 0, 0, 1, 2, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 120, 255, 50, 0, 255, + 255, + ], + ), + )]), + SoundcoreDeviceConfig::default(), + ) + .await; + + device.assert_setting_values([(SettingId::AmbientSoundMode, "NoiseCanceling".into())]); + } + + #[tokio::test(start_paused = true)] + async fn parses_transparency_mode() { + let device = TestSoundcoreDevice::new( + super::device_registry, + DeviceModel::SoundcoreA3953, + HashMap::from([( + packet::Command([1, 1]), + packet::Inbound::new( + packet::Command([1, 1]), + vec![ + 1, 1, 5, 5, 0, 0, 48, 51, 46, 50, 51, 48, 51, 46, 50, 51, 51, 57, 53, 51, + 53, 52, 67, 54, 51, 51, 67, 67, 69, 69, 69, 56, 0, 0, 120, 120, 120, 120, + 120, 120, 120, 120, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 0, 60, 60, 60, 60, 60, 60, 60, 60, 0, 0, 60, 60, 60, 60, 60, 60, 60, + 60, 0, 0, 0, 0, 0, 0, 0, 60, 60, 60, 60, 60, 60, 60, 60, 0, 0, 60, 60, 60, + 60, 60, 60, 60, 60, 0, 0, 0, 0, 18, 17, 102, 17, 102, 17, 52, 17, 52, 17, + 36, 17, 36, 17, 82, 17, 83, 3, 1, 48, 0, 0, 1, 1, 0, 0, 0, 0, 255, 255, 1, + 1, 0, 5, 0, 0, 0, 1, 2, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 120, 255, 50, 0, 255, + 255, + ], + ), + )]), + SoundcoreDeviceConfig::default(), + ) + .await; + + device.assert_setting_values([(SettingId::AmbientSoundMode, "Transparency".into())]); + } } diff --git a/lib/src/devices/soundcore/a3953/modules.rs b/lib/src/devices/soundcore/a3953/modules.rs new file mode 100644 index 000000000..a1cbf700d --- /dev/null +++ b/lib/src/devices/soundcore/a3953/modules.rs @@ -0,0 +1,18 @@ +use openscq30_lib_has::Has; + +use crate::devices::soundcore::common::device::SoundcoreDeviceBuilder; + +use super::structures::SoundModes; + +mod sound_modes; + +impl SoundcoreDeviceBuilder +where + StateType: Has + Send + Sync + Clone + 'static, +{ + pub fn a3953_sound_modes(&mut self) { + let packet_io_controller = self.packet_io_controller().clone(); + self.module_collection() + .add_a3953_sound_modes(packet_io_controller); + } +} diff --git a/lib/src/devices/soundcore/a3953/modules/sound_modes.rs b/lib/src/devices/soundcore/a3953/modules/sound_modes.rs new file mode 100644 index 000000000..debcef87e --- /dev/null +++ b/lib/src/devices/soundcore/a3953/modules/sound_modes.rs @@ -0,0 +1,35 @@ +mod setting_handler; + +use std::sync::Arc; + +use openscq30_lib_has::Has; +use setting_handler::SoundModesSettingHandler; +use strum::{EnumIter, EnumString, IntoStaticStr}; + +use crate::{ + api::settings::{CategoryId, SettingId}, + devices::soundcore::{ + a3953::structures::SoundModes, + common::{modules::ModuleCollection, packet::PacketIOController}, + }, + macros::enum_subset, +}; + +enum_subset! { + SettingId, + #[derive(EnumString, EnumIter, IntoStaticStr)] + enum SoundModesSetting { + AmbientSoundMode, + } +} + +impl ModuleCollection +where + T: Has + Clone + Send + Sync, +{ + pub fn add_a3953_sound_modes(&mut self, packet_io: Arc) { + self.setting_manager + .add_handler(CategoryId::SoundModes, SoundModesSettingHandler); + self.add_partial_sound_modes_v2(packet_io); + } +} diff --git a/lib/src/devices/soundcore/a3953/modules/sound_modes/setting_handler.rs b/lib/src/devices/soundcore/a3953/modules/sound_modes/setting_handler.rs new file mode 100644 index 000000000..0f7767c40 --- /dev/null +++ b/lib/src/devices/soundcore/a3953/modules/sound_modes/setting_handler.rs @@ -0,0 +1,54 @@ +use async_trait::async_trait; +use openscq30_lib_has::Has; +use strum::IntoEnumIterator; + +use crate::{ + api::settings::{Setting, SettingId, Value}, + devices::soundcore::{ + a3953::structures::SoundModes, + common::settings_manager::{SettingHandler, SettingHandlerResult}, + }, +}; + +use super::SoundModesSetting; + +#[derive(Default)] +pub struct SoundModesSettingHandler; + +#[async_trait] +impl SettingHandler for SoundModesSettingHandler +where + T: Has + Send, +{ + fn settings(&self) -> Vec { + SoundModesSetting::iter().map(Into::into).collect() + } + + fn get(&self, state: &T, setting_id: &SettingId) -> Option { + let sound_modes: &SoundModes = state.get(); + let sound_mode_setting: SoundModesSetting = (*setting_id).try_into().ok()?; + match sound_mode_setting { + SoundModesSetting::AmbientSoundMode => Some(Setting::select_from_enum_all_variants( + sound_modes.ambient_sound_mode, + )), + } + } + + async fn set( + &self, + state: &mut T, + setting_id: &SettingId, + value: Value, + ) -> SettingHandlerResult<()> { + let sound_mode_setting: SoundModesSetting = (*setting_id) + .try_into() + .expect("already filtered to valid values only by SettingsManager"); + match sound_mode_setting { + SoundModesSetting::AmbientSoundMode => { + let sound_modes: &mut SoundModes = state.get_mut(); + sound_modes.ambient_sound_mode = value.try_as_enum_variant()?; + } + } + Ok(()) + } +} diff --git a/lib/src/devices/soundcore/a3953/packets/state_update.rs b/lib/src/devices/soundcore/a3953/packets/state_update.rs index c029197a8..e0b3dba7a 100644 --- a/lib/src/devices/soundcore/a3953/packets/state_update.rs +++ b/lib/src/devices/soundcore/a3953/packets/state_update.rs @@ -1,59 +1,125 @@ +use std::iter; + use nom::{ IResult, Parser, + bytes::complete::take, combinator::{map, rest}, error::{ContextError, ParseError, context}, + number::complete::le_u8, }; use crate::devices::soundcore::{ - a3953::state::A3953State, + a3953::{self, state::A3953State}, common::{ macros::state_update_packet_module, packet::{self, inbound::FromPacketBody, outbound::ToPacket}, - structures::{DualBattery, DualFirmwareVersion, SerialNumber, TwsStatus}, + structures::{ + AmbientSoundModeCycle, DualBattery, DualFirmwareVersion, SerialNumber, TwsStatus, + }, }, }; -/// Only `tws_status`, `battery`, `dual_firmware_version`, and `serial_number` have been -/// reverse-engineered so far (confirmed against a real capture from the device: the serial number -/// decodes to "3953" + the device's own MAC address, byte-reversed). Everything after that is -/// captured verbatim in `unknown_suffix` rather than guessed at, so round-tripping stays exact -/// until more fields (equalizer, sound modes, buttons, etc.) are identified from real captures. -#[derive(Debug, Default, Clone, PartialEq, Eq)] +/// Offsets below are reverse-engineered from the official Soundcore Android app +/// (com.oceanwing.soundcore v6.4.0-17), decompiling +/// `com.oceanwing.devicecmd.manager.product.a3953.A3953AnalysisService.R0`, which parses this same +/// packet body (there, `bArr[9..]`; here, `input[0..]`, i.e. every cited `bArr` index there is this +/// packet's index minus 9). +/// +/// - `unknown_before_custom_length` (`bArr[41..112]`, 71 bytes): read by `R0` for EQ index/type, +/// custom EQ values, and Hear ID data, none of which are implemented here yet. +/// - `custom_length` (`bArr[112]`, `R0`'s local variable `m6`, logged as `"customLength"`): used to +/// compute every following offset as `custom_length + N`. In the one real capture this is built +/// from, its value is 18. +/// - `button_config` (`bArr[113..129]`, 16 bytes): parsed by `R0`'s `G0` into a `ControllerBtnModel` +/// (left/right x single/double/triple/long press, TWS and non-TWS). Not decoded into a typed +/// struct yet. +/// - `unknown_gap`: `R0` computes the next two fields' positions as `custom_length + 111` and +/// `custom_length + 112` (`bArr` indices). Converted to indices into this packet body (`- 9`) and +/// relative to the end of `button_config` (`bArr[129]`, i.e. index 120 here), that leaves a gap of +/// `custom_length - 18` unknown bytes whenever `custom_length != 18`; zero-length in the captures +/// seen so far. +/// - `ambient_sound_mode_cycle` (`bArr[custom_length + 111]`): bits read via `BytesUtil.J(b, 1..3)` +/// into `cmm2BtDeviceInfo.setAncSelected/TransSelected/NormalSelected`, matching this project's +/// existing `AmbientSoundModeCycle` bit layout exactly (bit0=noise_canceling, bit1=transparency, +/// bit2=normal). No outbound command that sets these bits was found anywhere in the decompiled +/// `CmmBtCmdService`/`A3953CmdService`, so this is parsed for round-trip fidelity only and not +/// exposed as a setting. +/// - `sound_modes` (`bArr[custom_length + 112 .. custom_length + 118]`, 6 bytes): see +/// `a3953::structures::SoundModes` for the per-byte citation. +/// - `unknown_suffix`: everything after that (wear detection, LDAC, auto power off, spatial audio, +/// health-tracking fields, device colour, press sensitivity, per `R0`) is not implemented yet and +/// is preserved verbatim. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct A3953StateUpdatePacket { pub tws_status: TwsStatus, pub battery: DualBattery, pub dual_firmware_version: DualFirmwareVersion, pub serial_number: SerialNumber, + pub unknown_before_custom_length: Vec, + pub custom_length: u8, + pub button_config: Vec, + pub unknown_gap: Vec, + pub ambient_sound_mode_cycle: AmbientSoundModeCycle, + pub sound_modes: a3953::structures::SoundModes, pub unknown_suffix: Vec, } +impl Default for A3953StateUpdatePacket { + fn default() -> Self { + Self { + tws_status: Default::default(), + battery: Default::default(), + dual_firmware_version: Default::default(), + serial_number: Default::default(), + // sized to match the fixed-width `take()` calls in `FromPacketBody::take` below, so + // that `Self::default().to_packet()` round-trips through `take()` correctly + unknown_before_custom_length: vec![0; 71], + custom_length: 0, + button_config: vec![0; 16], + unknown_gap: Vec::new(), + ambient_sound_mode_cycle: Default::default(), + sound_modes: Default::default(), + unknown_suffix: Vec::new(), + } + } +} + impl FromPacketBody for A3953StateUpdatePacket { type DirectionMarker = packet::InboundMarker; fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( input: &'a [u8], ) -> IResult<&'a [u8], Self, E> { - context( - "a3953 state update packet", - map( - ( - TwsStatus::take, - DualBattery::take, - DualFirmwareVersion::take, - SerialNumber::take, - map(rest, |suffix: &[u8]| suffix.to_vec()), - ), - |(tws_status, battery, dual_firmware_version, serial_number, unknown_suffix)| { - Self { - tws_status, - battery, - dual_firmware_version, - serial_number, - unknown_suffix, - } + context("a3953 state update packet", |input| { + let (input, tws_status) = TwsStatus::take(input)?; + let (input, battery) = DualBattery::take(input)?; + let (input, dual_firmware_version) = DualFirmwareVersion::take(input)?; + let (input, serial_number) = SerialNumber::take(input)?; + let (input, unknown_before_custom_length) = take(71usize)(input)?; + let (input, custom_length) = le_u8(input)?; + let (input, button_config) = take(16usize)(input)?; + let gap_len = (custom_length as usize).saturating_sub(18); + let (input, unknown_gap) = take(gap_len)(input)?; + let (input, ambient_sound_mode_cycle) = AmbientSoundModeCycle::take(input)?; + let (input, sound_modes) = a3953::structures::SoundModes::take(input)?; + let (input, unknown_suffix) = map(rest, |s: &[u8]| s.to_vec()).parse(input)?; + Ok(( + input, + Self { + tws_status, + battery, + dual_firmware_version, + serial_number, + unknown_before_custom_length: unknown_before_custom_length.to_vec(), + custom_length, + button_config: button_config.to_vec(), + unknown_gap: unknown_gap.to_vec(), + ambient_sound_mode_cycle, + sound_modes, + unknown_suffix, }, - ), - ) + )) + }) .parse_complete(input) } } @@ -72,6 +138,12 @@ impl ToPacket for A3953StateUpdatePacket { .chain(self.battery.bytes()) .chain(self.dual_firmware_version.bytes()) .chain(self.serial_number.to_string().into_bytes()) + .chain(self.unknown_before_custom_length.iter().copied()) + .chain(iter::once(self.custom_length)) + .chain(self.button_config.iter().copied()) + .chain(self.unknown_gap.iter().copied()) + .chain(self.ambient_sound_mode_cycle.bytes()) + .chain(self.sound_modes.bytes()) .chain(self.unknown_suffix.iter().copied()) .collect() } diff --git a/lib/src/devices/soundcore/a3953/state.rs b/lib/src/devices/soundcore/a3953/state.rs index ae6937316..069cd63b3 100644 --- a/lib/src/devices/soundcore/a3953/state.rs +++ b/lib/src/devices/soundcore/a3953/state.rs @@ -11,6 +11,7 @@ pub struct A3953State { battery: DualBattery, dual_firmware_version: DualFirmwareVersion, serial_number: SerialNumber, + sound_modes: a3953::structures::SoundModes, } impl From for A3953State { @@ -20,6 +21,12 @@ impl From for A3953State { battery, dual_firmware_version, serial_number, + unknown_before_custom_length: _, + custom_length: _, + button_config: _, + unknown_gap: _, + ambient_sound_mode_cycle: _, + sound_modes, unknown_suffix: _, } = packet; @@ -28,6 +35,7 @@ impl From for A3953State { battery, dual_firmware_version, serial_number, + sound_modes, } } } diff --git a/lib/src/devices/soundcore/a3953/structures.rs b/lib/src/devices/soundcore/a3953/structures.rs new file mode 100644 index 000000000..69f810cd9 --- /dev/null +++ b/lib/src/devices/soundcore/a3953/structures.rs @@ -0,0 +1,91 @@ +use nom::{ + IResult, Parser, + combinator::map, + error::{ContextError, ParseError, context}, + number::complete::le_u8, +}; + +use crate::devices::soundcore::common::{ + modules::sound_modes_v2::ToPacketBody, + packet::{self, inbound::FromPacketBody}, + structures::{AmbientSoundMode, WindNoise}, +}; + +/// Byte layout reverse-engineered from the official Soundcore Android app (com.oceanwing.soundcore +/// v6.4.0-17), decompiling `com.oceanwing.devicecmd.manager.product.a3953.A3953CmdService.G1` +/// (outbound, command `[0x06, 0x81]`) and `A3953AnalysisService.T2` (inbound, same byte range +/// within the state update packet). `ambient_sound_mode` is validated to exactly {0,1,2} by +/// `CmmBtCmdService.h()` before sending. `anc_option_manual` is clamped to 1-6 by +/// `A3953CmdService.f()` on both read and write. `anc_option_auto`, `trans_option`, +/// `anc_automation_mode`, and `anc_auto_sensitivity_level` are read as raw, unclamped bytes; +/// `trans_option` is additionally clamped to 0-2 by `A3953CmdService.i()`, but only when sending. +/// `wind_noise.is_detected` is read from the device but is never sent back (the app always writes +/// only the suppression-enabled bit). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct SoundModes { + pub ambient_sound_mode: AmbientSoundMode, + pub anc_option_manual: u8, + pub anc_option_auto: u8, + pub trans_option: u8, + pub anc_automation_mode: u8, + pub wind_noise: WindNoise, + pub anc_auto_sensitivity_level: u8, +} + +impl SoundModes { + pub fn bytes(&self) -> [u8; 6] { + [ + self.ambient_sound_mode as u8, + (self.anc_option_manual.clamp(1, 6) << 4) | (self.anc_option_auto & 0x0F), + self.trans_option.clamp(0, 2), + self.anc_automation_mode, + u8::from(self.wind_noise.is_suppression_enabled), + self.anc_auto_sensitivity_level, + ] + } +} + +impl FromPacketBody for SoundModes { + type DirectionMarker = packet::InboundMarker; + + fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( + input: &'a [u8], + ) -> IResult<&'a [u8], Self, E> { + context( + "a3953 sound modes", + map( + ( + AmbientSoundMode::take, + le_u8, + le_u8, + le_u8, + WindNoise::take, + le_u8, + ), + |( + ambient_sound_mode, + manual_auto_byte, + trans_option, + anc_automation_mode, + wind_noise, + anc_auto_sensitivity_level, + )| Self { + ambient_sound_mode, + anc_option_manual: (manual_auto_byte >> 4).clamp(1, 6), + anc_option_auto: manual_auto_byte & 0x0F, + trans_option, + anc_automation_mode, + wind_noise, + anc_auto_sensitivity_level, + }, + ), + ) + .parse_complete(input) + } +} + +impl ToPacketBody for SoundModes { + fn bytes(&self) -> Vec { + self.bytes().to_vec() + } +} From 23592d8c3c5c15f136eb5e465e3ffe3d33066982 Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Sat, 12 Sep 2026 14:24:40 -0400 Subject: [PATCH 05/22] feat(lib): add wear detection, LDAC, auto power off, wearing tone, case battery, press sensitivity, and button config decoding for the A3953 - add WearingDetection, Ldac, AutoPowerOff, WearingTone, and CaseBatteryLevel settings, each reusing this project's existing shared structures/commands, cross-confirmed against the decompiled app rather than assumed from the sibling A3947/A3954 - confirm A3953's auto power off options (10m/20m/30m/60m) directly from a literal array in the app's own PowerOffModel, upgrading that assumption from "matches a sibling device" to "confirmed for this model by name" - add PressSensitivity as a new cross-device SettingId (0-4 integer), traced end-to-end from the app's UI layer through to its command bytes - decode the full 16-byte button configuration block into a typed ButtonConfig/ButtonAssignment structure (32 sub-fields: 8 press-types x switch/action x TWS/non-TWS), verified bit-for-bit against a real capture; not exposed as a setting since no set command or action-ID meaning was found - expose WindNoiseSuppression, already-parsed but previously unused - document every remaining unparsed byte range with its cause (no command found, EQ encoding conflicts with this project's existing convention, or not yet implemented) so the next pass has exact offsets to start from instead of starting over Co-Authored-By: Claude --- lib/i18n/en/openscq30-lib.ftl | 1 + lib/src/api/settings.rs | 1 + lib/src/devices/soundcore/a3953.rs | 28 +++- lib/src/devices/soundcore/a3953/modules.rs | 14 +- .../a3953/modules/press_sensitivity.rs | 38 +++++ .../press_sensitivity/setting_handler.rs | 52 +++++++ .../press_sensitivity/state_modifier.rs | 55 +++++++ .../soundcore/a3953/modules/sound_modes.rs | 1 + .../modules/sound_modes/setting_handler.rs | 7 + .../soundcore/a3953/packets/state_update.rs | 145 ++++++++++++++++-- lib/src/devices/soundcore/a3953/state.rs | 30 +++- lib/src/devices/soundcore/a3953/structures.rs | 130 ++++++++++++++++ 12 files changed, 483 insertions(+), 19 deletions(-) create mode 100644 lib/src/devices/soundcore/a3953/modules/press_sensitivity.rs create mode 100644 lib/src/devices/soundcore/a3953/modules/press_sensitivity/setting_handler.rs create mode 100644 lib/src/devices/soundcore/a3953/modules/press_sensitivity/state_modifier.rs diff --git a/lib/i18n/en/openscq30-lib.ftl b/lib/i18n/en/openscq30-lib.ftl index fdb03b8bc..f8028fb0f 100644 --- a/lib/i18n/en/openscq30-lib.ftl +++ b/lib/i18n/en/openscq30-lib.ftl @@ -208,6 +208,7 @@ wearing-tone = Wearing Tone touch-lock = Touch Lock low-battery-prompt = Low Battery Prompt wearing-detection = Wearing Detection +press-sensitivity = Press Sensitivity voice-prompt = Voice Prompt side-tone = Side Tone dolby-audio = Dolby Audio diff --git a/lib/src/api/settings.rs b/lib/src/api/settings.rs index 17526e84d..00c534678 100644 --- a/lib/src/api/settings.rs +++ b/lib/src/api/settings.rs @@ -143,6 +143,7 @@ pub enum SettingId { TouchLock, LowBatteryPrompt, WearingDetection, + PressSensitivity, Volume, VoicePrompt, AncPersonalizedToEarCanal, diff --git a/lib/src/devices/soundcore/a3953.rs b/lib/src/devices/soundcore/a3953.rs index 5733647cc..2b93051b4 100644 --- a/lib/src/devices/soundcore/a3953.rs +++ b/lib/src/devices/soundcore/a3953.rs @@ -5,6 +5,7 @@ use crate::devices::soundcore::{ common::{ device::fetch_state_from_state_update_packet, macros::soundcore_device, + modules::auto_power_off::AutoPowerOffDuration, packet::outbound::{RequestState, ToPacket}, }, }; @@ -14,10 +15,14 @@ mod packets; mod state; pub mod structures; -// Battery, dual firmware version, serial number, and ambient sound mode have been -// reverse-engineered (see packets::state_update and structures::SoundModes, both cited against the -// official app's decompiled source). Equalizer, buttons, and the remaining flags still need more -// captures (or further decompilation) to identify field offsets. +// Battery, dual firmware version, serial number, ambient sound mode, wind noise suppression, wear +// detection, case battery level, LDAC, auto power off, wearing tone (the app calls it "in ear +// beep"), and press sensitivity have all been reverse-engineered against the official app's +// decompiled source (see packets::state_update, structures.rs). Button configuration is parsed +// (see structures::ButtonConfig) but not exposed: no set command or action-ID meaning was found. +// Equalizer and the remaining flags (bass up, dual connection, low battery alert, ambient sound +// prompt, spatial audio) are parsed but not yet exposed either, for the same reason (see +// packets::state_update for the full field list). soundcore_device!( A3953State, async |packet_io| { @@ -31,6 +36,14 @@ soundcore_device!( // similar A3947 (Liberty 4 NC) until a partial-charge capture confirms or corrects this. builder.dual_battery(5); builder.a3953_sound_modes(); + builder.wearing_detection(); + // Same caveat as dual_battery above: the device's own S() clamp permits 0-9, but no + // partial-charge capture exists to confirm whether 5 is really this device's max. + builder.case_battery_level(5); + builder.ldac(); + builder.auto_power_off(AutoPowerOffDuration::ten_twenty_thirty_sixty()); + builder.wearing_tone(); + builder.a3953_press_sensitivity(); }, { HashMap::from([( @@ -88,6 +101,13 @@ mod tests { (SettingId::FirmwareVersionRight, "03.23".into()), (SettingId::SerialNumber, "395354C633CCEEE8".into()), (SettingId::AmbientSoundMode, "Normal".into()), + (SettingId::WindNoiseSuppression, true.into()), + (SettingId::WearingDetection, true.into()), + (SettingId::CaseBatteryLevel, "5/5".into()), + (SettingId::Ldac, false.into()), + (SettingId::AutoPowerOff, "30m".into()), + (SettingId::WearingTone, true.into()), + (SettingId::PressSensitivity, 0.into()), ]); } diff --git a/lib/src/devices/soundcore/a3953/modules.rs b/lib/src/devices/soundcore/a3953/modules.rs index a1cbf700d..3fd0b4529 100644 --- a/lib/src/devices/soundcore/a3953/modules.rs +++ b/lib/src/devices/soundcore/a3953/modules.rs @@ -2,8 +2,9 @@ use openscq30_lib_has::Has; use crate::devices::soundcore::common::device::SoundcoreDeviceBuilder; -use super::structures::SoundModes; +use super::structures::{PressSensitivity, SoundModes}; +mod press_sensitivity; mod sound_modes; impl SoundcoreDeviceBuilder @@ -16,3 +17,14 @@ where .add_a3953_sound_modes(packet_io_controller); } } + +impl SoundcoreDeviceBuilder +where + StateType: Has + Send + Sync + Clone + 'static, +{ + pub fn a3953_press_sensitivity(&mut self) { + let packet_io_controller = self.packet_io_controller().clone(); + self.module_collection() + .add_a3953_press_sensitivity(packet_io_controller); + } +} diff --git a/lib/src/devices/soundcore/a3953/modules/press_sensitivity.rs b/lib/src/devices/soundcore/a3953/modules/press_sensitivity.rs new file mode 100644 index 000000000..f73460a95 --- /dev/null +++ b/lib/src/devices/soundcore/a3953/modules/press_sensitivity.rs @@ -0,0 +1,38 @@ +mod setting_handler; +mod state_modifier; + +use std::sync::Arc; + +use openscq30_lib_has::Has; +use setting_handler::PressSensitivitySettingHandler; +use strum::{EnumIter, EnumString, IntoStaticStr}; + +use crate::{ + api::settings::{CategoryId, SettingId}, + devices::soundcore::{ + a3953::structures::PressSensitivity, + common::{modules::ModuleCollection, packet::PacketIOController}, + }, + macros::enum_subset, +}; + +enum_subset! { + SettingId, + #[derive(EnumString, EnumIter, IntoStaticStr)] + enum PressSensitivitySetting { + PressSensitivity, + } +} + +impl ModuleCollection +where + T: Has + Clone + Send + Sync, +{ + pub fn add_a3953_press_sensitivity(&mut self, packet_io: Arc) { + self.setting_manager + .add_handler(CategoryId::Miscellaneous, PressSensitivitySettingHandler); + self.state_modifiers.push(Box::new( + state_modifier::PressSensitivityStateModifier::new(packet_io), + )); + } +} diff --git a/lib/src/devices/soundcore/a3953/modules/press_sensitivity/setting_handler.rs b/lib/src/devices/soundcore/a3953/modules/press_sensitivity/setting_handler.rs new file mode 100644 index 000000000..485170c48 --- /dev/null +++ b/lib/src/devices/soundcore/a3953/modules/press_sensitivity/setting_handler.rs @@ -0,0 +1,52 @@ +use async_trait::async_trait; +use openscq30_lib_has::Has; +use strum::IntoEnumIterator; + +use crate::{ + api::settings::{self, Setting, SettingId, Value}, + devices::soundcore::{ + a3953::structures::PressSensitivity, + common::settings_manager::{SettingHandler, SettingHandlerResult}, + }, +}; + +use super::PressSensitivitySetting; + +#[derive(Default)] +pub struct PressSensitivitySettingHandler; + +#[async_trait] +impl SettingHandler for PressSensitivitySettingHandler +where + T: Has + Send, +{ + fn settings(&self) -> Vec { + PressSensitivitySetting::iter().map(Into::into).collect() + } + + fn get(&self, state: &T, setting_id: &SettingId) -> Option { + let press_sensitivity: &PressSensitivity = state.get(); + let _: PressSensitivitySetting = (*setting_id).try_into().ok()?; + Some(Setting::I32Range { + setting: settings::Range { + range: 0..=4, + step: 1, + }, + value: i32::from(press_sensitivity.0), + }) + } + + async fn set( + &self, + state: &mut T, + setting_id: &SettingId, + value: Value, + ) -> SettingHandlerResult<()> { + let _: PressSensitivitySetting = (*setting_id) + .try_into() + .expect("already filtered to valid values only by SettingsManager"); + let press_sensitivity: &mut PressSensitivity = state.get_mut(); + press_sensitivity.0 = u8::try_from(value.try_as_i32()?.clamp(0, 4)).expect("clamped"); + Ok(()) + } +} diff --git a/lib/src/devices/soundcore/a3953/modules/press_sensitivity/state_modifier.rs b/lib/src/devices/soundcore/a3953/modules/press_sensitivity/state_modifier.rs new file mode 100644 index 000000000..39981a6b7 --- /dev/null +++ b/lib/src/devices/soundcore/a3953/modules/press_sensitivity/state_modifier.rs @@ -0,0 +1,55 @@ +use async_trait::async_trait; +use openscq30_lib_has::Has; +use std::sync::Arc; +use tokio::sync::watch; + +use crate::{ + api::device, + devices::soundcore::{ + a3953::structures::PressSensitivity, + common::{ + packet::{self, PacketIOController}, + state_modifier::StateModifier, + }, + }, +}; + +/// Command cited in a3953/structures.rs's `PressSensitivity` doc comment. +const COMMAND: packet::Command = packet::Command([0x04, 0x85]); + +pub struct PressSensitivityStateModifier { + packet_io: Arc, +} + +impl PressSensitivityStateModifier { + pub fn new(packet_io: Arc) -> Self { + Self { packet_io } + } +} + +#[async_trait] +impl StateModifier for PressSensitivityStateModifier +where + T: Has + Clone + Send + Sync, +{ + async fn move_to_state( + &self, + state_sender: &watch::Sender, + target_state: &T, + ) -> device::Result<()> { + let target = *target_state.get(); + { + let state = state_sender.borrow(); + if *state.get() == target { + return Ok(()); + } + } + self.packet_io + .send_with_response(&packet::Outbound::new(COMMAND, target.bytes().to_vec())) + .await?; + state_sender.send_modify(|state| { + *state.get_mut() = target; + }); + Ok(()) + } +} diff --git a/lib/src/devices/soundcore/a3953/modules/sound_modes.rs b/lib/src/devices/soundcore/a3953/modules/sound_modes.rs index debcef87e..1f62c99ed 100644 --- a/lib/src/devices/soundcore/a3953/modules/sound_modes.rs +++ b/lib/src/devices/soundcore/a3953/modules/sound_modes.rs @@ -20,6 +20,7 @@ enum_subset! { #[derive(EnumString, EnumIter, IntoStaticStr)] enum SoundModesSetting { AmbientSoundMode, + WindNoiseSuppression, } } diff --git a/lib/src/devices/soundcore/a3953/modules/sound_modes/setting_handler.rs b/lib/src/devices/soundcore/a3953/modules/sound_modes/setting_handler.rs index 0f7767c40..169d4bfe5 100644 --- a/lib/src/devices/soundcore/a3953/modules/sound_modes/setting_handler.rs +++ b/lib/src/devices/soundcore/a3953/modules/sound_modes/setting_handler.rs @@ -31,6 +31,9 @@ where SoundModesSetting::AmbientSoundMode => Some(Setting::select_from_enum_all_variants( sound_modes.ambient_sound_mode, )), + SoundModesSetting::WindNoiseSuppression => Some(Setting::Toggle { + value: sound_modes.wind_noise.is_suppression_enabled, + }), } } @@ -48,6 +51,10 @@ where let sound_modes: &mut SoundModes = state.get_mut(); sound_modes.ambient_sound_mode = value.try_as_enum_variant()?; } + SoundModesSetting::WindNoiseSuppression => { + let sound_modes: &mut SoundModes = state.get_mut(); + sound_modes.wind_noise.is_suppression_enabled = value.try_as_bool()?; + } } Ok(()) } diff --git a/lib/src/devices/soundcore/a3953/packets/state_update.rs b/lib/src/devices/soundcore/a3953/packets/state_update.rs index e0b3dba7a..2ada2f9d0 100644 --- a/lib/src/devices/soundcore/a3953/packets/state_update.rs +++ b/lib/src/devices/soundcore/a3953/packets/state_update.rs @@ -14,7 +14,8 @@ use crate::devices::soundcore::{ macros::state_update_packet_module, packet::{self, inbound::FromPacketBody, outbound::ToPacket}, structures::{ - AmbientSoundModeCycle, DualBattery, DualFirmwareVersion, SerialNumber, TwsStatus, + AmbientSoundModeCycle, AutoPowerOff, CaseBatteryLevel, DualBattery, + DualFirmwareVersion, Ldac, SerialNumber, TwsStatus, WearingDetection, WearingTone, }, }, }; @@ -30,9 +31,9 @@ use crate::devices::soundcore::{ /// - `custom_length` (`bArr[112]`, `R0`'s local variable `m6`, logged as `"customLength"`): used to /// compute every following offset as `custom_length + N`. In the one real capture this is built /// from, its value is 18. -/// - `button_config` (`bArr[113..129]`, 16 bytes): parsed by `R0`'s `G0` into a `ControllerBtnModel` -/// (left/right x single/double/triple/long press, TWS and non-TWS). Not decoded into a typed -/// struct yet. +/// - `button_config` (`bArr[113..129]`, 16 bytes): see `a3953::structures::ButtonConfig` for the +/// per-byte citation. Parsed for round-trip fidelity but not exposed as a setting: no outbound +/// command that writes it back was found, and no action-ID-to-behavior mapping was found either. /// - `unknown_gap`: `R0` computes the next two fields' positions as `custom_length + 111` and /// `custom_length + 112` (`bArr` indices). Converted to indices into this packet body (`- 9`) and /// relative to the end of `button_config` (`bArr[129]`, i.e. index 120 here), that leaves a gap of @@ -46,9 +47,52 @@ use crate::devices::soundcore::{ /// exposed as a setting. /// - `sound_modes` (`bArr[custom_length + 112 .. custom_length + 118]`, 6 bytes): see /// `a3953::structures::SoundModes` for the per-byte citation. -/// - `unknown_suffix`: everything after that (wear detection, LDAC, auto power off, spatial audio, -/// health-tracking fields, device colour, press sensitivity, per `R0`) is not implemented yet and -/// is preserved verbatim. +/// - `unknown_personal_anc_test_info` (`bArr[custom_length + 118 .. custom_length + 124]`, 6 bytes): +/// `PersonalAncInfo` test time (4 bytes, unix-ish timestamp), volume dB, and result index. Not +/// implemented; both observed captures show the "unset" sentinel `255` for the latter two. +/// - `wearing_detection` (`bArr[custom_length + 124]`): same command (`[0x01, 0x81]`, decompiled +/// constant `Cmm2CmdData.J`, used by a base `CmmBtCmdService` method not overridden by +/// `A3953CmdService`) as this project's shared `WearingDetection` flag. Cross-confirmed via the +/// UI layer: `A3953MoreVM.sendWearTestCmd` → `Cmm2BtDeviceManager.y5` → `CmmBtCmdService.Y0` → +/// same `Cmm2CmdData.J`, whose success callback (`dealSendWearDetectionCmd`) writes the result to +/// `Cmm2BtDeviceInfo.setWearDetectionSwitch`, the same bean field `R0` reads here. +/// - `unknown_wearing_status` (`bArr[custom_length + 125 .. custom_length + 127]`, 2 bytes): left +/// and right earbud in-ear status, reported only, no corresponding set command exists. +/// - `case_battery_level` (`bArr[custom_length + 127]`): read via the same `S()` clamp +/// (`0..=9`) as the earbuds' own battery level (see a3953.rs's `dual_battery` comment for why +/// that clamp alone isn't enough to conclude the true max level); displayed out of 5 to match +/// the structurally identical A3947 pending a lower-charge capture. +/// - `unknown_bass_up` (`bArr[custom_length + 128]`): a "bass up" toggle with no located set +/// command. +/// - `ldac` (`bArr[custom_length + 129]`): same command (`[0x01, 0xFF]`, decompiled constant +/// `Cmm2CmdData.f16o0`) as this project's shared `Ldac` flag. +/// - `unknown_dual_connection` (`bArr[custom_length + 130]`): a two-simultaneous-connections +/// toggle. This project's `DualConnections` structure is a much larger feature (a whole device +/// list with its own inbound/outbound packets) that doesn't match this single bit, so it isn't +/// reused here, and no matching single-bit set command was located either. +/// - `auto_power_off` (`bArr[custom_length + 131 .. custom_length + 133]`, 2 bytes): same command +/// (`[0x01, 0x86]`, decompiled constant `Cmm2CmdData.L`) and same +/// `(bool is_enabled, u8 duration_index)` layout as this project's shared `AutoPowerOff` +/// structure. The duration index is clamped to `0..=3` by `A3953AnalysisService.U2`. Confirmed, +/// not just inferred from the clamp: `A3952PowerOffModel.A3953_POWER_OFF_OPTIONS` is a literal +/// `{"10 ", "20 ", "30 ", "60 "}` array, selected specifically via +/// `"A3953".equalsIgnoreCase(str)`, matching `AutoPowerOffDuration::ten_twenty_thirty_sixty()` +/// exactly. +/// - `unknown_hear_id_volume_db` (`bArr[custom_length + 133]`): part of the Hear ID feature, not +/// implemented. +/// - `wearing_tone` (`bArr[custom_length + 134]`): the app calls this field "in ear beep", but it +/// uses the same command (`[0x01, 0x8C]`, decompiled constant `Cmm2CmdData.f17p0`) as this +/// project's shared `WearingTone` flag, so it's reused under that name. +/// - `unknown_tail` (`bArr[custom_length + 135 .. custom_length + 145]`, 10 bytes): low battery +/// alert, ambient sound prompt, spatial audio switch/effect mode/sound mode, and four +/// "daily care" health-tracking fields (sedentary reminder, sitting posture, heart rate +/// abnormality alarm and its threshold) that read as all-zero on this earbud and may belong to a +/// different product category sharing the same parser. None have a located set command. +/// - `device_colour` and `press_sensitivity`: only present when `R0`'s own length check +/// (`bArr.length > 163`, i.e. this packet's body is longer than 154 bytes) holds; a 1-byte ASCII +/// colour code with no located meaning, and a 1-byte value (see +/// `a3953::structures::PressSensitivity` for its citation). +/// - `unknown_suffix`: whatever's left; 2 bytes in the captures seen so far. #[derive(Debug, Clone, PartialEq, Eq)] pub struct A3953StateUpdatePacket { pub tws_status: TwsStatus, @@ -57,10 +101,23 @@ pub struct A3953StateUpdatePacket { pub serial_number: SerialNumber, pub unknown_before_custom_length: Vec, pub custom_length: u8, - pub button_config: Vec, + pub button_config: a3953::structures::ButtonConfig, pub unknown_gap: Vec, pub ambient_sound_mode_cycle: AmbientSoundModeCycle, pub sound_modes: a3953::structures::SoundModes, + pub unknown_personal_anc_test_info: Vec, + pub wearing_detection: WearingDetection, + pub unknown_wearing_status: Vec, + pub case_battery_level: CaseBatteryLevel, + pub unknown_bass_up: Vec, + pub ldac: Ldac, + pub unknown_dual_connection: Vec, + pub auto_power_off: AutoPowerOff, + pub unknown_hear_id_volume_db: Vec, + pub wearing_tone: WearingTone, + pub unknown_tail: Vec, + pub device_colour: Option, + pub press_sensitivity: Option, pub unknown_suffix: Vec, } @@ -75,10 +132,23 @@ impl Default for A3953StateUpdatePacket { // that `Self::default().to_packet()` round-trips through `take()` correctly unknown_before_custom_length: vec![0; 71], custom_length: 0, - button_config: vec![0; 16], + button_config: Default::default(), unknown_gap: Vec::new(), ambient_sound_mode_cycle: Default::default(), sound_modes: Default::default(), + unknown_personal_anc_test_info: vec![0; 6], + wearing_detection: Default::default(), + unknown_wearing_status: vec![0; 2], + case_battery_level: Default::default(), + unknown_bass_up: vec![0; 1], + ldac: Default::default(), + unknown_dual_connection: vec![0; 1], + auto_power_off: Default::default(), + unknown_hear_id_volume_db: vec![0; 1], + wearing_tone: Default::default(), + unknown_tail: vec![0; 10], + device_colour: None, + press_sensitivity: None, unknown_suffix: Vec::new(), } } @@ -90,18 +160,37 @@ impl FromPacketBody for A3953StateUpdatePacket { fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( input: &'a [u8], ) -> IResult<&'a [u8], Self, E> { - context("a3953 state update packet", |input| { + let total_len = input.len(); + context("a3953 state update packet", move |input| { let (input, tws_status) = TwsStatus::take(input)?; let (input, battery) = DualBattery::take(input)?; let (input, dual_firmware_version) = DualFirmwareVersion::take(input)?; let (input, serial_number) = SerialNumber::take(input)?; let (input, unknown_before_custom_length) = take(71usize)(input)?; let (input, custom_length) = le_u8(input)?; - let (input, button_config) = take(16usize)(input)?; + let (input, button_config) = a3953::structures::ButtonConfig::take(input)?; let gap_len = (custom_length as usize).saturating_sub(18); let (input, unknown_gap) = take(gap_len)(input)?; let (input, ambient_sound_mode_cycle) = AmbientSoundModeCycle::take(input)?; let (input, sound_modes) = a3953::structures::SoundModes::take(input)?; + let (input, unknown_personal_anc_test_info) = take(6usize)(input)?; + let (input, wearing_detection) = WearingDetection::take(input)?; + let (input, unknown_wearing_status) = take(2usize)(input)?; + let (input, case_battery_level) = CaseBatteryLevel::take(input)?; + let (input, unknown_bass_up) = take(1usize)(input)?; + let (input, ldac) = Ldac::take(input)?; + let (input, unknown_dual_connection) = take(1usize)(input)?; + let (input, auto_power_off) = AutoPowerOff::take(input)?; + let (input, unknown_hear_id_volume_db) = take(1usize)(input)?; + let (input, wearing_tone) = WearingTone::take(input)?; + let (input, unknown_tail) = take(10usize)(input)?; + let (input, (device_colour, press_sensitivity)) = if total_len > 154 { + let (input, colour_byte) = le_u8(input)?; + let (input, press_sensitivity) = a3953::structures::PressSensitivity::take(input)?; + (input, (Some(colour_byte), Some(press_sensitivity))) + } else { + (input, (None, None)) + }; let (input, unknown_suffix) = map(rest, |s: &[u8]| s.to_vec()).parse(input)?; Ok(( input, @@ -112,10 +201,23 @@ impl FromPacketBody for A3953StateUpdatePacket { serial_number, unknown_before_custom_length: unknown_before_custom_length.to_vec(), custom_length, - button_config: button_config.to_vec(), + button_config, unknown_gap: unknown_gap.to_vec(), ambient_sound_mode_cycle, sound_modes, + unknown_personal_anc_test_info: unknown_personal_anc_test_info.to_vec(), + wearing_detection, + unknown_wearing_status: unknown_wearing_status.to_vec(), + case_battery_level, + unknown_bass_up: unknown_bass_up.to_vec(), + ldac, + unknown_dual_connection: unknown_dual_connection.to_vec(), + auto_power_off, + unknown_hear_id_volume_db: unknown_hear_id_volume_db.to_vec(), + wearing_tone, + unknown_tail: unknown_tail.to_vec(), + device_colour, + press_sensitivity, unknown_suffix, }, )) @@ -140,10 +242,27 @@ impl ToPacket for A3953StateUpdatePacket { .chain(self.serial_number.to_string().into_bytes()) .chain(self.unknown_before_custom_length.iter().copied()) .chain(iter::once(self.custom_length)) - .chain(self.button_config.iter().copied()) + .chain(self.button_config.bytes()) .chain(self.unknown_gap.iter().copied()) .chain(self.ambient_sound_mode_cycle.bytes()) .chain(self.sound_modes.bytes()) + .chain(self.unknown_personal_anc_test_info.iter().copied()) + .chain(self.wearing_detection.bytes()) + .chain(self.unknown_wearing_status.iter().copied()) + .chain(self.case_battery_level.bytes()) + .chain(self.unknown_bass_up.iter().copied()) + .chain(self.ldac.bytes()) + .chain(self.unknown_dual_connection.iter().copied()) + .chain(self.auto_power_off.bytes()) + .chain(self.unknown_hear_id_volume_db.iter().copied()) + .chain(self.wearing_tone.bytes()) + .chain(self.unknown_tail.iter().copied()) + .chain(self.device_colour) + .chain( + self.press_sensitivity + .into_iter() + .flat_map(|value| value.bytes()), + ) .chain(self.unknown_suffix.iter().copied()) .collect() } diff --git a/lib/src/devices/soundcore/a3953/state.rs b/lib/src/devices/soundcore/a3953/state.rs index 069cd63b3..647daf386 100644 --- a/lib/src/devices/soundcore/a3953/state.rs +++ b/lib/src/devices/soundcore/a3953/state.rs @@ -2,7 +2,10 @@ use openscq30_lib_macros::Has; use crate::devices::soundcore::{ a3953, - common::structures::{DualBattery, DualFirmwareVersion, SerialNumber, TwsStatus}, + common::structures::{ + AutoPowerOff, CaseBatteryLevel, DualBattery, DualFirmwareVersion, Ldac, SerialNumber, + TwsStatus, WearingDetection, WearingTone, + }, }; #[derive(Debug, Clone, PartialEq, Eq, Has)] @@ -12,6 +15,12 @@ pub struct A3953State { dual_firmware_version: DualFirmwareVersion, serial_number: SerialNumber, sound_modes: a3953::structures::SoundModes, + wearing_detection: WearingDetection, + case_battery_level: CaseBatteryLevel, + ldac: Ldac, + auto_power_off: AutoPowerOff, + wearing_tone: WearingTone, + press_sensitivity: a3953::structures::PressSensitivity, } impl From for A3953State { @@ -27,6 +36,19 @@ impl From for A3953State { unknown_gap: _, ambient_sound_mode_cycle: _, sound_modes, + unknown_personal_anc_test_info: _, + wearing_detection, + unknown_wearing_status: _, + case_battery_level, + unknown_bass_up: _, + ldac, + unknown_dual_connection: _, + auto_power_off, + unknown_hear_id_volume_db: _, + wearing_tone, + unknown_tail: _, + device_colour: _, + press_sensitivity, unknown_suffix: _, } = packet; @@ -36,6 +58,12 @@ impl From for A3953State { dual_firmware_version, serial_number, sound_modes, + wearing_detection, + case_battery_level, + ldac, + auto_power_off, + wearing_tone, + press_sensitivity: press_sensitivity.unwrap_or_default(), } } } diff --git a/lib/src/devices/soundcore/a3953/structures.rs b/lib/src/devices/soundcore/a3953/structures.rs index 69f810cd9..ddf0ae32e 100644 --- a/lib/src/devices/soundcore/a3953/structures.rs +++ b/lib/src/devices/soundcore/a3953/structures.rs @@ -89,3 +89,133 @@ impl ToPacketBody for SoundModes { self.bytes().to_vec() } } + +/// One button press-type's (single/double/long/triple press, for one earbud) configuration, as +/// read by `com.oceanwing.devicecmd.manager.product.a3953.A3953AnalysisService.G0`: two bytes, +/// each nibble-packed as `(BytesUtil.G(byte), BytesUtil.K(byte))` = (high nibble, low nibble). +/// `untws_*` fields apply when the earbud is used alone (not connected as a TWS pair); the +/// unprefixed fields apply when TWS-connected. `action`/`untws_action` are raw 0-15 IDs; no +/// enum mapping from ID to behavior (e.g. "volume up") was found anywhere in the decompiled +/// source, so they're left as plain numbers rather than guessed at. No outbound command that +/// writes this struct back was found either, so it's read-only for now. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct ButtonAssignment { + pub untws_enabled: bool, + pub enabled: bool, + pub untws_action: u8, + pub action: u8, +} + +impl ButtonAssignment { + pub fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( + input: &'a [u8], + ) -> IResult<&'a [u8], Self, E> { + map((le_u8, le_u8), |(switch_byte, action_byte)| Self { + untws_enabled: switch_byte >> 4 == 1, + enabled: switch_byte & 0x0F == 1, + untws_action: action_byte >> 4, + action: action_byte & 0x0F, + }) + .parse_complete(input) + } + + pub fn bytes(&self) -> [u8; 2] { + [ + (u8::from(self.untws_enabled) << 4) | u8::from(self.enabled), + ((self.untws_action & 0x0F) << 4) | (self.action & 0x0F), + ] + } +} + +/// Full 16-byte button configuration block (`bArr[113..129]` in `A3953AnalysisService.R0`, i.e. +/// `bArr[113..129]`, passed to `G0` starting at `bArr[113]`). Field order (single, double, long, +/// triple; left before right for each) matches the order `G0` populates them in, which is not the +/// same order this project's other Soundcore devices use (they go single/double/triple/long). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct ButtonConfig { + pub left_single: ButtonAssignment, + pub right_single: ButtonAssignment, + pub left_double: ButtonAssignment, + pub right_double: ButtonAssignment, + pub left_long: ButtonAssignment, + pub right_long: ButtonAssignment, + pub left_triple: ButtonAssignment, + pub right_triple: ButtonAssignment, +} + +impl ButtonConfig { + pub fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( + input: &'a [u8], + ) -> IResult<&'a [u8], Self, E> { + context( + "a3953 button config", + map( + ( + ButtonAssignment::take, + ButtonAssignment::take, + ButtonAssignment::take, + ButtonAssignment::take, + ButtonAssignment::take, + ButtonAssignment::take, + ButtonAssignment::take, + ButtonAssignment::take, + ), + |( + left_single, + right_single, + left_double, + right_double, + left_long, + right_long, + left_triple, + right_triple, + )| Self { + left_single, + right_single, + left_double, + right_double, + left_long, + right_long, + left_triple, + right_triple, + }, + ), + ) + .parse_complete(input) + } + + pub fn bytes(&self) -> impl Iterator { + [ + self.left_single, + self.right_single, + self.left_double, + self.right_double, + self.left_long, + self.right_long, + self.left_triple, + self.right_triple, + ] + .into_iter() + .flat_map(|button| button.bytes()) + } +} + +/// A 0-4 selection with no known display labels (the app's own `A3953PressSensVM.initData` +/// pairs each value with a string resource ID rather than a literal name, and that string table +/// wasn't decoded), sent with command `[0x04, 0x85]` (decompiled constant `Cmm2CmdData.L0`, used +/// by the base `CmmBtCmdService.O(int)`, called from `Cmm2BtDeviceManager.g5(int)`, called from +/// `A3953PressSensVM.setPressSensItem`). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct PressSensitivity(pub u8); + +impl PressSensitivity { + pub fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( + input: &'a [u8], + ) -> IResult<&'a [u8], Self, E> { + map(le_u8, |value: u8| Self(value.min(4))).parse_complete(input) + } + + pub fn bytes(&self) -> [u8; 1] { + [self.0.min(4)] + } +} From 47ef0fc689e60a1999d0ec8467005b8e788b4bce Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Sat, 12 Sep 2026 14:49:51 -0400 Subject: [PATCH 06/22] feat(lib): add low battery prompt, ambient sound prompt, dual connection, and spatial audio settings for the A3953 - add LowBatteryPrompt (reusing this project's existing shared flag and command), AmbientSoundPrompt, SupportTwoConnections, and SpatialAudio (switch, effect mode, content mode), each traced end to end from the app's per-feature Activity/ViewModel classes through the manager layer to concrete command bytes - resolve the earlier custom-EQ byte-to-dB formula question: the app's own EqVM applies a +12 offset before the raw byte transform, which combined is identical to this project's existing VolumeAdjustments encoding, not a conflicting one - document why EQ write support and a few remaining fields (bass up, four health-tracking bytes, button action IDs) are still not implemented, now with a specific blocking reason for each rather than "not found" Co-Authored-By: Claude --- lib/i18n/en/openscq30-lib.ftl | 2 + lib/src/api/settings.rs | 2 + lib/src/devices/soundcore/a3953.rs | 21 +- lib/src/devices/soundcore/a3953/modules.rs | 28 ++- .../soundcore/a3953/modules/misc_toggles.rs | 184 ++++++++++++++++++ .../soundcore/a3953/modules/spatial_audio.rs | 41 ++++ .../modules/spatial_audio/setting_handler.rs | 69 +++++++ .../modules/spatial_audio/state_modifier.rs | 57 ++++++ .../soundcore/a3953/packets/state_update.rs | 63 ++++-- lib/src/devices/soundcore/a3953/state.rs | 19 +- lib/src/devices/soundcore/a3953/structures.rs | 148 +++++++++++++- 11 files changed, 604 insertions(+), 30 deletions(-) create mode 100644 lib/src/devices/soundcore/a3953/modules/misc_toggles.rs create mode 100644 lib/src/devices/soundcore/a3953/modules/spatial_audio.rs create mode 100644 lib/src/devices/soundcore/a3953/modules/spatial_audio/setting_handler.rs create mode 100644 lib/src/devices/soundcore/a3953/modules/spatial_audio/state_modifier.rs diff --git a/lib/i18n/en/openscq30-lib.ftl b/lib/i18n/en/openscq30-lib.ftl index f8028fb0f..100a3b50b 100644 --- a/lib/i18n/en/openscq30-lib.ftl +++ b/lib/i18n/en/openscq30-lib.ftl @@ -209,6 +209,8 @@ touch-lock = Touch Lock low-battery-prompt = Low Battery Prompt wearing-detection = Wearing Detection press-sensitivity = Press Sensitivity +ambient-sound-prompt = Ambient Sound Prompt +support-two-connections = Two Simultaneous Connections voice-prompt = Voice Prompt side-tone = Side Tone dolby-audio = Dolby Audio diff --git a/lib/src/api/settings.rs b/lib/src/api/settings.rs index 00c534678..fac6dcb66 100644 --- a/lib/src/api/settings.rs +++ b/lib/src/api/settings.rs @@ -144,6 +144,8 @@ pub enum SettingId { LowBatteryPrompt, WearingDetection, PressSensitivity, + AmbientSoundPrompt, + SupportTwoConnections, Volume, VoicePrompt, AncPersonalizedToEarCanal, diff --git a/lib/src/devices/soundcore/a3953.rs b/lib/src/devices/soundcore/a3953.rs index 2b93051b4..09e2d7197 100644 --- a/lib/src/devices/soundcore/a3953.rs +++ b/lib/src/devices/soundcore/a3953.rs @@ -16,12 +16,12 @@ mod state; pub mod structures; // Battery, dual firmware version, serial number, ambient sound mode, wind noise suppression, wear -// detection, case battery level, LDAC, auto power off, wearing tone (the app calls it "in ear -// beep"), and press sensitivity have all been reverse-engineered against the official app's -// decompiled source (see packets::state_update, structures.rs). Button configuration is parsed -// (see structures::ButtonConfig) but not exposed: no set command or action-ID meaning was found. -// Equalizer and the remaining flags (bass up, dual connection, low battery alert, ambient sound -// prompt, spatial audio) are parsed but not yet exposed either, for the same reason (see +// detection, case battery level, LDAC, dual-connection support, auto power off, wearing tone (the +// app calls it "in ear beep"), low battery prompt, ambient sound prompt, spatial audio, and press +// sensitivity have all been reverse-engineered against the official app's decompiled source (see +// packets::state_update, structures.rs). Button configuration is parsed (see +// structures::ButtonConfig) but not exposed: no set command or action-ID meaning was found. +// Equalizer and "bass up" are parsed but not yet exposed either, for the same reason (see // packets::state_update for the full field list). soundcore_device!( A3953State, @@ -43,6 +43,9 @@ soundcore_device!( builder.ldac(); builder.auto_power_off(AutoPowerOffDuration::ten_twenty_thirty_sixty()); builder.wearing_tone(); + builder.low_battery_prompt(); + builder.a3953_misc_toggles(); + builder.a3953_spatial_audio(); builder.a3953_press_sensitivity(); }, { @@ -108,6 +111,12 @@ mod tests { (SettingId::AutoPowerOff, "30m".into()), (SettingId::WearingTone, true.into()), (SettingId::PressSensitivity, 0.into()), + (SettingId::LowBatteryPrompt, true.into()), + (SettingId::AmbientSoundPrompt, true.into()), + (SettingId::SupportTwoConnections, false.into()), + (SettingId::SpatialAudio, false.into()), + (SettingId::SpatialAudioMode, "Music".into()), + (SettingId::SpatialAudioMusicMode, "Fixed".into()), ]); } diff --git a/lib/src/devices/soundcore/a3953/modules.rs b/lib/src/devices/soundcore/a3953/modules.rs index 3fd0b4529..d82bae673 100644 --- a/lib/src/devices/soundcore/a3953/modules.rs +++ b/lib/src/devices/soundcore/a3953/modules.rs @@ -2,10 +2,14 @@ use openscq30_lib_has::Has; use crate::devices::soundcore::common::device::SoundcoreDeviceBuilder; -use super::structures::{PressSensitivity, SoundModes}; +use super::structures::{ + AmbientSoundPrompt, PressSensitivity, SoundModes, SpatialAudio, SupportTwoConnections, +}; +mod misc_toggles; mod press_sensitivity; mod sound_modes; +mod spatial_audio; impl SoundcoreDeviceBuilder where @@ -28,3 +32,25 @@ where .add_a3953_press_sensitivity(packet_io_controller); } } + +impl SoundcoreDeviceBuilder +where + StateType: Has + Has + Send + Sync + Clone + 'static, +{ + pub fn a3953_misc_toggles(&mut self) { + let packet_io_controller = self.packet_io_controller().clone(); + self.module_collection() + .add_a3953_misc_toggles(packet_io_controller); + } +} + +impl SoundcoreDeviceBuilder +where + StateType: Has + Send + Sync + Clone + 'static, +{ + pub fn a3953_spatial_audio(&mut self) { + let packet_io_controller = self.packet_io_controller().clone(); + self.module_collection() + .add_a3953_spatial_audio(packet_io_controller); + } +} diff --git a/lib/src/devices/soundcore/a3953/modules/misc_toggles.rs b/lib/src/devices/soundcore/a3953/modules/misc_toggles.rs new file mode 100644 index 000000000..74338f208 --- /dev/null +++ b/lib/src/devices/soundcore/a3953/modules/misc_toggles.rs @@ -0,0 +1,184 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use openscq30_lib_has::Has; +use strum::{EnumIter, EnumString, IntoEnumIterator, IntoStaticStr}; +use tokio::sync::watch; + +use crate::{ + api::{ + device, + settings::{CategoryId, Setting, SettingId, Value}, + }, + devices::soundcore::{ + a3953::structures::{AmbientSoundPrompt, SupportTwoConnections}, + common::{ + modules::ModuleCollection, + packet::{self, PacketIOController}, + settings_manager::{SettingHandler, SettingHandlerResult}, + state_modifier::StateModifier, + }, + }, + macros::enum_subset, +}; + +const AMBIENT_SOUND_PROMPT_COMMAND: packet::Command = packet::Command([0x10, 0x83]); +const SUPPORT_TWO_CONNECTIONS_COMMAND: packet::Command = packet::Command([0x0B, 0x84]); + +enum_subset! { + SettingId, + #[derive(EnumString, EnumIter, IntoStaticStr)] + enum MiscToggleSetting { + AmbientSoundPrompt, + SupportTwoConnections, + } +} + +impl ModuleCollection +where + T: Has + Has + Clone + Send + Sync, +{ + pub fn add_a3953_misc_toggles(&mut self, packet_io: Arc) { + self.setting_manager + .add_handler(CategoryId::Miscellaneous, MiscToggleSettingHandler); + self.state_modifiers + .push(Box::new(AmbientSoundPromptStateModifier::new( + packet_io.clone(), + ))); + self.state_modifiers + .push(Box::new(SupportTwoConnectionsStateModifier::new(packet_io))); + } +} + +#[derive(Default)] +struct MiscToggleSettingHandler; + +#[async_trait] +impl SettingHandler for MiscToggleSettingHandler +where + T: Has + Has + Send, +{ + fn settings(&self) -> Vec { + MiscToggleSetting::iter().map(Into::into).collect() + } + + fn get(&self, state: &T, setting_id: &SettingId) -> Option { + let setting: MiscToggleSetting = (*setting_id).try_into().ok()?; + Some(match setting { + MiscToggleSetting::AmbientSoundPrompt => { + let value: &AmbientSoundPrompt = state.get(); + Setting::Toggle { value: value.0 } + } + MiscToggleSetting::SupportTwoConnections => { + let value: &SupportTwoConnections = state.get(); + Setting::Toggle { value: value.0 } + } + }) + } + + async fn set( + &self, + state: &mut T, + setting_id: &SettingId, + value: Value, + ) -> SettingHandlerResult<()> { + let setting: MiscToggleSetting = (*setting_id) + .try_into() + .expect("already filtered to valid values only by SettingsManager"); + match setting { + MiscToggleSetting::AmbientSoundPrompt => { + let target: &mut AmbientSoundPrompt = state.get_mut(); + target.0 = value.try_as_bool()?; + } + MiscToggleSetting::SupportTwoConnections => { + let target: &mut SupportTwoConnections = state.get_mut(); + target.0 = value.try_as_bool()?; + } + } + Ok(()) + } +} + +struct AmbientSoundPromptStateModifier { + packet_io: Arc, +} + +impl AmbientSoundPromptStateModifier { + fn new(packet_io: Arc) -> Self { + Self { packet_io } + } +} + +#[async_trait] +impl StateModifier for AmbientSoundPromptStateModifier +where + T: Has + Clone + Send + Sync, +{ + async fn move_to_state( + &self, + state_sender: &watch::Sender, + target_state: &T, + ) -> device::Result<()> { + let target: AmbientSoundPrompt = *target_state.get(); + { + let state = state_sender.borrow(); + let current: &AmbientSoundPrompt = state.get(); + if *current == target { + return Ok(()); + } + } + self.packet_io + .send_with_response(&packet::Outbound::new( + AMBIENT_SOUND_PROMPT_COMMAND, + target.bytes().to_vec(), + )) + .await?; + state_sender.send_modify(|state| { + let value: &mut AmbientSoundPrompt = state.get_mut(); + *value = target; + }); + Ok(()) + } +} + +struct SupportTwoConnectionsStateModifier { + packet_io: Arc, +} + +impl SupportTwoConnectionsStateModifier { + fn new(packet_io: Arc) -> Self { + Self { packet_io } + } +} + +#[async_trait] +impl StateModifier for SupportTwoConnectionsStateModifier +where + T: Has + Clone + Send + Sync, +{ + async fn move_to_state( + &self, + state_sender: &watch::Sender, + target_state: &T, + ) -> device::Result<()> { + let target: SupportTwoConnections = *target_state.get(); + { + let state = state_sender.borrow(); + let current: &SupportTwoConnections = state.get(); + if *current == target { + return Ok(()); + } + } + self.packet_io + .send_with_response(&packet::Outbound::new( + SUPPORT_TWO_CONNECTIONS_COMMAND, + target.bytes().to_vec(), + )) + .await?; + state_sender.send_modify(|state| { + let value: &mut SupportTwoConnections = state.get_mut(); + *value = target; + }); + Ok(()) + } +} diff --git a/lib/src/devices/soundcore/a3953/modules/spatial_audio.rs b/lib/src/devices/soundcore/a3953/modules/spatial_audio.rs new file mode 100644 index 000000000..6e81e8878 --- /dev/null +++ b/lib/src/devices/soundcore/a3953/modules/spatial_audio.rs @@ -0,0 +1,41 @@ +mod setting_handler; +mod state_modifier; + +use std::sync::Arc; + +use openscq30_lib_has::Has; +use setting_handler::SpatialAudioSettingHandler; +use strum::{EnumIter, EnumString, IntoStaticStr}; + +use crate::{ + api::settings::{CategoryId, SettingId}, + devices::soundcore::{ + a3953::structures::SpatialAudio, + common::{modules::ModuleCollection, packet::PacketIOController}, + }, + macros::enum_subset, +}; + +enum_subset! { + SettingId, + #[derive(EnumString, EnumIter, IntoStaticStr)] + enum SpatialAudioSetting { + SpatialAudio, + SpatialAudioMode, + SpatialAudioMusicMode, + } +} + +impl ModuleCollection +where + T: Has + Clone + Send + Sync, +{ + pub fn add_a3953_spatial_audio(&mut self, packet_io: Arc) { + self.setting_manager + .add_handler(CategoryId::Miscellaneous, SpatialAudioSettingHandler); + self.state_modifiers + .push(Box::new(state_modifier::SpatialAudioStateModifier::new( + packet_io, + ))); + } +} diff --git a/lib/src/devices/soundcore/a3953/modules/spatial_audio/setting_handler.rs b/lib/src/devices/soundcore/a3953/modules/spatial_audio/setting_handler.rs new file mode 100644 index 000000000..14ce746bc --- /dev/null +++ b/lib/src/devices/soundcore/a3953/modules/spatial_audio/setting_handler.rs @@ -0,0 +1,69 @@ +use async_trait::async_trait; +use openscq30_lib_has::Has; +use strum::IntoEnumIterator; + +use crate::{ + api::settings::{Setting, SettingId, Value}, + devices::soundcore::{ + a3953::structures::SpatialAudio, + common::settings_manager::{SettingHandler, SettingHandlerResult}, + }, +}; + +use super::SpatialAudioSetting; + +/// `SpatialAudioMode` maps to `sound_mode` (Music/Movie) and `SpatialAudioMusicMode` maps to +/// `effect_mode` (Fixed/HeadTracking), matching the naming this project already uses for A3954's +/// otherwise-unrelated (differently encoded) spatial audio feature. +#[derive(Default)] +pub struct SpatialAudioSettingHandler; + +#[async_trait] +impl SettingHandler for SpatialAudioSettingHandler +where + T: Has + Send, +{ + fn settings(&self) -> Vec { + SpatialAudioSetting::iter().map(Into::into).collect() + } + + fn get(&self, state: &T, setting_id: &SettingId) -> Option { + let spatial_audio: &SpatialAudio = state.get(); + let spatial_audio_setting: SpatialAudioSetting = (*setting_id).try_into().ok()?; + match spatial_audio_setting { + SpatialAudioSetting::SpatialAudio => Some(Setting::Toggle { + value: spatial_audio.is_enabled, + }), + SpatialAudioSetting::SpatialAudioMode => Some(Setting::select_from_enum_all_variants( + spatial_audio.sound_mode, + )), + SpatialAudioSetting::SpatialAudioMusicMode => Some( + Setting::select_from_enum_all_variants(spatial_audio.effect_mode), + ), + } + } + + async fn set( + &self, + state: &mut T, + setting_id: &SettingId, + value: Value, + ) -> SettingHandlerResult<()> { + let spatial_audio_setting: SpatialAudioSetting = (*setting_id) + .try_into() + .expect("already filtered to valid values only by SettingsManager"); + let spatial_audio: &mut SpatialAudio = state.get_mut(); + match spatial_audio_setting { + SpatialAudioSetting::SpatialAudio => { + spatial_audio.is_enabled = value.try_as_bool()?; + } + SpatialAudioSetting::SpatialAudioMode => { + spatial_audio.sound_mode = value.try_as_enum_variant()?; + } + SpatialAudioSetting::SpatialAudioMusicMode => { + spatial_audio.effect_mode = value.try_as_enum_variant()?; + } + } + Ok(()) + } +} diff --git a/lib/src/devices/soundcore/a3953/modules/spatial_audio/state_modifier.rs b/lib/src/devices/soundcore/a3953/modules/spatial_audio/state_modifier.rs new file mode 100644 index 000000000..63b7428fd --- /dev/null +++ b/lib/src/devices/soundcore/a3953/modules/spatial_audio/state_modifier.rs @@ -0,0 +1,57 @@ +use async_trait::async_trait; +use openscq30_lib_has::Has; +use std::sync::Arc; +use tokio::sync::watch; + +use crate::{ + api::device, + devices::soundcore::{ + a3953::structures::SpatialAudio, + common::{ + packet::{self, PacketIOController}, + state_modifier::StateModifier, + }, + }, +}; + +/// Command cited in a3953/structures.rs's `SpatialAudio` doc comment. +const COMMAND: packet::Command = packet::Command([0x10, 0x81]); + +pub struct SpatialAudioStateModifier { + packet_io: Arc, +} + +impl SpatialAudioStateModifier { + pub fn new(packet_io: Arc) -> Self { + Self { packet_io } + } +} + +#[async_trait] +impl StateModifier for SpatialAudioStateModifier +where + T: Has + Clone + Send + Sync, +{ + async fn move_to_state( + &self, + state_sender: &watch::Sender, + target_state: &T, + ) -> device::Result<()> { + let target: SpatialAudio = *target_state.get(); + { + let state = state_sender.borrow(); + let current: &SpatialAudio = state.get(); + if *current == target { + return Ok(()); + } + } + self.packet_io + .send_with_response(&packet::Outbound::new(COMMAND, target.bytes().to_vec())) + .await?; + state_sender.send_modify(|state| { + let value: &mut SpatialAudio = state.get_mut(); + *value = target; + }); + Ok(()) + } +} diff --git a/lib/src/devices/soundcore/a3953/packets/state_update.rs b/lib/src/devices/soundcore/a3953/packets/state_update.rs index 2ada2f9d0..861d4472c 100644 --- a/lib/src/devices/soundcore/a3953/packets/state_update.rs +++ b/lib/src/devices/soundcore/a3953/packets/state_update.rs @@ -15,7 +15,8 @@ use crate::devices::soundcore::{ packet::{self, inbound::FromPacketBody, outbound::ToPacket}, structures::{ AmbientSoundModeCycle, AutoPowerOff, CaseBatteryLevel, DualBattery, - DualFirmwareVersion, Ldac, SerialNumber, TwsStatus, WearingDetection, WearingTone, + DualFirmwareVersion, Ldac, LowBatteryPrompt, SerialNumber, TwsStatus, WearingDetection, + WearingTone, }, }, }; @@ -66,10 +67,12 @@ use crate::devices::soundcore::{ /// command. /// - `ldac` (`bArr[custom_length + 129]`): same command (`[0x01, 0xFF]`, decompiled constant /// `Cmm2CmdData.f16o0`) as this project's shared `Ldac` flag. -/// - `unknown_dual_connection` (`bArr[custom_length + 130]`): a two-simultaneous-connections -/// toggle. This project's `DualConnections` structure is a much larger feature (a whole device -/// list with its own inbound/outbound packets) that doesn't match this single bit, so it isn't -/// reused here, and no matching single-bit set command was located either. +/// - `support_two_connections` (`bArr[custom_length + 130]`): this project's `DualConnections` +/// structure is a much larger feature (a whole device list with its own inbound/outbound +/// packets), so this single bit (labeled `SupportTwoCnnSwitch` in the decompiled bean) gets its +/// own minimal type instead. Command `[0x0B, 0x84]` (decompiled constant `Cmm2CmdData.E1`, built +/// by `CmmBtCmdService.B0`), traced from `A3953DeviceListActivity` through the shared +/// `A3952DeviceListVM.sendDeviceListSwitchCmd` → `Cmm2BtDeviceManager.a7`. /// - `auto_power_off` (`bArr[custom_length + 131 .. custom_length + 133]`, 2 bytes): same command /// (`[0x01, 0x86]`, decompiled constant `Cmm2CmdData.L`) and same /// `(bool is_enabled, u8 duration_index)` layout as this project's shared `AutoPowerOff` @@ -83,11 +86,19 @@ use crate::devices::soundcore::{ /// - `wearing_tone` (`bArr[custom_length + 134]`): the app calls this field "in ear beep", but it /// uses the same command (`[0x01, 0x8C]`, decompiled constant `Cmm2CmdData.f17p0`) as this /// project's shared `WearingTone` flag, so it's reused under that name. -/// - `unknown_tail` (`bArr[custom_length + 135 .. custom_length + 145]`, 10 bytes): low battery -/// alert, ambient sound prompt, spatial audio switch/effect mode/sound mode, and four +/// - `low_battery_prompt` (`custom_length + 126` here; `bArr[custom_length + 135]` in `R0`'s own +/// indexing): command `[0x10, 0x82]` (decompiled constant `Cmm2CmdData.v2`), matching this +/// project's existing `SET_LOW_BATTERY_PROMPT_COMMAND` exactly; traced via +/// `A3953PromptVM.setLowBatterySwitch` → `Cmm2BtDeviceManager.m6` → `CmmBtCmdService.F1`. +/// - `ambient_sound_prompt` (`custom_length + 127` here): command `[0x10, 0x83]` (decompiled +/// constant `Cmm2CmdData.w2`), see `a3953::structures::AmbientSoundPrompt` for the full citation. +/// - `spatial_audio` (`custom_length + 128 .. custom_length + 131` here, 3 bytes): switch, effect +/// mode, content mode; see `a3953::structures::SpatialAudio` for the full citation. +/// - `unknown_health_and_gap` (`custom_length + 131 .. custom_length + 136` here, 5 bytes): four /// "daily care" health-tracking fields (sedentary reminder, sitting posture, heart rate /// abnormality alarm and its threshold) that read as all-zero on this earbud and may belong to a -/// different product category sharing the same parser. None have a located set command. +/// different product category sharing the same parser, plus one trailing byte `R0` never reads +/// at all. None have a located set command. /// - `device_colour` and `press_sensitivity`: only present when `R0`'s own length check /// (`bArr.length > 163`, i.e. this packet's body is longer than 154 bytes) holds; a 1-byte ASCII /// colour code with no located meaning, and a 1-byte value (see @@ -111,11 +122,14 @@ pub struct A3953StateUpdatePacket { pub case_battery_level: CaseBatteryLevel, pub unknown_bass_up: Vec, pub ldac: Ldac, - pub unknown_dual_connection: Vec, + pub support_two_connections: a3953::structures::SupportTwoConnections, pub auto_power_off: AutoPowerOff, pub unknown_hear_id_volume_db: Vec, pub wearing_tone: WearingTone, - pub unknown_tail: Vec, + pub low_battery_prompt: LowBatteryPrompt, + pub ambient_sound_prompt: a3953::structures::AmbientSoundPrompt, + pub spatial_audio: a3953::structures::SpatialAudio, + pub unknown_health_and_gap: Vec, pub device_colour: Option, pub press_sensitivity: Option, pub unknown_suffix: Vec, @@ -142,11 +156,14 @@ impl Default for A3953StateUpdatePacket { case_battery_level: Default::default(), unknown_bass_up: vec![0; 1], ldac: Default::default(), - unknown_dual_connection: vec![0; 1], + support_two_connections: Default::default(), auto_power_off: Default::default(), unknown_hear_id_volume_db: vec![0; 1], wearing_tone: Default::default(), - unknown_tail: vec![0; 10], + low_battery_prompt: Default::default(), + ambient_sound_prompt: Default::default(), + spatial_audio: Default::default(), + unknown_health_and_gap: vec![0; 5], device_colour: None, press_sensitivity: None, unknown_suffix: Vec::new(), @@ -179,11 +196,15 @@ impl FromPacketBody for A3953StateUpdatePacket { let (input, case_battery_level) = CaseBatteryLevel::take(input)?; let (input, unknown_bass_up) = take(1usize)(input)?; let (input, ldac) = Ldac::take(input)?; - let (input, unknown_dual_connection) = take(1usize)(input)?; + let (input, support_two_connections) = + a3953::structures::SupportTwoConnections::take(input)?; let (input, auto_power_off) = AutoPowerOff::take(input)?; let (input, unknown_hear_id_volume_db) = take(1usize)(input)?; let (input, wearing_tone) = WearingTone::take(input)?; - let (input, unknown_tail) = take(10usize)(input)?; + let (input, low_battery_prompt) = LowBatteryPrompt::take(input)?; + let (input, ambient_sound_prompt) = a3953::structures::AmbientSoundPrompt::take(input)?; + let (input, spatial_audio) = a3953::structures::SpatialAudio::take(input)?; + let (input, unknown_health_and_gap) = take(5usize)(input)?; let (input, (device_colour, press_sensitivity)) = if total_len > 154 { let (input, colour_byte) = le_u8(input)?; let (input, press_sensitivity) = a3953::structures::PressSensitivity::take(input)?; @@ -211,11 +232,14 @@ impl FromPacketBody for A3953StateUpdatePacket { case_battery_level, unknown_bass_up: unknown_bass_up.to_vec(), ldac, - unknown_dual_connection: unknown_dual_connection.to_vec(), + support_two_connections, auto_power_off, unknown_hear_id_volume_db: unknown_hear_id_volume_db.to_vec(), wearing_tone, - unknown_tail: unknown_tail.to_vec(), + low_battery_prompt, + ambient_sound_prompt, + spatial_audio, + unknown_health_and_gap: unknown_health_and_gap.to_vec(), device_colour, press_sensitivity, unknown_suffix, @@ -252,11 +276,14 @@ impl ToPacket for A3953StateUpdatePacket { .chain(self.case_battery_level.bytes()) .chain(self.unknown_bass_up.iter().copied()) .chain(self.ldac.bytes()) - .chain(self.unknown_dual_connection.iter().copied()) + .chain(self.support_two_connections.bytes()) .chain(self.auto_power_off.bytes()) .chain(self.unknown_hear_id_volume_db.iter().copied()) .chain(self.wearing_tone.bytes()) - .chain(self.unknown_tail.iter().copied()) + .chain(self.low_battery_prompt.bytes()) + .chain(self.ambient_sound_prompt.bytes()) + .chain(self.spatial_audio.bytes()) + .chain(self.unknown_health_and_gap.iter().copied()) .chain(self.device_colour) .chain( self.press_sensitivity diff --git a/lib/src/devices/soundcore/a3953/state.rs b/lib/src/devices/soundcore/a3953/state.rs index 647daf386..a344e9e54 100644 --- a/lib/src/devices/soundcore/a3953/state.rs +++ b/lib/src/devices/soundcore/a3953/state.rs @@ -3,8 +3,8 @@ use openscq30_lib_macros::Has; use crate::devices::soundcore::{ a3953, common::structures::{ - AutoPowerOff, CaseBatteryLevel, DualBattery, DualFirmwareVersion, Ldac, SerialNumber, - TwsStatus, WearingDetection, WearingTone, + AutoPowerOff, CaseBatteryLevel, DualBattery, DualFirmwareVersion, Ldac, LowBatteryPrompt, + SerialNumber, TwsStatus, WearingDetection, WearingTone, }, }; @@ -18,8 +18,12 @@ pub struct A3953State { wearing_detection: WearingDetection, case_battery_level: CaseBatteryLevel, ldac: Ldac, + support_two_connections: a3953::structures::SupportTwoConnections, auto_power_off: AutoPowerOff, wearing_tone: WearingTone, + low_battery_prompt: LowBatteryPrompt, + ambient_sound_prompt: a3953::structures::AmbientSoundPrompt, + spatial_audio: a3953::structures::SpatialAudio, press_sensitivity: a3953::structures::PressSensitivity, } @@ -42,11 +46,14 @@ impl From for A3953State { case_battery_level, unknown_bass_up: _, ldac, - unknown_dual_connection: _, + support_two_connections, auto_power_off, unknown_hear_id_volume_db: _, wearing_tone, - unknown_tail: _, + low_battery_prompt, + ambient_sound_prompt, + spatial_audio, + unknown_health_and_gap: _, device_colour: _, press_sensitivity, unknown_suffix: _, @@ -61,8 +68,12 @@ impl From for A3953State { wearing_detection, case_battery_level, ldac, + support_two_connections, auto_power_off, wearing_tone, + low_battery_prompt, + ambient_sound_prompt, + spatial_audio, press_sensitivity: press_sensitivity.unwrap_or_default(), } } diff --git a/lib/src/devices/soundcore/a3953/structures.rs b/lib/src/devices/soundcore/a3953/structures.rs index ddf0ae32e..103e9b7c2 100644 --- a/lib/src/devices/soundcore/a3953/structures.rs +++ b/lib/src/devices/soundcore/a3953/structures.rs @@ -7,7 +7,7 @@ use nom::{ use crate::devices::soundcore::common::{ modules::sound_modes_v2::ToPacketBody, - packet::{self, inbound::FromPacketBody}, + packet::{self, inbound::FromPacketBody, parsing::take_bool}, structures::{AmbientSoundMode, WindNoise}, }; @@ -219,3 +219,149 @@ impl PressSensitivity { [self.0.min(4)] } } + +/// Whether the spatial-audio "effect" is fixed in place or head-tracked, cited from the literal +/// constants `A3953SpatialAudioVM.SPATIAL_MODE_FIXED = 1` / `SPATIAL_MODE_HEAD_TRACKING = 2` +/// (confirmed against `A3953SpatialActivity`'s toggle handlers, which call +/// `effectMode.set(1)`/`effectMode.set(2)`). +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Default, + ::strum::FromRepr, + ::strum::Display, + ::strum::IntoStaticStr, + ::strum::EnumString, + ::strum::EnumIter, + ::strum::VariantArray, + ::openscq30_i18n_macros::Translate, +)] +#[repr(u8)] +pub enum SpatialMode { + #[default] + Fixed = 1, + HeadTracking = 2, +} + +impl SpatialMode { + pub fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( + input: &'a [u8], + ) -> IResult<&'a [u8], Self, E> { + map(le_u8, |b| Self::from_repr(b).unwrap_or_default()).parse_complete(input) + } +} + +/// The spatial-audio content-type selector, cited from the literal constants +/// `A3953SpatialAudioVM.SOUND_MODE_MUSIC = 0` / `SOUND_MODE_MOVIE = 1` (confirmed against +/// `A3953SpatialActivity`'s toggle handlers, which call `soundMode.set(0)`/`soundMode.set(1)`). +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Default, + ::strum::FromRepr, + ::strum::Display, + ::strum::IntoStaticStr, + ::strum::EnumString, + ::strum::EnumIter, + ::strum::VariantArray, + ::openscq30_i18n_macros::Translate, +)] +#[repr(u8)] +pub enum SpatialContentMode { + #[default] + Music = 0, + Movie = 1, +} + +impl SpatialContentMode { + pub fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( + input: &'a [u8], + ) -> IResult<&'a [u8], Self, E> { + map(le_u8, |b| Self::from_repr(b).unwrap_or_default()).parse_complete(input) + } +} + +/// Spatial audio (switch, effect mode, content mode), sent together as 3 bytes with command +/// `[0x10, 0x81]` (decompiled constant `Cmm2CmdData.u2`, built by `CmmBtCmdService.b4`, called from +/// `Cmm2BtDeviceManager.T6`, called from `A3953SpatialAudioVM.setSpatialAudioFun`). Byte order +/// (switch, effect mode, content mode) matches `A3953AnalysisService.R0`'s read order exactly +/// (`spatialSwitch`, `spatialEffectMode`, `spatialSoundMode`). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct SpatialAudio { + pub is_enabled: bool, + pub effect_mode: SpatialMode, + pub sound_mode: SpatialContentMode, +} + +impl SpatialAudio { + pub fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( + input: &'a [u8], + ) -> IResult<&'a [u8], Self, E> { + context( + "a3953 spatial audio", + map( + (take_bool, SpatialMode::take, SpatialContentMode::take), + |(is_enabled, effect_mode, sound_mode)| Self { + is_enabled, + effect_mode, + sound_mode, + }, + ), + ) + .parse_complete(input) + } + + pub fn bytes(&self) -> [u8; 3] { + [ + u8::from(self.is_enabled), + self.effect_mode as u8, + self.sound_mode as u8, + ] + } +} + +/// Sent with command `[0x10, 0x83]` (decompiled constant `Cmm2CmdData.w2`, built by +/// `CmmBtCmdService.e1`, called from `Cmm2BtDeviceManager.v4`, called from +/// `A3953PromptVM.setAmbientChangeSwitch`). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct AmbientSoundPrompt(pub bool); + +impl AmbientSoundPrompt { + pub fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( + input: &'a [u8], + ) -> IResult<&'a [u8], Self, E> { + map(take_bool, Self).parse_complete(input) + } + + pub fn bytes(&self) -> [u8; 1] { + [u8::from(self.0)] + } +} + +/// Lets the earbuds stay connected to two phones/devices at once (labeled "SupportTwoCnnSwitch" in +/// the decompiled bean, toggled from the device list screen). Sent with command `[0x0B, 0x84]` +/// (decompiled constant `Cmm2CmdData.E1`, built by `CmmBtCmdService.B0`, called from +/// `Cmm2BtDeviceManager.a7`, called from `A3952DeviceListVM.sendDeviceListSwitchCmd`, the shared +/// device-list view model this device's `A3953DeviceListActivity` reuses). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct SupportTwoConnections(pub bool); + +impl SupportTwoConnections { + pub fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( + input: &'a [u8], + ) -> IResult<&'a [u8], Self, E> { + map(take_bool, Self).parse_complete(input) + } + + pub fn bytes(&self) -> [u8; 1] { + [u8::from(self.0)] + } +} From a8cbcaf1d09a687e6dbb0b9427a8699fd4c6630c Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Sat, 12 Sep 2026 18:24:31 -0400 Subject: [PATCH 07/22] feat(lib): add custom equalizer support and decode button action names for the A3953 Reverse-engineered the equalizer write path (command [0x03, 0x87]) and the button-config action IDs from the official app's decompiled source, cross- validated against this project's own a3947/a3955 devices which share the same wire format. Button config stays read-only: no device family in the decompiled app has a command that writes it back. Co-Authored-By: Claude --- lib/src/devices/soundcore/a3953.rs | 105 ++++++++++++++++-- lib/src/devices/soundcore/a3953/modules.rs | 35 +++++- .../soundcore/a3953/modules/equalizer.rs | 68 ++++++++++++ .../a3953/modules/equalizer/state_modifier.rs | 104 +++++++++++++++++ lib/src/devices/soundcore/a3953/packets.rs | 2 + .../packets/set_equalizer_configuration.rs | 68 ++++++++++++ .../soundcore/a3953/packets/state_update.rs | 63 +++++++++-- lib/src/devices/soundcore/a3953/state.rs | 15 ++- lib/src/devices/soundcore/a3953/structures.rs | 102 +++++++++++++++-- 9 files changed, 528 insertions(+), 34 deletions(-) create mode 100644 lib/src/devices/soundcore/a3953/modules/equalizer.rs create mode 100644 lib/src/devices/soundcore/a3953/modules/equalizer/state_modifier.rs create mode 100644 lib/src/devices/soundcore/a3953/packets/set_equalizer_configuration.rs diff --git a/lib/src/devices/soundcore/a3953.rs b/lib/src/devices/soundcore/a3953.rs index 09e2d7197..ced307260 100644 --- a/lib/src/devices/soundcore/a3953.rs +++ b/lib/src/devices/soundcore/a3953.rs @@ -5,7 +5,7 @@ use crate::devices::soundcore::{ common::{ device::fetch_state_from_state_update_packet, macros::soundcore_device, - modules::auto_power_off::AutoPowerOffDuration, + modules::{auto_power_off::AutoPowerOffDuration, equalizer::common_settings_type_2}, packet::outbound::{RequestState, ToPacket}, }, }; @@ -17,12 +17,20 @@ pub mod structures; // Battery, dual firmware version, serial number, ambient sound mode, wind noise suppression, wear // detection, case battery level, LDAC, dual-connection support, auto power off, wearing tone (the -// app calls it "in ear beep"), low battery prompt, ambient sound prompt, spatial audio, and press -// sensitivity have all been reverse-engineered against the official app's decompiled source (see -// packets::state_update, structures.rs). Button configuration is parsed (see -// structures::ButtonConfig) but not exposed: no set command or action-ID meaning was found. -// Equalizer and "bass up" are parsed but not yet exposed either, for the same reason (see -// packets::state_update for the full field list). +// app calls it "in ear beep"), low battery prompt, ambient sound prompt, spatial audio, press +// sensitivity, and custom equalizer have all been reverse-engineered against the official app's +// decompiled source (see packets::state_update, structures.rs, +// packets::set_equalizer_configuration). Button configuration is parsed, and each assignment's +// action ID is now decoded to a name (see structures::ButtonAction), but it's still not exposed as +// a setting: no outbound command that writes a button assignment back was found anywhere in the +// decompiled source, for this device or any other device family that shares the same +// `ControllerBtnModel` read path. +// "Bass up" (a device-family-wide EQ model, `BaseDefaultEqHasBassUpM`) has no confirmed link to +// A3953's own equalizer data (`A3953EqData`, `A3952EqM extends BaseM` directly, not the bass-up +// base class) and no located command, so it's left unimplemented. Hear ID (personalized hearing +// profile) is read and round-tripped through equalizer writes unchanged, but not exposed for +// editing, matching this project's own `a3947`/`a3955` devices, which share this exact command +// family and byte layout (down to the DRC coefficients). soundcore_device!( A3953State, async |packet_io| { @@ -47,6 +55,7 @@ soundcore_device!( builder.a3953_misc_toggles(); builder.a3953_spatial_audio(); builder.a3953_press_sensitivity(); + builder.a3953_equalizer(common_settings_type_2()).await; }, { HashMap::from([( @@ -65,8 +74,9 @@ mod tests { devices::soundcore::common::{ device::{SoundcoreDeviceConfig, test_utils::TestSoundcoreDevice}, packet, + structures::CommonVolumeAdjustments, }, - settings::SettingId, + settings::{SettingId, Value}, }; #[tokio::test(start_paused = true)] @@ -117,9 +127,88 @@ mod tests { (SettingId::SpatialAudio, false.into()), (SettingId::SpatialAudioMode, "Music".into()), (SettingId::SpatialAudioMusicMode, "Fixed".into()), + (SettingId::VolumeAdjustments, Value::I16Vec(vec![0; 8])), ]); } + // Same real capture as `parses_known_packet`. Verifies the outbound custom EQ write packet + // byte for byte: command `[0x03, 0x87]` (decompiled `Cmm2CmdData.p1`, reached via + // `A3953CmdService.c5()` -> `CmmBtCmdService.z5`/`v5`), the plain custom EQ values, the + // Hear-ID-uninitialized `255, 255` sentinel (this capture's own Hear ID data, round-tripped + // unchanged from what `parses_known_packet` decodes: not enabled, all-`-60` stored values, + // `time=0`, `hear_id_type=Initial`, `favorite_music_genre=0`), and the DRC-transformed copy of + // the new custom EQ values as the trailing "real eq" block (see + // `common::structures::VolumeAdjustments::apply_drc`, itself a verbatim transcription of the + // decompiled app's `HearId2Utils.b`). + #[tokio::test(start_paused = true)] + async fn sets_custom_equalizer() { + let mut device = TestSoundcoreDevice::new( + super::device_registry, + DeviceModel::SoundcoreA3953, + HashMap::from([( + packet::Command([1, 1]), + packet::Inbound::new( + packet::Command([1, 1]), + vec![ + 1, 1, 5, 5, 0, 0, 48, 51, 46, 50, 51, 48, 51, 46, 50, 51, 51, 57, 53, 51, + 53, 52, 67, 54, 51, 51, 67, 67, 69, 69, 69, 56, 0, 0, 120, 120, 120, 120, + 120, 120, 120, 120, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 0, 60, 60, 60, 60, 60, 60, 60, 60, 0, 0, 60, 60, 60, 60, 60, 60, 60, + 60, 0, 0, 0, 0, 0, 0, 0, 60, 60, 60, 60, 60, 60, 60, 60, 0, 0, 60, 60, 60, + 60, 60, 60, 60, 60, 0, 0, 0, 0, 18, 17, 102, 17, 102, 17, 52, 17, 52, 17, + 36, 17, 36, 17, 82, 17, 83, 3, 2, 48, 0, 0, 1, 1, 0, 0, 0, 0, 255, 255, 1, + 1, 0, 5, 0, 0, 0, 1, 2, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 120, 255, 50, 0, 255, + 255, + ], + ), + )]), + SoundcoreDeviceConfig::default(), + ) + .await; + + // Bands 8 and 9 aren't settable through `SettingId::VolumeAdjustments` (only the 8 visible + // bands are), so the equalizer module leaves them at 0 rather than the DRC transform's own + // internal defaults for those two positions. + let new_adjustments = + CommonVolumeAdjustments::<10>::new([60, -60, 60, -60, 60, -60, 60, -60, 0, 0]); + // Decoded from this capture's own Hear ID block by `parses_known_packet`'s sibling + // assertions; round-tripped unchanged since this project doesn't expose Hear ID for + // editing. + let stored_hear_id_adjustments = CommonVolumeAdjustments::<10>::new([ + -60, -60, -60, -60, -60, -60, -60, -60, -120, -120, + ]); + + let mut expected_body = Vec::new(); + expected_body.extend([254u8, 254u8]); // preset id: custom (0xFEFE) + expected_body.extend([0u8, 0u8]); // favorite_music_genre (unchanged from the capture) + expected_body.extend(new_adjustments.bytes()); + expected_body.extend(new_adjustments.bytes()); + expected_body.extend([255u8, 255u8]); // Hear ID not initialized in this capture + expected_body.push(0); // hear_id.is_enabled: always disabled on write + expected_body.extend(stored_hear_id_adjustments.bytes()); + expected_body.extend(stored_hear_id_adjustments.bytes()); + expected_body.extend(0u32.to_be_bytes()); // hear_id.time + expected_body.push(0); // hear_id_type: Initial + expected_body.extend(stored_hear_id_adjustments.bytes()); + expected_body.extend(stored_hear_id_adjustments.bytes()); + expected_body.extend(new_adjustments.apply_drc().bytes()); + expected_body.extend(new_adjustments.apply_drc().bytes()); + expected_body.push(0); // trailing byte + + device + .assert_set_settings_response( + vec![( + SettingId::VolumeAdjustments, + Value::I16Vec(vec![60, -60, 60, -60, 60, -60, 60, -60]), + )], + vec![packet::Outbound::new( + packet::Command([3, 135]), + expected_body, + )], + ) + .await; + } + // The three tests below are real captures of the same device, differing only in byte 121 (the // ambient sound mode toggled in the official app between captures): 2 (Normal), 0 (Noise // Canceling), and 1 (Transparency). See a3953/structures.rs for the byte citation. diff --git a/lib/src/devices/soundcore/a3953/modules.rs b/lib/src/devices/soundcore/a3953/modules.rs index d82bae673..1e79644db 100644 --- a/lib/src/devices/soundcore/a3953/modules.rs +++ b/lib/src/devices/soundcore/a3953/modules.rs @@ -1,16 +1,47 @@ use openscq30_lib_has::Has; -use crate::devices::soundcore::common::device::SoundcoreDeviceBuilder; +use crate::devices::soundcore::common::{ + self, device::SoundcoreDeviceBuilder, modules::equalizer::EqualizerModuleSettings, + structures::TwsStatus, +}; use super::structures::{ - AmbientSoundPrompt, PressSensitivity, SoundModes, SpatialAudio, SupportTwoConnections, + AmbientSoundPrompt, IsHearIdInitialized, PressSensitivity, SoundModes, SpatialAudio, + SupportTwoConnections, }; +mod equalizer; mod misc_toggles; mod press_sensitivity; mod sound_modes; mod spatial_audio; +impl SoundcoreDeviceBuilder +where + StateType: Has + + Has> + + Has> + + Has + + Send + + Sync + + Clone + + 'static, +{ + pub async fn a3953_equalizer( + &mut self, + settings: EqualizerModuleSettings, + ) { + let packet_io = self.packet_io_controller().clone(); + let database = self.database(); + let device_model = self.device_model(); + let change_notify = self.change_notify(); + + self.module_collection() + .add_a3953_equalizer(packet_io, database, device_model, change_notify, settings) + .await; + } +} + impl SoundcoreDeviceBuilder where StateType: Has + Send + Sync + Clone + 'static, diff --git a/lib/src/devices/soundcore/a3953/modules/equalizer.rs b/lib/src/devices/soundcore/a3953/modules/equalizer.rs new file mode 100644 index 000000000..5d64f628e --- /dev/null +++ b/lib/src/devices/soundcore/a3953/modules/equalizer.rs @@ -0,0 +1,68 @@ +mod state_modifier; + +use std::sync::Arc; + +use openscq30_lib_has::Has; +use tokio::sync::watch; + +use crate::{ + DeviceModel, + devices::soundcore::{ + a3953, + common::{ + self, + modules::{ModuleCollection, equalizer::EqualizerModuleSettings}, + packet::PacketIOController, + structures::TwsStatus, + }, + }, + storage::OpenSCQ30Database, +}; + +impl ModuleCollection +where + T: Has + Clone + Send + Sync + 'static, +{ + pub async fn add_a3953_equalizer< + const CHANNELS: usize, + const BANDS: usize, + const VISIBLE_BANDS: usize, + const PRESET_BANDS: usize, + const MIN_VOLUME: i16, + const MAX_VOLUME: i16, + const FRACTION_DIGITS: u8, + >( + &mut self, + packet_io: Arc, + database: Arc, + device_model: DeviceModel, + change_notify: watch::Sender<()>, + settings: EqualizerModuleSettings< + VISIBLE_BANDS, + PRESET_BANDS, + MIN_VOLUME, + MAX_VOLUME, + FRACTION_DIGITS, + >, + ) where + T: Has< + common::structures::EqualizerConfiguration< + CHANNELS, + BANDS, + MIN_VOLUME, + MAX_VOLUME, + FRACTION_DIGITS, + >, + > + Has> + + Has, + { + self.add_equalizer_with_custom_state_modifier_tws( + database, + device_model, + change_notify, + Box::new(state_modifier::EqualizerStateModifier::new(packet_io)), + settings, + ) + .await; + } +} diff --git a/lib/src/devices/soundcore/a3953/modules/equalizer/state_modifier.rs b/lib/src/devices/soundcore/a3953/modules/equalizer/state_modifier.rs new file mode 100644 index 000000000..b47612efc --- /dev/null +++ b/lib/src/devices/soundcore/a3953/modules/equalizer/state_modifier.rs @@ -0,0 +1,104 @@ +use async_trait::async_trait; +use openscq30_lib_has::Has; +use std::{marker::PhantomData, sync::Arc}; +use tokio::sync::watch; + +use crate::{ + api::device, + devices::soundcore::{ + a3953, + common::{self, packet::PacketIOController, state_modifier::StateModifier}, + }, +}; + +pub struct EqualizerStateModifier< + T, + const CHANNELS: usize, + const BANDS: usize, + const MIN_VOLUME: i16, + const MAX_VOLUME: i16, + const FRACTION_DIGITS: u8, +> { + _state: PhantomData, + packet_io: Arc, +} + +impl< + T, + const CHANNELS: usize, + const BANDS: usize, + const MIN_VOLUME: i16, + const MAX_VOLUME: i16, + const FRACTION_DIGITS: u8, +> EqualizerStateModifier +{ + pub fn new(packet_io: Arc) -> Self { + Self { + packet_io, + _state: PhantomData, + } + } +} + +#[async_trait] +impl< + T, + const CHANNELS: usize, + const BANDS: usize, + const MIN_VOLUME: i16, + const MAX_VOLUME: i16, + const FRACTION_DIGITS: u8, +> StateModifier + for EqualizerStateModifier +where + T: Has< + common::structures::EqualizerConfiguration< + CHANNELS, + BANDS, + MIN_VOLUME, + MAX_VOLUME, + FRACTION_DIGITS, + >, + > + Has> + + Has + + Clone + + Send + + Sync, +{ + async fn move_to_state( + &self, + state_sender: &watch::Sender, + target_state: &T, + ) -> device::Result<()> { + let target_eq: &common::structures::EqualizerConfiguration<_, _, _, _, _> = + target_state.get(); + { + let state = state_sender.borrow(); + let current_eq: &common::structures::EqualizerConfiguration<_, _, _, _, _> = + state.get(); + if current_eq == target_eq { + return Ok(()); + } + } + + let mut target_hear_id: common::structures::CustomHearId<_, _> = *target_state.get(); + // We don't expose hear id in any way, so it should be disabled to ensure the equalizer + // configuration that we're applying is in effect + target_hear_id.is_enabled = false; + + let is_hear_id_initialized: a3953::structures::IsHearIdInitialized = *target_state.get(); + + self.packet_io + .send_with_response(&a3953::packets::set_equalizer_configuration( + target_eq, + &target_hear_id, + is_hear_id_initialized.0, + )) + .await?; + state_sender.send_modify(|state| { + *state.get_mut() = *target_eq; + *state.get_mut() = target_hear_id; + }); + Ok(()) + } +} diff --git a/lib/src/devices/soundcore/a3953/packets.rs b/lib/src/devices/soundcore/a3953/packets.rs index 3282c85d9..7310c1f38 100644 --- a/lib/src/devices/soundcore/a3953/packets.rs +++ b/lib/src/devices/soundcore/a3953/packets.rs @@ -1,3 +1,5 @@ +mod set_equalizer_configuration; mod state_update; +pub use set_equalizer_configuration::*; pub use state_update::*; diff --git a/lib/src/devices/soundcore/a3953/packets/set_equalizer_configuration.rs b/lib/src/devices/soundcore/a3953/packets/set_equalizer_configuration.rs new file mode 100644 index 000000000..87d2ad3a6 --- /dev/null +++ b/lib/src/devices/soundcore/a3953/packets/set_equalizer_configuration.rs @@ -0,0 +1,68 @@ +use crate::devices::soundcore::common::{self, packet, structures::OptionalVolumeAdjustmentsExt}; + +/// Command `[0x03, 0x87]` (decompiled constant `Cmm2CmdData.p1`), built by +/// `CmmBtCmdService.v5`, reached from `A3953CmdService.c5()` returning `true`, which routes +/// `CmmBtCmdService.d4` to `z5` (which always applies DRC to the plain custom EQ values via `n5`, +/// unconditionally on Hear ID state) and then to `v5` itself. Byte layout cross-checked against this +/// project's own `a3955` device, which shares the identical wire format for the same command: +/// preset id, then the Hear ID "favorite music genre"/"hearIdEqIndex" slot (same two bytes, meaning +/// depends on `hear_id_type`), then the plain custom EQ values, then a 2-byte slot that's `0` when +/// the device already has Hear ID data or `255` when it doesn't (`Cmm2CmdData.x`), then the Hear ID +/// switch, Hear ID values, Hear ID time (big-endian), Hear ID type, custom Hear ID values, the DRC +/// (`apply_drc`) transform of the plain custom EQ values, and a trailing always-zero byte. +pub fn set_equalizer_configuration< + const CHANNELS: usize, + const BANDS: usize, + const MIN_VOLUME: i16, + const MAX_VOLUME: i16, + const FRACTION_DIGITS: u8, +>( + equalizer_configuration: &common::structures::EqualizerConfiguration< + CHANNELS, + BANDS, + MIN_VOLUME, + MAX_VOLUME, + FRACTION_DIGITS, + >, + hear_id: &common::structures::CustomHearId, + is_hear_id_initialized: bool, +) -> packet::Outbound { + let body = equalizer_configuration + .preset_id() + .to_le_bytes() + .into_iter() + .chain(hear_id.favorite_music_genre.bytes()) + .chain(equalizer_configuration.volume_adjustments_bytes()) + .chain(if is_hear_id_initialized { + [0; 2] + } else { + [255; 2] + }) + .chain(std::iter::once(u8::from(hear_id.is_enabled))) + .chain(hear_id.volume_adjustments.iter().flat_map(|v| { + let mut bytes = v.bytes(); + if v.is_none() { + bytes[bytes.len() - 1] = 0; + } + bytes + })) + .chain(hear_id.time.to_be_bytes()) + .chain(std::iter::once(hear_id.hear_id_type as u8)) + .chain(hear_id.custom_volume_adjustments.iter().flat_map(|v| { + let mut bytes = v.bytes(); + if v.is_none() { + bytes[bytes.len() - 1] = 0; + } + bytes + })) + .chain( + equalizer_configuration + .volume_adjustments() + .iter() + .flat_map(|v| v.apply_drc().bytes()), + ) + .chain(std::iter::once(0)) + .collect(); + + packet::Outbound::new(packet::Command([3, 135]), body) +} diff --git a/lib/src/devices/soundcore/a3953/packets/state_update.rs b/lib/src/devices/soundcore/a3953/packets/state_update.rs index 861d4472c..19bc35097 100644 --- a/lib/src/devices/soundcore/a3953/packets/state_update.rs +++ b/lib/src/devices/soundcore/a3953/packets/state_update.rs @@ -14,9 +14,9 @@ use crate::devices::soundcore::{ macros::state_update_packet_module, packet::{self, inbound::FromPacketBody, outbound::ToPacket}, structures::{ - AmbientSoundModeCycle, AutoPowerOff, CaseBatteryLevel, DualBattery, - DualFirmwareVersion, Ldac, LowBatteryPrompt, SerialNumber, TwsStatus, WearingDetection, - WearingTone, + AmbientSoundModeCycle, AutoPowerOff, CaseBatteryLevel, CommonEqualizerConfiguration, + CustomHearId, DualBattery, DualFirmwareVersion, Ldac, LowBatteryPrompt, SerialNumber, + TwsStatus, WearingDetection, WearingTone, }, }, }; @@ -27,8 +27,31 @@ use crate::devices::soundcore::{ /// packet body (there, `bArr[9..]`; here, `input[0..]`, i.e. every cited `bArr` index there is this /// packet's index minus 9). /// -/// - `unknown_before_custom_length` (`bArr[41..112]`, 71 bytes): read by `R0` for EQ index/type, -/// custom EQ values, and Hear ID data, none of which are implemented here yet. +/// - `equalizer_configuration` (`bArr[41..63]`, 22 bytes: preset id, then 2×10 raw band bytes) and +/// `is_hear_id_initialized`/`hear_id` (`bArr[63..112]`, 49 bytes) together replace what was +/// previously an opaque 71-byte blob (`bArr[41..112]`). Traced via `A3953AnalysisService.R0`: +/// `bArr[41..43]` (preset id, u16 LE) and `I0(bArr, 43)` (left band values, `bArr[43..53]`) feed +/// `cmm2BtDeviceInfo.getEqDetails()`; `bArr[53..63]` (right band values) is present in the packet +/// but not read by `R0` itself (it duplicates the left curve for display), though the outbound +/// write path does send both channels distinctly (see below), so both are parsed here for full +/// fidelity. `bArr[63]` is `m3`, checked by `R0` against `Cmm2CmdData.x`/`y` (255/254) to decide +/// `hasHearIdData`; see `a3953::structures::IsHearIdInitialized` for the citation. +/// `bArr[64..112]` matches `common::structures::CustomHearId::take_with_music_genre_at_end` byte +/// for byte: switch (`bArr[64]`), left+right Hear ID values (`q1(bArr, 65)`, `bArr[65..85]`, right +/// half unread by `R0` same as the EQ values), Hear ID time (`bArr[85..89]`, big-endian, matches +/// `BytesUtil.I(_, true)`), Hear ID type (`bArr[89]`), left+right custom Hear ID values +/// (`q1(bArr, 90)`, `bArr[90..110]`, right half unread; `bArr[90]` doubles as `R0`'s `m5` +/// "has custom data" sentinel check, since the placeholder curve's position 0 byte is always the +/// same 255 sentinel), and finally `bArr[110..112]` (`H2`, "hearIdEqIndex", read little-endian via +/// `BytesUtil.H`), which lines up with `CustomHearId`'s trailing `favorite_music_genre` slot. +/// Cross-checked against this project's own `a3955` device, which uses the identical wire format +/// (down to the DRC coefficients in `common::structures::VolumeAdjustments::apply_drc`, which are +/// a verbatim transcription of this app's own `HearId2Utils.b`) for the same command family +/// (`[0x03, 0x87]`/`[0x03, 0x86]`, decompiled constants `Cmm2CmdData.p1`/`o1`). Custom EQ write is +/// implemented (see `a3953::packets::set_equalizer_configuration`); Hear ID itself is not exposed +/// for editing (same reasoning as `a3947`/`a3955`: the write path always disables it so that the +/// custom EQ being applied takes effect), so `hear_id`'s only purpose here is round-tripping +/// whatever the device already has stored, unchanged, when writing a new custom EQ. /// - `custom_length` (`bArr[112]`, `R0`'s local variable `m6`, logged as `"customLength"`): used to /// compute every following offset as `custom_length + N`. In the one real capture this is built /// from, its value is 18. @@ -110,7 +133,9 @@ pub struct A3953StateUpdatePacket { pub battery: DualBattery, pub dual_firmware_version: DualFirmwareVersion, pub serial_number: SerialNumber, - pub unknown_before_custom_length: Vec, + pub equalizer_configuration: CommonEqualizerConfiguration<2, 10>, + pub is_hear_id_initialized: a3953::structures::IsHearIdInitialized, + pub hear_id: CustomHearId<2, 10>, pub custom_length: u8, pub button_config: a3953::structures::ButtonConfig, pub unknown_gap: Vec, @@ -142,9 +167,9 @@ impl Default for A3953StateUpdatePacket { battery: Default::default(), dual_firmware_version: Default::default(), serial_number: Default::default(), - // sized to match the fixed-width `take()` calls in `FromPacketBody::take` below, so - // that `Self::default().to_packet()` round-trips through `take()` correctly - unknown_before_custom_length: vec![0; 71], + equalizer_configuration: Default::default(), + is_hear_id_initialized: Default::default(), + hear_id: Default::default(), custom_length: 0, button_config: Default::default(), unknown_gap: Vec::new(), @@ -183,7 +208,13 @@ impl FromPacketBody for A3953StateUpdatePacket { let (input, battery) = DualBattery::take(input)?; let (input, dual_firmware_version) = DualFirmwareVersion::take(input)?; let (input, serial_number) = SerialNumber::take(input)?; - let (input, unknown_before_custom_length) = take(71usize)(input)?; + let (input, equalizer_configuration) = + CommonEqualizerConfiguration::<2, 10>::take(input)?; + let (input, hear_id_status) = le_u8(input)?; + let is_hear_id_initialized = a3953::structures::IsHearIdInitialized( + hear_id_status != 255 && hear_id_status != 254, + ); + let (input, hear_id) = CustomHearId::<2, 10>::take_with_music_genre_at_end(input)?; let (input, custom_length) = le_u8(input)?; let (input, button_config) = a3953::structures::ButtonConfig::take(input)?; let gap_len = (custom_length as usize).saturating_sub(18); @@ -220,7 +251,9 @@ impl FromPacketBody for A3953StateUpdatePacket { battery, dual_firmware_version, serial_number, - unknown_before_custom_length: unknown_before_custom_length.to_vec(), + equalizer_configuration, + is_hear_id_initialized, + hear_id, custom_length, button_config, unknown_gap: unknown_gap.to_vec(), @@ -264,7 +297,13 @@ impl ToPacket for A3953StateUpdatePacket { .chain(self.battery.bytes()) .chain(self.dual_firmware_version.bytes()) .chain(self.serial_number.to_string().into_bytes()) - .chain(self.unknown_before_custom_length.iter().copied()) + .chain(self.equalizer_configuration.bytes()) + .chain(iter::once(if self.is_hear_id_initialized.0 { + 0 + } else { + 255 + })) + .chain(self.hear_id.bytes_with_music_genre_at_end()) .chain(iter::once(self.custom_length)) .chain(self.button_config.bytes()) .chain(self.unknown_gap.iter().copied()) diff --git a/lib/src/devices/soundcore/a3953/state.rs b/lib/src/devices/soundcore/a3953/state.rs index a344e9e54..c0e258409 100644 --- a/lib/src/devices/soundcore/a3953/state.rs +++ b/lib/src/devices/soundcore/a3953/state.rs @@ -3,8 +3,9 @@ use openscq30_lib_macros::Has; use crate::devices::soundcore::{ a3953, common::structures::{ - AutoPowerOff, CaseBatteryLevel, DualBattery, DualFirmwareVersion, Ldac, LowBatteryPrompt, - SerialNumber, TwsStatus, WearingDetection, WearingTone, + AutoPowerOff, CaseBatteryLevel, CommonEqualizerConfiguration, CustomHearId, DualBattery, + DualFirmwareVersion, Ldac, LowBatteryPrompt, SerialNumber, TwsStatus, WearingDetection, + WearingTone, }, }; @@ -14,6 +15,9 @@ pub struct A3953State { battery: DualBattery, dual_firmware_version: DualFirmwareVersion, serial_number: SerialNumber, + equalizer_configuration: CommonEqualizerConfiguration<2, 10>, + is_hear_id_initialized: a3953::structures::IsHearIdInitialized, + hear_id: CustomHearId<2, 10>, sound_modes: a3953::structures::SoundModes, wearing_detection: WearingDetection, case_battery_level: CaseBatteryLevel, @@ -34,7 +38,9 @@ impl From for A3953State { battery, dual_firmware_version, serial_number, - unknown_before_custom_length: _, + equalizer_configuration, + is_hear_id_initialized, + hear_id, custom_length: _, button_config: _, unknown_gap: _, @@ -64,6 +70,9 @@ impl From for A3953State { battery, dual_firmware_version, serial_number, + equalizer_configuration, + is_hear_id_initialized, + hear_id, sound_modes, wearing_detection, case_battery_level, diff --git a/lib/src/devices/soundcore/a3953/structures.rs b/lib/src/devices/soundcore/a3953/structures.rs index 103e9b7c2..b34975f08 100644 --- a/lib/src/devices/soundcore/a3953/structures.rs +++ b/lib/src/devices/soundcore/a3953/structures.rs @@ -90,20 +90,92 @@ impl ToPacketBody for SoundModes { } } +/// The behavior a button press is mapped to. IDs and names come from +/// `com.soundcore.control.utils.DeviceInfoUtil.t(String)`, a `cmdName` (e.g. +/// `PushLogConstant.VALUAS_APP_CUSTOM_PLAY_PAUSE`) to wire-ID switch shared by this app's whole +/// button-customization UI (not device-specific), and cross-checked against this device's own real +/// capture: the single-press assignments decode to `PlayPause` (6) and the double-press assignments +/// decode to `Next`/`AmbientSoundModeCycle` (3/4), all of which are named by this same switch. +/// `TakePhotoOrTranslate` and `StartSleepOrColorfulLight` share one wire ID each between two +/// differently-named `cmdName` constants in the decompiled source (`custom_take_photo`/ +/// `custom_translate` both map to 10; `custom_start_sleep`/`custom_color_ful_light` both fall +/// through to the same `return 14`), so this project can't tell those two pairs apart from the wire +/// value alone. IDs 7, 9, and 12 aren't produced by any `cmdName` in that switch, so they're left as +/// `Unknown` rather than guessed at. No outbound command that writes a `ButtonAssignment` back was +/// found anywhere in the decompiled source (confirmed absent from every `*AnalysisService`'s sibling +/// `*CmdService` across every device family that uses `ControllerBtnModel`, not just this one), so +/// this stays read-only. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum ButtonAction { + VolumeUp, + VolumeDown, + Previous, + Next, + AmbientSoundModeCycle, + VoiceAssistant, + PlayPause, + AncSwitch, + TakePhotoOrTranslate, + AskAnswer, + SwitchMode, + StartSleepOrColorfulLight, + #[default] + None, + Unknown(u8), +} + +impl ButtonAction { + fn from_nibble(nibble: u8) -> Self { + match nibble { + 0 => Self::VolumeUp, + 1 => Self::VolumeDown, + 2 => Self::Previous, + 3 => Self::Next, + 4 => Self::AmbientSoundModeCycle, + 5 => Self::VoiceAssistant, + 6 => Self::PlayPause, + 8 => Self::AncSwitch, + 10 => Self::TakePhotoOrTranslate, + 11 => Self::AskAnswer, + 13 => Self::SwitchMode, + 14 => Self::StartSleepOrColorfulLight, + 15 => Self::None, + other => Self::Unknown(other), + } + } + + fn to_nibble(self) -> u8 { + match self { + Self::VolumeUp => 0, + Self::VolumeDown => 1, + Self::Previous => 2, + Self::Next => 3, + Self::AmbientSoundModeCycle => 4, + Self::VoiceAssistant => 5, + Self::PlayPause => 6, + Self::AncSwitch => 8, + Self::TakePhotoOrTranslate => 10, + Self::AskAnswer => 11, + Self::SwitchMode => 13, + Self::StartSleepOrColorfulLight => 14, + Self::None => 15, + Self::Unknown(nibble) => nibble, + } + } +} + /// One button press-type's (single/double/long/triple press, for one earbud) configuration, as /// read by `com.oceanwing.devicecmd.manager.product.a3953.A3953AnalysisService.G0`: two bytes, /// each nibble-packed as `(BytesUtil.G(byte), BytesUtil.K(byte))` = (high nibble, low nibble). /// `untws_*` fields apply when the earbud is used alone (not connected as a TWS pair); the -/// unprefixed fields apply when TWS-connected. `action`/`untws_action` are raw 0-15 IDs; no -/// enum mapping from ID to behavior (e.g. "volume up") was found anywhere in the decompiled -/// source, so they're left as plain numbers rather than guessed at. No outbound command that -/// writes this struct back was found either, so it's read-only for now. +/// unprefixed fields apply when TWS-connected. See `ButtonAction` for what `action`/`untws_action` +/// mean. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct ButtonAssignment { pub untws_enabled: bool, pub enabled: bool, - pub untws_action: u8, - pub action: u8, + pub untws_action: ButtonAction, + pub action: ButtonAction, } impl ButtonAssignment { @@ -113,8 +185,8 @@ impl ButtonAssignment { map((le_u8, le_u8), |(switch_byte, action_byte)| Self { untws_enabled: switch_byte >> 4 == 1, enabled: switch_byte & 0x0F == 1, - untws_action: action_byte >> 4, - action: action_byte & 0x0F, + untws_action: ButtonAction::from_nibble(action_byte >> 4), + action: ButtonAction::from_nibble(action_byte & 0x0F), }) .parse_complete(input) } @@ -122,7 +194,7 @@ impl ButtonAssignment { pub fn bytes(&self) -> [u8; 2] { [ (u8::from(self.untws_enabled) << 4) | u8::from(self.enabled), - ((self.untws_action & 0x0F) << 4) | (self.action & 0x0F), + ((self.untws_action.to_nibble() & 0x0F) << 4) | (self.action.to_nibble() & 0x0F), ] } } @@ -365,3 +437,15 @@ impl SupportTwoConnections { [u8::from(self.0)] } } + +/// Whether the device has ever recorded a Hear ID (personalized hearing profile) result. Read from +/// the single byte immediately preceding the Hear ID block in the state update packet +/// (`A3953AnalysisService.R0`'s `m3`, `bArr[63]`), which `R0` treats as "no data" when it equals +/// either `Cmm2CmdData.x` (`-1`/255) or `Cmm2CmdData.y` (`-2`/254); this project's sibling A3955 +/// device only checks the single-sentinel case, but A3953's own decompiled `R0` checks both, so both +/// are checked here. This project doesn't expose Hear ID for editing (see `HearId` on this device's +/// state), so the only use of this flag is deciding whether the equalizer write path needs to send +/// the "uninitialized" sentinel bytes (`CmmBtCmdService.v5`, called through `A3953CmdService.c5`'s +/// `z5`) or the real ones already stored on the device. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct IsHearIdInitialized(pub bool); From 2617bfa1e0776cfc6e14be1d5a39af3ef0da6601 Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Sun, 13 Sep 2026 13:11:36 -0400 Subject: [PATCH 08/22] fix(macos): harden RFCOMM transport, trim a3953 dead weight and comment bloat - perform_sdp_query's timeout was checked against CFRunLoop::run_in_mode on the wrong thread and never actually bounded the wait; switched to recv_timeout on the (thread-agnostic) result channel. - rfcommChannelData's blocking_send could freeze the whole connection (reads, writes, and run-loop pumping all share one thread) if the read buffer ever filled; switched to try_send, dropping on backpressure. - cli/main.rs's macOS run loop busy-spun at 100% CPU whenever nothing was yet scheduled on it; now sleeps between pumps when that happens. - A panic on macOS's worker thread gave exit code 1 instead of the 101 other platforms use for the same panic; now joins and matches it. - Replaced a comment asserting a causal theory this branch's own investigation already falsified with what's actually true. - Shrunk WRITE_DRAIN_TIMEOUT (100ms -> 10ms) to tighten the latency floor on inbound callbacks between writes. - a3953's AmbientSoundPrompt/SupportTwoConnections now go through the shared flag! plumbing instead of hand-rolled handlers/modifiers. - Dropped six always-discarded Vec fields from A3953StateUpdatePacket in favor of fixed-size padding at parse time, matching a3947's pattern (kept unknown_gap/unknown_suffix, which are genuinely variable-length). - Condensed a3953's doc-comment citations into single-line notes. Co-Authored-By: Claude Sonnet 5 --- cli/src/main.rs | 22 +- lib/src/connection_backend/macos/rfcomm.rs | 58 +++--- .../soundcore/a3953/modules/misc_toggles.rs | 188 +++-------------- .../packets/set_equalizer_configuration.rs | 12 +- .../soundcore/a3953/packets/state_update.rs | 193 +++--------------- lib/src/devices/soundcore/a3953/state.rs | 5 - lib/src/devices/soundcore/a3953/structures.rs | 111 ++++------ 7 files changed, 139 insertions(+), 450 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index e7dfb6d57..a495431d2 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -24,7 +24,7 @@ async fn main() -> ExitCode { #[cfg(target_os = "macos")] fn main() -> ExitCode { let (exit_code_sender, exit_code_receiver) = std::sync::mpsc::channel(); - std::thread::spawn(move || { + let join_handle = std::thread::spawn(move || { let runtime = tokio::runtime::Runtime::new().expect("failed to start tokio runtime"); let _ = exit_code_sender.send(runtime.block_on(run())); }); @@ -32,16 +32,28 @@ fn main() -> ExitCode { loop { match exit_code_receiver.try_recv() { Ok(code) => return code, - // the worker thread panicked before sending a result - Err(std::sync::mpsc::TryRecvError::Disconnected) => return ExitCode::FAILURE, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + // The worker thread panicked before sending a result (the panic hook already + // printed it). Match the exit code Rust's default runtime uses when the real + // main thread panics, since the actual work here runs on a worker thread instead. + return match join_handle.join() { + Ok(()) => ExitCode::FAILURE, + Err(_) => ExitCode::from(101), + }; + } Err(std::sync::mpsc::TryRecvError::Empty) => {} } - unsafe { + let result = unsafe { objc2_core_foundation::CFRunLoop::run_in_mode( objc2_core_foundation::kCFRunLoopDefaultMode, 0.1, false, - ); + ) + }; + // run_in_mode returns immediately, not after the given timeout, whenever nothing is + // scheduled on the run loop yet: sleep here so that doesn't turn into a busy spin. + if result == objc2_core_foundation::CFRunLoopRunResult::Finished { + std::thread::sleep(std::time::Duration::from_millis(100)); } } } diff --git a/lib/src/connection_backend/macos/rfcomm.rs b/lib/src/connection_backend/macos/rfcomm.rs index d8205c57b..439bf6eb6 100644 --- a/lib/src/connection_backend/macos/rfcomm.rs +++ b/lib/src/connection_backend/macos/rfcomm.rs @@ -12,7 +12,7 @@ use std::{ use async_trait::async_trait; use macaddr::MacAddr6; use objc2::{AnyThread, DefinedClass, define_class, rc::Retained, runtime::AnyObject}; -use objc2_core_foundation::{CFRunLoop, CFRunLoopRunResult, CFTimeInterval, kCFRunLoopDefaultMode}; +use objc2_core_foundation::{CFRunLoop, CFTimeInterval, kCFRunLoopDefaultMode}; use objc2_foundation::{NSArray, NSObject, NSObjectProtocol, NSString}; use objc2_io_bluetooth::{ BluetoothRFCOMMChannelID, BluetoothSDPServiceAttributeID, IOBluetoothDevice, @@ -39,15 +39,15 @@ const IO_RETURN_NOT_OPEN: c_int = 0xE00002CDu32 as c_int; const WRITE_NOT_OPEN_RETRIES: u32 = 50; const WRITE_NOT_OPEN_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(200); -const SDP_QUERY_TIMEOUT_SECONDS: CFTimeInterval = 10.0; +const SDP_QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); // CFRunLoopRun() returns immediately if nothing is scheduled at the moment it's called, so the // connection's event loop re-enters it in short bursts instead of once indefinitely. const RFCOMM_EVENT_POLL_SECONDS: CFTimeInterval = 0.1; -// How long the connection thread waits for a write request before falling back to pumping -// the run loop, so incoming delegate callbacks keep getting delivered between writes. -const WRITE_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(100); +// How long the connection thread waits for a write request before falling back to pumping the +// run loop; bounds how stale an unpumped inbound callback can get, so kept short. +const WRITE_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(10); #[derive(Default)] pub struct MacosRfcommBackend; @@ -248,21 +248,24 @@ fn perform_sdp_query(device: &IOBluetoothDevice) -> connection::Result<()> { }); } - // Bounded so a delegate callback that never arrives can't hang the connection forever. - let run_result = - unsafe { CFRunLoop::run_in_mode(kCFRunLoopDefaultMode, SDP_QUERY_TIMEOUT_SECONDS, false) }; - if run_result == CFRunLoopRunResult::TimedOut { - return Err(connection::Error::TimedOut { - action: "SDP query", - }); - } - - match done_receiver.recv() { + // sdpQueryComplete: is delivered on the process main thread (pumped continuously by + // cli/src/main.rs's run loop), not this thread, so a run loop pumped here would never see + // it; recv_timeout on the plain mpsc channel is what actually bounds the wait. + match done_receiver.recv_timeout(SDP_QUERY_TIMEOUT) { Ok(0) => Ok(()), - _ => Err(connection::Error::DeviceNotFound { + Ok(_) => Err(connection::Error::DeviceNotFound { source: None, location: Location::caller(), }), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(connection::Error::TimedOut { + action: "SDP query", + }), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + Err(connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + }) + } } } @@ -281,9 +284,6 @@ define_class!( #[unsafe(method(sdpQueryComplete:status:))] fn sdp_query_complete(&self, _device: Option<&IOBluetoothDevice>, status: c_int) { let _ = self.ivars().done_sender.send(status); - if let Some(run_loop) = CFRunLoop::current() { - run_loop.stop(); - } } } ); @@ -374,8 +374,17 @@ define_class!( ) { let bytes = unsafe { std::slice::from_raw_parts(data.cast::(), length) }.to_vec(); trace!("received packet: {bytes:?}"); - if self.ivars().data_sender.blocking_send(bytes).is_err() { - debug!("read_channel receiver is closed"); + // This callback runs on the connection thread, which also services writes and pumps + // the run loop, so blocking here would freeze the whole connection if the reader + // ever stalls: drop the packet instead of waiting for buffer space. + match self.ivars().data_sender.try_send(bytes) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + debug!("read_channel buffer full, dropping packet"); + } + Err(mpsc::error::TrySendError::Closed(_)) => { + debug!("read_channel receiver is closed"); + } } } @@ -540,10 +549,9 @@ fn run_connection_thread( let _ = open_result_sender.send(Ok(())); - // The channel internally uses an NSPort (IOBluetoothRFCOMMChannel conforms to - // NSPortDelegate), and NSPort events are dispatched to the run loop of the thread that - // first performs I/O on the channel. So all writes and run-loop pumping must happen on this - // thread, which guarantees delegate callbacks (rfcommChannelData etc.) get dispatched here. + // Empirically, channel callbacks (rfcommChannelData etc.) are delivered on whichever thread + // pumps the run loop here, unlike SDP query callbacks which need the real main thread + // (see perform_sdp_query) — so writes and pumping stay on this dedicated thread. debug!("channel opened, running write/pump loop"); // Service any write requests queued while the channel was still opening. diff --git a/lib/src/devices/soundcore/a3953/modules/misc_toggles.rs b/lib/src/devices/soundcore/a3953/modules/misc_toggles.rs index 74338f208..86a49a9e6 100644 --- a/lib/src/devices/soundcore/a3953/modules/misc_toggles.rs +++ b/lib/src/devices/soundcore/a3953/modules/misc_toggles.rs @@ -1,184 +1,42 @@ use std::sync::Arc; -use async_trait::async_trait; use openscq30_lib_has::Has; -use strum::{EnumIter, EnumString, IntoEnumIterator, IntoStaticStr}; -use tokio::sync::watch; use crate::{ - api::{ - device, - settings::{CategoryId, Setting, SettingId, Value}, - }, + api::settings::{CategoryId, SettingId}, devices::soundcore::{ a3953::structures::{AmbientSoundPrompt, SupportTwoConnections}, common::{ - modules::ModuleCollection, + modules::{ModuleCollection, flag::FlagConfiguration}, packet::{self, PacketIOController}, - settings_manager::{SettingHandler, SettingHandlerResult}, - state_modifier::StateModifier, }, }, - macros::enum_subset, }; -const AMBIENT_SOUND_PROMPT_COMMAND: packet::Command = packet::Command([0x10, 0x83]); -const SUPPORT_TWO_CONNECTIONS_COMMAND: packet::Command = packet::Command([0x0B, 0x84]); - -enum_subset! { - SettingId, - #[derive(EnumString, EnumIter, IntoStaticStr)] - enum MiscToggleSetting { - AmbientSoundPrompt, - SupportTwoConnections, - } -} - impl ModuleCollection where - T: Has + Has + Clone + Send + Sync, + T: Has + Has + Send + Sync, { pub fn add_a3953_misc_toggles(&mut self, packet_io: Arc) { - self.setting_manager - .add_handler(CategoryId::Miscellaneous, MiscToggleSettingHandler); - self.state_modifiers - .push(Box::new(AmbientSoundPromptStateModifier::new( - packet_io.clone(), - ))); - self.state_modifiers - .push(Box::new(SupportTwoConnectionsStateModifier::new(packet_io))); - } -} - -#[derive(Default)] -struct MiscToggleSettingHandler; - -#[async_trait] -impl SettingHandler for MiscToggleSettingHandler -where - T: Has + Has + Send, -{ - fn settings(&self) -> Vec { - MiscToggleSetting::iter().map(Into::into).collect() - } - - fn get(&self, state: &T, setting_id: &SettingId) -> Option { - let setting: MiscToggleSetting = (*setting_id).try_into().ok()?; - Some(match setting { - MiscToggleSetting::AmbientSoundPrompt => { - let value: &AmbientSoundPrompt = state.get(); - Setting::Toggle { value: value.0 } - } - MiscToggleSetting::SupportTwoConnections => { - let value: &SupportTwoConnections = state.get(); - Setting::Toggle { value: value.0 } - } - }) - } - - async fn set( - &self, - state: &mut T, - setting_id: &SettingId, - value: Value, - ) -> SettingHandlerResult<()> { - let setting: MiscToggleSetting = (*setting_id) - .try_into() - .expect("already filtered to valid values only by SettingsManager"); - match setting { - MiscToggleSetting::AmbientSoundPrompt => { - let target: &mut AmbientSoundPrompt = state.get_mut(); - target.0 = value.try_as_bool()?; - } - MiscToggleSetting::SupportTwoConnections => { - let target: &mut SupportTwoConnections = state.get_mut(); - target.0 = value.try_as_bool()?; - } - } - Ok(()) - } -} - -struct AmbientSoundPromptStateModifier { - packet_io: Arc, -} - -impl AmbientSoundPromptStateModifier { - fn new(packet_io: Arc) -> Self { - Self { packet_io } - } -} - -#[async_trait] -impl StateModifier for AmbientSoundPromptStateModifier -where - T: Has + Clone + Send + Sync, -{ - async fn move_to_state( - &self, - state_sender: &watch::Sender, - target_state: &T, - ) -> device::Result<()> { - let target: AmbientSoundPrompt = *target_state.get(); - { - let state = state_sender.borrow(); - let current: &AmbientSoundPrompt = state.get(); - if *current == target { - return Ok(()); - } - } - self.packet_io - .send_with_response(&packet::Outbound::new( - AMBIENT_SOUND_PROMPT_COMMAND, - target.bytes().to_vec(), - )) - .await?; - state_sender.send_modify(|state| { - let value: &mut AmbientSoundPrompt = state.get_mut(); - *value = target; - }); - Ok(()) - } -} - -struct SupportTwoConnectionsStateModifier { - packet_io: Arc, -} - -impl SupportTwoConnectionsStateModifier { - fn new(packet_io: Arc) -> Self { - Self { packet_io } - } -} - -#[async_trait] -impl StateModifier for SupportTwoConnectionsStateModifier -where - T: Has + Clone + Send + Sync, -{ - async fn move_to_state( - &self, - state_sender: &watch::Sender, - target_state: &T, - ) -> device::Result<()> { - let target: SupportTwoConnections = *target_state.get(); - { - let state = state_sender.borrow(); - let current: &SupportTwoConnections = state.get(); - if *current == target { - return Ok(()); - } - } - self.packet_io - .send_with_response(&packet::Outbound::new( - SUPPORT_TWO_CONNECTIONS_COMMAND, - target.bytes().to_vec(), - )) - .await?; - state_sender.send_modify(|state| { - let value: &mut SupportTwoConnections = state.get_mut(); - *value = target; - }); - Ok(()) + self.add_flag::( + packet_io.clone(), + FlagConfiguration { + category_id: CategoryId::Miscellaneous, + setting_id: SettingId::AmbientSoundPrompt, + set_command: packet::Command([0x10, 0x83]), + update_command: None, + is_inverted: false, + }, + ); + self.add_flag::( + packet_io, + FlagConfiguration { + category_id: CategoryId::Miscellaneous, + setting_id: SettingId::SupportTwoConnections, + set_command: packet::Command([0x0B, 0x84]), + update_command: None, + is_inverted: false, + }, + ); } } diff --git a/lib/src/devices/soundcore/a3953/packets/set_equalizer_configuration.rs b/lib/src/devices/soundcore/a3953/packets/set_equalizer_configuration.rs index 87d2ad3a6..d3bf2666b 100644 --- a/lib/src/devices/soundcore/a3953/packets/set_equalizer_configuration.rs +++ b/lib/src/devices/soundcore/a3953/packets/set_equalizer_configuration.rs @@ -1,15 +1,7 @@ use crate::devices::soundcore::common::{self, packet, structures::OptionalVolumeAdjustmentsExt}; -/// Command `[0x03, 0x87]` (decompiled constant `Cmm2CmdData.p1`), built by -/// `CmmBtCmdService.v5`, reached from `A3953CmdService.c5()` returning `true`, which routes -/// `CmmBtCmdService.d4` to `z5` (which always applies DRC to the plain custom EQ values via `n5`, -/// unconditionally on Hear ID state) and then to `v5` itself. Byte layout cross-checked against this -/// project's own `a3955` device, which shares the identical wire format for the same command: -/// preset id, then the Hear ID "favorite music genre"/"hearIdEqIndex" slot (same two bytes, meaning -/// depends on `hear_id_type`), then the plain custom EQ values, then a 2-byte slot that's `0` when -/// the device already has Hear ID data or `255` when it doesn't (`Cmm2CmdData.x`), then the Hear ID -/// switch, Hear ID values, Hear ID time (big-endian), Hear ID type, custom Hear ID values, the DRC -/// (`apply_drc`) transform of the plain custom EQ values, and a trailing always-zero byte. +// [0x03,0x87] (Cmm2CmdData.p1/CmmBtCmdService.v5, reached via A3953CmdService.c5's z5); same wire +// format as a3955's own set_equalizer_configuration. pub fn set_equalizer_configuration< const CHANNELS: usize, const BANDS: usize, diff --git a/lib/src/devices/soundcore/a3953/packets/state_update.rs b/lib/src/devices/soundcore/a3953/packets/state_update.rs index 19bc35097..9687ae031 100644 --- a/lib/src/devices/soundcore/a3953/packets/state_update.rs +++ b/lib/src/devices/soundcore/a3953/packets/state_update.rs @@ -21,113 +21,9 @@ use crate::devices::soundcore::{ }, }; -/// Offsets below are reverse-engineered from the official Soundcore Android app -/// (com.oceanwing.soundcore v6.4.0-17), decompiling -/// `com.oceanwing.devicecmd.manager.product.a3953.A3953AnalysisService.R0`, which parses this same -/// packet body (there, `bArr[9..]`; here, `input[0..]`, i.e. every cited `bArr` index there is this -/// packet's index minus 9). -/// -/// - `equalizer_configuration` (`bArr[41..63]`, 22 bytes: preset id, then 2×10 raw band bytes) and -/// `is_hear_id_initialized`/`hear_id` (`bArr[63..112]`, 49 bytes) together replace what was -/// previously an opaque 71-byte blob (`bArr[41..112]`). Traced via `A3953AnalysisService.R0`: -/// `bArr[41..43]` (preset id, u16 LE) and `I0(bArr, 43)` (left band values, `bArr[43..53]`) feed -/// `cmm2BtDeviceInfo.getEqDetails()`; `bArr[53..63]` (right band values) is present in the packet -/// but not read by `R0` itself (it duplicates the left curve for display), though the outbound -/// write path does send both channels distinctly (see below), so both are parsed here for full -/// fidelity. `bArr[63]` is `m3`, checked by `R0` against `Cmm2CmdData.x`/`y` (255/254) to decide -/// `hasHearIdData`; see `a3953::structures::IsHearIdInitialized` for the citation. -/// `bArr[64..112]` matches `common::structures::CustomHearId::take_with_music_genre_at_end` byte -/// for byte: switch (`bArr[64]`), left+right Hear ID values (`q1(bArr, 65)`, `bArr[65..85]`, right -/// half unread by `R0` same as the EQ values), Hear ID time (`bArr[85..89]`, big-endian, matches -/// `BytesUtil.I(_, true)`), Hear ID type (`bArr[89]`), left+right custom Hear ID values -/// (`q1(bArr, 90)`, `bArr[90..110]`, right half unread; `bArr[90]` doubles as `R0`'s `m5` -/// "has custom data" sentinel check, since the placeholder curve's position 0 byte is always the -/// same 255 sentinel), and finally `bArr[110..112]` (`H2`, "hearIdEqIndex", read little-endian via -/// `BytesUtil.H`), which lines up with `CustomHearId`'s trailing `favorite_music_genre` slot. -/// Cross-checked against this project's own `a3955` device, which uses the identical wire format -/// (down to the DRC coefficients in `common::structures::VolumeAdjustments::apply_drc`, which are -/// a verbatim transcription of this app's own `HearId2Utils.b`) for the same command family -/// (`[0x03, 0x87]`/`[0x03, 0x86]`, decompiled constants `Cmm2CmdData.p1`/`o1`). Custom EQ write is -/// implemented (see `a3953::packets::set_equalizer_configuration`); Hear ID itself is not exposed -/// for editing (same reasoning as `a3947`/`a3955`: the write path always disables it so that the -/// custom EQ being applied takes effect), so `hear_id`'s only purpose here is round-tripping -/// whatever the device already has stored, unchanged, when writing a new custom EQ. -/// - `custom_length` (`bArr[112]`, `R0`'s local variable `m6`, logged as `"customLength"`): used to -/// compute every following offset as `custom_length + N`. In the one real capture this is built -/// from, its value is 18. -/// - `button_config` (`bArr[113..129]`, 16 bytes): see `a3953::structures::ButtonConfig` for the -/// per-byte citation. Parsed for round-trip fidelity but not exposed as a setting: no outbound -/// command that writes it back was found, and no action-ID-to-behavior mapping was found either. -/// - `unknown_gap`: `R0` computes the next two fields' positions as `custom_length + 111` and -/// `custom_length + 112` (`bArr` indices). Converted to indices into this packet body (`- 9`) and -/// relative to the end of `button_config` (`bArr[129]`, i.e. index 120 here), that leaves a gap of -/// `custom_length - 18` unknown bytes whenever `custom_length != 18`; zero-length in the captures -/// seen so far. -/// - `ambient_sound_mode_cycle` (`bArr[custom_length + 111]`): bits read via `BytesUtil.J(b, 1..3)` -/// into `cmm2BtDeviceInfo.setAncSelected/TransSelected/NormalSelected`, matching this project's -/// existing `AmbientSoundModeCycle` bit layout exactly (bit0=noise_canceling, bit1=transparency, -/// bit2=normal). No outbound command that sets these bits was found anywhere in the decompiled -/// `CmmBtCmdService`/`A3953CmdService`, so this is parsed for round-trip fidelity only and not -/// exposed as a setting. -/// - `sound_modes` (`bArr[custom_length + 112 .. custom_length + 118]`, 6 bytes): see -/// `a3953::structures::SoundModes` for the per-byte citation. -/// - `unknown_personal_anc_test_info` (`bArr[custom_length + 118 .. custom_length + 124]`, 6 bytes): -/// `PersonalAncInfo` test time (4 bytes, unix-ish timestamp), volume dB, and result index. Not -/// implemented; both observed captures show the "unset" sentinel `255` for the latter two. -/// - `wearing_detection` (`bArr[custom_length + 124]`): same command (`[0x01, 0x81]`, decompiled -/// constant `Cmm2CmdData.J`, used by a base `CmmBtCmdService` method not overridden by -/// `A3953CmdService`) as this project's shared `WearingDetection` flag. Cross-confirmed via the -/// UI layer: `A3953MoreVM.sendWearTestCmd` → `Cmm2BtDeviceManager.y5` → `CmmBtCmdService.Y0` → -/// same `Cmm2CmdData.J`, whose success callback (`dealSendWearDetectionCmd`) writes the result to -/// `Cmm2BtDeviceInfo.setWearDetectionSwitch`, the same bean field `R0` reads here. -/// - `unknown_wearing_status` (`bArr[custom_length + 125 .. custom_length + 127]`, 2 bytes): left -/// and right earbud in-ear status, reported only, no corresponding set command exists. -/// - `case_battery_level` (`bArr[custom_length + 127]`): read via the same `S()` clamp -/// (`0..=9`) as the earbuds' own battery level (see a3953.rs's `dual_battery` comment for why -/// that clamp alone isn't enough to conclude the true max level); displayed out of 5 to match -/// the structurally identical A3947 pending a lower-charge capture. -/// - `unknown_bass_up` (`bArr[custom_length + 128]`): a "bass up" toggle with no located set -/// command. -/// - `ldac` (`bArr[custom_length + 129]`): same command (`[0x01, 0xFF]`, decompiled constant -/// `Cmm2CmdData.f16o0`) as this project's shared `Ldac` flag. -/// - `support_two_connections` (`bArr[custom_length + 130]`): this project's `DualConnections` -/// structure is a much larger feature (a whole device list with its own inbound/outbound -/// packets), so this single bit (labeled `SupportTwoCnnSwitch` in the decompiled bean) gets its -/// own minimal type instead. Command `[0x0B, 0x84]` (decompiled constant `Cmm2CmdData.E1`, built -/// by `CmmBtCmdService.B0`), traced from `A3953DeviceListActivity` through the shared -/// `A3952DeviceListVM.sendDeviceListSwitchCmd` → `Cmm2BtDeviceManager.a7`. -/// - `auto_power_off` (`bArr[custom_length + 131 .. custom_length + 133]`, 2 bytes): same command -/// (`[0x01, 0x86]`, decompiled constant `Cmm2CmdData.L`) and same -/// `(bool is_enabled, u8 duration_index)` layout as this project's shared `AutoPowerOff` -/// structure. The duration index is clamped to `0..=3` by `A3953AnalysisService.U2`. Confirmed, -/// not just inferred from the clamp: `A3952PowerOffModel.A3953_POWER_OFF_OPTIONS` is a literal -/// `{"10 ", "20 ", "30 ", "60 "}` array, selected specifically via -/// `"A3953".equalsIgnoreCase(str)`, matching `AutoPowerOffDuration::ten_twenty_thirty_sixty()` -/// exactly. -/// - `unknown_hear_id_volume_db` (`bArr[custom_length + 133]`): part of the Hear ID feature, not -/// implemented. -/// - `wearing_tone` (`bArr[custom_length + 134]`): the app calls this field "in ear beep", but it -/// uses the same command (`[0x01, 0x8C]`, decompiled constant `Cmm2CmdData.f17p0`) as this -/// project's shared `WearingTone` flag, so it's reused under that name. -/// - `low_battery_prompt` (`custom_length + 126` here; `bArr[custom_length + 135]` in `R0`'s own -/// indexing): command `[0x10, 0x82]` (decompiled constant `Cmm2CmdData.v2`), matching this -/// project's existing `SET_LOW_BATTERY_PROMPT_COMMAND` exactly; traced via -/// `A3953PromptVM.setLowBatterySwitch` → `Cmm2BtDeviceManager.m6` → `CmmBtCmdService.F1`. -/// - `ambient_sound_prompt` (`custom_length + 127` here): command `[0x10, 0x83]` (decompiled -/// constant `Cmm2CmdData.w2`), see `a3953::structures::AmbientSoundPrompt` for the full citation. -/// - `spatial_audio` (`custom_length + 128 .. custom_length + 131` here, 3 bytes): switch, effect -/// mode, content mode; see `a3953::structures::SpatialAudio` for the full citation. -/// - `unknown_health_and_gap` (`custom_length + 131 .. custom_length + 136` here, 5 bytes): four -/// "daily care" health-tracking fields (sedentary reminder, sitting posture, heart rate -/// abnormality alarm and its threshold) that read as all-zero on this earbud and may belong to a -/// different product category sharing the same parser, plus one trailing byte `R0` never reads -/// at all. None have a located set command. -/// - `device_colour` and `press_sensitivity`: only present when `R0`'s own length check -/// (`bArr.length > 163`, i.e. this packet's body is longer than 154 bytes) holds; a 1-byte ASCII -/// colour code with no located meaning, and a 1-byte value (see -/// `a3953::structures::PressSensitivity` for its citation). -/// - `unknown_suffix`: whatever's left; 2 bytes in the captures seen so far. -#[derive(Debug, Clone, PartialEq, Eq)] +// Byte offsets cited inline below are from A3953AnalysisService.R0 (com.oceanwing.soundcore +// v6.4.0-17); R0's own bArr indices are this packet's index + 9. +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct A3953StateUpdatePacket { pub tws_status: TwsStatus, pub battery: DualBattery, @@ -141,61 +37,20 @@ pub struct A3953StateUpdatePacket { pub unknown_gap: Vec, pub ambient_sound_mode_cycle: AmbientSoundModeCycle, pub sound_modes: a3953::structures::SoundModes, - pub unknown_personal_anc_test_info: Vec, pub wearing_detection: WearingDetection, - pub unknown_wearing_status: Vec, pub case_battery_level: CaseBatteryLevel, - pub unknown_bass_up: Vec, pub ldac: Ldac, pub support_two_connections: a3953::structures::SupportTwoConnections, pub auto_power_off: AutoPowerOff, - pub unknown_hear_id_volume_db: Vec, pub wearing_tone: WearingTone, pub low_battery_prompt: LowBatteryPrompt, pub ambient_sound_prompt: a3953::structures::AmbientSoundPrompt, pub spatial_audio: a3953::structures::SpatialAudio, - pub unknown_health_and_gap: Vec, pub device_colour: Option, pub press_sensitivity: Option, pub unknown_suffix: Vec, } -impl Default for A3953StateUpdatePacket { - fn default() -> Self { - Self { - tws_status: Default::default(), - battery: Default::default(), - dual_firmware_version: Default::default(), - serial_number: Default::default(), - equalizer_configuration: Default::default(), - is_hear_id_initialized: Default::default(), - hear_id: Default::default(), - custom_length: 0, - button_config: Default::default(), - unknown_gap: Vec::new(), - ambient_sound_mode_cycle: Default::default(), - sound_modes: Default::default(), - unknown_personal_anc_test_info: vec![0; 6], - wearing_detection: Default::default(), - unknown_wearing_status: vec![0; 2], - case_battery_level: Default::default(), - unknown_bass_up: vec![0; 1], - ldac: Default::default(), - support_two_connections: Default::default(), - auto_power_off: Default::default(), - unknown_hear_id_volume_db: vec![0; 1], - wearing_tone: Default::default(), - low_battery_prompt: Default::default(), - ambient_sound_prompt: Default::default(), - spatial_audio: Default::default(), - unknown_health_and_gap: vec![0; 5], - device_colour: None, - press_sensitivity: None, - unknown_suffix: Vec::new(), - } - } -} - impl FromPacketBody for A3953StateUpdatePacket { type DirectionMarker = packet::InboundMarker; @@ -208,34 +63,37 @@ impl FromPacketBody for A3953StateUpdatePacket { let (input, battery) = DualBattery::take(input)?; let (input, dual_firmware_version) = DualFirmwareVersion::take(input)?; let (input, serial_number) = SerialNumber::take(input)?; + // bArr[41..63]: preset id + 2x10 raw band bytes, both channels (right duplicates left) let (input, equalizer_configuration) = CommonEqualizerConfiguration::<2, 10>::take(input)?; - let (input, hear_id_status) = le_u8(input)?; + let (input, hear_id_status) = le_u8(input)?; // bArr[63]: 255/254 = no hear id data let is_hear_id_initialized = a3953::structures::IsHearIdInitialized( hear_id_status != 255 && hear_id_status != 254, ); + // bArr[64..112], same wire format as a3955 (DRC coefficients in VolumeAdjustments::apply_drc) let (input, hear_id) = CustomHearId::<2, 10>::take_with_music_genre_at_end(input)?; - let (input, custom_length) = le_u8(input)?; - let (input, button_config) = a3953::structures::ButtonConfig::take(input)?; - let gap_len = (custom_length as usize).saturating_sub(18); + let (input, custom_length) = le_u8(input)?; // bArr[112]: base offset for fields below + let (input, button_config) = a3953::structures::ButtonConfig::take(input)?; // bArr[113..129] + let gap_len = (custom_length as usize).saturating_sub(18); // usually 0; nonzero if custom_length != 18 let (input, unknown_gap) = take(gap_len)(input)?; - let (input, ambient_sound_mode_cycle) = AmbientSoundModeCycle::take(input)?; + let (input, ambient_sound_mode_cycle) = AmbientSoundModeCycle::take(input)?; // read-only, no write command found let (input, sound_modes) = a3953::structures::SoundModes::take(input)?; - let (input, unknown_personal_anc_test_info) = take(6usize)(input)?; + let (input, _unknown_personal_anc_test_info) = take(6usize)(input)?; // test time/volume/result, always 255/255 seen so far let (input, wearing_detection) = WearingDetection::take(input)?; - let (input, unknown_wearing_status) = take(2usize)(input)?; + let (input, _unknown_wearing_status) = take(2usize)(input)?; // left/right in-ear status, read-only let (input, case_battery_level) = CaseBatteryLevel::take(input)?; - let (input, unknown_bass_up) = take(1usize)(input)?; + let (input, _unknown_bass_up) = take(1usize)(input)?; // "bass up" toggle, no write command found let (input, ldac) = Ldac::take(input)?; let (input, support_two_connections) = a3953::structures::SupportTwoConnections::take(input)?; let (input, auto_power_off) = AutoPowerOff::take(input)?; - let (input, unknown_hear_id_volume_db) = take(1usize)(input)?; - let (input, wearing_tone) = WearingTone::take(input)?; + let (input, _unknown_hear_id_volume_db) = take(1usize)(input)?; // hear id feature, not implemented + let (input, wearing_tone) = WearingTone::take(input)?; // app calls this "in ear beep" let (input, low_battery_prompt) = LowBatteryPrompt::take(input)?; let (input, ambient_sound_prompt) = a3953::structures::AmbientSoundPrompt::take(input)?; let (input, spatial_audio) = a3953::structures::SpatialAudio::take(input)?; - let (input, unknown_health_and_gap) = take(5usize)(input)?; + let (input, _unknown_health_and_gap) = take(5usize)(input)?; // daily-care health fields, all-zero on this earbud + // device_colour/press_sensitivity only present when the body is >154 bytes (R0's own length check) let (input, (device_colour, press_sensitivity)) = if total_len > 154 { let (input, colour_byte) = le_u8(input)?; let (input, press_sensitivity) = a3953::structures::PressSensitivity::take(input)?; @@ -243,7 +101,7 @@ impl FromPacketBody for A3953StateUpdatePacket { } else { (input, (None, None)) }; - let (input, unknown_suffix) = map(rest, |s: &[u8]| s.to_vec()).parse(input)?; + let (input, unknown_suffix) = map(rest, |s: &[u8]| s.to_vec()).parse(input)?; // trailing bytes, 2 in captures seen so far Ok(( input, Self { @@ -259,20 +117,15 @@ impl FromPacketBody for A3953StateUpdatePacket { unknown_gap: unknown_gap.to_vec(), ambient_sound_mode_cycle, sound_modes, - unknown_personal_anc_test_info: unknown_personal_anc_test_info.to_vec(), wearing_detection, - unknown_wearing_status: unknown_wearing_status.to_vec(), case_battery_level, - unknown_bass_up: unknown_bass_up.to_vec(), ldac, support_two_connections, auto_power_off, - unknown_hear_id_volume_db: unknown_hear_id_volume_db.to_vec(), wearing_tone, low_battery_prompt, ambient_sound_prompt, spatial_audio, - unknown_health_and_gap: unknown_health_and_gap.to_vec(), device_colour, press_sensitivity, unknown_suffix, @@ -309,20 +162,20 @@ impl ToPacket for A3953StateUpdatePacket { .chain(self.unknown_gap.iter().copied()) .chain(self.ambient_sound_mode_cycle.bytes()) .chain(self.sound_modes.bytes()) - .chain(self.unknown_personal_anc_test_info.iter().copied()) + .chain([0; 6]) // unknown personal ANC test info .chain(self.wearing_detection.bytes()) - .chain(self.unknown_wearing_status.iter().copied()) + .chain([0; 2]) // unknown wearing status .chain(self.case_battery_level.bytes()) - .chain(self.unknown_bass_up.iter().copied()) + .chain(iter::once(0)) // unknown bass up .chain(self.ldac.bytes()) .chain(self.support_two_connections.bytes()) .chain(self.auto_power_off.bytes()) - .chain(self.unknown_hear_id_volume_db.iter().copied()) + .chain(iter::once(0)) // unknown hear id volume db .chain(self.wearing_tone.bytes()) .chain(self.low_battery_prompt.bytes()) .chain(self.ambient_sound_prompt.bytes()) .chain(self.spatial_audio.bytes()) - .chain(self.unknown_health_and_gap.iter().copied()) + .chain([0; 5]) // unknown health and gap .chain(self.device_colour) .chain( self.press_sensitivity diff --git a/lib/src/devices/soundcore/a3953/state.rs b/lib/src/devices/soundcore/a3953/state.rs index c0e258409..506af462d 100644 --- a/lib/src/devices/soundcore/a3953/state.rs +++ b/lib/src/devices/soundcore/a3953/state.rs @@ -46,20 +46,15 @@ impl From for A3953State { unknown_gap: _, ambient_sound_mode_cycle: _, sound_modes, - unknown_personal_anc_test_info: _, wearing_detection, - unknown_wearing_status: _, case_battery_level, - unknown_bass_up: _, ldac, support_two_connections, auto_power_off, - unknown_hear_id_volume_db: _, wearing_tone, low_battery_prompt, ambient_sound_prompt, spatial_audio, - unknown_health_and_gap: _, device_colour: _, press_sensitivity, unknown_suffix: _, diff --git a/lib/src/devices/soundcore/a3953/structures.rs b/lib/src/devices/soundcore/a3953/structures.rs index b34975f08..a3b8f1f7e 100644 --- a/lib/src/devices/soundcore/a3953/structures.rs +++ b/lib/src/devices/soundcore/a3953/structures.rs @@ -8,19 +8,11 @@ use nom::{ use crate::devices::soundcore::common::{ modules::sound_modes_v2::ToPacketBody, packet::{self, inbound::FromPacketBody, parsing::take_bool}, - structures::{AmbientSoundMode, WindNoise}, + structures::{AmbientSoundMode, Flag, WindNoise}, }; -/// Byte layout reverse-engineered from the official Soundcore Android app (com.oceanwing.soundcore -/// v6.4.0-17), decompiling `com.oceanwing.devicecmd.manager.product.a3953.A3953CmdService.G1` -/// (outbound, command `[0x06, 0x81]`) and `A3953AnalysisService.T2` (inbound, same byte range -/// within the state update packet). `ambient_sound_mode` is validated to exactly {0,1,2} by -/// `CmmBtCmdService.h()` before sending. `anc_option_manual` is clamped to 1-6 by -/// `A3953CmdService.f()` on both read and write. `anc_option_auto`, `trans_option`, -/// `anc_automation_mode`, and `anc_auto_sensitivity_level` are read as raw, unclamped bytes; -/// `trans_option` is additionally clamped to 0-2 by `A3953CmdService.i()`, but only when sending. -/// `wind_noise.is_detected` is read from the device but is never sent back (the app always writes -/// only the suppression-enabled bit). +// [0x06,0x81] outbound (A3953CmdService.G1) / A3953AnalysisService.T2 inbound; anc_option_manual +// clamped 1-6, trans_option 0-2 (write only), other fields raw; wind_noise.is_detected never sent back #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct SoundModes { pub ambient_sound_mode: AmbientSoundMode, @@ -90,21 +82,10 @@ impl ToPacketBody for SoundModes { } } -/// The behavior a button press is mapped to. IDs and names come from -/// `com.soundcore.control.utils.DeviceInfoUtil.t(String)`, a `cmdName` (e.g. -/// `PushLogConstant.VALUAS_APP_CUSTOM_PLAY_PAUSE`) to wire-ID switch shared by this app's whole -/// button-customization UI (not device-specific), and cross-checked against this device's own real -/// capture: the single-press assignments decode to `PlayPause` (6) and the double-press assignments -/// decode to `Next`/`AmbientSoundModeCycle` (3/4), all of which are named by this same switch. -/// `TakePhotoOrTranslate` and `StartSleepOrColorfulLight` share one wire ID each between two -/// differently-named `cmdName` constants in the decompiled source (`custom_take_photo`/ -/// `custom_translate` both map to 10; `custom_start_sleep`/`custom_color_ful_light` both fall -/// through to the same `return 14`), so this project can't tell those two pairs apart from the wire -/// value alone. IDs 7, 9, and 12 aren't produced by any `cmdName` in that switch, so they're left as -/// `Unknown` rather than guessed at. No outbound command that writes a `ButtonAssignment` back was -/// found anywhere in the decompiled source (confirmed absent from every `*AnalysisService`'s sibling -/// `*CmdService` across every device family that uses `ControllerBtnModel`, not just this one), so -/// this stays read-only. +// Wire IDs/names from DeviceInfoUtil.t(cmdName), cross-checked against a real capture (PlayPause=6, +// Next/AmbientSoundModeCycle=3/4). 10 and 14 are each shared by two differently-named cmdNames +// (TakePhotoOrTranslate, StartSleepOrColorfulLight), so those pairs can't be told apart from the wire +// value alone; 7/9/12 are unused. Read-only: no write command found for any device family. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum ButtonAction { VolumeUp, @@ -164,12 +145,8 @@ impl ButtonAction { } } -/// One button press-type's (single/double/long/triple press, for one earbud) configuration, as -/// read by `com.oceanwing.devicecmd.manager.product.a3953.A3953AnalysisService.G0`: two bytes, -/// each nibble-packed as `(BytesUtil.G(byte), BytesUtil.K(byte))` = (high nibble, low nibble). -/// `untws_*` fields apply when the earbud is used alone (not connected as a TWS pair); the -/// unprefixed fields apply when TWS-connected. See `ButtonAction` for what `action`/`untws_action` -/// mean. +// Two nibble-packed bytes (A3953AnalysisService.G0); untws_* apply when used solo, unprefixed apply +// when TWS-connected. See ButtonAction for what action/untws_action mean. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct ButtonAssignment { pub untws_enabled: bool, @@ -199,10 +176,8 @@ impl ButtonAssignment { } } -/// Full 16-byte button configuration block (`bArr[113..129]` in `A3953AnalysisService.R0`, i.e. -/// `bArr[113..129]`, passed to `G0` starting at `bArr[113]`). Field order (single, double, long, -/// triple; left before right for each) matches the order `G0` populates them in, which is not the -/// same order this project's other Soundcore devices use (they go single/double/triple/long). +// bArr[113..129] (A3953AnalysisService.R0/G0); field order is single/double/long/triple, unlike +// this project's other Soundcore devices (single/double/triple/long). #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct ButtonConfig { pub left_single: ButtonAssignment, @@ -272,11 +247,7 @@ impl ButtonConfig { } } -/// A 0-4 selection with no known display labels (the app's own `A3953PressSensVM.initData` -/// pairs each value with a string resource ID rather than a literal name, and that string table -/// wasn't decoded), sent with command `[0x04, 0x85]` (decompiled constant `Cmm2CmdData.L0`, used -/// by the base `CmmBtCmdService.O(int)`, called from `Cmm2BtDeviceManager.g5(int)`, called from -/// `A3953PressSensVM.setPressSensItem`). +// [0x04,0x85] (Cmm2CmdData.L0/CmmBtCmdService.O); 0-4, no known display labels (string table wasn't decoded) #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct PressSensitivity(pub u8); @@ -292,10 +263,7 @@ impl PressSensitivity { } } -/// Whether the spatial-audio "effect" is fixed in place or head-tracked, cited from the literal -/// constants `A3953SpatialAudioVM.SPATIAL_MODE_FIXED = 1` / `SPATIAL_MODE_HEAD_TRACKING = 2` -/// (confirmed against `A3953SpatialActivity`'s toggle handlers, which call -/// `effectMode.set(1)`/`effectMode.set(2)`). +// A3953SpatialAudioVM.SPATIAL_MODE_FIXED/HEAD_TRACKING = 1/2 #[derive( Debug, Clone, @@ -327,9 +295,7 @@ impl SpatialMode { } } -/// The spatial-audio content-type selector, cited from the literal constants -/// `A3953SpatialAudioVM.SOUND_MODE_MUSIC = 0` / `SOUND_MODE_MOVIE = 1` (confirmed against -/// `A3953SpatialActivity`'s toggle handlers, which call `soundMode.set(0)`/`soundMode.set(1)`). +// A3953SpatialAudioVM.SOUND_MODE_MUSIC/MOVIE = 0/1 #[derive( Debug, Clone, @@ -361,11 +327,7 @@ impl SpatialContentMode { } } -/// Spatial audio (switch, effect mode, content mode), sent together as 3 bytes with command -/// `[0x10, 0x81]` (decompiled constant `Cmm2CmdData.u2`, built by `CmmBtCmdService.b4`, called from -/// `Cmm2BtDeviceManager.T6`, called from `A3953SpatialAudioVM.setSpatialAudioFun`). Byte order -/// (switch, effect mode, content mode) matches `A3953AnalysisService.R0`'s read order exactly -/// (`spatialSwitch`, `spatialEffectMode`, `spatialSoundMode`). +// [0x10,0x81] (Cmm2CmdData.u2/CmmBtCmdService.b4); byte order matches A3953AnalysisService.R0's read order #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct SpatialAudio { pub is_enabled: bool, @@ -400,9 +362,7 @@ impl SpatialAudio { } } -/// Sent with command `[0x10, 0x83]` (decompiled constant `Cmm2CmdData.w2`, built by -/// `CmmBtCmdService.e1`, called from `Cmm2BtDeviceManager.v4`, called from -/// `A3953PromptVM.setAmbientChangeSwitch`). +// [0x10,0x83] (Cmm2CmdData.w2/CmmBtCmdService.e1) #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct AmbientSoundPrompt(pub bool); @@ -418,11 +378,18 @@ impl AmbientSoundPrompt { } } -/// Lets the earbuds stay connected to two phones/devices at once (labeled "SupportTwoCnnSwitch" in -/// the decompiled bean, toggled from the device list screen). Sent with command `[0x0B, 0x84]` -/// (decompiled constant `Cmm2CmdData.E1`, built by `CmmBtCmdService.B0`, called from -/// `Cmm2BtDeviceManager.a7`, called from `A3952DeviceListVM.sendDeviceListSwitchCmd`, the shared -/// device-list view model this device's `A3953DeviceListActivity` reuses). +impl Flag for AmbientSoundPrompt { + fn get_bool(&self) -> bool { + self.0 + } + + fn set_bool(&mut self, value: bool) { + self.0 = value; + } +} + +// [0x0B,0x84] (Cmm2CmdData.E1/CmmBtCmdService.B0); single bit, not this project's full DualConnections +// device-list feature #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct SupportTwoConnections(pub bool); @@ -438,14 +405,18 @@ impl SupportTwoConnections { } } -/// Whether the device has ever recorded a Hear ID (personalized hearing profile) result. Read from -/// the single byte immediately preceding the Hear ID block in the state update packet -/// (`A3953AnalysisService.R0`'s `m3`, `bArr[63]`), which `R0` treats as "no data" when it equals -/// either `Cmm2CmdData.x` (`-1`/255) or `Cmm2CmdData.y` (`-2`/254); this project's sibling A3955 -/// device only checks the single-sentinel case, but A3953's own decompiled `R0` checks both, so both -/// are checked here. This project doesn't expose Hear ID for editing (see `HearId` on this device's -/// state), so the only use of this flag is deciding whether the equalizer write path needs to send -/// the "uninitialized" sentinel bytes (`CmmBtCmdService.v5`, called through `A3953CmdService.c5`'s -/// `z5`) or the real ones already stored on the device. +impl Flag for SupportTwoConnections { + fn get_bool(&self) -> bool { + self.0 + } + + fn set_bool(&mut self, value: bool) { + self.0 = value; + } +} + +// bArr[63] (A3953AnalysisService.R0's m3); 255 or 254 = uninitialized (unlike a3955, which only +// checks one sentinel). Determines whether the equalizer write path sends the "uninitialized" +// sentinel bytes or the real hear id already stored on the device. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct IsHearIdInitialized(pub bool); From fe0528255a56b4552c62b147b91eee0152a41945 Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Sun, 13 Sep 2026 13:34:10 -0400 Subject: [PATCH 09/22] feat(lib): wire full dual-connections support into the A3953, fix a shared parser edge case A real capture showed the earbuds already push [0x0B, 0x01] device-list packets matching this project's existing common::modules::dual_connections wire format exactly (same request/response command, same enabled-toggle command A3953 already implemented). Rather than the single toggle bit that was there before, A3953 now uses that shared module directly, the same way a3955 already does. - A3953State's Update impl is now hand-written instead of relying on the blanket From-based one: a plain full replace would wipe the fetched device list on every subsequent state refresh, since the list isn't part of the main state packet. - Testing against the real capture bytes found a genuine bug in the shared parser: a lone trailing device entry (alone in the final packet of a multi-packet response) is padded a couple bytes short of what its own length byte claims. DualConnectionsDevice::take now clamps to whatever's actually available instead of erroring, fixing this for every device that uses dual_connections, not just the A3953. Co-Authored-By: Claude Sonnet 5 --- lib/src/devices/soundcore/a3953.rs | 94 +++++++++++++++++-- lib/src/devices/soundcore/a3953/modules.rs | 3 +- .../soundcore/a3953/modules/misc_toggles.rs | 16 +--- .../soundcore/a3953/packets/state_update.rs | 11 +-- lib/src/devices/soundcore/a3953/state.rs | 91 ++++++++++++------ lib/src/devices/soundcore/a3953/structures.rs | 27 ------ .../common/structures/dual_connections.rs | 8 +- 7 files changed, 162 insertions(+), 88 deletions(-) diff --git a/lib/src/devices/soundcore/a3953.rs b/lib/src/devices/soundcore/a3953.rs index ced307260..10c5f71b3 100644 --- a/lib/src/devices/soundcore/a3953.rs +++ b/lib/src/devices/soundcore/a3953.rs @@ -3,10 +3,13 @@ use std::collections::HashMap; use crate::devices::soundcore::{ a3953::{packets::A3953StateUpdatePacket, state::A3953State}, common::{ - device::fetch_state_from_state_update_packet, + self, macros::soundcore_device, modules::{auto_power_off::AutoPowerOffDuration, equalizer::common_settings_type_2}, - packet::outbound::{RequestState, ToPacket}, + packet::{ + inbound::TryToPacket, + outbound::{RequestState, ToPacket}, + }, }, }; @@ -16,11 +19,15 @@ mod state; pub mod structures; // Battery, dual firmware version, serial number, ambient sound mode, wind noise suppression, wear -// detection, case battery level, LDAC, dual-connection support, auto power off, wearing tone (the -// app calls it "in ear beep"), low battery prompt, ambient sound prompt, spatial audio, press -// sensitivity, and custom equalizer have all been reverse-engineered against the official app's -// decompiled source (see packets::state_update, structures.rs, -// packets::set_equalizer_configuration). Button configuration is parsed, and each assignment's +// detection, case battery level, LDAC, auto power off, wearing tone (the app calls it "in ear +// beep"), low battery prompt, ambient sound prompt, spatial audio, press sensitivity, and custom +// equalizer have all been reverse-engineered against the official app's decompiled source (see +// packets::state_update, structures.rs, packets::set_equalizer_configuration). Dual connections +// (multi-device pairing list) reuses this project's shared common::modules::dual_connections +// wholesale: the enabled bit is the same command A3953CmdService.B0 sends +// ([0x0B,0x84]/Cmm2CmdData.E1), and a real capture confirmed the [0x0B,0x01] device list +// request/response matches common::structures::DualConnectionsDevice byte for byte. Button +// configuration is parsed, and each assignment's // action ID is now decoded to a name (see structures::ButtonAction), but it's still not exposed as // a setting: no outbound command that writes a button assignment back was found anywhere in the // decompiled source, for this device or any other device family that shares the same @@ -34,7 +41,19 @@ pub mod structures; soundcore_device!( A3953State, async |packet_io| { - fetch_state_from_state_update_packet::(packet_io).await + let state_update_packet: A3953StateUpdatePacket = packet_io + .send_with_response(&RequestState.to_packet()) + .await? + .try_to_packet()?; + let dual_connections_devices = if state_update_packet.dual_connections_enabled { + common::modules::dual_connections::take_dual_connection_devices(&packet_io).await? + } else { + Vec::new() + }; + Ok(A3953State::new( + state_update_packet, + dual_connections_devices, + )) }, async |builder| { builder.module_collection().add_state_update(); @@ -53,6 +72,7 @@ soundcore_device!( builder.wearing_tone(); builder.low_battery_prompt(); builder.a3953_misc_toggles(); + builder.dual_connections(); builder.a3953_spatial_audio(); builder.a3953_press_sensitivity(); builder.a3953_equalizer(common_settings_type_2()).await; @@ -123,7 +143,7 @@ mod tests { (SettingId::PressSensitivity, 0.into()), (SettingId::LowBatteryPrompt, true.into()), (SettingId::AmbientSoundPrompt, true.into()), - (SettingId::SupportTwoConnections, false.into()), + (SettingId::DualConnections, false.into()), (SettingId::SpatialAudio, false.into()), (SettingId::SpatialAudioMode, "Music".into()), (SettingId::SpatialAudioMusicMode, "Fixed".into()), @@ -269,4 +289,60 @@ mod tests { device.assert_setting_values([(SettingId::AmbientSoundMode, "Transparency".into())]); } + + // Real capture: two unsolicited [0x0B, 0x01] packets pushed by the device after connecting, + // confirming A3953 uses the same wire format as common::packet::inbound::DualConnectionsDevicePacket. + // The second packet's lone entry is padded 2 bytes short of what its own length byte implies, + // which is what motivated DualConnectionsDevice::take's clamp to available input. + #[test] + fn parses_real_dual_connections_device_list_capture() { + use nom_language::error::VerboseError; + + use crate::devices::soundcore::common::packet::inbound::{ + DualConnectionsDevicePacket, FromPacketBody, + }; + + let packet_1 = [ + 2, 1, 40, 1, 175, 149, 25, 87, 47, 132, 83, 101, 97, 110, 226, 128, 153, 115, 32, 77, + 97, 99, 66, 111, 111, 107, 32, 80, 114, 111, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 40, 0, + 117, 224, 41, 200, 87, 96, 83, 101, 97, 110, 226, 128, 153, 115, 32, 105, 80, 104, 111, + 110, 101, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 40, 0, 113, 159, 29, 98, + 59, 28, 78, 111, 107, 105, 97, 32, 50, 55, 56, 48, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 40, 0, 198, 14, 117, 171, 169, 60, 78, 105, 110, 116, + 101, 110, 100, 111, 32, 83, 119, 105, 116, 99, 104, 32, 50, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, + ]; + let (remaining_1, parsed_1) = + DualConnectionsDevicePacket::take::>(&packet_1).unwrap(); + assert_eq!(remaining_1.len(), 0); + assert_eq!(parsed_1.total_packets, 2); + assert_eq!(parsed_1.current_packet_index, 1); + let names: Vec<_> = parsed_1.devices.iter().map(|d| d.name.as_str()).collect(); + assert_eq!( + names, + [ + "Sean\u{2019}s MacBook Pro", + "Sean\u{2019}s iPhone", + "Nokia 2780", + "Nintendo Switch 2", + ] + ); + assert!(parsed_1.devices[0].is_connected); + assert!(!parsed_1.devices[1].is_connected); + assert!(!parsed_1.devices[2].is_connected); + assert!(!parsed_1.devices[3].is_connected); + + let packet_2 = [ + 2, 2, 40, 0, 96, 137, 137, 109, 73, 184, 83, 101, 97, 110, 226, 128, 153, 115, 32, 105, + 80, 97, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]; + let (remaining_2, parsed_2) = + DualConnectionsDevicePacket::take::>(&packet_2).unwrap(); + assert_eq!(remaining_2.len(), 0); + assert_eq!(parsed_2.total_packets, 2); + assert_eq!(parsed_2.current_packet_index, 2); + assert_eq!(parsed_2.devices.len(), 1); + assert_eq!(parsed_2.devices[0].name, "Sean\u{2019}s iPad"); + assert!(!parsed_2.devices[0].is_connected); + } } diff --git a/lib/src/devices/soundcore/a3953/modules.rs b/lib/src/devices/soundcore/a3953/modules.rs index 1e79644db..f014491f2 100644 --- a/lib/src/devices/soundcore/a3953/modules.rs +++ b/lib/src/devices/soundcore/a3953/modules.rs @@ -7,7 +7,6 @@ use crate::devices::soundcore::common::{ use super::structures::{ AmbientSoundPrompt, IsHearIdInitialized, PressSensitivity, SoundModes, SpatialAudio, - SupportTwoConnections, }; mod equalizer; @@ -66,7 +65,7 @@ where impl SoundcoreDeviceBuilder where - StateType: Has + Has + Send + Sync + Clone + 'static, + StateType: Has + Send + Sync + Clone + 'static, { pub fn a3953_misc_toggles(&mut self) { let packet_io_controller = self.packet_io_controller().clone(); diff --git a/lib/src/devices/soundcore/a3953/modules/misc_toggles.rs b/lib/src/devices/soundcore/a3953/modules/misc_toggles.rs index 86a49a9e6..f963c5595 100644 --- a/lib/src/devices/soundcore/a3953/modules/misc_toggles.rs +++ b/lib/src/devices/soundcore/a3953/modules/misc_toggles.rs @@ -5,7 +5,7 @@ use openscq30_lib_has::Has; use crate::{ api::settings::{CategoryId, SettingId}, devices::soundcore::{ - a3953::structures::{AmbientSoundPrompt, SupportTwoConnections}, + a3953::structures::AmbientSoundPrompt, common::{ modules::{ModuleCollection, flag::FlagConfiguration}, packet::{self, PacketIOController}, @@ -15,11 +15,11 @@ use crate::{ impl ModuleCollection where - T: Has + Has + Send + Sync, + T: Has + Send + Sync, { pub fn add_a3953_misc_toggles(&mut self, packet_io: Arc) { self.add_flag::( - packet_io.clone(), + packet_io, FlagConfiguration { category_id: CategoryId::Miscellaneous, setting_id: SettingId::AmbientSoundPrompt, @@ -28,15 +28,5 @@ where is_inverted: false, }, ); - self.add_flag::( - packet_io, - FlagConfiguration { - category_id: CategoryId::Miscellaneous, - setting_id: SettingId::SupportTwoConnections, - set_command: packet::Command([0x0B, 0x84]), - update_command: None, - is_inverted: false, - }, - ); } } diff --git a/lib/src/devices/soundcore/a3953/packets/state_update.rs b/lib/src/devices/soundcore/a3953/packets/state_update.rs index 9687ae031..c002b16f2 100644 --- a/lib/src/devices/soundcore/a3953/packets/state_update.rs +++ b/lib/src/devices/soundcore/a3953/packets/state_update.rs @@ -12,7 +12,7 @@ use crate::devices::soundcore::{ a3953::{self, state::A3953State}, common::{ macros::state_update_packet_module, - packet::{self, inbound::FromPacketBody, outbound::ToPacket}, + packet::{self, inbound::FromPacketBody, outbound::ToPacket, parsing::take_bool}, structures::{ AmbientSoundModeCycle, AutoPowerOff, CaseBatteryLevel, CommonEqualizerConfiguration, CustomHearId, DualBattery, DualFirmwareVersion, Ldac, LowBatteryPrompt, SerialNumber, @@ -40,7 +40,7 @@ pub struct A3953StateUpdatePacket { pub wearing_detection: WearingDetection, pub case_battery_level: CaseBatteryLevel, pub ldac: Ldac, - pub support_two_connections: a3953::structures::SupportTwoConnections, + pub dual_connections_enabled: bool, pub auto_power_off: AutoPowerOff, pub wearing_tone: WearingTone, pub low_battery_prompt: LowBatteryPrompt, @@ -84,8 +84,7 @@ impl FromPacketBody for A3953StateUpdatePacket { let (input, case_battery_level) = CaseBatteryLevel::take(input)?; let (input, _unknown_bass_up) = take(1usize)(input)?; // "bass up" toggle, no write command found let (input, ldac) = Ldac::take(input)?; - let (input, support_two_connections) = - a3953::structures::SupportTwoConnections::take(input)?; + let (input, dual_connections_enabled) = take_bool(input)?; // same [0x0B,0x84] toggle as common::modules::dual_connections let (input, auto_power_off) = AutoPowerOff::take(input)?; let (input, _unknown_hear_id_volume_db) = take(1usize)(input)?; // hear id feature, not implemented let (input, wearing_tone) = WearingTone::take(input)?; // app calls this "in ear beep" @@ -120,7 +119,7 @@ impl FromPacketBody for A3953StateUpdatePacket { wearing_detection, case_battery_level, ldac, - support_two_connections, + dual_connections_enabled, auto_power_off, wearing_tone, low_battery_prompt, @@ -168,7 +167,7 @@ impl ToPacket for A3953StateUpdatePacket { .chain(self.case_battery_level.bytes()) .chain(iter::once(0)) // unknown bass up .chain(self.ldac.bytes()) - .chain(self.support_two_connections.bytes()) + .chain(iter::once(self.dual_connections_enabled.into())) .chain(self.auto_power_off.bytes()) .chain(iter::once(0)) // unknown hear id volume db .chain(self.wearing_tone.bytes()) diff --git a/lib/src/devices/soundcore/a3953/state.rs b/lib/src/devices/soundcore/a3953/state.rs index 506af462d..46ac81838 100644 --- a/lib/src/devices/soundcore/a3953/state.rs +++ b/lib/src/devices/soundcore/a3953/state.rs @@ -2,10 +2,13 @@ use openscq30_lib_macros::Has; use crate::devices::soundcore::{ a3953, - common::structures::{ - AutoPowerOff, CaseBatteryLevel, CommonEqualizerConfiguration, CustomHearId, DualBattery, - DualFirmwareVersion, Ldac, LowBatteryPrompt, SerialNumber, TwsStatus, WearingDetection, - WearingTone, + common::{ + state::Update, + structures::{ + AutoPowerOff, CaseBatteryLevel, CommonEqualizerConfiguration, CustomHearId, + DualBattery, DualConnections, DualConnectionsDevice, DualFirmwareVersion, Ldac, + LowBatteryPrompt, SerialNumber, TwsStatus, WearingDetection, WearingTone, + }, }, }; @@ -22,7 +25,7 @@ pub struct A3953State { wearing_detection: WearingDetection, case_battery_level: CaseBatteryLevel, ldac: Ldac, - support_two_connections: a3953::structures::SupportTwoConnections, + dual_connections: DualConnections, auto_power_off: AutoPowerOff, wearing_tone: WearingTone, low_battery_prompt: LowBatteryPrompt, @@ -31,8 +34,42 @@ pub struct A3953State { press_sensitivity: a3953::structures::PressSensitivity, } -impl From for A3953State { - fn from(packet: a3953::packets::A3953StateUpdatePacket) -> Self { +impl A3953State { + pub fn new( + packet: a3953::packets::A3953StateUpdatePacket, + dual_connections_devices: Vec, + ) -> Self { + Self { + tws_status: packet.tws_status, + battery: packet.battery, + dual_firmware_version: packet.dual_firmware_version, + serial_number: packet.serial_number, + equalizer_configuration: packet.equalizer_configuration, + is_hear_id_initialized: packet.is_hear_id_initialized, + hear_id: packet.hear_id, + sound_modes: packet.sound_modes, + wearing_detection: packet.wearing_detection, + case_battery_level: packet.case_battery_level, + ldac: packet.ldac, + dual_connections: DualConnections { + is_enabled: packet.dual_connections_enabled, + devices: dual_connections_devices, + }, + auto_power_off: packet.auto_power_off, + wearing_tone: packet.wearing_tone, + low_battery_prompt: packet.low_battery_prompt, + ambient_sound_prompt: packet.ambient_sound_prompt, + spatial_audio: packet.spatial_audio, + press_sensitivity: packet.press_sensitivity.unwrap_or_default(), + } + } +} + +// Custom instead of the usual blanket From-based Update: a full state update packet doesn't carry +// the dual connections device list (that's fetched separately), so a plain replace would wipe it +// out on every subsequent state refresh. Only dual_connections.is_enabled gets refreshed here. +impl Update for A3953State { + fn update(&mut self, packet: a3953::packets::A3953StateUpdatePacket) { let a3953::packets::A3953StateUpdatePacket { tws_status, battery, @@ -49,7 +86,7 @@ impl From for A3953State { wearing_detection, case_battery_level, ldac, - support_two_connections, + dual_connections_enabled, auto_power_off, wearing_tone, low_battery_prompt, @@ -60,25 +97,23 @@ impl From for A3953State { unknown_suffix: _, } = packet; - Self { - tws_status, - battery, - dual_firmware_version, - serial_number, - equalizer_configuration, - is_hear_id_initialized, - hear_id, - sound_modes, - wearing_detection, - case_battery_level, - ldac, - support_two_connections, - auto_power_off, - wearing_tone, - low_battery_prompt, - ambient_sound_prompt, - spatial_audio, - press_sensitivity: press_sensitivity.unwrap_or_default(), - } + self.tws_status = tws_status; + self.battery = battery; + self.dual_firmware_version = dual_firmware_version; + self.serial_number = serial_number; + self.equalizer_configuration = equalizer_configuration; + self.is_hear_id_initialized = is_hear_id_initialized; + self.hear_id = hear_id; + self.sound_modes = sound_modes; + self.wearing_detection = wearing_detection; + self.case_battery_level = case_battery_level; + self.ldac = ldac; + self.dual_connections.is_enabled = dual_connections_enabled; + self.auto_power_off = auto_power_off; + self.wearing_tone = wearing_tone; + self.low_battery_prompt = low_battery_prompt; + self.ambient_sound_prompt = ambient_sound_prompt; + self.spatial_audio = spatial_audio; + self.press_sensitivity = press_sensitivity.unwrap_or_default(); } } diff --git a/lib/src/devices/soundcore/a3953/structures.rs b/lib/src/devices/soundcore/a3953/structures.rs index a3b8f1f7e..4fd9cadef 100644 --- a/lib/src/devices/soundcore/a3953/structures.rs +++ b/lib/src/devices/soundcore/a3953/structures.rs @@ -388,33 +388,6 @@ impl Flag for AmbientSoundPrompt { } } -// [0x0B,0x84] (Cmm2CmdData.E1/CmmBtCmdService.B0); single bit, not this project's full DualConnections -// device-list feature -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub struct SupportTwoConnections(pub bool); - -impl SupportTwoConnections { - pub fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( - input: &'a [u8], - ) -> IResult<&'a [u8], Self, E> { - map(take_bool, Self).parse_complete(input) - } - - pub fn bytes(&self) -> [u8; 1] { - [u8::from(self.0)] - } -} - -impl Flag for SupportTwoConnections { - fn get_bool(&self) -> bool { - self.0 - } - - fn set_bool(&mut self, value: bool) { - self.0 = value; - } -} - // bArr[63] (A3953AnalysisService.R0's m3); 255 or 254 = uninitialized (unlike a3955, which only // checks one sentinel). Determines whether the equalizer write path sends the "uninitialized" // sentinel bytes or the real hear id already stored on the device. diff --git a/lib/src/devices/soundcore/common/structures/dual_connections.rs b/lib/src/devices/soundcore/common/structures/dual_connections.rs index c9ccfe87f..777a9bb8f 100644 --- a/lib/src/devices/soundcore/common/structures/dual_connections.rs +++ b/lib/src/devices/soundcore/common/structures/dual_connections.rs @@ -32,9 +32,11 @@ impl DualConnectionsDevice { (le_u8, le_u8, le_u8, le_u8, le_u8, le_u8), ) .parse_complete(input)?; - let name_length = length - 8; - // Name, right padded with 0s - let (input, name_bytes) = take(usize::from(name_length)).parse_complete(input)?; + // Name, right padded with 0s. Clamped to what's actually left: real devices have been + // observed sending a lone trailing entry (alone in the final packet of a multi-packet + // response) padded a few bytes short of what its own length byte implies. + let name_length = usize::from(length.saturating_sub(8)).min(input.len()); + let (input, name_bytes) = take(name_length).parse_complete(input)?; let mac_address = MacAddr6::from(<[u8; 6]>::from(mac_address_bytes)); From 8d0ed1d6d0001dc4e550383c084b6bb00434c868 Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Mon, 14 Sep 2026 08:55:22 -0400 Subject: [PATCH 10/22] fix(macos): prevent use-after-free in SDP query delegate on timeout IOBluetoothDevice doesn't retain the target passed to performSDPQuery, and there's no way to cancel a query in flight. The recv_timeout fix from the previous commit made the 10s timeout actually fire for the first time; before that it was effectively unreachable since the wait blocked until the real callback. Now that it's reachable, dropping the delegate normally on timeout risked IOBluetooth calling back into deallocated memory if the query completed afterward. Leak it deliberately on the timeout path instead, bounded to at most once per timed-out query. Co-Authored-By: Claude Sonnet 5 --- lib/src/connection_backend/macos/rfcomm.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/src/connection_backend/macos/rfcomm.rs b/lib/src/connection_backend/macos/rfcomm.rs index 439bf6eb6..7477ac341 100644 --- a/lib/src/connection_backend/macos/rfcomm.rs +++ b/lib/src/connection_backend/macos/rfcomm.rs @@ -257,9 +257,16 @@ fn perform_sdp_query(device: &IOBluetoothDevice) -> connection::Result<()> { source: None, location: Location::caller(), }), - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(connection::Error::TimedOut { - action: "SDP query", - }), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + // IOBluetoothDevice doesn't retain the target passed to performSDPQuery, and there's + // no way to cancel it, so the query may still be in flight here. Dropping delegate + // normally would risk sdpQueryComplete: firing into deallocated memory later; leak it + // instead (bounded: at most once per timed-out query). + std::mem::forget(delegate); + Err(connection::Error::TimedOut { + action: "SDP query", + }) + } Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { Err(connection::Error::DeviceNotFound { source: None, From 632407e071549061c3823856479e49b30340f3f1 Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Mon, 14 Sep 2026 09:20:19 -0400 Subject: [PATCH 11/22] chore(lib): remove dead code and align a3953 with codebase style conventions Audited the branch's deltas for cohesion with the rest of the Soundcore device implementations: - Drop SettingId::SupportTwoConnections and its FTL string, both orphaned once A3953 was rewired onto the shared dual_connections module. - Remove a3953.rs's 20-line top-of-file comment; it only duplicated citations already inline at their actual definition sites. - Match the private `mod structures;` visibility used by every sibling device (was `pub mod structures;`). - Replace three `///` doc comments with `//` or remove them where redundant, matching this project's no-doc-comment convention. - Drop a `let result = ...; result` binding in the macOS write() path to match how windows/linux end the same function; incidentally clears a pre-existing clippy let_and_return warning. Co-Authored-By: Claude Sonnet 5 --- lib/i18n/en/openscq30-lib.ftl | 1 - lib/src/api/settings.rs | 1 - lib/src/connection_backend/macos/rfcomm.rs | 5 ++--- lib/src/devices/soundcore/a3953.rs | 22 +------------------ .../press_sensitivity/state_modifier.rs | 1 - .../modules/spatial_audio/setting_handler.rs | 6 ++--- .../modules/spatial_audio/state_modifier.rs | 1 - 7 files changed, 6 insertions(+), 31 deletions(-) diff --git a/lib/i18n/en/openscq30-lib.ftl b/lib/i18n/en/openscq30-lib.ftl index 100a3b50b..a31ebd2c3 100644 --- a/lib/i18n/en/openscq30-lib.ftl +++ b/lib/i18n/en/openscq30-lib.ftl @@ -210,7 +210,6 @@ low-battery-prompt = Low Battery Prompt wearing-detection = Wearing Detection press-sensitivity = Press Sensitivity ambient-sound-prompt = Ambient Sound Prompt -support-two-connections = Two Simultaneous Connections voice-prompt = Voice Prompt side-tone = Side Tone dolby-audio = Dolby Audio diff --git a/lib/src/api/settings.rs b/lib/src/api/settings.rs index fac6dcb66..158f3ca46 100644 --- a/lib/src/api/settings.rs +++ b/lib/src/api/settings.rs @@ -145,7 +145,6 @@ pub enum SettingId { WearingDetection, PressSensitivity, AmbientSoundPrompt, - SupportTwoConnections, Volume, VoicePrompt, AncPersonalizedToEarCanal, diff --git a/lib/src/connection_backend/macos/rfcomm.rs b/lib/src/connection_backend/macos/rfcomm.rs index 7477ac341..4507cdc3a 100644 --- a/lib/src/connection_backend/macos/rfcomm.rs +++ b/lib/src/connection_backend/macos/rfcomm.rs @@ -608,14 +608,13 @@ impl RfcommConnection for MacosRfcommConnection { source: Box::new(err), location: Location::caller(), })?; - let result = tokio::task::spawn_blocking(move || response_receiver.recv()) + tokio::task::spawn_blocking(move || response_receiver.recv()) .await .expect("connection thread panicked while handling a write") .map_err(|err| connection::Error::Other { source: Box::new(err), location: Location::caller(), - })?; - result + })? } fn read_channel(&self) -> mpsc::Receiver> { diff --git a/lib/src/devices/soundcore/a3953.rs b/lib/src/devices/soundcore/a3953.rs index 10c5f71b3..b71649897 100644 --- a/lib/src/devices/soundcore/a3953.rs +++ b/lib/src/devices/soundcore/a3953.rs @@ -16,28 +16,8 @@ use crate::devices::soundcore::{ mod modules; mod packets; mod state; -pub mod structures; +mod structures; -// Battery, dual firmware version, serial number, ambient sound mode, wind noise suppression, wear -// detection, case battery level, LDAC, auto power off, wearing tone (the app calls it "in ear -// beep"), low battery prompt, ambient sound prompt, spatial audio, press sensitivity, and custom -// equalizer have all been reverse-engineered against the official app's decompiled source (see -// packets::state_update, structures.rs, packets::set_equalizer_configuration). Dual connections -// (multi-device pairing list) reuses this project's shared common::modules::dual_connections -// wholesale: the enabled bit is the same command A3953CmdService.B0 sends -// ([0x0B,0x84]/Cmm2CmdData.E1), and a real capture confirmed the [0x0B,0x01] device list -// request/response matches common::structures::DualConnectionsDevice byte for byte. Button -// configuration is parsed, and each assignment's -// action ID is now decoded to a name (see structures::ButtonAction), but it's still not exposed as -// a setting: no outbound command that writes a button assignment back was found anywhere in the -// decompiled source, for this device or any other device family that shares the same -// `ControllerBtnModel` read path. -// "Bass up" (a device-family-wide EQ model, `BaseDefaultEqHasBassUpM`) has no confirmed link to -// A3953's own equalizer data (`A3953EqData`, `A3952EqM extends BaseM` directly, not the bass-up -// base class) and no located command, so it's left unimplemented. Hear ID (personalized hearing -// profile) is read and round-tripped through equalizer writes unchanged, but not exposed for -// editing, matching this project's own `a3947`/`a3955` devices, which share this exact command -// family and byte layout (down to the DRC coefficients). soundcore_device!( A3953State, async |packet_io| { diff --git a/lib/src/devices/soundcore/a3953/modules/press_sensitivity/state_modifier.rs b/lib/src/devices/soundcore/a3953/modules/press_sensitivity/state_modifier.rs index 39981a6b7..abf1410ed 100644 --- a/lib/src/devices/soundcore/a3953/modules/press_sensitivity/state_modifier.rs +++ b/lib/src/devices/soundcore/a3953/modules/press_sensitivity/state_modifier.rs @@ -14,7 +14,6 @@ use crate::{ }, }; -/// Command cited in a3953/structures.rs's `PressSensitivity` doc comment. const COMMAND: packet::Command = packet::Command([0x04, 0x85]); pub struct PressSensitivityStateModifier { diff --git a/lib/src/devices/soundcore/a3953/modules/spatial_audio/setting_handler.rs b/lib/src/devices/soundcore/a3953/modules/spatial_audio/setting_handler.rs index 14ce746bc..fb4cab4dd 100644 --- a/lib/src/devices/soundcore/a3953/modules/spatial_audio/setting_handler.rs +++ b/lib/src/devices/soundcore/a3953/modules/spatial_audio/setting_handler.rs @@ -12,9 +12,9 @@ use crate::{ use super::SpatialAudioSetting; -/// `SpatialAudioMode` maps to `sound_mode` (Music/Movie) and `SpatialAudioMusicMode` maps to -/// `effect_mode` (Fixed/HeadTracking), matching the naming this project already uses for A3954's -/// otherwise-unrelated (differently encoded) spatial audio feature. +// SpatialAudioMode maps to sound_mode (Music/Movie) and SpatialAudioMusicMode maps to effect_mode +// (Fixed/HeadTracking), matching the naming this project already uses for A3954's +// otherwise-unrelated (differently encoded) spatial audio feature. #[derive(Default)] pub struct SpatialAudioSettingHandler; diff --git a/lib/src/devices/soundcore/a3953/modules/spatial_audio/state_modifier.rs b/lib/src/devices/soundcore/a3953/modules/spatial_audio/state_modifier.rs index 63b7428fd..c0d878416 100644 --- a/lib/src/devices/soundcore/a3953/modules/spatial_audio/state_modifier.rs +++ b/lib/src/devices/soundcore/a3953/modules/spatial_audio/state_modifier.rs @@ -14,7 +14,6 @@ use crate::{ }, }; -/// Command cited in a3953/structures.rs's `SpatialAudio` doc comment. const COMMAND: packet::Command = packet::Command([0x10, 0x81]); pub struct SpatialAudioStateModifier { From c93fd2cf3c4610fa2424574161af3347c51ef85e Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Mon, 14 Sep 2026 10:59:57 -0400 Subject: [PATCH 12/22] feat(lib): add ambient-sound-mode-cycle and button config write support for the A3953 - Corrected two "no write command found" claims from earlier reverse- engineering: both commands are generic, un-overridden methods on the shared CmmBtCmdService base class, not per-device overrides, so grepping A3953CmdService alone for them found nothing. ambient-sound-mode-cycle now wires into the same shared builder method 10 other devices use. Button config is rewired from a hand-rolled read-only struct onto the shared common::modules::button_configuration machinery, using the same button_id convention already verified on 4+ other devices and traced byte-for-byte through the decompiled CmmBtCmdService.t2 and its ViewModel caller. Both are confirmed end to end on real hardware, including a physical button-behavior check for button config, not just protocol-level. - Also closes out a full-implementation audit of every A3953StateUpdatePacket field: one more candidate (adjustable ANC strength) was checked and ruled out, confirmed on real hardware that the official app doesn't expose it for this device. - Adds a CHANGELOG entry for both macOS support and the Liberty 4. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 9 + lib/src/devices/soundcore/a3953.rs | 85 ++++++++- .../soundcore/a3953/packets/state_update.rs | 51 ++++- lib/src/devices/soundcore/a3953/state.rs | 20 +- lib/src/devices/soundcore/a3953/structures.rs | 174 +----------------- 5 files changed, 161 insertions(+), 178 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07e6d4cba..34ec953d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +### General + +#### Features + +- Add support for Soundcore Liberty 4 (A3953) +- Add macOS support (beta) + ## v2.12.0 ### General diff --git a/lib/src/devices/soundcore/a3953.rs b/lib/src/devices/soundcore/a3953.rs index b71649897..50b456615 100644 --- a/lib/src/devices/soundcore/a3953.rs +++ b/lib/src/devices/soundcore/a3953.rs @@ -5,11 +5,20 @@ use crate::devices::soundcore::{ common::{ self, macros::soundcore_device, - modules::{auto_power_off::AutoPowerOffDuration, equalizer::common_settings_type_2}, + modules::{ + auto_power_off::AutoPowerOffDuration, + button_configuration::{ + ButtonConfigurationSettings, ButtonDisableMode, ButtonSettings, COMMON_ACTIONS, + }, + equalizer::common_settings_type_2, + }, packet::{ inbound::TryToPacket, outbound::{RequestState, ToPacket}, }, + structures::button_configuration::{ + ActionKind, Button, ButtonParseSettings, ButtonPressKind, EnabledFlagKind, + }, }, }; @@ -18,6 +27,74 @@ mod packets; mod state; mod structures; +// button_id per press kind (2/0/1/5 for single/double/long/triple) matches this project's +// existing convention for every other device on this shared command, and was cross-checked byte +// for byte against CmmBtCmdService.t2's ViewModel caller (BaseControllerSelectVM.sendClickTypeCmd). +// Single-press was confirmed end to end on real hardware (2026-09-14): the write packet decoded +// exactly as predicted (command [4,129], side=0 for left, button_id=2, TwsLowBits-packed action +// byte), the read-back matched, and the physical button's behavior changed on the correct earbud +// only. Double/long/triple use the identical mechanism and the same button_id convention already +// verified on 4+ other devices, but weren't independently exercised on this device. +pub const BUTTON_CONFIGURATION_SETTINGS: ButtonConfigurationSettings<8, 4> = + ButtonConfigurationSettings { + supports_set_all_packet: false, + ignore_enabled_flag: true, + set_button_action_command_override: None, + setting_id_override: None, + order: [ + Button::LeftSinglePress, + Button::RightSinglePress, + Button::LeftDoublePress, + Button::RightDoublePress, + Button::LeftLongPress, + Button::RightLongPress, + Button::LeftTriplePress, + Button::RightTriplePress, + ], + settings: [ + ButtonSettings { + parse_settings: ButtonParseSettings { + enabled_flag_kind: EnabledFlagKind::TwsLowBits, + action_kind: ActionKind::TwsLowBits, + }, + button_id: 2, + press_kind: ButtonPressKind::Single, + available_actions: COMMON_ACTIONS, + disable_mode: ButtonDisableMode::IndividualDisable, + }, + ButtonSettings { + parse_settings: ButtonParseSettings { + enabled_flag_kind: EnabledFlagKind::TwsLowBits, + action_kind: ActionKind::TwsLowBits, + }, + button_id: 0, + press_kind: ButtonPressKind::Double, + available_actions: COMMON_ACTIONS, + disable_mode: ButtonDisableMode::IndividualDisable, + }, + ButtonSettings { + parse_settings: ButtonParseSettings { + enabled_flag_kind: EnabledFlagKind::TwsLowBits, + action_kind: ActionKind::TwsLowBits, + }, + button_id: 1, + press_kind: ButtonPressKind::Long, + available_actions: COMMON_ACTIONS, + disable_mode: ButtonDisableMode::IndividualDisable, + }, + ButtonSettings { + parse_settings: ButtonParseSettings { + enabled_flag_kind: EnabledFlagKind::TwsLowBits, + action_kind: ActionKind::TwsLowBits, + }, + button_id: 5, + press_kind: ButtonPressKind::Triple, + available_actions: COMMON_ACTIONS, + disable_mode: ButtonDisableMode::IndividualDisable, + }, + ], + }; + soundcore_device!( A3953State, async |packet_io| { @@ -43,6 +120,9 @@ soundcore_device!( // similar A3947 (Liberty 4 NC) until a partial-charge capture confirms or corrects this. builder.dual_battery(5); builder.a3953_sound_modes(); + builder.ambient_sound_mode_cycle(); + builder.button_configuration(&BUTTON_CONFIGURATION_SETTINGS); + builder.reset_button_configuration::(RequestState.to_packet()); builder.wearing_detection(); // Same caveat as dual_battery above: the device's own S() clamp permits 0-9, but no // partial-charge capture exists to confirm whether 5 is really this device's max. @@ -115,6 +195,9 @@ mod tests { (SettingId::SerialNumber, "395354C633CCEEE8".into()), (SettingId::AmbientSoundMode, "Normal".into()), (SettingId::WindNoiseSuppression, true.into()), + (SettingId::NormalModeInCycle, false.into()), + (SettingId::TransparencyModeInCycle, true.into()), + (SettingId::NoiseCancelingModeInCycle, true.into()), (SettingId::WearingDetection, true.into()), (SettingId::CaseBatteryLevel, "5/5".into()), (SettingId::Ldac, false.into()), diff --git a/lib/src/devices/soundcore/a3953/packets/state_update.rs b/lib/src/devices/soundcore/a3953/packets/state_update.rs index c002b16f2..085698e68 100644 --- a/lib/src/devices/soundcore/a3953/packets/state_update.rs +++ b/lib/src/devices/soundcore/a3953/packets/state_update.rs @@ -16,14 +16,14 @@ use crate::devices::soundcore::{ structures::{ AmbientSoundModeCycle, AutoPowerOff, CaseBatteryLevel, CommonEqualizerConfiguration, CustomHearId, DualBattery, DualFirmwareVersion, Ldac, LowBatteryPrompt, SerialNumber, - TwsStatus, WearingDetection, WearingTone, + TwsStatus, WearingDetection, WearingTone, button_configuration::ButtonStatusCollection, }, }, }; // Byte offsets cited inline below are from A3953AnalysisService.R0 (com.oceanwing.soundcore // v6.4.0-17); R0's own bArr indices are this packet's index + 9. -#[derive(Debug, Default, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct A3953StateUpdatePacket { pub tws_status: TwsStatus, pub battery: DualBattery, @@ -33,7 +33,7 @@ pub struct A3953StateUpdatePacket { pub is_hear_id_initialized: a3953::structures::IsHearIdInitialized, pub hear_id: CustomHearId<2, 10>, pub custom_length: u8, - pub button_config: a3953::structures::ButtonConfig, + pub button_configuration: ButtonStatusCollection<8>, pub unknown_gap: Vec, pub ambient_sound_mode_cycle: AmbientSoundModeCycle, pub sound_modes: a3953::structures::SoundModes, @@ -51,6 +51,37 @@ pub struct A3953StateUpdatePacket { pub unknown_suffix: Vec, } +impl Default for A3953StateUpdatePacket { + fn default() -> Self { + Self { + tws_status: Default::default(), + battery: Default::default(), + dual_firmware_version: Default::default(), + serial_number: Default::default(), + equalizer_configuration: Default::default(), + is_hear_id_initialized: Default::default(), + hear_id: Default::default(), + custom_length: Default::default(), + button_configuration: a3953::BUTTON_CONFIGURATION_SETTINGS.default_status_collection(), + unknown_gap: Default::default(), + ambient_sound_mode_cycle: Default::default(), + sound_modes: Default::default(), + wearing_detection: Default::default(), + case_battery_level: Default::default(), + ldac: Default::default(), + dual_connections_enabled: Default::default(), + auto_power_off: Default::default(), + wearing_tone: Default::default(), + low_battery_prompt: Default::default(), + ambient_sound_prompt: Default::default(), + spatial_audio: Default::default(), + device_colour: Default::default(), + press_sensitivity: Default::default(), + unknown_suffix: Default::default(), + } + } +} + impl FromPacketBody for A3953StateUpdatePacket { type DirectionMarker = packet::InboundMarker; @@ -73,10 +104,13 @@ impl FromPacketBody for A3953StateUpdatePacket { // bArr[64..112], same wire format as a3955 (DRC coefficients in VolumeAdjustments::apply_drc) let (input, hear_id) = CustomHearId::<2, 10>::take_with_music_genre_at_end(input)?; let (input, custom_length) = le_u8(input)?; // bArr[112]: base offset for fields below - let (input, button_config) = a3953::structures::ButtonConfig::take(input)?; // bArr[113..129] + // bArr[113..129], see a3953::BUTTON_CONFIGURATION_SETTINGS (write confirmed on real hardware) + let (input, button_configuration) = ButtonStatusCollection::take( + a3953::BUTTON_CONFIGURATION_SETTINGS.parse_settings(), + )(input)?; let gap_len = (custom_length as usize).saturating_sub(18); // usually 0; nonzero if custom_length != 18 let (input, unknown_gap) = take(gap_len)(input)?; - let (input, ambient_sound_mode_cycle) = AmbientSoundModeCycle::take(input)?; // read-only, no write command found + let (input, ambient_sound_mode_cycle) = AmbientSoundModeCycle::take(input)?; let (input, sound_modes) = a3953::structures::SoundModes::take(input)?; let (input, _unknown_personal_anc_test_info) = take(6usize)(input)?; // test time/volume/result, always 255/255 seen so far let (input, wearing_detection) = WearingDetection::take(input)?; @@ -112,7 +146,7 @@ impl FromPacketBody for A3953StateUpdatePacket { is_hear_id_initialized, hear_id, custom_length, - button_config, + button_configuration, unknown_gap: unknown_gap.to_vec(), ambient_sound_mode_cycle, sound_modes, @@ -157,7 +191,10 @@ impl ToPacket for A3953StateUpdatePacket { })) .chain(self.hear_id.bytes_with_music_genre_at_end()) .chain(iter::once(self.custom_length)) - .chain(self.button_config.bytes()) + .chain( + self.button_configuration + .bytes(a3953::BUTTON_CONFIGURATION_SETTINGS.parse_settings()), + ) .chain(self.unknown_gap.iter().copied()) .chain(self.ambient_sound_mode_cycle.bytes()) .chain(self.sound_modes.bytes()) diff --git a/lib/src/devices/soundcore/a3953/state.rs b/lib/src/devices/soundcore/a3953/state.rs index 46ac81838..cbcfecbc2 100644 --- a/lib/src/devices/soundcore/a3953/state.rs +++ b/lib/src/devices/soundcore/a3953/state.rs @@ -3,11 +3,13 @@ use openscq30_lib_macros::Has; use crate::devices::soundcore::{ a3953, common::{ + modules::reset_button_configuration::ResetButtonConfigurationPending, state::Update, structures::{ - AutoPowerOff, CaseBatteryLevel, CommonEqualizerConfiguration, CustomHearId, - DualBattery, DualConnections, DualConnectionsDevice, DualFirmwareVersion, Ldac, - LowBatteryPrompt, SerialNumber, TwsStatus, WearingDetection, WearingTone, + AmbientSoundModeCycle, AutoPowerOff, CaseBatteryLevel, CommonEqualizerConfiguration, + CustomHearId, DualBattery, DualConnections, DualConnectionsDevice, DualFirmwareVersion, + Ldac, LowBatteryPrompt, SerialNumber, TwsStatus, WearingDetection, WearingTone, + button_configuration::ButtonStatusCollection, }, }, }; @@ -21,6 +23,7 @@ pub struct A3953State { equalizer_configuration: CommonEqualizerConfiguration<2, 10>, is_hear_id_initialized: a3953::structures::IsHearIdInitialized, hear_id: CustomHearId<2, 10>, + ambient_sound_mode_cycle: AmbientSoundModeCycle, sound_modes: a3953::structures::SoundModes, wearing_detection: WearingDetection, case_battery_level: CaseBatteryLevel, @@ -32,6 +35,8 @@ pub struct A3953State { ambient_sound_prompt: a3953::structures::AmbientSoundPrompt, spatial_audio: a3953::structures::SpatialAudio, press_sensitivity: a3953::structures::PressSensitivity, + button_configuration: ButtonStatusCollection<8>, + button_reset_pending: ResetButtonConfigurationPending, } impl A3953State { @@ -47,6 +52,7 @@ impl A3953State { equalizer_configuration: packet.equalizer_configuration, is_hear_id_initialized: packet.is_hear_id_initialized, hear_id: packet.hear_id, + ambient_sound_mode_cycle: packet.ambient_sound_mode_cycle, sound_modes: packet.sound_modes, wearing_detection: packet.wearing_detection, case_battery_level: packet.case_battery_level, @@ -61,6 +67,8 @@ impl A3953State { ambient_sound_prompt: packet.ambient_sound_prompt, spatial_audio: packet.spatial_audio, press_sensitivity: packet.press_sensitivity.unwrap_or_default(), + button_configuration: packet.button_configuration, + button_reset_pending: ResetButtonConfigurationPending::default(), } } } @@ -79,9 +87,9 @@ impl Update for A3953State { is_hear_id_initialized, hear_id, custom_length: _, - button_config: _, + button_configuration, unknown_gap: _, - ambient_sound_mode_cycle: _, + ambient_sound_mode_cycle, sound_modes, wearing_detection, case_battery_level, @@ -104,6 +112,7 @@ impl Update for A3953State { self.equalizer_configuration = equalizer_configuration; self.is_hear_id_initialized = is_hear_id_initialized; self.hear_id = hear_id; + self.ambient_sound_mode_cycle = ambient_sound_mode_cycle; self.sound_modes = sound_modes; self.wearing_detection = wearing_detection; self.case_battery_level = case_battery_level; @@ -115,5 +124,6 @@ impl Update for A3953State { self.ambient_sound_prompt = ambient_sound_prompt; self.spatial_audio = spatial_audio; self.press_sensitivity = press_sensitivity.unwrap_or_default(); + self.button_configuration = button_configuration; } } diff --git a/lib/src/devices/soundcore/a3953/structures.rs b/lib/src/devices/soundcore/a3953/structures.rs index 4fd9cadef..52742ae3f 100644 --- a/lib/src/devices/soundcore/a3953/structures.rs +++ b/lib/src/devices/soundcore/a3953/structures.rs @@ -12,7 +12,11 @@ use crate::devices::soundcore::common::{ }; // [0x06,0x81] outbound (A3953CmdService.G1) / A3953AnalysisService.T2 inbound; anc_option_manual -// clamped 1-6, trans_option 0-2 (write only), other fields raw; wind_noise.is_detected never sent back +// clamped 1-6, trans_option 0-2 (write only), other fields raw; wind_noise.is_detected never sent +// back. The official app only shows 3 ambient sound modes for this device (Normal/Transparency/ +// Noise Canceling), no adjustable ANC strength — confirmed by direct inspection on real hardware +// (2026-09-14) — so anc_option_manual/anc_option_auto/anc_automation_mode/anc_auto_sensitivity_level +// and trans_option are preserved on write but deliberately not exposed as settings. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct SoundModes { pub ambient_sound_mode: AmbientSoundMode, @@ -82,170 +86,10 @@ impl ToPacketBody for SoundModes { } } -// Wire IDs/names from DeviceInfoUtil.t(cmdName), cross-checked against a real capture (PlayPause=6, -// Next/AmbientSoundModeCycle=3/4). 10 and 14 are each shared by two differently-named cmdNames -// (TakePhotoOrTranslate, StartSleepOrColorfulLight), so those pairs can't be told apart from the wire -// value alone; 7/9/12 are unused. Read-only: no write command found for any device family. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub enum ButtonAction { - VolumeUp, - VolumeDown, - Previous, - Next, - AmbientSoundModeCycle, - VoiceAssistant, - PlayPause, - AncSwitch, - TakePhotoOrTranslate, - AskAnswer, - SwitchMode, - StartSleepOrColorfulLight, - #[default] - None, - Unknown(u8), -} - -impl ButtonAction { - fn from_nibble(nibble: u8) -> Self { - match nibble { - 0 => Self::VolumeUp, - 1 => Self::VolumeDown, - 2 => Self::Previous, - 3 => Self::Next, - 4 => Self::AmbientSoundModeCycle, - 5 => Self::VoiceAssistant, - 6 => Self::PlayPause, - 8 => Self::AncSwitch, - 10 => Self::TakePhotoOrTranslate, - 11 => Self::AskAnswer, - 13 => Self::SwitchMode, - 14 => Self::StartSleepOrColorfulLight, - 15 => Self::None, - other => Self::Unknown(other), - } - } - - fn to_nibble(self) -> u8 { - match self { - Self::VolumeUp => 0, - Self::VolumeDown => 1, - Self::Previous => 2, - Self::Next => 3, - Self::AmbientSoundModeCycle => 4, - Self::VoiceAssistant => 5, - Self::PlayPause => 6, - Self::AncSwitch => 8, - Self::TakePhotoOrTranslate => 10, - Self::AskAnswer => 11, - Self::SwitchMode => 13, - Self::StartSleepOrColorfulLight => 14, - Self::None => 15, - Self::Unknown(nibble) => nibble, - } - } -} - -// Two nibble-packed bytes (A3953AnalysisService.G0); untws_* apply when used solo, unprefixed apply -// when TWS-connected. See ButtonAction for what action/untws_action mean. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub struct ButtonAssignment { - pub untws_enabled: bool, - pub enabled: bool, - pub untws_action: ButtonAction, - pub action: ButtonAction, -} - -impl ButtonAssignment { - pub fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( - input: &'a [u8], - ) -> IResult<&'a [u8], Self, E> { - map((le_u8, le_u8), |(switch_byte, action_byte)| Self { - untws_enabled: switch_byte >> 4 == 1, - enabled: switch_byte & 0x0F == 1, - untws_action: ButtonAction::from_nibble(action_byte >> 4), - action: ButtonAction::from_nibble(action_byte & 0x0F), - }) - .parse_complete(input) - } - - pub fn bytes(&self) -> [u8; 2] { - [ - (u8::from(self.untws_enabled) << 4) | u8::from(self.enabled), - ((self.untws_action.to_nibble() & 0x0F) << 4) | (self.action.to_nibble() & 0x0F), - ] - } -} - -// bArr[113..129] (A3953AnalysisService.R0/G0); field order is single/double/long/triple, unlike -// this project's other Soundcore devices (single/double/triple/long). -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub struct ButtonConfig { - pub left_single: ButtonAssignment, - pub right_single: ButtonAssignment, - pub left_double: ButtonAssignment, - pub right_double: ButtonAssignment, - pub left_long: ButtonAssignment, - pub right_long: ButtonAssignment, - pub left_triple: ButtonAssignment, - pub right_triple: ButtonAssignment, -} - -impl ButtonConfig { - pub fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( - input: &'a [u8], - ) -> IResult<&'a [u8], Self, E> { - context( - "a3953 button config", - map( - ( - ButtonAssignment::take, - ButtonAssignment::take, - ButtonAssignment::take, - ButtonAssignment::take, - ButtonAssignment::take, - ButtonAssignment::take, - ButtonAssignment::take, - ButtonAssignment::take, - ), - |( - left_single, - right_single, - left_double, - right_double, - left_long, - right_long, - left_triple, - right_triple, - )| Self { - left_single, - right_single, - left_double, - right_double, - left_long, - right_long, - left_triple, - right_triple, - }, - ), - ) - .parse_complete(input) - } - - pub fn bytes(&self) -> impl Iterator { - [ - self.left_single, - self.right_single, - self.left_double, - self.right_double, - self.left_long, - self.right_long, - self.left_triple, - self.right_triple, - ] - .into_iter() - .flat_map(|button| button.bytes()) - } -} +// bArr[113..129] (A3953AnalysisService.R0/G0) wired into the shared +// common::modules::button_configuration machinery instead of a hand-rolled read-only struct. +// button_id per press kind and the TwsLowBits nibble packing were confirmed byte-for-byte on real +// hardware (2026-09-14). See a3953.rs's BUTTON_CONFIGURATION_SETTINGS for the write confirmation. // [0x04,0x85] (Cmm2CmdData.L0/CmmBtCmdService.O); 0-4, no known display labels (string table wasn't decoded) #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] From 1148180e80411a5ce9baf0b9f0762ca560bf7c26 Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Mon, 14 Sep 2026 11:24:26 -0400 Subject: [PATCH 13/22] docs(lib): document the a3953 device_colour byte's meaning Traced it through the decompiled app: it's a single ASCII character, not a numeric id. Confirmed '2' = Cloud White against a real capture; the other three retail colors are unconfirmed, so they're left undocumented rather than guessed. Not exposed as a setting, matching every other device in this project (none of which expose case color). Co-Authored-By: Claude Sonnet 5 --- lib/src/devices/soundcore/a3953/packets/state_update.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/src/devices/soundcore/a3953/packets/state_update.rs b/lib/src/devices/soundcore/a3953/packets/state_update.rs index 085698e68..d2ff16bb5 100644 --- a/lib/src/devices/soundcore/a3953/packets/state_update.rs +++ b/lib/src/devices/soundcore/a3953/packets/state_update.rs @@ -126,7 +126,10 @@ impl FromPacketBody for A3953StateUpdatePacket { let (input, ambient_sound_prompt) = a3953::structures::AmbientSoundPrompt::take(input)?; let (input, spatial_audio) = a3953::structures::SpatialAudio::take(input)?; let (input, _unknown_health_and_gap) = take(5usize)(input)?; // daily-care health fields, all-zero on this earbud - // device_colour/press_sensitivity only present when the body is >154 bytes (R0's own length check) + // device_colour/press_sensitivity only present when the body is >154 bytes (R0's own + // length check). device_colour is an ASCII char, not a numeric id; '2' = Cloud White + // confirmed on real hardware, other colors unconfirmed. Not exposed: no device in this + // project exposes case color. let (input, (device_colour, press_sensitivity)) = if total_len > 154 { let (input, colour_byte) = le_u8(input)?; let (input, press_sensitivity) = a3953::structures::PressSensitivity::take(input)?; From 53c29edfe6cb8646487f0f622553ed56294bb3a3 Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Mon, 14 Sep 2026 11:58:56 -0400 Subject: [PATCH 14/22] feat(lib): expose TWS status and host device for the A3953 Found via a feature-parity audit against the A3947 and A3954: tws_status is already parsed from every state packet, since other features depend on it, but nothing exposed it as a setting. Wire up the existing shared module, matching both siblings. Confirmed on the real capture: Connected, host device Right. Co-Authored-By: Claude Sonnet 5 --- lib/src/devices/soundcore/a3953.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/src/devices/soundcore/a3953.rs b/lib/src/devices/soundcore/a3953.rs index 50b456615..25884a400 100644 --- a/lib/src/devices/soundcore/a3953.rs +++ b/lib/src/devices/soundcore/a3953.rs @@ -115,6 +115,7 @@ soundcore_device!( async |builder| { builder.module_collection().add_state_update(); + builder.tws_status(); builder.serial_number_and_dual_firmware_version(); // Only ever observed at 5/5 (fully charged); assumed max_level 5 to match the structurally // similar A3947 (Liberty 4 NC) until a partial-charge capture confirms or corrects this. @@ -186,6 +187,8 @@ mod tests { .await; device.assert_setting_values([ + (SettingId::TwsStatus, "Connected".into()), + (SettingId::HostDevice, "Right".into()), (SettingId::BatteryLevelLeft, "5/5".into()), (SettingId::BatteryLevelRight, "5/5".into()), (SettingId::IsChargingLeft, "No".into()), From 279d3e242a4a51224874b7cacfde2284f02ef503 Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Mon, 14 Sep 2026 12:30:44 -0400 Subject: [PATCH 15/22] fix(macos): force reconnect cycle when already connected; reorder a3953 device info - ensure_connected() now always closes an existing baseband connection before reopening it, instead of no-opping when the device is already connected. openRFCOMMChannelAsync can otherwise leave the channel permanently stuck "not open" if the device was already connected before connect() ran, confirmed to affect the CLI and GUI equally. Trade-off: an audible disconnect/reconnect on every connect now, not just when this bug would otherwise hit. - Also swaps the A3953's serial number/firmware and TWS status ordering on the device information page to match the A3947 (settings display in builder registration order, not sorted). Co-Authored-By: Claude Sonnet 5 --- lib/src/connection_backend/macos/rfcomm.rs | 13 +++++++++++-- lib/src/devices/soundcore/a3953.rs | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/src/connection_backend/macos/rfcomm.rs b/lib/src/connection_backend/macos/rfcomm.rs index 4507cdc3a..908452de8 100644 --- a/lib/src/connection_backend/macos/rfcomm.rs +++ b/lib/src/connection_backend/macos/rfcomm.rs @@ -149,11 +149,20 @@ fn device_with_mac_address( ) } +// A device that's already connected (e.g. auto-reconnected for audio, or from a previous +// session) can leave openRFCOMMChannelAsync stuck in "not open" forever (see +// MACOS_RFCOMM_BRIEFING.md's "Known limitation" section); the only known workaround is closing +// the existing connection first, so this always cycles it rather than no-opping when already +// connected. Audibly disconnects/reconnects the device (e.g. an audio chime) on every connect. fn ensure_connected(device: &IOBluetoothDevice) -> connection::Result<()> { if unsafe { device.isConnected() } { - return Ok(()); + debug!("device already connected, closing first to avoid a stuck RFCOMM channel"); + let close_status = unsafe { device.closeConnection() }; + if close_status != 0 { + debug!("closeConnection returned status {close_status}, opening anyway"); + } } - debug!("device not connected, opening baseband connection"); + debug!("opening baseband connection"); let status = unsafe { device.openConnection() }; if status != 0 { return Err(connection::Error::DeviceNotFound { diff --git a/lib/src/devices/soundcore/a3953.rs b/lib/src/devices/soundcore/a3953.rs index 25884a400..469a76651 100644 --- a/lib/src/devices/soundcore/a3953.rs +++ b/lib/src/devices/soundcore/a3953.rs @@ -115,8 +115,8 @@ soundcore_device!( async |builder| { builder.module_collection().add_state_update(); - builder.tws_status(); builder.serial_number_and_dual_firmware_version(); + builder.tws_status(); // Only ever observed at 5/5 (fully charged); assumed max_level 5 to match the structurally // similar A3947 (Liberty 4 NC) until a partial-charge capture confirms or corrects this. builder.dual_battery(5); From a58a28efc90bcec135dd8b8ccca0a16ee39e4d04 Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Mon, 14 Sep 2026 12:40:22 -0400 Subject: [PATCH 16/22] docs(macos): drop a reference to an untracked file from a code comment - ensure_connected's comment named MACOS_RFCOMM_BRIEFING.md directly, an untracked, uncommitted local doc that doesn't ship with this project. Removed the reference, kept the explanation. Co-Authored-By: Claude Sonnet 5 --- lib/src/connection_backend/macos/rfcomm.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/src/connection_backend/macos/rfcomm.rs b/lib/src/connection_backend/macos/rfcomm.rs index 908452de8..50c623700 100644 --- a/lib/src/connection_backend/macos/rfcomm.rs +++ b/lib/src/connection_backend/macos/rfcomm.rs @@ -150,10 +150,10 @@ fn device_with_mac_address( } // A device that's already connected (e.g. auto-reconnected for audio, or from a previous -// session) can leave openRFCOMMChannelAsync stuck in "not open" forever (see -// MACOS_RFCOMM_BRIEFING.md's "Known limitation" section); the only known workaround is closing -// the existing connection first, so this always cycles it rather than no-opping when already -// connected. Audibly disconnects/reconnects the device (e.g. an audio chime) on every connect. +// session) can leave openRFCOMMChannelAsync stuck in "not open" forever; the only known +// workaround is closing the existing connection first, so this always cycles it rather than +// no-opping when already connected. Audibly disconnects/reconnects the device (e.g. an audio +// chime) on every connect. fn ensure_connected(device: &IOBluetoothDevice) -> connection::Result<()> { if unsafe { device.isConnected() } { debug!("device already connected, closing first to avoid a stuck RFCOMM channel"); From 6ffc1f5081d25c7425a1ecf215a57b247d9d01c5 Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Thu, 17 Sep 2026 10:47:00 -0400 Subject: [PATCH 17/22] fix(macos): split RFCOMM writes at the channel MTU writeSync:length: must not be called with more bytes than the channel's negotiated MTU, per IOBluetoothRFCOMMChannel's own documented contract. Our do_write sent the whole buffer in one call with no MTU check at all. Credit to afrijaldz's competing macOS PR (#374) for flagging this; splits do_write into an MTU-chunking outer loop plus a small per-chunk write_chunk helper carrying the existing not-open-yet retry logic unchanged. Co-Authored-By: Afrijal Dzuhri Co-Authored-By: Claude Sonnet 5 --- lib/src/connection_backend/macos/rfcomm.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/src/connection_backend/macos/rfcomm.rs b/lib/src/connection_backend/macos/rfcomm.rs index 50c623700..ff8b79f1d 100644 --- a/lib/src/connection_backend/macos/rfcomm.rs +++ b/lib/src/connection_backend/macos/rfcomm.rs @@ -494,8 +494,22 @@ fn pump_run_loop() { } } +// writeSync:length: must not be called with more bytes than the channel's negotiated MTU (per +// IOBluetoothRFCOMMChannel's own documented contract). MTU can read back as 0 in the brief +// window right after connecting before it's negotiated (see WRITE_NOT_OPEN_RETRIES below); +// fall back to one unsplit write in that case rather than guessing a default. fn do_write(channel: &IOBluetoothRFCOMMChannel, data: &[u8]) -> connection::Result<()> { - let mut buffer = data.to_vec(); + let mtu = usize::from(unsafe { channel.getMTU() }); + let chunk_size = if mtu == 0 { data.len().max(1) } else { mtu }; + for chunk in data.chunks(chunk_size) { + write_chunk(channel, chunk)?; + } + trace!("wrote packet: {data:?}"); + Ok(()) +} + +fn write_chunk(channel: &IOBluetoothRFCOMMChannel, chunk: &[u8]) -> connection::Result<()> { + let mut buffer = chunk.to_vec(); let mut status; let mut attempt = 0; loop { @@ -520,7 +534,6 @@ fn do_write(channel: &IOBluetoothRFCOMMChannel, data: &[u8]) -> connection::Resu location: Location::caller(), }); } - trace!("wrote packet: {buffer:?}"); Ok(()) } From 404318dee52370bb94a574a89960ad67deb41e99 Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Thu, 17 Sep 2026 11:02:35 -0400 Subject: [PATCH 18/22] fix(macos): fix app bundle showing wrong Kind in Finder, add ad-hoc signing The .app bundle had no LSMinimumSystemVersion/CFBundleSupportedPlatforms and was never signed, which Launch Services was misidentifying as an iOS app instead of a native macOS one. Adds both plist keys and an ad-hoc codesign step in build.sh after bundle assembly. Ad-hoc signing fixes the local build/Kind issue but isn't notarization: downloaded zips still need right-click -> Open, docs updated to reflect that. afrijaldz's competing macOS PR (#376) independently landed the same fix. Co-Authored-By: Afrijal Dzuhri Co-Authored-By: Claude Sonnet 5 --- docs/build-macos.md | 2 +- packaging/macos/Info.plist | 6 ++++++ packaging/macos/build.sh | 5 +++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/build-macos.md b/docs/build-macos.md index bdb434d3e..1cad3e3e5 100644 --- a/docs/build-macos.md +++ b/docs/build-macos.md @@ -21,4 +21,4 @@ 3. Run `just build-gui-bundle` 4. The app bundle can be found at `build-output/OpenSCQ30.app` -The app bundle isn't code signed or notarized, so macOS will refuse to open it with a plain double click. Right click the app and choose "Open" instead, then confirm in the dialog that appears. +The app bundle is ad-hoc signed but not notarized. A locally built copy should open normally. A copy downloaded from elsewhere (e.g. a release zip) will still have the quarantine flag set, so macOS will refuse to open it with a plain double click; right click the app and choose "Open" instead, then confirm in the dialog that appears. diff --git a/packaging/macos/Info.plist b/packaging/macos/Info.plist index 7fc81fecb..ca93e375b 100644 --- a/packaging/macos/Info.plist +++ b/packaging/macos/Info.plist @@ -16,6 +16,12 @@ openscq30-gui CFBundlePackageType APPL + LSMinimumSystemVersion + 11.0 + CFBundleSupportedPlatforms + + MacOSX + NSHighResolutionCapable NSBluetoothAlwaysUsageDescription diff --git a/packaging/macos/build.sh b/packaging/macos/build.sh index f3cdf0378..9de742738 100755 --- a/packaging/macos/build.sh +++ b/packaging/macos/build.sh @@ -13,3 +13,8 @@ mkdir -p "$app_dir/Contents/MacOS" cp "$script_dir/Info.plist" "$app_dir/Contents/Info.plist" cp "$input_binary" "$app_dir/Contents/MacOS/openscq30-gui" chmod +x "$app_dir/Contents/MacOS/openscq30-gui" + +# Ad-hoc signature so Launch Services identifies the bundle correctly and Gatekeeper allows +# running a locally built app. Not a substitute for real notarization: downloaded (quarantined) +# builds still need right-click -> Open. +codesign --force --sign - "$app_dir" From b7166ae2209b29d728c696ade5b5583378fb8fa7 Mon Sep 17 00:00:00 2001 From: Afrijal Dzuhri Date: Thu, 17 Sep 2026 23:50:44 +0700 Subject: [PATCH 19/22] fix(macos): bundle sqlite so the CLI/GUI start on macOS 15 The system sqlite on macOS 15 is 3.43, which lacks the two-argument json_valid() used by the quick_preset migration, so the storage layer fails to open with "malformed database schema". Windows and Android already build with the bundled sqlite for the same reason. --- lib/Cargo.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 54241e3af..3bfd81ddc 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -69,6 +69,8 @@ rusqlite = { workspace = true, features = ["bundled"] } rust-embed = { workspace = true, features = ["debug-embed"] } [target.'cfg(target_os = "macos")'.dependencies] +# The system sqlite on macOS 15 (3.43) is too old for the migrations (json_valid with 2 args needs 3.45+) +rusqlite = { workspace = true, features = ["bundled"] } tokio = { workspace = true, features = ["rt-multi-thread"] } objc2 = { workspace = true } objc2-foundation = { workspace = true } From 48c00a2fc0a6a47bea46b32030a0ca89073ea7f8 Mon Sep 17 00:00:00 2001 From: Afrijal Dzuhri Date: Thu, 17 Sep 2026 23:50:44 +0700 Subject: [PATCH 20/22] fix(macos): wait for rfcommChannelOpenComplete and only cycle the baseband connection as a fallback On macOS 15 (Apple Silicon, Soundcore R50i) every other connect failed with kIOReturnNotOpen after exhausting the write retries. closeConnection is asynchronous, so calling openConnection/openRFCOMMChannelAsync right after it (which ensure_connected did whenever the device was already connected) raced the teardown and the channel never finished opening. When that failed the device was left disconnected, so the next attempt took the plain openConnection path and succeeded, hence the alternation. - Reuse an existing baseband connection instead of cycling it on every connect. This also removes the audible disconnect/reconnect and cuts connect time from ~3.5s to ~0.5s when the device is already connected. - Wait for rfcommChannelOpenComplete (with a timeout) before reporting the channel as open, rather than finding out on the first write. Only the callback is trusted: isOpen() flips to true a few hundred ms earlier and writes in that window fail. - Keep the baseband cycle as a fallback for when the open times out on a pre-existing connection (the stuck-channel case), and wait for isConnected() to go false before reopening so the cycle actually works. Tested on macOS 15.4 / M-series / Soundcore R50i: 15/15 back-to-back connects + EQ writes (4 of them recovered via the fallback), 10/10 with a 1s gap, EQ presets and button config verified to persist from a fresh process. --- lib/src/connection_backend/macos/rfcomm.rs | 130 +++++++++++++++++---- 1 file changed, 110 insertions(+), 20 deletions(-) diff --git a/lib/src/connection_backend/macos/rfcomm.rs b/lib/src/connection_backend/macos/rfcomm.rs index ff8b79f1d..e91fe263e 100644 --- a/lib/src/connection_backend/macos/rfcomm.rs +++ b/lib/src/connection_backend/macos/rfcomm.rs @@ -41,6 +41,16 @@ const WRITE_NOT_OPEN_RETRY_DELAY: std::time::Duration = std::time::Duration::fro const SDP_QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +// How long to wait for rfcommChannelOpenComplete after openRFCOMMChannelAsync returns success. +// Observed at ~0.5-1s on macOS 15 when it succeeds; when the channel is stuck it never arrives. +const RFCOMM_OPEN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +// closeConnection is asynchronous: isConnected() stays true for a while afterwards (~0.5s +// observed on macOS 15), and calling openConnection/openRFCOMMChannelAsync during that window +// leaves the RFCOMM channel permanently "not open". Bounds how long to wait for it to settle. +const BASEBAND_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +const BASEBAND_CLOSE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50); + // CFRunLoopRun() returns immediately if nothing is scheduled at the moment it's called, so the // connection's event loop re-enters it in short bursts instead of once indefinitely. const RFCOMM_EVENT_POLL_SECONDS: CFTimeInterval = 0.1; @@ -90,15 +100,32 @@ impl RfcommBackend for MacosRfcommBackend { debug!("finding device with desired mac address"); let device = device_with_mac_address(mac_address)?; - debug!("ensuring baseband connection"); - ensure_connected(&device)?; + let was_connected = unsafe { device.isConnected() }; + if !was_connected { + debug!("opening baseband connection"); + open_baseband_connection(&device)?; + } debug!("selecting RFCOMM service"); let channel_id = select_channel_id(&device, &service_selection_strategy)?; debug!("opening RFCOMM channel"); - let connection = MacosRfcommConnection::open(device, channel_id)?; - Ok(Arc::new(connection)) + match MacosRfcommConnection::open(device.clone(), channel_id) { + Ok(connection) => Ok(Arc::new(connection)), + // A pre-existing baseband connection (auto-reconnected for audio, or left + // over from a previous session) can leave the RFCOMM channel stuck in "not + // open" forever. Cycling the baseband connection is the only known + // workaround, but it audibly disconnects/reconnects the device (e.g. an + // audio chime) and costs a few seconds, so it's only done once the fast + // path has demonstrably failed rather than on every connect. + Err(connection::Error::TimedOut { .. }) if was_connected => { + debug!("RFCOMM channel did not open, cycling baseband connection"); + cycle_baseband_connection(&device)?; + debug!("retrying RFCOMM channel open"); + Ok(Arc::new(MacosRfcommConnection::open(device, channel_id)?)) + } + Err(err) => Err(err), + } }, ) .await @@ -149,20 +176,27 @@ fn device_with_mac_address( ) } -// A device that's already connected (e.g. auto-reconnected for audio, or from a previous -// session) can leave openRFCOMMChannelAsync stuck in "not open" forever; the only known -// workaround is closing the existing connection first, so this always cycles it rather than -// no-opping when already connected. Audibly disconnects/reconnects the device (e.g. an audio -// chime) on every connect. -fn ensure_connected(device: &IOBluetoothDevice) -> connection::Result<()> { - if unsafe { device.isConnected() } { - debug!("device already connected, closing first to avoid a stuck RFCOMM channel"); - let close_status = unsafe { device.closeConnection() }; - if close_status != 0 { - debug!("closeConnection returned status {close_status}, opening anyway"); +fn cycle_baseband_connection(device: &IOBluetoothDevice) -> connection::Result<()> { + let close_status = unsafe { device.closeConnection() }; + if close_status != 0 { + debug!("closeConnection returned status {close_status}, opening anyway"); + } + let started_at = std::time::Instant::now(); + while unsafe { device.isConnected() } { + if started_at.elapsed() >= BASEBAND_CLOSE_TIMEOUT { + debug!("baseband connection still open after close timeout, opening anyway"); + break; } + thread::sleep(BASEBAND_CLOSE_POLL_INTERVAL); } - debug!("opening baseband connection"); + debug!( + "baseband connection closed after {:?}", + started_at.elapsed() + ); + open_baseband_connection(device) +} + +fn open_baseband_connection(device: &IOBluetoothDevice) -> connection::Result<()> { let status = unsafe { device.openConnection() }; if status != 0 { return Err(connection::Error::DeviceNotFound { @@ -359,6 +393,7 @@ fn uuid_from_sdp_uuid(sdp_uuid: &IOBluetoothSDPUUID) -> Option { } struct ConnectionState { + open_complete_sender: std::sync::mpsc::Sender, data_sender: mpsc::Sender>, status_sender: watch::Sender, running: Arc, @@ -379,6 +414,7 @@ define_class!( status: c_int, ) { debug!("rfcommChannelOpenComplete status: {status}"); + let _ = self.ivars().open_complete_sender.send(status); } #[unsafe(method(rfcommChannelData:data:length:))] @@ -420,11 +456,13 @@ define_class!( impl RfcommDelegate { fn new( + open_complete_sender: std::sync::mpsc::Sender, data_sender: mpsc::Sender>, status_sender: watch::Sender, running: Arc, ) -> Retained { let this = Self::alloc().set_ivars(ConnectionState { + open_complete_sender, data_sender, running, status_sender, @@ -488,6 +526,40 @@ impl MacosRfcommConnection { } } +// Only the callback counts: isOpen() flips to true a few hundred ms before +// rfcommChannelOpenComplete fires, and writes in that window fail (observed on macOS 15). +fn wait_for_open_complete( + open_complete_receiver: &std::sync::mpsc::Receiver, +) -> connection::Result<()> { + let started_at = std::time::Instant::now(); + loop { + match open_complete_receiver.try_recv() { + Ok(0) => return Ok(()), + Ok(status) => { + debug!("RFCOMM channel failed to open with status {status}"); + return Err(connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + }); + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + return Err(connection::Error::DeviceNotFound { + source: None, + location: Location::caller(), + }); + } + } + if started_at.elapsed() >= RFCOMM_OPEN_TIMEOUT { + debug!("timed out waiting for rfcommChannelOpenComplete"); + return Err(connection::Error::TimedOut { + action: "open RFCOMM channel", + }); + } + pump_run_loop(); + } +} + fn pump_run_loop() { unsafe { CFRunLoop::run_in_mode(kCFRunLoopDefaultMode, RFCOMM_EVENT_POLL_SECONDS, false); @@ -552,7 +624,13 @@ fn run_connection_thread( let span = debug_span!("MacosRfcommConnection connection thread"); let _span_guard = span.enter(); - let delegate = RfcommDelegate::new(data_sender, status_sender, running.clone()); + let (open_complete_sender, open_complete_receiver) = std::sync::mpsc::channel(); + let delegate = RfcommDelegate::new( + open_complete_sender, + data_sender, + status_sender, + running.clone(), + ); let mut channel: Option> = None; let delegate_object: &AnyObject = &delegate; @@ -564,9 +642,6 @@ fn run_connection_thread( ) }; - // openRFCOMMChannelAsync's return here (not the later rfcommChannelOpenComplete callback, - // which is unreliable and only useful for logging) is the actual success signal: the channel - // object is already valid and retained once this returns kIOReturnSuccess. let Some(channel) = channel.filter(|_| start_status == 0) else { debug!("openRFCOMMChannelAsync failed to start with status {start_status}"); let _ = open_result_sender.send(Err(connection::Error::DeviceNotFound { @@ -576,6 +651,21 @@ fn run_connection_thread( return; }; + // openRFCOMMChannelAsync returning kIOReturnSuccess only means the open was started. The + // channel isn't usable until rfcommChannelOpenComplete fires, and if it never does (see + // cycle_baseband_connection) writes fail with kIOReturnNotOpen forever, so wait for it here + // rather than discovering that on the first write. The callback may be delivered on this + // thread's run loop or the main thread's depending on macOS version, so pump ours while + // waiting and let the channel carry the result either way. + if let Err(err) = wait_for_open_complete(&open_complete_receiver) { + unsafe { + channel.setDelegate(None); + channel.closeChannel(); + } + let _ = open_result_sender.send(Err(err)); + return; + } + let _ = open_result_sender.send(Ok(())); // Empirically, channel callbacks (rfcommChannelData etc.) are delivered on whichever thread From 617b0bd30b767f56a08f9dd67f3b48790a7ec3da Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Thu, 17 Sep 2026 16:29:49 -0400 Subject: [PATCH 21/22] fix(macos): fix connection-status, retry, and stuck-channel edge cases - connection_status() no longer sticks at Connected forever after a local disconnect: rfcommChannelClosed: can't fire once the delegate is cleared, so send Disconnected explicitly during cleanup too. - Baseband cycle-and-retry fallback now covers first-time connects, not just reconnects; the underlying race isn't specific to either. - cycle_baseband_connection fails instead of opening into a baseband connection that never finished closing, which would just recreate the stuck-channel bug it exists to work around. - Write retry pumps the run loop instead of blindly sleeping, so a real disconnect during a transient write stall is caught instead of blocked through for up to several seconds. - dual_connections parser logs when a corrupt length byte forces a truncated read, instead of failing silently in that case. - Drop a redundant clamp on PressSensitivity's wire serialization; the only two producers already clamp at their input boundary. - Comment cleanup: drop a stale ponytail marker with no real upgrade path, an iteration-narrative comment, and two em-dashes. Co-Authored-By: Claude --- lib/src/connection_backend/macos/rfcomm.rs | 45 +++++++++++-------- lib/src/devices/soundcore/a3953/structures.rs | 6 +-- .../common/structures/dual_connections.rs | 8 +++- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/lib/src/connection_backend/macos/rfcomm.rs b/lib/src/connection_backend/macos/rfcomm.rs index e91fe263e..be6f5d9b7 100644 --- a/lib/src/connection_backend/macos/rfcomm.rs +++ b/lib/src/connection_backend/macos/rfcomm.rs @@ -37,7 +37,6 @@ const SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID: BluetoothSDPServiceAttributeID = 0x000 // retry budget is generous rather than tight. const IO_RETURN_NOT_OPEN: c_int = 0xE00002CDu32 as c_int; const WRITE_NOT_OPEN_RETRIES: u32 = 50; -const WRITE_NOT_OPEN_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(200); const SDP_QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -112,13 +111,12 @@ impl RfcommBackend for MacosRfcommBackend { debug!("opening RFCOMM channel"); match MacosRfcommConnection::open(device.clone(), channel_id) { Ok(connection) => Ok(Arc::new(connection)), - // A pre-existing baseband connection (auto-reconnected for audio, or left - // over from a previous session) can leave the RFCOMM channel stuck in "not - // open" forever. Cycling the baseband connection is the only known - // workaround, but it audibly disconnects/reconnects the device (e.g. an - // audio chime) and costs a few seconds, so it's only done once the fast - // path has demonstrably failed rather than on every connect. - Err(connection::Error::TimedOut { .. }) if was_connected => { + // The RFCOMM channel can get stuck "not open" forever regardless of whether + // the baseband connection was just opened or already existed. Cycling it is + // the only known workaround, but it audibly disconnects/reconnects the device + // (e.g. an audio chime) and costs a few seconds, so it's only done once the + // fast path has demonstrably failed rather than on every connect. + Err(connection::Error::TimedOut { .. }) => { debug!("RFCOMM channel did not open, cycling baseband connection"); cycle_baseband_connection(&device)?; debug!("retrying RFCOMM channel open"); @@ -184,8 +182,12 @@ fn cycle_baseband_connection(device: &IOBluetoothDevice) -> connection::Result<( let started_at = std::time::Instant::now(); while unsafe { device.isConnected() } { if started_at.elapsed() >= BASEBAND_CLOSE_TIMEOUT { - debug!("baseband connection still open after close timeout, opening anyway"); - break; + // Opening now would race the still-in-flight close: the exact precondition that + // leaves the RFCOMM channel permanently stuck (see BASEBAND_CLOSE_TIMEOUT above). + // Fail instead of guaranteeing that same failure. + return Err(connection::Error::TimedOut { + action: "close baseband connection", + }); } thread::sleep(BASEBAND_CLOSE_POLL_INTERVAL); } @@ -251,9 +253,9 @@ fn service_record_for_uuid( ) } -// macOS only populates the SDP cache once System Settings' device info pane has been opened, so query if empty. -// ponytail: only queries on a fully empty cache; forcing a fresh query on every connect was tried and reverted -// after it caused performSDPQuery to hang indefinitely when called repeatedly in quick succession. +// macOS only populates the SDP cache once System Settings' device info pane has been opened, so +// query if empty. Not forced on every connect: performSDPQuery can hang indefinitely when called +// repeatedly in quick succession. fn service_uuids(device: &IOBluetoothDevice) -> HashSet { let mut records = cached_service_records(device); if records.is_empty() { @@ -592,10 +594,11 @@ fn write_chunk(channel: &IOBluetoothRFCOMMChannel, chunk: &[u8]) -> connection:: break; } debug!("channel not open yet, retrying write ({attempt})"); - // The port that delegate callbacks are dispatched through isn't registered until the - // first successful write, so pumping the run loop here has nothing to catch yet: it - // needs an actual wall-clock wait for the channel to finish opening, not a pump. - thread::sleep(WRITE_NOT_OPEN_RETRY_DELAY); + // wait_for_open_complete already confirmed the channel opened once before any write is + // attempted, so a not-open status here is a transient stall on a live connection, not + // the initial-open race: pump so a real rfcommChannelClosed: during the stall is caught + // instead of blocking through it. + pump_run_loop(); } debug!("writeSync status: {status}, channel isOpen: {}", unsafe { channel.isOpen() @@ -628,7 +631,7 @@ fn run_connection_thread( let delegate = RfcommDelegate::new( open_complete_sender, data_sender, - status_sender, + status_sender.clone(), running.clone(), ); @@ -670,7 +673,7 @@ fn run_connection_thread( // Empirically, channel callbacks (rfcommChannelData etc.) are delivered on whichever thread // pumps the run loop here, unlike SDP query callbacks which need the real main thread - // (see perform_sdp_query) — so writes and pumping stay on this dedicated thread. + // (see perform_sdp_query), so writes and pumping stay on this dedicated thread. debug!("channel opened, running write/pump loop"); // Service any write requests queued while the channel was still opening. @@ -707,6 +710,10 @@ fn run_connection_thread( channel.setDelegate(None); channel.closeChannel(); } + // rfcommChannelClosed: (the only other place this is sent) can't fire once the delegate + // above is cleared, so a locally-initiated close would otherwise leave connection_status() + // stuck reporting Connected forever. + status_sender.send_replace(connection::ConnectionStatus::Disconnected); debug!("connection thread exiting"); } diff --git a/lib/src/devices/soundcore/a3953/structures.rs b/lib/src/devices/soundcore/a3953/structures.rs index 52742ae3f..29dd7ccd3 100644 --- a/lib/src/devices/soundcore/a3953/structures.rs +++ b/lib/src/devices/soundcore/a3953/structures.rs @@ -14,8 +14,8 @@ use crate::devices::soundcore::common::{ // [0x06,0x81] outbound (A3953CmdService.G1) / A3953AnalysisService.T2 inbound; anc_option_manual // clamped 1-6, trans_option 0-2 (write only), other fields raw; wind_noise.is_detected never sent // back. The official app only shows 3 ambient sound modes for this device (Normal/Transparency/ -// Noise Canceling), no adjustable ANC strength — confirmed by direct inspection on real hardware -// (2026-09-14) — so anc_option_manual/anc_option_auto/anc_automation_mode/anc_auto_sensitivity_level +// Noise Canceling), no adjustable ANC strength, confirmed by direct inspection on real hardware +// (2026-09-14), so anc_option_manual/anc_option_auto/anc_automation_mode/anc_auto_sensitivity_level // and trans_option are preserved on write but deliberately not exposed as settings. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct SoundModes { @@ -103,7 +103,7 @@ impl PressSensitivity { } pub fn bytes(&self) -> [u8; 1] { - [self.0.min(4)] + [self.0] } } diff --git a/lib/src/devices/soundcore/common/structures/dual_connections.rs b/lib/src/devices/soundcore/common/structures/dual_connections.rs index 777a9bb8f..0e4241be0 100644 --- a/lib/src/devices/soundcore/common/structures/dual_connections.rs +++ b/lib/src/devices/soundcore/common/structures/dual_connections.rs @@ -35,7 +35,13 @@ impl DualConnectionsDevice { // Name, right padded with 0s. Clamped to what's actually left: real devices have been // observed sending a lone trailing entry (alone in the final packet of a multi-packet // response) padded a few bytes short of what its own length byte implies. - let name_length = usize::from(length.saturating_sub(8)).min(input.len()); + let declared_name_length = usize::from(length.saturating_sub(8)); + let name_length = declared_name_length.min(input.len()); + if name_length < declared_name_length { + tracing::warn!( + "dual connections device length byte claims {declared_name_length} name bytes, only {name_length} remain, truncating" + ); + } let (input, name_bytes) = take(name_length).parse_complete(input)?; let mac_address = MacAddr6::from(<[u8; 6]>::from(mac_address_bytes)); From 0af8c979516cf91bc98a0cf62186fbae28c5dc47 Mon Sep 17 00:00:00 2001 From: Sean Halpin Date: Fri, 25 Sep 2026 09:59:16 -0400 Subject: [PATCH 22/22] chore: move A3953 support to its own PR - Soundcore Liberty 4 (A3953) device support, including the shared dual connections parser fix, now lives in its own PR at the maintainer's request. - No macOS backend changes; the two PRs don't depend on each other. Co-Authored-By: Claude --- CHANGELOG.md | 1 - README.md | 1 - lib/i18n/en/openscq30-lib.ftl | 3 - lib/src/api/settings.rs | 2 - lib/src/devices/device_model.rs | 3 - lib/src/devices/soundcore.rs | 1 - lib/src/devices/soundcore/a3953.rs | 414 ------------------ lib/src/devices/soundcore/a3953/modules.rs | 86 ---- .../soundcore/a3953/modules/equalizer.rs | 68 --- .../a3953/modules/equalizer/state_modifier.rs | 104 ----- .../soundcore/a3953/modules/misc_toggles.rs | 32 -- .../a3953/modules/press_sensitivity.rs | 38 -- .../press_sensitivity/setting_handler.rs | 52 --- .../press_sensitivity/state_modifier.rs | 54 --- .../soundcore/a3953/modules/sound_modes.rs | 36 -- .../modules/sound_modes/setting_handler.rs | 61 --- .../soundcore/a3953/modules/spatial_audio.rs | 41 -- .../modules/spatial_audio/setting_handler.rs | 69 --- .../modules/spatial_audio/state_modifier.rs | 56 --- lib/src/devices/soundcore/a3953/packets.rs | 5 - .../packets/set_equalizer_configuration.rs | 60 --- .../soundcore/a3953/packets/state_update.rs | 247 ----------- lib/src/devices/soundcore/a3953/state.rs | 129 ------ lib/src/devices/soundcore/a3953/structures.rs | 239 ---------- .../common/structures/dual_connections.rs | 14 +- 25 files changed, 3 insertions(+), 1813 deletions(-) delete mode 100644 lib/src/devices/soundcore/a3953.rs delete mode 100644 lib/src/devices/soundcore/a3953/modules.rs delete mode 100644 lib/src/devices/soundcore/a3953/modules/equalizer.rs delete mode 100644 lib/src/devices/soundcore/a3953/modules/equalizer/state_modifier.rs delete mode 100644 lib/src/devices/soundcore/a3953/modules/misc_toggles.rs delete mode 100644 lib/src/devices/soundcore/a3953/modules/press_sensitivity.rs delete mode 100644 lib/src/devices/soundcore/a3953/modules/press_sensitivity/setting_handler.rs delete mode 100644 lib/src/devices/soundcore/a3953/modules/press_sensitivity/state_modifier.rs delete mode 100644 lib/src/devices/soundcore/a3953/modules/sound_modes.rs delete mode 100644 lib/src/devices/soundcore/a3953/modules/sound_modes/setting_handler.rs delete mode 100644 lib/src/devices/soundcore/a3953/modules/spatial_audio.rs delete mode 100644 lib/src/devices/soundcore/a3953/modules/spatial_audio/setting_handler.rs delete mode 100644 lib/src/devices/soundcore/a3953/modules/spatial_audio/state_modifier.rs delete mode 100644 lib/src/devices/soundcore/a3953/packets.rs delete mode 100644 lib/src/devices/soundcore/a3953/packets/set_equalizer_configuration.rs delete mode 100644 lib/src/devices/soundcore/a3953/packets/state_update.rs delete mode 100644 lib/src/devices/soundcore/a3953/state.rs delete mode 100644 lib/src/devices/soundcore/a3953/structures.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 34ec953d9..c3404adc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,6 @@ #### Features -- Add support for Soundcore Liberty 4 (A3953) - Add macOS support (beta) ## v2.12.0 diff --git a/README.md b/README.md index 4c3d6e880..3dc8dd4d9 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,6 @@ If there's a device not in this list that you want to see supported, check if th | A3949 | Soundcore P20i / P25i / R50i | | A3951 | Soundcore Liberty Air 2 Pro | | A3952 | Soundcore Liberty 3 Pro | -| A3953 | Soundcore Liberty 4 | | A3954 | Soundcore Liberty 4 Pro | | A3955 | Soundcore P40i | | A3957 | Soundcore Liberty 5 | diff --git a/lib/i18n/en/openscq30-lib.ftl b/lib/i18n/en/openscq30-lib.ftl index a31ebd2c3..2ed7f3d92 100644 --- a/lib/i18n/en/openscq30-lib.ftl +++ b/lib/i18n/en/openscq30-lib.ftl @@ -25,7 +25,6 @@ soundcore-a3948 = Soundcore A20i soundcore-a3949 = Soundcore P20i / P25i / R50i soundcore-a3951 = Soundcore Liberty Air 2 Pro soundcore-a3952 = Soundcore Liberty 3 Pro -soundcore-a3953 = Soundcore Liberty 4 soundcore-a3939 = Soundcore Life P3 soundcore-a3935 = Soundcore Life A2 NC soundcore-a3959 = Soundcore P30i / R50i NC @@ -208,8 +207,6 @@ wearing-tone = Wearing Tone touch-lock = Touch Lock low-battery-prompt = Low Battery Prompt wearing-detection = Wearing Detection -press-sensitivity = Press Sensitivity -ambient-sound-prompt = Ambient Sound Prompt voice-prompt = Voice Prompt side-tone = Side Tone dolby-audio = Dolby Audio diff --git a/lib/src/api/settings.rs b/lib/src/api/settings.rs index 158f3ca46..17526e84d 100644 --- a/lib/src/api/settings.rs +++ b/lib/src/api/settings.rs @@ -143,8 +143,6 @@ pub enum SettingId { TouchLock, LowBatteryPrompt, WearingDetection, - PressSensitivity, - AmbientSoundPrompt, Volume, VoicePrompt, AncPersonalizedToEarCanal, diff --git a/lib/src/devices/device_model.rs b/lib/src/devices/device_model.rs index c43439e3f..43b967e60 100644 --- a/lib/src/devices/device_model.rs +++ b/lib/src/devices/device_model.rs @@ -54,7 +54,6 @@ pub enum DeviceModel { SoundcoreA3945, SoundcoreA3951, SoundcoreA3952, - SoundcoreA3953, SoundcoreA3939, SoundcoreA3935, SoundcoreA3954, @@ -121,7 +120,6 @@ impl DeviceModel { Self::SoundcoreA3949 => new_soundcore_device!(soundcore::a3949), Self::SoundcoreA3951 => new_soundcore_device!(soundcore::a3951), Self::SoundcoreA3952 => new_soundcore_device!(soundcore::a3952), - Self::SoundcoreA3953 => new_soundcore_device!(soundcore::a3953), Self::SoundcoreA3954 => new_soundcore_device!(soundcore::a3954), Self::SoundcoreA3955 => new_soundcore_device!(soundcore::a3955), Self::SoundcoreA3957 => new_soundcore_device!(soundcore::a3957), @@ -178,7 +176,6 @@ impl DeviceModel { Self::SoundcoreA3949 => new_soundcore_device!(soundcore::a3949), Self::SoundcoreA3951 => new_soundcore_device!(soundcore::a3951), Self::SoundcoreA3952 => new_soundcore_device!(soundcore::a3952), - Self::SoundcoreA3953 => new_soundcore_device!(soundcore::a3953), Self::SoundcoreA3954 => new_soundcore_device!(soundcore::a3954), Self::SoundcoreA3955 => new_soundcore_device!(soundcore::a3955), Self::SoundcoreA3957 => new_soundcore_device!(soundcore::a3957), diff --git a/lib/src/devices/soundcore.rs b/lib/src/devices/soundcore.rs index b3c4c5891..19c770dc4 100644 --- a/lib/src/devices/soundcore.rs +++ b/lib/src/devices/soundcore.rs @@ -23,7 +23,6 @@ pub mod a3948; pub mod a3949; pub mod a3951; pub mod a3952; -pub mod a3953; pub mod a3954; pub mod a3955; pub mod a3957; diff --git a/lib/src/devices/soundcore/a3953.rs b/lib/src/devices/soundcore/a3953.rs deleted file mode 100644 index 469a76651..000000000 --- a/lib/src/devices/soundcore/a3953.rs +++ /dev/null @@ -1,414 +0,0 @@ -use std::collections::HashMap; - -use crate::devices::soundcore::{ - a3953::{packets::A3953StateUpdatePacket, state::A3953State}, - common::{ - self, - macros::soundcore_device, - modules::{ - auto_power_off::AutoPowerOffDuration, - button_configuration::{ - ButtonConfigurationSettings, ButtonDisableMode, ButtonSettings, COMMON_ACTIONS, - }, - equalizer::common_settings_type_2, - }, - packet::{ - inbound::TryToPacket, - outbound::{RequestState, ToPacket}, - }, - structures::button_configuration::{ - ActionKind, Button, ButtonParseSettings, ButtonPressKind, EnabledFlagKind, - }, - }, -}; - -mod modules; -mod packets; -mod state; -mod structures; - -// button_id per press kind (2/0/1/5 for single/double/long/triple) matches this project's -// existing convention for every other device on this shared command, and was cross-checked byte -// for byte against CmmBtCmdService.t2's ViewModel caller (BaseControllerSelectVM.sendClickTypeCmd). -// Single-press was confirmed end to end on real hardware (2026-09-14): the write packet decoded -// exactly as predicted (command [4,129], side=0 for left, button_id=2, TwsLowBits-packed action -// byte), the read-back matched, and the physical button's behavior changed on the correct earbud -// only. Double/long/triple use the identical mechanism and the same button_id convention already -// verified on 4+ other devices, but weren't independently exercised on this device. -pub const BUTTON_CONFIGURATION_SETTINGS: ButtonConfigurationSettings<8, 4> = - ButtonConfigurationSettings { - supports_set_all_packet: false, - ignore_enabled_flag: true, - set_button_action_command_override: None, - setting_id_override: None, - order: [ - Button::LeftSinglePress, - Button::RightSinglePress, - Button::LeftDoublePress, - Button::RightDoublePress, - Button::LeftLongPress, - Button::RightLongPress, - Button::LeftTriplePress, - Button::RightTriplePress, - ], - settings: [ - ButtonSettings { - parse_settings: ButtonParseSettings { - enabled_flag_kind: EnabledFlagKind::TwsLowBits, - action_kind: ActionKind::TwsLowBits, - }, - button_id: 2, - press_kind: ButtonPressKind::Single, - available_actions: COMMON_ACTIONS, - disable_mode: ButtonDisableMode::IndividualDisable, - }, - ButtonSettings { - parse_settings: ButtonParseSettings { - enabled_flag_kind: EnabledFlagKind::TwsLowBits, - action_kind: ActionKind::TwsLowBits, - }, - button_id: 0, - press_kind: ButtonPressKind::Double, - available_actions: COMMON_ACTIONS, - disable_mode: ButtonDisableMode::IndividualDisable, - }, - ButtonSettings { - parse_settings: ButtonParseSettings { - enabled_flag_kind: EnabledFlagKind::TwsLowBits, - action_kind: ActionKind::TwsLowBits, - }, - button_id: 1, - press_kind: ButtonPressKind::Long, - available_actions: COMMON_ACTIONS, - disable_mode: ButtonDisableMode::IndividualDisable, - }, - ButtonSettings { - parse_settings: ButtonParseSettings { - enabled_flag_kind: EnabledFlagKind::TwsLowBits, - action_kind: ActionKind::TwsLowBits, - }, - button_id: 5, - press_kind: ButtonPressKind::Triple, - available_actions: COMMON_ACTIONS, - disable_mode: ButtonDisableMode::IndividualDisable, - }, - ], - }; - -soundcore_device!( - A3953State, - async |packet_io| { - let state_update_packet: A3953StateUpdatePacket = packet_io - .send_with_response(&RequestState.to_packet()) - .await? - .try_to_packet()?; - let dual_connections_devices = if state_update_packet.dual_connections_enabled { - common::modules::dual_connections::take_dual_connection_devices(&packet_io).await? - } else { - Vec::new() - }; - Ok(A3953State::new( - state_update_packet, - dual_connections_devices, - )) - }, - async |builder| { - builder.module_collection().add_state_update(); - - builder.serial_number_and_dual_firmware_version(); - builder.tws_status(); - // Only ever observed at 5/5 (fully charged); assumed max_level 5 to match the structurally - // similar A3947 (Liberty 4 NC) until a partial-charge capture confirms or corrects this. - builder.dual_battery(5); - builder.a3953_sound_modes(); - builder.ambient_sound_mode_cycle(); - builder.button_configuration(&BUTTON_CONFIGURATION_SETTINGS); - builder.reset_button_configuration::(RequestState.to_packet()); - builder.wearing_detection(); - // Same caveat as dual_battery above: the device's own S() clamp permits 0-9, but no - // partial-charge capture exists to confirm whether 5 is really this device's max. - builder.case_battery_level(5); - builder.ldac(); - builder.auto_power_off(AutoPowerOffDuration::ten_twenty_thirty_sixty()); - builder.wearing_tone(); - builder.low_battery_prompt(); - builder.a3953_misc_toggles(); - builder.dual_connections(); - builder.a3953_spatial_audio(); - builder.a3953_press_sensitivity(); - builder.a3953_equalizer(common_settings_type_2()).await; - }, - { - HashMap::from([( - RequestState::COMMAND, - A3953StateUpdatePacket::default().to_packet(), - )]) - }, -); - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use crate::{ - DeviceModel, - devices::soundcore::common::{ - device::{SoundcoreDeviceConfig, test_utils::TestSoundcoreDevice}, - packet, - structures::CommonVolumeAdjustments, - }, - settings::{SettingId, Value}, - }; - - #[tokio::test(start_paused = true)] - async fn parses_known_packet() { - let device = TestSoundcoreDevice::new( - super::device_registry, - DeviceModel::SoundcoreA3953, - HashMap::from([( - packet::Command([1, 1]), - packet::Inbound::new( - packet::Command([1, 1]), - vec![ - 1, 1, 5, 5, 0, 0, 48, 51, 46, 50, 51, 48, 51, 46, 50, 51, 51, 57, 53, 51, - 53, 52, 67, 54, 51, 51, 67, 67, 69, 69, 69, 56, 0, 0, 120, 120, 120, 120, - 120, 120, 120, 120, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, - 255, 0, 60, 60, 60, 60, 60, 60, 60, 60, 0, 0, 60, 60, 60, 60, 60, 60, 60, - 60, 0, 0, 0, 0, 0, 0, 0, 60, 60, 60, 60, 60, 60, 60, 60, 0, 0, 60, 60, 60, - 60, 60, 60, 60, 60, 0, 0, 0, 0, 18, 17, 102, 17, 102, 17, 52, 17, 52, 17, - 36, 17, 36, 17, 82, 17, 83, 3, 2, 48, 0, 0, 1, 1, 0, 0, 0, 0, 255, 255, 1, - 1, 0, 5, 0, 0, 0, 1, 2, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 120, 255, 50, 0, 255, - 255, - ], - ), - )]), - SoundcoreDeviceConfig::default(), - ) - .await; - - device.assert_setting_values([ - (SettingId::TwsStatus, "Connected".into()), - (SettingId::HostDevice, "Right".into()), - (SettingId::BatteryLevelLeft, "5/5".into()), - (SettingId::BatteryLevelRight, "5/5".into()), - (SettingId::IsChargingLeft, "No".into()), - (SettingId::IsChargingRight, "No".into()), - (SettingId::FirmwareVersionLeft, "03.23".into()), - (SettingId::FirmwareVersionRight, "03.23".into()), - (SettingId::SerialNumber, "395354C633CCEEE8".into()), - (SettingId::AmbientSoundMode, "Normal".into()), - (SettingId::WindNoiseSuppression, true.into()), - (SettingId::NormalModeInCycle, false.into()), - (SettingId::TransparencyModeInCycle, true.into()), - (SettingId::NoiseCancelingModeInCycle, true.into()), - (SettingId::WearingDetection, true.into()), - (SettingId::CaseBatteryLevel, "5/5".into()), - (SettingId::Ldac, false.into()), - (SettingId::AutoPowerOff, "30m".into()), - (SettingId::WearingTone, true.into()), - (SettingId::PressSensitivity, 0.into()), - (SettingId::LowBatteryPrompt, true.into()), - (SettingId::AmbientSoundPrompt, true.into()), - (SettingId::DualConnections, false.into()), - (SettingId::SpatialAudio, false.into()), - (SettingId::SpatialAudioMode, "Music".into()), - (SettingId::SpatialAudioMusicMode, "Fixed".into()), - (SettingId::VolumeAdjustments, Value::I16Vec(vec![0; 8])), - ]); - } - - // Same real capture as `parses_known_packet`. Verifies the outbound custom EQ write packet - // byte for byte: command `[0x03, 0x87]` (decompiled `Cmm2CmdData.p1`, reached via - // `A3953CmdService.c5()` -> `CmmBtCmdService.z5`/`v5`), the plain custom EQ values, the - // Hear-ID-uninitialized `255, 255` sentinel (this capture's own Hear ID data, round-tripped - // unchanged from what `parses_known_packet` decodes: not enabled, all-`-60` stored values, - // `time=0`, `hear_id_type=Initial`, `favorite_music_genre=0`), and the DRC-transformed copy of - // the new custom EQ values as the trailing "real eq" block (see - // `common::structures::VolumeAdjustments::apply_drc`, itself a verbatim transcription of the - // decompiled app's `HearId2Utils.b`). - #[tokio::test(start_paused = true)] - async fn sets_custom_equalizer() { - let mut device = TestSoundcoreDevice::new( - super::device_registry, - DeviceModel::SoundcoreA3953, - HashMap::from([( - packet::Command([1, 1]), - packet::Inbound::new( - packet::Command([1, 1]), - vec![ - 1, 1, 5, 5, 0, 0, 48, 51, 46, 50, 51, 48, 51, 46, 50, 51, 51, 57, 53, 51, - 53, 52, 67, 54, 51, 51, 67, 67, 69, 69, 69, 56, 0, 0, 120, 120, 120, 120, - 120, 120, 120, 120, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, - 255, 0, 60, 60, 60, 60, 60, 60, 60, 60, 0, 0, 60, 60, 60, 60, 60, 60, 60, - 60, 0, 0, 0, 0, 0, 0, 0, 60, 60, 60, 60, 60, 60, 60, 60, 0, 0, 60, 60, 60, - 60, 60, 60, 60, 60, 0, 0, 0, 0, 18, 17, 102, 17, 102, 17, 52, 17, 52, 17, - 36, 17, 36, 17, 82, 17, 83, 3, 2, 48, 0, 0, 1, 1, 0, 0, 0, 0, 255, 255, 1, - 1, 0, 5, 0, 0, 0, 1, 2, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 120, 255, 50, 0, 255, - 255, - ], - ), - )]), - SoundcoreDeviceConfig::default(), - ) - .await; - - // Bands 8 and 9 aren't settable through `SettingId::VolumeAdjustments` (only the 8 visible - // bands are), so the equalizer module leaves them at 0 rather than the DRC transform's own - // internal defaults for those two positions. - let new_adjustments = - CommonVolumeAdjustments::<10>::new([60, -60, 60, -60, 60, -60, 60, -60, 0, 0]); - // Decoded from this capture's own Hear ID block by `parses_known_packet`'s sibling - // assertions; round-tripped unchanged since this project doesn't expose Hear ID for - // editing. - let stored_hear_id_adjustments = CommonVolumeAdjustments::<10>::new([ - -60, -60, -60, -60, -60, -60, -60, -60, -120, -120, - ]); - - let mut expected_body = Vec::new(); - expected_body.extend([254u8, 254u8]); // preset id: custom (0xFEFE) - expected_body.extend([0u8, 0u8]); // favorite_music_genre (unchanged from the capture) - expected_body.extend(new_adjustments.bytes()); - expected_body.extend(new_adjustments.bytes()); - expected_body.extend([255u8, 255u8]); // Hear ID not initialized in this capture - expected_body.push(0); // hear_id.is_enabled: always disabled on write - expected_body.extend(stored_hear_id_adjustments.bytes()); - expected_body.extend(stored_hear_id_adjustments.bytes()); - expected_body.extend(0u32.to_be_bytes()); // hear_id.time - expected_body.push(0); // hear_id_type: Initial - expected_body.extend(stored_hear_id_adjustments.bytes()); - expected_body.extend(stored_hear_id_adjustments.bytes()); - expected_body.extend(new_adjustments.apply_drc().bytes()); - expected_body.extend(new_adjustments.apply_drc().bytes()); - expected_body.push(0); // trailing byte - - device - .assert_set_settings_response( - vec![( - SettingId::VolumeAdjustments, - Value::I16Vec(vec![60, -60, 60, -60, 60, -60, 60, -60]), - )], - vec![packet::Outbound::new( - packet::Command([3, 135]), - expected_body, - )], - ) - .await; - } - - // The three tests below are real captures of the same device, differing only in byte 121 (the - // ambient sound mode toggled in the official app between captures): 2 (Normal), 0 (Noise - // Canceling), and 1 (Transparency). See a3953/structures.rs for the byte citation. - #[tokio::test(start_paused = true)] - async fn parses_noise_canceling_mode() { - let device = TestSoundcoreDevice::new( - super::device_registry, - DeviceModel::SoundcoreA3953, - HashMap::from([( - packet::Command([1, 1]), - packet::Inbound::new( - packet::Command([1, 1]), - vec![ - 1, 1, 5, 5, 0, 0, 48, 51, 46, 50, 51, 48, 51, 46, 50, 51, 51, 57, 53, 51, - 53, 52, 67, 54, 51, 51, 67, 67, 69, 69, 69, 56, 0, 0, 120, 120, 120, 120, - 120, 120, 120, 120, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, - 255, 0, 60, 60, 60, 60, 60, 60, 60, 60, 0, 0, 60, 60, 60, 60, 60, 60, 60, - 60, 0, 0, 0, 0, 0, 0, 0, 60, 60, 60, 60, 60, 60, 60, 60, 0, 0, 60, 60, 60, - 60, 60, 60, 60, 60, 0, 0, 0, 0, 18, 17, 102, 17, 102, 17, 52, 17, 52, 17, - 36, 17, 36, 17, 82, 17, 83, 3, 0, 48, 0, 0, 1, 1, 0, 0, 0, 0, 255, 255, 1, - 1, 0, 5, 0, 0, 0, 1, 2, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 120, 255, 50, 0, 255, - 255, - ], - ), - )]), - SoundcoreDeviceConfig::default(), - ) - .await; - - device.assert_setting_values([(SettingId::AmbientSoundMode, "NoiseCanceling".into())]); - } - - #[tokio::test(start_paused = true)] - async fn parses_transparency_mode() { - let device = TestSoundcoreDevice::new( - super::device_registry, - DeviceModel::SoundcoreA3953, - HashMap::from([( - packet::Command([1, 1]), - packet::Inbound::new( - packet::Command([1, 1]), - vec![ - 1, 1, 5, 5, 0, 0, 48, 51, 46, 50, 51, 48, 51, 46, 50, 51, 51, 57, 53, 51, - 53, 52, 67, 54, 51, 51, 67, 67, 69, 69, 69, 56, 0, 0, 120, 120, 120, 120, - 120, 120, 120, 120, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, - 255, 0, 60, 60, 60, 60, 60, 60, 60, 60, 0, 0, 60, 60, 60, 60, 60, 60, 60, - 60, 0, 0, 0, 0, 0, 0, 0, 60, 60, 60, 60, 60, 60, 60, 60, 0, 0, 60, 60, 60, - 60, 60, 60, 60, 60, 0, 0, 0, 0, 18, 17, 102, 17, 102, 17, 52, 17, 52, 17, - 36, 17, 36, 17, 82, 17, 83, 3, 1, 48, 0, 0, 1, 1, 0, 0, 0, 0, 255, 255, 1, - 1, 0, 5, 0, 0, 0, 1, 2, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 120, 255, 50, 0, 255, - 255, - ], - ), - )]), - SoundcoreDeviceConfig::default(), - ) - .await; - - device.assert_setting_values([(SettingId::AmbientSoundMode, "Transparency".into())]); - } - - // Real capture: two unsolicited [0x0B, 0x01] packets pushed by the device after connecting, - // confirming A3953 uses the same wire format as common::packet::inbound::DualConnectionsDevicePacket. - // The second packet's lone entry is padded 2 bytes short of what its own length byte implies, - // which is what motivated DualConnectionsDevice::take's clamp to available input. - #[test] - fn parses_real_dual_connections_device_list_capture() { - use nom_language::error::VerboseError; - - use crate::devices::soundcore::common::packet::inbound::{ - DualConnectionsDevicePacket, FromPacketBody, - }; - - let packet_1 = [ - 2, 1, 40, 1, 175, 149, 25, 87, 47, 132, 83, 101, 97, 110, 226, 128, 153, 115, 32, 77, - 97, 99, 66, 111, 111, 107, 32, 80, 114, 111, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 40, 0, - 117, 224, 41, 200, 87, 96, 83, 101, 97, 110, 226, 128, 153, 115, 32, 105, 80, 104, 111, - 110, 101, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 40, 0, 113, 159, 29, 98, - 59, 28, 78, 111, 107, 105, 97, 32, 50, 55, 56, 48, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 40, 0, 198, 14, 117, 171, 169, 60, 78, 105, 110, 116, - 101, 110, 100, 111, 32, 83, 119, 105, 116, 99, 104, 32, 50, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, - ]; - let (remaining_1, parsed_1) = - DualConnectionsDevicePacket::take::>(&packet_1).unwrap(); - assert_eq!(remaining_1.len(), 0); - assert_eq!(parsed_1.total_packets, 2); - assert_eq!(parsed_1.current_packet_index, 1); - let names: Vec<_> = parsed_1.devices.iter().map(|d| d.name.as_str()).collect(); - assert_eq!( - names, - [ - "Sean\u{2019}s MacBook Pro", - "Sean\u{2019}s iPhone", - "Nokia 2780", - "Nintendo Switch 2", - ] - ); - assert!(parsed_1.devices[0].is_connected); - assert!(!parsed_1.devices[1].is_connected); - assert!(!parsed_1.devices[2].is_connected); - assert!(!parsed_1.devices[3].is_connected); - - let packet_2 = [ - 2, 2, 40, 0, 96, 137, 137, 109, 73, 184, 83, 101, 97, 110, 226, 128, 153, 115, 32, 105, - 80, 97, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - ]; - let (remaining_2, parsed_2) = - DualConnectionsDevicePacket::take::>(&packet_2).unwrap(); - assert_eq!(remaining_2.len(), 0); - assert_eq!(parsed_2.total_packets, 2); - assert_eq!(parsed_2.current_packet_index, 2); - assert_eq!(parsed_2.devices.len(), 1); - assert_eq!(parsed_2.devices[0].name, "Sean\u{2019}s iPad"); - assert!(!parsed_2.devices[0].is_connected); - } -} diff --git a/lib/src/devices/soundcore/a3953/modules.rs b/lib/src/devices/soundcore/a3953/modules.rs deleted file mode 100644 index f014491f2..000000000 --- a/lib/src/devices/soundcore/a3953/modules.rs +++ /dev/null @@ -1,86 +0,0 @@ -use openscq30_lib_has::Has; - -use crate::devices::soundcore::common::{ - self, device::SoundcoreDeviceBuilder, modules::equalizer::EqualizerModuleSettings, - structures::TwsStatus, -}; - -use super::structures::{ - AmbientSoundPrompt, IsHearIdInitialized, PressSensitivity, SoundModes, SpatialAudio, -}; - -mod equalizer; -mod misc_toggles; -mod press_sensitivity; -mod sound_modes; -mod spatial_audio; - -impl SoundcoreDeviceBuilder -where - StateType: Has - + Has> - + Has> - + Has - + Send - + Sync - + Clone - + 'static, -{ - pub async fn a3953_equalizer( - &mut self, - settings: EqualizerModuleSettings, - ) { - let packet_io = self.packet_io_controller().clone(); - let database = self.database(); - let device_model = self.device_model(); - let change_notify = self.change_notify(); - - self.module_collection() - .add_a3953_equalizer(packet_io, database, device_model, change_notify, settings) - .await; - } -} - -impl SoundcoreDeviceBuilder -where - StateType: Has + Send + Sync + Clone + 'static, -{ - pub fn a3953_sound_modes(&mut self) { - let packet_io_controller = self.packet_io_controller().clone(); - self.module_collection() - .add_a3953_sound_modes(packet_io_controller); - } -} - -impl SoundcoreDeviceBuilder -where - StateType: Has + Send + Sync + Clone + 'static, -{ - pub fn a3953_press_sensitivity(&mut self) { - let packet_io_controller = self.packet_io_controller().clone(); - self.module_collection() - .add_a3953_press_sensitivity(packet_io_controller); - } -} - -impl SoundcoreDeviceBuilder -where - StateType: Has + Send + Sync + Clone + 'static, -{ - pub fn a3953_misc_toggles(&mut self) { - let packet_io_controller = self.packet_io_controller().clone(); - self.module_collection() - .add_a3953_misc_toggles(packet_io_controller); - } -} - -impl SoundcoreDeviceBuilder -where - StateType: Has + Send + Sync + Clone + 'static, -{ - pub fn a3953_spatial_audio(&mut self) { - let packet_io_controller = self.packet_io_controller().clone(); - self.module_collection() - .add_a3953_spatial_audio(packet_io_controller); - } -} diff --git a/lib/src/devices/soundcore/a3953/modules/equalizer.rs b/lib/src/devices/soundcore/a3953/modules/equalizer.rs deleted file mode 100644 index 5d64f628e..000000000 --- a/lib/src/devices/soundcore/a3953/modules/equalizer.rs +++ /dev/null @@ -1,68 +0,0 @@ -mod state_modifier; - -use std::sync::Arc; - -use openscq30_lib_has::Has; -use tokio::sync::watch; - -use crate::{ - DeviceModel, - devices::soundcore::{ - a3953, - common::{ - self, - modules::{ModuleCollection, equalizer::EqualizerModuleSettings}, - packet::PacketIOController, - structures::TwsStatus, - }, - }, - storage::OpenSCQ30Database, -}; - -impl ModuleCollection -where - T: Has + Clone + Send + Sync + 'static, -{ - pub async fn add_a3953_equalizer< - const CHANNELS: usize, - const BANDS: usize, - const VISIBLE_BANDS: usize, - const PRESET_BANDS: usize, - const MIN_VOLUME: i16, - const MAX_VOLUME: i16, - const FRACTION_DIGITS: u8, - >( - &mut self, - packet_io: Arc, - database: Arc, - device_model: DeviceModel, - change_notify: watch::Sender<()>, - settings: EqualizerModuleSettings< - VISIBLE_BANDS, - PRESET_BANDS, - MIN_VOLUME, - MAX_VOLUME, - FRACTION_DIGITS, - >, - ) where - T: Has< - common::structures::EqualizerConfiguration< - CHANNELS, - BANDS, - MIN_VOLUME, - MAX_VOLUME, - FRACTION_DIGITS, - >, - > + Has> - + Has, - { - self.add_equalizer_with_custom_state_modifier_tws( - database, - device_model, - change_notify, - Box::new(state_modifier::EqualizerStateModifier::new(packet_io)), - settings, - ) - .await; - } -} diff --git a/lib/src/devices/soundcore/a3953/modules/equalizer/state_modifier.rs b/lib/src/devices/soundcore/a3953/modules/equalizer/state_modifier.rs deleted file mode 100644 index b47612efc..000000000 --- a/lib/src/devices/soundcore/a3953/modules/equalizer/state_modifier.rs +++ /dev/null @@ -1,104 +0,0 @@ -use async_trait::async_trait; -use openscq30_lib_has::Has; -use std::{marker::PhantomData, sync::Arc}; -use tokio::sync::watch; - -use crate::{ - api::device, - devices::soundcore::{ - a3953, - common::{self, packet::PacketIOController, state_modifier::StateModifier}, - }, -}; - -pub struct EqualizerStateModifier< - T, - const CHANNELS: usize, - const BANDS: usize, - const MIN_VOLUME: i16, - const MAX_VOLUME: i16, - const FRACTION_DIGITS: u8, -> { - _state: PhantomData, - packet_io: Arc, -} - -impl< - T, - const CHANNELS: usize, - const BANDS: usize, - const MIN_VOLUME: i16, - const MAX_VOLUME: i16, - const FRACTION_DIGITS: u8, -> EqualizerStateModifier -{ - pub fn new(packet_io: Arc) -> Self { - Self { - packet_io, - _state: PhantomData, - } - } -} - -#[async_trait] -impl< - T, - const CHANNELS: usize, - const BANDS: usize, - const MIN_VOLUME: i16, - const MAX_VOLUME: i16, - const FRACTION_DIGITS: u8, -> StateModifier - for EqualizerStateModifier -where - T: Has< - common::structures::EqualizerConfiguration< - CHANNELS, - BANDS, - MIN_VOLUME, - MAX_VOLUME, - FRACTION_DIGITS, - >, - > + Has> - + Has - + Clone - + Send - + Sync, -{ - async fn move_to_state( - &self, - state_sender: &watch::Sender, - target_state: &T, - ) -> device::Result<()> { - let target_eq: &common::structures::EqualizerConfiguration<_, _, _, _, _> = - target_state.get(); - { - let state = state_sender.borrow(); - let current_eq: &common::structures::EqualizerConfiguration<_, _, _, _, _> = - state.get(); - if current_eq == target_eq { - return Ok(()); - } - } - - let mut target_hear_id: common::structures::CustomHearId<_, _> = *target_state.get(); - // We don't expose hear id in any way, so it should be disabled to ensure the equalizer - // configuration that we're applying is in effect - target_hear_id.is_enabled = false; - - let is_hear_id_initialized: a3953::structures::IsHearIdInitialized = *target_state.get(); - - self.packet_io - .send_with_response(&a3953::packets::set_equalizer_configuration( - target_eq, - &target_hear_id, - is_hear_id_initialized.0, - )) - .await?; - state_sender.send_modify(|state| { - *state.get_mut() = *target_eq; - *state.get_mut() = target_hear_id; - }); - Ok(()) - } -} diff --git a/lib/src/devices/soundcore/a3953/modules/misc_toggles.rs b/lib/src/devices/soundcore/a3953/modules/misc_toggles.rs deleted file mode 100644 index f963c5595..000000000 --- a/lib/src/devices/soundcore/a3953/modules/misc_toggles.rs +++ /dev/null @@ -1,32 +0,0 @@ -use std::sync::Arc; - -use openscq30_lib_has::Has; - -use crate::{ - api::settings::{CategoryId, SettingId}, - devices::soundcore::{ - a3953::structures::AmbientSoundPrompt, - common::{ - modules::{ModuleCollection, flag::FlagConfiguration}, - packet::{self, PacketIOController}, - }, - }, -}; - -impl ModuleCollection -where - T: Has + Send + Sync, -{ - pub fn add_a3953_misc_toggles(&mut self, packet_io: Arc) { - self.add_flag::( - packet_io, - FlagConfiguration { - category_id: CategoryId::Miscellaneous, - setting_id: SettingId::AmbientSoundPrompt, - set_command: packet::Command([0x10, 0x83]), - update_command: None, - is_inverted: false, - }, - ); - } -} diff --git a/lib/src/devices/soundcore/a3953/modules/press_sensitivity.rs b/lib/src/devices/soundcore/a3953/modules/press_sensitivity.rs deleted file mode 100644 index f73460a95..000000000 --- a/lib/src/devices/soundcore/a3953/modules/press_sensitivity.rs +++ /dev/null @@ -1,38 +0,0 @@ -mod setting_handler; -mod state_modifier; - -use std::sync::Arc; - -use openscq30_lib_has::Has; -use setting_handler::PressSensitivitySettingHandler; -use strum::{EnumIter, EnumString, IntoStaticStr}; - -use crate::{ - api::settings::{CategoryId, SettingId}, - devices::soundcore::{ - a3953::structures::PressSensitivity, - common::{modules::ModuleCollection, packet::PacketIOController}, - }, - macros::enum_subset, -}; - -enum_subset! { - SettingId, - #[derive(EnumString, EnumIter, IntoStaticStr)] - enum PressSensitivitySetting { - PressSensitivity, - } -} - -impl ModuleCollection -where - T: Has + Clone + Send + Sync, -{ - pub fn add_a3953_press_sensitivity(&mut self, packet_io: Arc) { - self.setting_manager - .add_handler(CategoryId::Miscellaneous, PressSensitivitySettingHandler); - self.state_modifiers.push(Box::new( - state_modifier::PressSensitivityStateModifier::new(packet_io), - )); - } -} diff --git a/lib/src/devices/soundcore/a3953/modules/press_sensitivity/setting_handler.rs b/lib/src/devices/soundcore/a3953/modules/press_sensitivity/setting_handler.rs deleted file mode 100644 index 485170c48..000000000 --- a/lib/src/devices/soundcore/a3953/modules/press_sensitivity/setting_handler.rs +++ /dev/null @@ -1,52 +0,0 @@ -use async_trait::async_trait; -use openscq30_lib_has::Has; -use strum::IntoEnumIterator; - -use crate::{ - api::settings::{self, Setting, SettingId, Value}, - devices::soundcore::{ - a3953::structures::PressSensitivity, - common::settings_manager::{SettingHandler, SettingHandlerResult}, - }, -}; - -use super::PressSensitivitySetting; - -#[derive(Default)] -pub struct PressSensitivitySettingHandler; - -#[async_trait] -impl SettingHandler for PressSensitivitySettingHandler -where - T: Has + Send, -{ - fn settings(&self) -> Vec { - PressSensitivitySetting::iter().map(Into::into).collect() - } - - fn get(&self, state: &T, setting_id: &SettingId) -> Option { - let press_sensitivity: &PressSensitivity = state.get(); - let _: PressSensitivitySetting = (*setting_id).try_into().ok()?; - Some(Setting::I32Range { - setting: settings::Range { - range: 0..=4, - step: 1, - }, - value: i32::from(press_sensitivity.0), - }) - } - - async fn set( - &self, - state: &mut T, - setting_id: &SettingId, - value: Value, - ) -> SettingHandlerResult<()> { - let _: PressSensitivitySetting = (*setting_id) - .try_into() - .expect("already filtered to valid values only by SettingsManager"); - let press_sensitivity: &mut PressSensitivity = state.get_mut(); - press_sensitivity.0 = u8::try_from(value.try_as_i32()?.clamp(0, 4)).expect("clamped"); - Ok(()) - } -} diff --git a/lib/src/devices/soundcore/a3953/modules/press_sensitivity/state_modifier.rs b/lib/src/devices/soundcore/a3953/modules/press_sensitivity/state_modifier.rs deleted file mode 100644 index abf1410ed..000000000 --- a/lib/src/devices/soundcore/a3953/modules/press_sensitivity/state_modifier.rs +++ /dev/null @@ -1,54 +0,0 @@ -use async_trait::async_trait; -use openscq30_lib_has::Has; -use std::sync::Arc; -use tokio::sync::watch; - -use crate::{ - api::device, - devices::soundcore::{ - a3953::structures::PressSensitivity, - common::{ - packet::{self, PacketIOController}, - state_modifier::StateModifier, - }, - }, -}; - -const COMMAND: packet::Command = packet::Command([0x04, 0x85]); - -pub struct PressSensitivityStateModifier { - packet_io: Arc, -} - -impl PressSensitivityStateModifier { - pub fn new(packet_io: Arc) -> Self { - Self { packet_io } - } -} - -#[async_trait] -impl StateModifier for PressSensitivityStateModifier -where - T: Has + Clone + Send + Sync, -{ - async fn move_to_state( - &self, - state_sender: &watch::Sender, - target_state: &T, - ) -> device::Result<()> { - let target = *target_state.get(); - { - let state = state_sender.borrow(); - if *state.get() == target { - return Ok(()); - } - } - self.packet_io - .send_with_response(&packet::Outbound::new(COMMAND, target.bytes().to_vec())) - .await?; - state_sender.send_modify(|state| { - *state.get_mut() = target; - }); - Ok(()) - } -} diff --git a/lib/src/devices/soundcore/a3953/modules/sound_modes.rs b/lib/src/devices/soundcore/a3953/modules/sound_modes.rs deleted file mode 100644 index 1f62c99ed..000000000 --- a/lib/src/devices/soundcore/a3953/modules/sound_modes.rs +++ /dev/null @@ -1,36 +0,0 @@ -mod setting_handler; - -use std::sync::Arc; - -use openscq30_lib_has::Has; -use setting_handler::SoundModesSettingHandler; -use strum::{EnumIter, EnumString, IntoStaticStr}; - -use crate::{ - api::settings::{CategoryId, SettingId}, - devices::soundcore::{ - a3953::structures::SoundModes, - common::{modules::ModuleCollection, packet::PacketIOController}, - }, - macros::enum_subset, -}; - -enum_subset! { - SettingId, - #[derive(EnumString, EnumIter, IntoStaticStr)] - enum SoundModesSetting { - AmbientSoundMode, - WindNoiseSuppression, - } -} - -impl ModuleCollection -where - T: Has + Clone + Send + Sync, -{ - pub fn add_a3953_sound_modes(&mut self, packet_io: Arc) { - self.setting_manager - .add_handler(CategoryId::SoundModes, SoundModesSettingHandler); - self.add_partial_sound_modes_v2(packet_io); - } -} diff --git a/lib/src/devices/soundcore/a3953/modules/sound_modes/setting_handler.rs b/lib/src/devices/soundcore/a3953/modules/sound_modes/setting_handler.rs deleted file mode 100644 index 169d4bfe5..000000000 --- a/lib/src/devices/soundcore/a3953/modules/sound_modes/setting_handler.rs +++ /dev/null @@ -1,61 +0,0 @@ -use async_trait::async_trait; -use openscq30_lib_has::Has; -use strum::IntoEnumIterator; - -use crate::{ - api::settings::{Setting, SettingId, Value}, - devices::soundcore::{ - a3953::structures::SoundModes, - common::settings_manager::{SettingHandler, SettingHandlerResult}, - }, -}; - -use super::SoundModesSetting; - -#[derive(Default)] -pub struct SoundModesSettingHandler; - -#[async_trait] -impl SettingHandler for SoundModesSettingHandler -where - T: Has + Send, -{ - fn settings(&self) -> Vec { - SoundModesSetting::iter().map(Into::into).collect() - } - - fn get(&self, state: &T, setting_id: &SettingId) -> Option { - let sound_modes: &SoundModes = state.get(); - let sound_mode_setting: SoundModesSetting = (*setting_id).try_into().ok()?; - match sound_mode_setting { - SoundModesSetting::AmbientSoundMode => Some(Setting::select_from_enum_all_variants( - sound_modes.ambient_sound_mode, - )), - SoundModesSetting::WindNoiseSuppression => Some(Setting::Toggle { - value: sound_modes.wind_noise.is_suppression_enabled, - }), - } - } - - async fn set( - &self, - state: &mut T, - setting_id: &SettingId, - value: Value, - ) -> SettingHandlerResult<()> { - let sound_mode_setting: SoundModesSetting = (*setting_id) - .try_into() - .expect("already filtered to valid values only by SettingsManager"); - match sound_mode_setting { - SoundModesSetting::AmbientSoundMode => { - let sound_modes: &mut SoundModes = state.get_mut(); - sound_modes.ambient_sound_mode = value.try_as_enum_variant()?; - } - SoundModesSetting::WindNoiseSuppression => { - let sound_modes: &mut SoundModes = state.get_mut(); - sound_modes.wind_noise.is_suppression_enabled = value.try_as_bool()?; - } - } - Ok(()) - } -} diff --git a/lib/src/devices/soundcore/a3953/modules/spatial_audio.rs b/lib/src/devices/soundcore/a3953/modules/spatial_audio.rs deleted file mode 100644 index 6e81e8878..000000000 --- a/lib/src/devices/soundcore/a3953/modules/spatial_audio.rs +++ /dev/null @@ -1,41 +0,0 @@ -mod setting_handler; -mod state_modifier; - -use std::sync::Arc; - -use openscq30_lib_has::Has; -use setting_handler::SpatialAudioSettingHandler; -use strum::{EnumIter, EnumString, IntoStaticStr}; - -use crate::{ - api::settings::{CategoryId, SettingId}, - devices::soundcore::{ - a3953::structures::SpatialAudio, - common::{modules::ModuleCollection, packet::PacketIOController}, - }, - macros::enum_subset, -}; - -enum_subset! { - SettingId, - #[derive(EnumString, EnumIter, IntoStaticStr)] - enum SpatialAudioSetting { - SpatialAudio, - SpatialAudioMode, - SpatialAudioMusicMode, - } -} - -impl ModuleCollection -where - T: Has + Clone + Send + Sync, -{ - pub fn add_a3953_spatial_audio(&mut self, packet_io: Arc) { - self.setting_manager - .add_handler(CategoryId::Miscellaneous, SpatialAudioSettingHandler); - self.state_modifiers - .push(Box::new(state_modifier::SpatialAudioStateModifier::new( - packet_io, - ))); - } -} diff --git a/lib/src/devices/soundcore/a3953/modules/spatial_audio/setting_handler.rs b/lib/src/devices/soundcore/a3953/modules/spatial_audio/setting_handler.rs deleted file mode 100644 index fb4cab4dd..000000000 --- a/lib/src/devices/soundcore/a3953/modules/spatial_audio/setting_handler.rs +++ /dev/null @@ -1,69 +0,0 @@ -use async_trait::async_trait; -use openscq30_lib_has::Has; -use strum::IntoEnumIterator; - -use crate::{ - api::settings::{Setting, SettingId, Value}, - devices::soundcore::{ - a3953::structures::SpatialAudio, - common::settings_manager::{SettingHandler, SettingHandlerResult}, - }, -}; - -use super::SpatialAudioSetting; - -// SpatialAudioMode maps to sound_mode (Music/Movie) and SpatialAudioMusicMode maps to effect_mode -// (Fixed/HeadTracking), matching the naming this project already uses for A3954's -// otherwise-unrelated (differently encoded) spatial audio feature. -#[derive(Default)] -pub struct SpatialAudioSettingHandler; - -#[async_trait] -impl SettingHandler for SpatialAudioSettingHandler -where - T: Has + Send, -{ - fn settings(&self) -> Vec { - SpatialAudioSetting::iter().map(Into::into).collect() - } - - fn get(&self, state: &T, setting_id: &SettingId) -> Option { - let spatial_audio: &SpatialAudio = state.get(); - let spatial_audio_setting: SpatialAudioSetting = (*setting_id).try_into().ok()?; - match spatial_audio_setting { - SpatialAudioSetting::SpatialAudio => Some(Setting::Toggle { - value: spatial_audio.is_enabled, - }), - SpatialAudioSetting::SpatialAudioMode => Some(Setting::select_from_enum_all_variants( - spatial_audio.sound_mode, - )), - SpatialAudioSetting::SpatialAudioMusicMode => Some( - Setting::select_from_enum_all_variants(spatial_audio.effect_mode), - ), - } - } - - async fn set( - &self, - state: &mut T, - setting_id: &SettingId, - value: Value, - ) -> SettingHandlerResult<()> { - let spatial_audio_setting: SpatialAudioSetting = (*setting_id) - .try_into() - .expect("already filtered to valid values only by SettingsManager"); - let spatial_audio: &mut SpatialAudio = state.get_mut(); - match spatial_audio_setting { - SpatialAudioSetting::SpatialAudio => { - spatial_audio.is_enabled = value.try_as_bool()?; - } - SpatialAudioSetting::SpatialAudioMode => { - spatial_audio.sound_mode = value.try_as_enum_variant()?; - } - SpatialAudioSetting::SpatialAudioMusicMode => { - spatial_audio.effect_mode = value.try_as_enum_variant()?; - } - } - Ok(()) - } -} diff --git a/lib/src/devices/soundcore/a3953/modules/spatial_audio/state_modifier.rs b/lib/src/devices/soundcore/a3953/modules/spatial_audio/state_modifier.rs deleted file mode 100644 index c0d878416..000000000 --- a/lib/src/devices/soundcore/a3953/modules/spatial_audio/state_modifier.rs +++ /dev/null @@ -1,56 +0,0 @@ -use async_trait::async_trait; -use openscq30_lib_has::Has; -use std::sync::Arc; -use tokio::sync::watch; - -use crate::{ - api::device, - devices::soundcore::{ - a3953::structures::SpatialAudio, - common::{ - packet::{self, PacketIOController}, - state_modifier::StateModifier, - }, - }, -}; - -const COMMAND: packet::Command = packet::Command([0x10, 0x81]); - -pub struct SpatialAudioStateModifier { - packet_io: Arc, -} - -impl SpatialAudioStateModifier { - pub fn new(packet_io: Arc) -> Self { - Self { packet_io } - } -} - -#[async_trait] -impl StateModifier for SpatialAudioStateModifier -where - T: Has + Clone + Send + Sync, -{ - async fn move_to_state( - &self, - state_sender: &watch::Sender, - target_state: &T, - ) -> device::Result<()> { - let target: SpatialAudio = *target_state.get(); - { - let state = state_sender.borrow(); - let current: &SpatialAudio = state.get(); - if *current == target { - return Ok(()); - } - } - self.packet_io - .send_with_response(&packet::Outbound::new(COMMAND, target.bytes().to_vec())) - .await?; - state_sender.send_modify(|state| { - let value: &mut SpatialAudio = state.get_mut(); - *value = target; - }); - Ok(()) - } -} diff --git a/lib/src/devices/soundcore/a3953/packets.rs b/lib/src/devices/soundcore/a3953/packets.rs deleted file mode 100644 index 7310c1f38..000000000 --- a/lib/src/devices/soundcore/a3953/packets.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod set_equalizer_configuration; -mod state_update; - -pub use set_equalizer_configuration::*; -pub use state_update::*; diff --git a/lib/src/devices/soundcore/a3953/packets/set_equalizer_configuration.rs b/lib/src/devices/soundcore/a3953/packets/set_equalizer_configuration.rs deleted file mode 100644 index d3bf2666b..000000000 --- a/lib/src/devices/soundcore/a3953/packets/set_equalizer_configuration.rs +++ /dev/null @@ -1,60 +0,0 @@ -use crate::devices::soundcore::common::{self, packet, structures::OptionalVolumeAdjustmentsExt}; - -// [0x03,0x87] (Cmm2CmdData.p1/CmmBtCmdService.v5, reached via A3953CmdService.c5's z5); same wire -// format as a3955's own set_equalizer_configuration. -pub fn set_equalizer_configuration< - const CHANNELS: usize, - const BANDS: usize, - const MIN_VOLUME: i16, - const MAX_VOLUME: i16, - const FRACTION_DIGITS: u8, ->( - equalizer_configuration: &common::structures::EqualizerConfiguration< - CHANNELS, - BANDS, - MIN_VOLUME, - MAX_VOLUME, - FRACTION_DIGITS, - >, - hear_id: &common::structures::CustomHearId, - is_hear_id_initialized: bool, -) -> packet::Outbound { - let body = equalizer_configuration - .preset_id() - .to_le_bytes() - .into_iter() - .chain(hear_id.favorite_music_genre.bytes()) - .chain(equalizer_configuration.volume_adjustments_bytes()) - .chain(if is_hear_id_initialized { - [0; 2] - } else { - [255; 2] - }) - .chain(std::iter::once(u8::from(hear_id.is_enabled))) - .chain(hear_id.volume_adjustments.iter().flat_map(|v| { - let mut bytes = v.bytes(); - if v.is_none() { - bytes[bytes.len() - 1] = 0; - } - bytes - })) - .chain(hear_id.time.to_be_bytes()) - .chain(std::iter::once(hear_id.hear_id_type as u8)) - .chain(hear_id.custom_volume_adjustments.iter().flat_map(|v| { - let mut bytes = v.bytes(); - if v.is_none() { - bytes[bytes.len() - 1] = 0; - } - bytes - })) - .chain( - equalizer_configuration - .volume_adjustments() - .iter() - .flat_map(|v| v.apply_drc().bytes()), - ) - .chain(std::iter::once(0)) - .collect(); - - packet::Outbound::new(packet::Command([3, 135]), body) -} diff --git a/lib/src/devices/soundcore/a3953/packets/state_update.rs b/lib/src/devices/soundcore/a3953/packets/state_update.rs deleted file mode 100644 index d2ff16bb5..000000000 --- a/lib/src/devices/soundcore/a3953/packets/state_update.rs +++ /dev/null @@ -1,247 +0,0 @@ -use std::iter; - -use nom::{ - IResult, Parser, - bytes::complete::take, - combinator::{map, rest}, - error::{ContextError, ParseError, context}, - number::complete::le_u8, -}; - -use crate::devices::soundcore::{ - a3953::{self, state::A3953State}, - common::{ - macros::state_update_packet_module, - packet::{self, inbound::FromPacketBody, outbound::ToPacket, parsing::take_bool}, - structures::{ - AmbientSoundModeCycle, AutoPowerOff, CaseBatteryLevel, CommonEqualizerConfiguration, - CustomHearId, DualBattery, DualFirmwareVersion, Ldac, LowBatteryPrompt, SerialNumber, - TwsStatus, WearingDetection, WearingTone, button_configuration::ButtonStatusCollection, - }, - }, -}; - -// Byte offsets cited inline below are from A3953AnalysisService.R0 (com.oceanwing.soundcore -// v6.4.0-17); R0's own bArr indices are this packet's index + 9. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct A3953StateUpdatePacket { - pub tws_status: TwsStatus, - pub battery: DualBattery, - pub dual_firmware_version: DualFirmwareVersion, - pub serial_number: SerialNumber, - pub equalizer_configuration: CommonEqualizerConfiguration<2, 10>, - pub is_hear_id_initialized: a3953::structures::IsHearIdInitialized, - pub hear_id: CustomHearId<2, 10>, - pub custom_length: u8, - pub button_configuration: ButtonStatusCollection<8>, - pub unknown_gap: Vec, - pub ambient_sound_mode_cycle: AmbientSoundModeCycle, - pub sound_modes: a3953::structures::SoundModes, - pub wearing_detection: WearingDetection, - pub case_battery_level: CaseBatteryLevel, - pub ldac: Ldac, - pub dual_connections_enabled: bool, - pub auto_power_off: AutoPowerOff, - pub wearing_tone: WearingTone, - pub low_battery_prompt: LowBatteryPrompt, - pub ambient_sound_prompt: a3953::structures::AmbientSoundPrompt, - pub spatial_audio: a3953::structures::SpatialAudio, - pub device_colour: Option, - pub press_sensitivity: Option, - pub unknown_suffix: Vec, -} - -impl Default for A3953StateUpdatePacket { - fn default() -> Self { - Self { - tws_status: Default::default(), - battery: Default::default(), - dual_firmware_version: Default::default(), - serial_number: Default::default(), - equalizer_configuration: Default::default(), - is_hear_id_initialized: Default::default(), - hear_id: Default::default(), - custom_length: Default::default(), - button_configuration: a3953::BUTTON_CONFIGURATION_SETTINGS.default_status_collection(), - unknown_gap: Default::default(), - ambient_sound_mode_cycle: Default::default(), - sound_modes: Default::default(), - wearing_detection: Default::default(), - case_battery_level: Default::default(), - ldac: Default::default(), - dual_connections_enabled: Default::default(), - auto_power_off: Default::default(), - wearing_tone: Default::default(), - low_battery_prompt: Default::default(), - ambient_sound_prompt: Default::default(), - spatial_audio: Default::default(), - device_colour: Default::default(), - press_sensitivity: Default::default(), - unknown_suffix: Default::default(), - } - } -} - -impl FromPacketBody for A3953StateUpdatePacket { - type DirectionMarker = packet::InboundMarker; - - fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( - input: &'a [u8], - ) -> IResult<&'a [u8], Self, E> { - let total_len = input.len(); - context("a3953 state update packet", move |input| { - let (input, tws_status) = TwsStatus::take(input)?; - let (input, battery) = DualBattery::take(input)?; - let (input, dual_firmware_version) = DualFirmwareVersion::take(input)?; - let (input, serial_number) = SerialNumber::take(input)?; - // bArr[41..63]: preset id + 2x10 raw band bytes, both channels (right duplicates left) - let (input, equalizer_configuration) = - CommonEqualizerConfiguration::<2, 10>::take(input)?; - let (input, hear_id_status) = le_u8(input)?; // bArr[63]: 255/254 = no hear id data - let is_hear_id_initialized = a3953::structures::IsHearIdInitialized( - hear_id_status != 255 && hear_id_status != 254, - ); - // bArr[64..112], same wire format as a3955 (DRC coefficients in VolumeAdjustments::apply_drc) - let (input, hear_id) = CustomHearId::<2, 10>::take_with_music_genre_at_end(input)?; - let (input, custom_length) = le_u8(input)?; // bArr[112]: base offset for fields below - // bArr[113..129], see a3953::BUTTON_CONFIGURATION_SETTINGS (write confirmed on real hardware) - let (input, button_configuration) = ButtonStatusCollection::take( - a3953::BUTTON_CONFIGURATION_SETTINGS.parse_settings(), - )(input)?; - let gap_len = (custom_length as usize).saturating_sub(18); // usually 0; nonzero if custom_length != 18 - let (input, unknown_gap) = take(gap_len)(input)?; - let (input, ambient_sound_mode_cycle) = AmbientSoundModeCycle::take(input)?; - let (input, sound_modes) = a3953::structures::SoundModes::take(input)?; - let (input, _unknown_personal_anc_test_info) = take(6usize)(input)?; // test time/volume/result, always 255/255 seen so far - let (input, wearing_detection) = WearingDetection::take(input)?; - let (input, _unknown_wearing_status) = take(2usize)(input)?; // left/right in-ear status, read-only - let (input, case_battery_level) = CaseBatteryLevel::take(input)?; - let (input, _unknown_bass_up) = take(1usize)(input)?; // "bass up" toggle, no write command found - let (input, ldac) = Ldac::take(input)?; - let (input, dual_connections_enabled) = take_bool(input)?; // same [0x0B,0x84] toggle as common::modules::dual_connections - let (input, auto_power_off) = AutoPowerOff::take(input)?; - let (input, _unknown_hear_id_volume_db) = take(1usize)(input)?; // hear id feature, not implemented - let (input, wearing_tone) = WearingTone::take(input)?; // app calls this "in ear beep" - let (input, low_battery_prompt) = LowBatteryPrompt::take(input)?; - let (input, ambient_sound_prompt) = a3953::structures::AmbientSoundPrompt::take(input)?; - let (input, spatial_audio) = a3953::structures::SpatialAudio::take(input)?; - let (input, _unknown_health_and_gap) = take(5usize)(input)?; // daily-care health fields, all-zero on this earbud - // device_colour/press_sensitivity only present when the body is >154 bytes (R0's own - // length check). device_colour is an ASCII char, not a numeric id; '2' = Cloud White - // confirmed on real hardware, other colors unconfirmed. Not exposed: no device in this - // project exposes case color. - let (input, (device_colour, press_sensitivity)) = if total_len > 154 { - let (input, colour_byte) = le_u8(input)?; - let (input, press_sensitivity) = a3953::structures::PressSensitivity::take(input)?; - (input, (Some(colour_byte), Some(press_sensitivity))) - } else { - (input, (None, None)) - }; - let (input, unknown_suffix) = map(rest, |s: &[u8]| s.to_vec()).parse(input)?; // trailing bytes, 2 in captures seen so far - Ok(( - input, - Self { - tws_status, - battery, - dual_firmware_version, - serial_number, - equalizer_configuration, - is_hear_id_initialized, - hear_id, - custom_length, - button_configuration, - unknown_gap: unknown_gap.to_vec(), - ambient_sound_mode_cycle, - sound_modes, - wearing_detection, - case_battery_level, - ldac, - dual_connections_enabled, - auto_power_off, - wearing_tone, - low_battery_prompt, - ambient_sound_prompt, - spatial_audio, - device_colour, - press_sensitivity, - unknown_suffix, - }, - )) - }) - .parse_complete(input) - } -} - -impl ToPacket for A3953StateUpdatePacket { - type DirectionMarker = packet::InboundMarker; - - fn command(&self) -> packet::Command { - packet::inbound::STATE_COMMAND - } - - fn body(&self) -> Vec { - self.tws_status - .bytes() - .into_iter() - .chain(self.battery.bytes()) - .chain(self.dual_firmware_version.bytes()) - .chain(self.serial_number.to_string().into_bytes()) - .chain(self.equalizer_configuration.bytes()) - .chain(iter::once(if self.is_hear_id_initialized.0 { - 0 - } else { - 255 - })) - .chain(self.hear_id.bytes_with_music_genre_at_end()) - .chain(iter::once(self.custom_length)) - .chain( - self.button_configuration - .bytes(a3953::BUTTON_CONFIGURATION_SETTINGS.parse_settings()), - ) - .chain(self.unknown_gap.iter().copied()) - .chain(self.ambient_sound_mode_cycle.bytes()) - .chain(self.sound_modes.bytes()) - .chain([0; 6]) // unknown personal ANC test info - .chain(self.wearing_detection.bytes()) - .chain([0; 2]) // unknown wearing status - .chain(self.case_battery_level.bytes()) - .chain(iter::once(0)) // unknown bass up - .chain(self.ldac.bytes()) - .chain(iter::once(self.dual_connections_enabled.into())) - .chain(self.auto_power_off.bytes()) - .chain(iter::once(0)) // unknown hear id volume db - .chain(self.wearing_tone.bytes()) - .chain(self.low_battery_prompt.bytes()) - .chain(self.ambient_sound_prompt.bytes()) - .chain(self.spatial_audio.bytes()) - .chain([0; 5]) // unknown health and gap - .chain(self.device_colour) - .chain( - self.press_sensitivity - .into_iter() - .flat_map(|value| value.bytes()), - ) - .chain(self.unknown_suffix.iter().copied()) - .collect() - } -} - -state_update_packet_module!(A3953State, A3953StateUpdatePacket); - -#[cfg(test)] -mod tests { - use nom_language::error::VerboseError; - - use crate::devices::soundcore::common::packet::inbound::TryToPacket; - - use super::*; - - #[test] - fn serialize_and_deserialize() { - let bytes = A3953StateUpdatePacket::default() - .to_packet() - .bytes_with_checksum(); - let (_, packet) = packet::Inbound::take_with_checksum::>(&bytes).unwrap(); - let _: A3953StateUpdatePacket = packet.try_to_packet().unwrap(); - } -} diff --git a/lib/src/devices/soundcore/a3953/state.rs b/lib/src/devices/soundcore/a3953/state.rs deleted file mode 100644 index cbcfecbc2..000000000 --- a/lib/src/devices/soundcore/a3953/state.rs +++ /dev/null @@ -1,129 +0,0 @@ -use openscq30_lib_macros::Has; - -use crate::devices::soundcore::{ - a3953, - common::{ - modules::reset_button_configuration::ResetButtonConfigurationPending, - state::Update, - structures::{ - AmbientSoundModeCycle, AutoPowerOff, CaseBatteryLevel, CommonEqualizerConfiguration, - CustomHearId, DualBattery, DualConnections, DualConnectionsDevice, DualFirmwareVersion, - Ldac, LowBatteryPrompt, SerialNumber, TwsStatus, WearingDetection, WearingTone, - button_configuration::ButtonStatusCollection, - }, - }, -}; - -#[derive(Debug, Clone, PartialEq, Eq, Has)] -pub struct A3953State { - tws_status: TwsStatus, - battery: DualBattery, - dual_firmware_version: DualFirmwareVersion, - serial_number: SerialNumber, - equalizer_configuration: CommonEqualizerConfiguration<2, 10>, - is_hear_id_initialized: a3953::structures::IsHearIdInitialized, - hear_id: CustomHearId<2, 10>, - ambient_sound_mode_cycle: AmbientSoundModeCycle, - sound_modes: a3953::structures::SoundModes, - wearing_detection: WearingDetection, - case_battery_level: CaseBatteryLevel, - ldac: Ldac, - dual_connections: DualConnections, - auto_power_off: AutoPowerOff, - wearing_tone: WearingTone, - low_battery_prompt: LowBatteryPrompt, - ambient_sound_prompt: a3953::structures::AmbientSoundPrompt, - spatial_audio: a3953::structures::SpatialAudio, - press_sensitivity: a3953::structures::PressSensitivity, - button_configuration: ButtonStatusCollection<8>, - button_reset_pending: ResetButtonConfigurationPending, -} - -impl A3953State { - pub fn new( - packet: a3953::packets::A3953StateUpdatePacket, - dual_connections_devices: Vec, - ) -> Self { - Self { - tws_status: packet.tws_status, - battery: packet.battery, - dual_firmware_version: packet.dual_firmware_version, - serial_number: packet.serial_number, - equalizer_configuration: packet.equalizer_configuration, - is_hear_id_initialized: packet.is_hear_id_initialized, - hear_id: packet.hear_id, - ambient_sound_mode_cycle: packet.ambient_sound_mode_cycle, - sound_modes: packet.sound_modes, - wearing_detection: packet.wearing_detection, - case_battery_level: packet.case_battery_level, - ldac: packet.ldac, - dual_connections: DualConnections { - is_enabled: packet.dual_connections_enabled, - devices: dual_connections_devices, - }, - auto_power_off: packet.auto_power_off, - wearing_tone: packet.wearing_tone, - low_battery_prompt: packet.low_battery_prompt, - ambient_sound_prompt: packet.ambient_sound_prompt, - spatial_audio: packet.spatial_audio, - press_sensitivity: packet.press_sensitivity.unwrap_or_default(), - button_configuration: packet.button_configuration, - button_reset_pending: ResetButtonConfigurationPending::default(), - } - } -} - -// Custom instead of the usual blanket From-based Update: a full state update packet doesn't carry -// the dual connections device list (that's fetched separately), so a plain replace would wipe it -// out on every subsequent state refresh. Only dual_connections.is_enabled gets refreshed here. -impl Update for A3953State { - fn update(&mut self, packet: a3953::packets::A3953StateUpdatePacket) { - let a3953::packets::A3953StateUpdatePacket { - tws_status, - battery, - dual_firmware_version, - serial_number, - equalizer_configuration, - is_hear_id_initialized, - hear_id, - custom_length: _, - button_configuration, - unknown_gap: _, - ambient_sound_mode_cycle, - sound_modes, - wearing_detection, - case_battery_level, - ldac, - dual_connections_enabled, - auto_power_off, - wearing_tone, - low_battery_prompt, - ambient_sound_prompt, - spatial_audio, - device_colour: _, - press_sensitivity, - unknown_suffix: _, - } = packet; - - self.tws_status = tws_status; - self.battery = battery; - self.dual_firmware_version = dual_firmware_version; - self.serial_number = serial_number; - self.equalizer_configuration = equalizer_configuration; - self.is_hear_id_initialized = is_hear_id_initialized; - self.hear_id = hear_id; - self.ambient_sound_mode_cycle = ambient_sound_mode_cycle; - self.sound_modes = sound_modes; - self.wearing_detection = wearing_detection; - self.case_battery_level = case_battery_level; - self.ldac = ldac; - self.dual_connections.is_enabled = dual_connections_enabled; - self.auto_power_off = auto_power_off; - self.wearing_tone = wearing_tone; - self.low_battery_prompt = low_battery_prompt; - self.ambient_sound_prompt = ambient_sound_prompt; - self.spatial_audio = spatial_audio; - self.press_sensitivity = press_sensitivity.unwrap_or_default(); - self.button_configuration = button_configuration; - } -} diff --git a/lib/src/devices/soundcore/a3953/structures.rs b/lib/src/devices/soundcore/a3953/structures.rs deleted file mode 100644 index 29dd7ccd3..000000000 --- a/lib/src/devices/soundcore/a3953/structures.rs +++ /dev/null @@ -1,239 +0,0 @@ -use nom::{ - IResult, Parser, - combinator::map, - error::{ContextError, ParseError, context}, - number::complete::le_u8, -}; - -use crate::devices::soundcore::common::{ - modules::sound_modes_v2::ToPacketBody, - packet::{self, inbound::FromPacketBody, parsing::take_bool}, - structures::{AmbientSoundMode, Flag, WindNoise}, -}; - -// [0x06,0x81] outbound (A3953CmdService.G1) / A3953AnalysisService.T2 inbound; anc_option_manual -// clamped 1-6, trans_option 0-2 (write only), other fields raw; wind_noise.is_detected never sent -// back. The official app only shows 3 ambient sound modes for this device (Normal/Transparency/ -// Noise Canceling), no adjustable ANC strength, confirmed by direct inspection on real hardware -// (2026-09-14), so anc_option_manual/anc_option_auto/anc_automation_mode/anc_auto_sensitivity_level -// and trans_option are preserved on write but deliberately not exposed as settings. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub struct SoundModes { - pub ambient_sound_mode: AmbientSoundMode, - pub anc_option_manual: u8, - pub anc_option_auto: u8, - pub trans_option: u8, - pub anc_automation_mode: u8, - pub wind_noise: WindNoise, - pub anc_auto_sensitivity_level: u8, -} - -impl SoundModes { - pub fn bytes(&self) -> [u8; 6] { - [ - self.ambient_sound_mode as u8, - (self.anc_option_manual.clamp(1, 6) << 4) | (self.anc_option_auto & 0x0F), - self.trans_option.clamp(0, 2), - self.anc_automation_mode, - u8::from(self.wind_noise.is_suppression_enabled), - self.anc_auto_sensitivity_level, - ] - } -} - -impl FromPacketBody for SoundModes { - type DirectionMarker = packet::InboundMarker; - - fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( - input: &'a [u8], - ) -> IResult<&'a [u8], Self, E> { - context( - "a3953 sound modes", - map( - ( - AmbientSoundMode::take, - le_u8, - le_u8, - le_u8, - WindNoise::take, - le_u8, - ), - |( - ambient_sound_mode, - manual_auto_byte, - trans_option, - anc_automation_mode, - wind_noise, - anc_auto_sensitivity_level, - )| Self { - ambient_sound_mode, - anc_option_manual: (manual_auto_byte >> 4).clamp(1, 6), - anc_option_auto: manual_auto_byte & 0x0F, - trans_option, - anc_automation_mode, - wind_noise, - anc_auto_sensitivity_level, - }, - ), - ) - .parse_complete(input) - } -} - -impl ToPacketBody for SoundModes { - fn bytes(&self) -> Vec { - self.bytes().to_vec() - } -} - -// bArr[113..129] (A3953AnalysisService.R0/G0) wired into the shared -// common::modules::button_configuration machinery instead of a hand-rolled read-only struct. -// button_id per press kind and the TwsLowBits nibble packing were confirmed byte-for-byte on real -// hardware (2026-09-14). See a3953.rs's BUTTON_CONFIGURATION_SETTINGS for the write confirmation. - -// [0x04,0x85] (Cmm2CmdData.L0/CmmBtCmdService.O); 0-4, no known display labels (string table wasn't decoded) -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub struct PressSensitivity(pub u8); - -impl PressSensitivity { - pub fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( - input: &'a [u8], - ) -> IResult<&'a [u8], Self, E> { - map(le_u8, |value: u8| Self(value.min(4))).parse_complete(input) - } - - pub fn bytes(&self) -> [u8; 1] { - [self.0] - } -} - -// A3953SpatialAudioVM.SPATIAL_MODE_FIXED/HEAD_TRACKING = 1/2 -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Default, - ::strum::FromRepr, - ::strum::Display, - ::strum::IntoStaticStr, - ::strum::EnumString, - ::strum::EnumIter, - ::strum::VariantArray, - ::openscq30_i18n_macros::Translate, -)] -#[repr(u8)] -pub enum SpatialMode { - #[default] - Fixed = 1, - HeadTracking = 2, -} - -impl SpatialMode { - pub fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( - input: &'a [u8], - ) -> IResult<&'a [u8], Self, E> { - map(le_u8, |b| Self::from_repr(b).unwrap_or_default()).parse_complete(input) - } -} - -// A3953SpatialAudioVM.SOUND_MODE_MUSIC/MOVIE = 0/1 -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Default, - ::strum::FromRepr, - ::strum::Display, - ::strum::IntoStaticStr, - ::strum::EnumString, - ::strum::EnumIter, - ::strum::VariantArray, - ::openscq30_i18n_macros::Translate, -)] -#[repr(u8)] -pub enum SpatialContentMode { - #[default] - Music = 0, - Movie = 1, -} - -impl SpatialContentMode { - pub fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( - input: &'a [u8], - ) -> IResult<&'a [u8], Self, E> { - map(le_u8, |b| Self::from_repr(b).unwrap_or_default()).parse_complete(input) - } -} - -// [0x10,0x81] (Cmm2CmdData.u2/CmmBtCmdService.b4); byte order matches A3953AnalysisService.R0's read order -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub struct SpatialAudio { - pub is_enabled: bool, - pub effect_mode: SpatialMode, - pub sound_mode: SpatialContentMode, -} - -impl SpatialAudio { - pub fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( - input: &'a [u8], - ) -> IResult<&'a [u8], Self, E> { - context( - "a3953 spatial audio", - map( - (take_bool, SpatialMode::take, SpatialContentMode::take), - |(is_enabled, effect_mode, sound_mode)| Self { - is_enabled, - effect_mode, - sound_mode, - }, - ), - ) - .parse_complete(input) - } - - pub fn bytes(&self) -> [u8; 3] { - [ - u8::from(self.is_enabled), - self.effect_mode as u8, - self.sound_mode as u8, - ] - } -} - -// [0x10,0x83] (Cmm2CmdData.w2/CmmBtCmdService.e1) -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub struct AmbientSoundPrompt(pub bool); - -impl AmbientSoundPrompt { - pub fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( - input: &'a [u8], - ) -> IResult<&'a [u8], Self, E> { - map(take_bool, Self).parse_complete(input) - } - - pub fn bytes(&self) -> [u8; 1] { - [u8::from(self.0)] - } -} - -impl Flag for AmbientSoundPrompt { - fn get_bool(&self) -> bool { - self.0 - } - - fn set_bool(&mut self, value: bool) { - self.0 = value; - } -} - -// bArr[63] (A3953AnalysisService.R0's m3); 255 or 254 = uninitialized (unlike a3955, which only -// checks one sentinel). Determines whether the equalizer write path sends the "uninitialized" -// sentinel bytes or the real hear id already stored on the device. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub struct IsHearIdInitialized(pub bool); diff --git a/lib/src/devices/soundcore/common/structures/dual_connections.rs b/lib/src/devices/soundcore/common/structures/dual_connections.rs index 0e4241be0..c9ccfe87f 100644 --- a/lib/src/devices/soundcore/common/structures/dual_connections.rs +++ b/lib/src/devices/soundcore/common/structures/dual_connections.rs @@ -32,17 +32,9 @@ impl DualConnectionsDevice { (le_u8, le_u8, le_u8, le_u8, le_u8, le_u8), ) .parse_complete(input)?; - // Name, right padded with 0s. Clamped to what's actually left: real devices have been - // observed sending a lone trailing entry (alone in the final packet of a multi-packet - // response) padded a few bytes short of what its own length byte implies. - let declared_name_length = usize::from(length.saturating_sub(8)); - let name_length = declared_name_length.min(input.len()); - if name_length < declared_name_length { - tracing::warn!( - "dual connections device length byte claims {declared_name_length} name bytes, only {name_length} remain, truncating" - ); - } - let (input, name_bytes) = take(name_length).parse_complete(input)?; + let name_length = length - 8; + // Name, right padded with 0s + let (input, name_bytes) = take(usize::from(name_length)).parse_complete(input)?; let mac_address = MacAddr6::from(<[u8; 6]>::from(mac_address_bytes));