diff --git a/CHANGELOG.md b/CHANGELOG.md index 07e6d4cba..ead8fdef7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### General + +#### Features + +- Add support for Soundcore Liberty 4 (A3953) + ## v2.12.0 ### General diff --git a/README.md b/README.md index b43bc426b..b9993d8a0 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,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..a31ebd2c3 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 @@ -207,6 +208,8 @@ 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 17526e84d..158f3ca46 100644 --- a/lib/src/api/settings.rs +++ b/lib/src/api/settings.rs @@ -143,6 +143,8 @@ 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 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..2d8e6361e --- /dev/null +++ b/lib/src/devices/soundcore/a3953.rs @@ -0,0 +1,403 @@ +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/5 for single/double/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 and triple press were also confirmed on real hardware. Long press is left out: the +// official app hides it for this device (A3952BaseCustomUiM.init3953CustomTapBean), and writing it +// has no effect on real hardware. +pub const BUTTON_CONFIGURATION_SETTINGS: ButtonConfigurationSettings<6, 3> = + 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::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: 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 new file mode 100644 index 000000000..f014491f2 --- /dev/null +++ b/lib/src/devices/soundcore/a3953/modules.rs @@ -0,0 +1,86 @@ +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 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/modules/misc_toggles.rs b/lib/src/devices/soundcore/a3953/modules/misc_toggles.rs new file mode 100644 index 000000000..f963c5595 --- /dev/null +++ b/lib/src/devices/soundcore/a3953/modules/misc_toggles.rs @@ -0,0 +1,32 @@ +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 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..abf1410ed --- /dev/null +++ b/lib/src/devices/soundcore/a3953/modules/press_sensitivity/state_modifier.rs @@ -0,0 +1,54 @@ +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 new file mode 100644 index 000000000..1f62c99ed --- /dev/null +++ b/lib/src/devices/soundcore/a3953/modules/sound_modes.rs @@ -0,0 +1,36 @@ +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 new file mode 100644 index 000000000..169d4bfe5 --- /dev/null +++ b/lib/src/devices/soundcore/a3953/modules/sound_modes/setting_handler.rs @@ -0,0 +1,61 @@ +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 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..fb4cab4dd --- /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..c0d878416 --- /dev/null +++ b/lib/src/devices/soundcore/a3953/modules/spatial_audio/state_modifier.rs @@ -0,0 +1,56 @@ +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 new file mode 100644 index 000000000..7310c1f38 --- /dev/null +++ b/lib/src/devices/soundcore/a3953/packets.rs @@ -0,0 +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..d3bf2666b --- /dev/null +++ b/lib/src/devices/soundcore/a3953/packets/set_equalizer_configuration.rs @@ -0,0 +1,60 @@ +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 new file mode 100644 index 000000000..c7d7623d8 --- /dev/null +++ b/lib/src/devices/soundcore/a3953/packets/state_update.rs @@ -0,0 +1,264 @@ +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<6>, + 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 [ls, rs, ld, rd, lt, rt] = a3953::BUTTON_CONFIGURATION_SETTINGS.parse_settings(); + let ( + input, + ButtonStatusCollection([left_single, right_single, left_double, right_double]), + ) = ButtonStatusCollection::take([ls, rs, ld, rd])(input)?; + let (input, _long_press) = take(4usize)(input)?; // bArr[121..125], not configurable on this device + let (input, ButtonStatusCollection([left_triple, right_triple])) = + ButtonStatusCollection::take([lt, rt])(input)?; + let button_configuration = ButtonStatusCollection::new([ + left_single, + right_single, + left_double, + right_double, + left_triple, + right_triple, + ]); + 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 { + let buttons = self + .button_configuration + .bytes(a3953::BUTTON_CONFIGURATION_SETTINGS.parse_settings()) + .collect::>(); + let (single_double_buttons, triple_buttons) = buttons.split_at(8); // 4 buttons, 2 bytes each + 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(single_double_buttons.iter().copied()) + .chain([0; 4]) // long press + .chain(triple_buttons.iter().copied()) + .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 new file mode 100644 index 000000000..19efd7413 --- /dev/null +++ b/lib/src/devices/soundcore/a3953/state.rs @@ -0,0 +1,129 @@ +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<6>, + 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 new file mode 100644 index 000000000..29dd7ccd3 --- /dev/null +++ b/lib/src/devices/soundcore/a3953/structures.rs @@ -0,0 +1,239 @@ +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 c9ccfe87f..0e4241be0 100644 --- a/lib/src/devices/soundcore/common/structures/dual_connections.rs +++ b/lib/src/devices/soundcore/common/structures/dual_connections.rs @@ -32,9 +32,17 @@ 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 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));