diff --git a/README.md b/README.md index 39b7ec4ea..4d93154db 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ If there's a device not in this list that you want to see supported, check if th | A3957 | Soundcore Liberty 5 | | A3959 | Soundcore P30i / R50i NC | | A3968 | Soundcore Sport X20 | +| A6611 | Soundcore Sleep A20 | | D1101 | Soundcore C50i | | D1202 | Soundcore P31i | | D1202C | Soundcore R60i NC | diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/deviceselection/components/DeviceCard.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/deviceselection/components/DeviceCard.kt index 508cfd9d8..2a8c0b5fe 100644 --- a/android/app/src/main/java/com/oppzippy/openscq30/ui/deviceselection/components/DeviceCard.kt +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/deviceselection/components/DeviceCard.kt @@ -146,6 +146,7 @@ private fun DeviceModelIcon(modifier: Modifier, model: String) { "SoundcoreA3951" -> EarbudsIcon(modifier) "SoundcoreA3955" -> EarbudsIcon(modifier) "SoundcoreA3959" -> EarbudsIcon(modifier) + "SoundcoreA6611" -> EarbudsIcon(modifier) "SoundcoreDevelopment" -> HeadphonesIcon(modifier) else -> HeadphonesIcon(modifier) } diff --git a/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/screens/SettingCategoryScreen.kt b/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/screens/SettingCategoryScreen.kt index 6d770b11b..abae82cdd 100644 --- a/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/screens/SettingCategoryScreen.kt +++ b/android/app/src/main/java/com/oppzippy/openscq30/ui/devicesettings/screens/SettingCategoryScreen.kt @@ -26,18 +26,22 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TimePicker import androidx.compose.material3.TimePickerDialog import androidx.compose.material3.TimePickerState import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag @@ -46,6 +50,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.getSystemService import com.oppzippy.openscq30.R +import com.oppzippy.openscq30.lib.bindings.settingRequiresConfirmation import com.oppzippy.openscq30.lib.bindings.translateSettingId import com.oppzippy.openscq30.lib.wrapper.ModifiableSelectCommandInner import com.oppzippy.openscq30.lib.wrapper.MultiSelectWithRemoveCommandInner @@ -98,11 +103,19 @@ private fun FallbackSettingCategoryScreen( settings.forEach { (settingId, setting) -> val name = translateSettingId(settingId) when (setting) { - is Setting.ToggleSetting -> Toggle( - name = name, - isEnabled = setting.value, - onChange = { setSetting(settingId, it.toValue()) }, - ) + is Setting.ToggleSetting -> if (settingRequiresConfirmation(settingId)) { + ConfirmedToggle( + name = name, + isEnabled = setting.value, + onChange = { setSetting(settingId, it.toValue()) }, + ) + } else { + Toggle( + name = name, + isEnabled = setting.value, + onChange = { setSetting(settingId, it.toValue()) }, + ) + } is Setting.EqualizerSetting -> Equalizer( name = name, @@ -187,6 +200,55 @@ private fun Toggle(name: String, isEnabled: Boolean, onChange: (Boolean) -> Unit LabeledSwitch(name, isEnabled, onChange) } +// Turning on requires explicit confirmation, cancel is the default for every dismissal path. Turning off is immediate. +@Composable +private fun ConfirmedToggle(name: String, isEnabled: Boolean, onChange: (Boolean) -> Unit) { + var isDialogOpen by remember { mutableStateOf(false) } + LabeledSwitch( + label = name, + isChecked = isEnabled, + onCheckedChange = { newValue -> + if (newValue) { + isDialogOpen = true + } else { + onChange(false) + } + }, + ) + if (isDialogOpen) { + val cancelFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { cancelFocusRequester.requestFocus() } + AlertDialog( + modifier = Modifier.testTag("confirmToggleDialog"), + onDismissRequest = { isDialogOpen = false }, + icon = { Icon(painterResource(R.drawable.warning_24px), contentDescription = null) }, + title = { Text(stringResource(R.string.loud_sound_warning_title)) }, + text = { Text(stringResource(R.string.loud_sound_warning_message, name)) }, + confirmButton = { + Button( + modifier = Modifier + .focusRequester(cancelFocusRequester) + .testTag("confirmToggleCancel"), + onClick = { isDialogOpen = false }, + ) { + Text(stringResource(R.string.cancel)) + } + }, + dismissButton = { + TextButton( + modifier = Modifier.testTag("confirmToggleYes"), + onClick = { + isDialogOpen = false + onChange(true) + }, + ) { + Text(stringResource(R.string.yes)) + } + }, + ) + } +} + @Composable private fun InformationSetting(name: String, text: String) { val context = LocalContext.current diff --git a/android/app/src/main/java/com/oppzippy/openscq30/widget/SettingWidget.kt b/android/app/src/main/java/com/oppzippy/openscq30/widget/SettingWidget.kt index ce39c8b4e..ebcfb2415 100644 --- a/android/app/src/main/java/com/oppzippy/openscq30/widget/SettingWidget.kt +++ b/android/app/src/main/java/com/oppzippy/openscq30/widget/SettingWidget.kt @@ -63,6 +63,7 @@ import com.oppzippy.openscq30.R import com.oppzippy.openscq30.features.soundcoredevice.service.ConnectionStatus import com.oppzippy.openscq30.features.soundcoredevice.service.DeviceService import com.oppzippy.openscq30.lib.bindings.OpenScq30Session +import com.oppzippy.openscq30.lib.bindings.settingRequiresConfirmation import com.oppzippy.openscq30.lib.bindings.translateDeviceModel import com.oppzippy.openscq30.lib.bindings.translateSettingId import com.oppzippy.openscq30.lib.wrapper.PairedDevice @@ -202,7 +203,7 @@ class SettingWidget : GlanceAppWidget() { } else { SettingWidgetState.Connected( deviceName = translateDeviceModel(device.model()), - settings = settingIds.map { settingId -> + settings = settingIds.filterNot { settingRequiresConfirmation(it) }.map { settingId -> Pair( settingId, device.setting(settingId), diff --git a/android/app/src/main/java/com/oppzippy/openscq30/widget/SettingWidgetConfigurationActivity.kt b/android/app/src/main/java/com/oppzippy/openscq30/widget/SettingWidgetConfigurationActivity.kt index e1baa77fb..857c1f505 100644 --- a/android/app/src/main/java/com/oppzippy/openscq30/widget/SettingWidgetConfigurationActivity.kt +++ b/android/app/src/main/java/com/oppzippy/openscq30/widget/SettingWidgetConfigurationActivity.kt @@ -51,6 +51,7 @@ import com.oppzippy.openscq30.R import com.oppzippy.openscq30.features.soundcoredevice.service.ConnectionStatus import com.oppzippy.openscq30.features.soundcoredevice.service.DeviceService import com.oppzippy.openscq30.lib.bindings.OpenScq30Session +import com.oppzippy.openscq30.lib.bindings.settingRequiresConfirmation import com.oppzippy.openscq30.lib.bindings.translateCategoryId import com.oppzippy.openscq30.lib.bindings.translateSettingId import com.oppzippy.openscq30.ui.DeviceServiceConnection @@ -290,7 +291,11 @@ private fun Content( enabledSettingIds = enabledSettingIds, settingCategories = device.categories() .map { categoryId -> - Pair(categoryId, device.settingsInCategory(categoryId)) + Pair( + categoryId, + device.settingsInCategory(categoryId) + .filterNot { settingRequiresConfirmation(it) }, + ) }, onToggle = { settingId, isEnabled -> onSetSettingIdEnabled(device.model(), settingId, isEnabled) diff --git a/android/app/src/main/res/drawable/warning_24px.xml b/android/app/src/main/res/drawable/warning_24px.xml new file mode 100644 index 000000000..6c1092558 --- /dev/null +++ b/android/app/src/main/res/drawable/warning_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 9a8dee83d..0c920ac5d 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -116,4 +116,7 @@ Visible Settings Appearance Compact Layout + Yes + Loud sound warning + %s plays a very loud tone that can damage your hearing. Remove the earbud from your ear before continuing. Turn it on? diff --git a/android/src/lib.rs b/android/src/lib.rs index 23dd019f6..c3bd13987 100644 --- a/android/src/lib.rs +++ b/android/src/lib.rs @@ -64,3 +64,8 @@ pub enum AndroidError { pub fn device_models() -> Vec { DeviceModel::iter().map(serializable::DeviceModel).collect() } + +#[uniffi::export] +pub fn setting_requires_confirmation(setting_id: serializable::SettingId) -> bool { + setting_id.0.requires_confirmation() +} diff --git a/lib/i18n/en/openscq30-lib.ftl b/lib/i18n/en/openscq30-lib.ftl index f178224d1..f1f6a19d1 100644 --- a/lib/i18n/en/openscq30-lib.ftl +++ b/lib/i18n/en/openscq30-lib.ftl @@ -37,6 +37,7 @@ soundcore-d1101 = Soundcore C50i soundcore-d1202 = Soundcore P31i soundcore-d1202c = Soundcore R60i NC soundcore-d1301 = Soundcore Sleep A30 +soundcore-a6611 = Soundcore Sleep A20 soundcore-development = Soundcore Development Information general = General @@ -243,6 +244,9 @@ case-language = Case Language atmospheric = Atmospheric remote-camera = Remote Camera find-device = Find Device +find-device-left = Find Left Earbud +find-device-right = Find Right Earbud +sleep-mode = Sleep Mode spatial-audio = Spatial Audio spatial-audio-mode = Spatial Audio Mode spatial-audio-music-mode = Spatial Audio Music Mode diff --git a/lib/src/api/quick_presets.rs b/lib/src/api/quick_presets.rs index d5802edff..507ffe2f0 100644 --- a/lib/src/api/quick_presets.rs +++ b/lib/src/api/quick_presets.rs @@ -53,7 +53,7 @@ impl QuickPresetsHandler { setting_id, SettingId::ImportCustomEqualizerProfiles | SettingId::ExportCustomEqualizerProfiles, - ) + ) && !setting_id.requires_confirmation() }) .filter_map(|setting_id| { device diff --git a/lib/src/api/settings.rs b/lib/src/api/settings.rs index 8a05488ac..ef904e6a7 100644 --- a/lib/src/api/settings.rs +++ b/lib/src/api/settings.rs @@ -262,6 +262,17 @@ pub enum SettingId { Alarm5Volume, #[translate("alarm-n-snooze-duration-minutes", number = 5)] Alarm5SnoozeDuration, + FindDeviceLeft, + FindDeviceRight, + SleepMode, +} + +impl SettingId { + /// Settings that can cause harm if changed by accident, such as ringing an earbud while it is being worn. Frontends + /// should ask for confirmation before enabling these, and they are excluded from quick presets. + pub fn requires_confirmation(&self) -> bool { + matches!(self, Self::FindDeviceLeft | Self::FindDeviceRight) + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/lib/src/devices/device_model.rs b/lib/src/devices/device_model.rs index 88be059ed..19fdaa9d6 100644 --- a/lib/src/devices/device_model.rs +++ b/lib/src/devices/device_model.rs @@ -69,6 +69,7 @@ pub enum DeviceModel { SoundcoreD1202, SoundcoreD1202C, SoundcoreD1301, + SoundcoreA6611, SoundcoreDevelopment, } @@ -131,6 +132,7 @@ impl DeviceModel { Self::SoundcoreD1202 => new_soundcore_device!(soundcore::d1202), Self::SoundcoreD1202C => new_soundcore_device!(soundcore::d1202), Self::SoundcoreD1301 => new_soundcore_device!(soundcore::d1301), + Self::SoundcoreA6611 => new_soundcore_device!(soundcore::a6611), Self::SoundcoreDevelopment => new_soundcore_device!(soundcore::development), } } @@ -188,6 +190,7 @@ impl DeviceModel { Self::SoundcoreD1202 => new_soundcore_device!(soundcore::d1202), Self::SoundcoreD1202C => new_soundcore_device!(soundcore::d1202), Self::SoundcoreD1301 => new_soundcore_device!(soundcore::d1301), + Self::SoundcoreA6611 => new_soundcore_device!(soundcore::a6611), Self::SoundcoreDevelopment => new_soundcore_device!(soundcore::development), } } diff --git a/lib/src/devices/soundcore.rs b/lib/src/devices/soundcore.rs index 0dc10d818..14c95608d 100644 --- a/lib/src/devices/soundcore.rs +++ b/lib/src/devices/soundcore.rs @@ -29,6 +29,7 @@ pub mod a3955; pub mod a3957; pub mod a3959; pub mod a3968; +pub mod a6611; pub mod common; pub mod d1101; pub mod d1202; diff --git a/lib/src/devices/soundcore/a6611.rs b/lib/src/devices/soundcore/a6611.rs new file mode 100644 index 000000000..2d1646536 --- /dev/null +++ b/lib/src/devices/soundcore/a6611.rs @@ -0,0 +1,247 @@ +use std::collections::HashMap; + +use crate::devices::soundcore::{ + a6611::{packets::inbound::A6611StateUpdatePacket, state::A6611State}, + common::{ + macros::soundcore_device, + modules::dual_battery_level::DualBatteryLevelConfiguration, + packet::{ + inbound::TryToPacket, + outbound::{RequestState, ToPacket}, + }, + }, +}; + +mod modules; +mod packets; +mod state; +mod structures; + +soundcore_device!( + A6611State, + async |packet_io| { + let state_update_packet: A6611StateUpdatePacket = packet_io + .send_with_response(&RequestState.to_packet()) + .await? + .try_to_packet()?; + Ok(A6611State::new(state_update_packet)) + }, + async |builder| { + builder.module_collection().add_state_update(); + + builder.a6611_sleep_mode(); + builder.a6611_find_device(); + + builder.tws_status(); + builder.dual_battery_level_custom(DualBatteryLevelConfiguration { + max_level: 10, + level_offset: 1, + }); + builder.serial_number_and_dual_firmware_version(); + }, + { + HashMap::from([( + RequestState::COMMAND, + A6611StateUpdatePacket::default().to_packet(), + )]) + }, +); + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, sync::Arc, time::Duration}; + + use macaddr::MacAddr6; + use tokio::sync::mpsc; + + use crate::{ + DeviceModel, + api::{device::OpenSCQ30DeviceRegistry, settings::Value}, + connection_backend::mock::rfcomm::MockRfcommBackend, + devices::soundcore::{ + a6611::packets::inbound::BLUETOOTH_MODE_UPDATE_COMMAND, + common::{ + device::{SoundcoreDeviceConfig, test_utils::TestSoundcoreDevice}, + packet, + }, + }, + settings::SettingId, + storage::OpenSCQ30Database, + }; + + // Captured from a real A6611 on firmware 01.57 in sleep mode + const REAL_STATE_UPDATE_BODY: &[u8] = &[ + 0x01, 0x01, 0x09, 0x09, 0x30, 0x31, 0x2e, 0x35, 0x37, 0x30, 0x31, 0x2e, 0x35, 0x37, 0x36, + 0x36, 0x31, 0x31, 0x46, 0x34, 0x39, 0x44, 0x38, 0x41, 0x31, 0x45, 0x44, 0x42, 0x38, 0x38, + 0x00, 0x00, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x06, 0xff, 0xff, 0x66, 0x66, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x01, 0x3c, 0x00, 0x00, 0xff, 0xff, 0xff, 0x01, + 0x01, 0x00, 0x31, 0x37, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x01, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x01, 0x00, 0x00, 0x01, 0x07, 0x00, 0x00, 0x00, 0x00, + ]; + + const BLUETOOTH_MODE_INDEX: usize = 65; + + async fn device_with_state(body: Vec) -> TestSoundcoreDevice { + TestSoundcoreDevice::new( + super::device_registry, + DeviceModel::SoundcoreA6611, + HashMap::from([( + packet::Command([1, 1]), + packet::Inbound::new(packet::Command([1, 1]), body), + )]), + SoundcoreDeviceConfig::default(), + ) + .await + } + + async fn real_device() -> TestSoundcoreDevice { + device_with_state(REAL_STATE_UPDATE_BODY.to_vec()).await + } + + #[tokio::test(start_paused = true)] + async fn test_parses_real_state_update() { + let device = real_device().await; + device.assert_setting_values([ + (SettingId::FirmwareVersionLeft, "01.57".into()), + (SettingId::FirmwareVersionRight, "01.57".into()), + (SettingId::SerialNumber, "6611F49D8A1EDB88".into()), + (SettingId::BatteryLevelLeft, "10/10".into()), + (SettingId::BatteryLevelRight, "10/10".into()), + (SettingId::SleepMode, true.into()), + (SettingId::FindDeviceLeft, false.into()), + (SettingId::FindDeviceRight, false.into()), + ]); + } + + // Captured from a real A6611 on firmware 03.22 in Bluetooth mode + const REAL_BLUETOOTH_MODE_STATE_UPDATE_BODY: &[u8] = &[ + 0x01, 0x01, 0x09, 0x09, 0x30, 0x33, 0x2e, 0x32, 0x32, 0x30, 0x33, 0x2e, 0x32, 0x32, 0x36, + 0x36, 0x31, 0x31, 0x39, 0x30, 0x42, 0x46, 0x44, 0x39, 0x37, 0x30, 0x31, 0x38, 0x35, 0x38, + 0xfe, 0xfe, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x06, 0xff, 0xff, 0xff, 0xdd, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0x01, 0x01, 0x01, 0x3c, 0x00, 0x00, 0xff, 0xff, 0xff, 0x01, + 0x01, 0x00, 0x31, 0x0d, 0x00, 0x00, 0x02, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x01, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x01, 0x00, 0x00, 0x01, 0x05, 0x00, 0x00, 0x00, 0x00, + ]; + + #[tokio::test(start_paused = true)] + async fn test_parses_bluetooth_mode() { + assert_eq!(REAL_STATE_UPDATE_BODY[BLUETOOTH_MODE_INDEX], 0); + assert_eq!( + REAL_BLUETOOTH_MODE_STATE_UPDATE_BODY[BLUETOOTH_MODE_INDEX], + 1 + ); + let device = device_with_state(REAL_BLUETOOTH_MODE_STATE_UPDATE_BODY.to_vec()).await; + device.assert_setting_values([ + (SettingId::SleepMode, false.into()), + (SettingId::FirmwareVersionLeft, "03.22".into()), + (SettingId::SerialNumber, "661190BFD9701858".into()), + ]); + } + + #[tokio::test(start_paused = true)] + async fn test_sleep_mode_follows_device_notifications() { + let device = real_device().await; + device + .receive_packet(packet::Inbound::new(BLUETOOTH_MODE_UPDATE_COMMAND, vec![1])) + .await; + device.assert_setting_values([(SettingId::SleepMode, false.into())]); + device + .receive_packet(packet::Inbound::new(BLUETOOTH_MODE_UPDATE_COMMAND, vec![0])) + .await; + device.assert_setting_values([(SettingId::SleepMode, true.into())]); + } + + // Expected packets are the official app's, captured from real earbuds. Every toggle must update the setting and + // notify the UI, which never happened on the A20 because its change notifications closed right after connecting. + #[tokio::test(start_paused = true)] + async fn test_toggles_match_official_app_and_notify() { + let (inbound_sender, inbound_receiver) = mpsc::channel(100); + let (outbound_sender, mut outbound_receiver) = mpsc::channel(100); + let registry = super::device_registry( + Arc::new(MockRfcommBackend::new(inbound_receiver, outbound_sender)), + Arc::new(OpenSCQ30Database::new_in_memory().await.unwrap()), + DeviceModel::SoundcoreA6611, + ); + let connect = tokio::spawn(async move { registry.connect(MacAddr6::nil()).await.unwrap() }); + outbound_receiver.recv().await.expect("state request"); + inbound_sender + .send( + packet::Inbound::new(packet::Command([1, 1]), REAL_STATE_UPDATE_BODY.to_vec()) + .bytes_with_checksum(), + ) + .await + .unwrap(); + let device = connect.await.unwrap(); + let mut changes = device.watch_for_changes(); + changes.borrow_and_update(); + + for (setting_id, value, command, body) in [ + (SettingId::FindDeviceLeft, true, [0x10, 0x89], vec![1, 0, 0]), + ( + SettingId::FindDeviceLeft, + false, + [0x10, 0x89], + vec![0, 0, 0], + ), + ( + SettingId::FindDeviceRight, + true, + [0x10, 0x89], + vec![0, 1, 0], + ), + ( + SettingId::FindDeviceRight, + false, + [0x10, 0x89], + vec![0, 0, 0], + ), + (SettingId::SleepMode, false, [0x01, 0xa9], vec![1]), + (SettingId::SleepMode, true, [0x01, 0xa9], vec![0]), + (SettingId::SleepMode, false, [0x01, 0xa9], vec![1]), + ] { + let set = tokio::spawn({ + let device = device.clone(); + async move { + device + .set_setting_values(vec![(setting_id, value.into())]) + .await + } + }); + assert_eq!( + outbound_receiver.recv().await.unwrap(), + packet::Outbound::new(packet::Command(command), body).bytes_with_checksum(), + ); + // Acked like the real earbuds do, with an empty body + inbound_sender + .send( + packet::Inbound::new(packet::Command(command), Vec::new()) + .bytes_with_checksum(), + ) + .await + .unwrap(); + set.await.unwrap().expect("acked write should succeed"); + assert_eq!( + Value::from(device.setting(&setting_id).unwrap()), + value.into(), + "{setting_id} should reflect the toggle", + ); + tokio::time::timeout(Duration::from_secs(1), changes.changed()) + .await + .expect("the UI must be notified of the change") + .expect("change notifications must stay open"); + } + } + + #[test] + fn test_find_device_requires_confirmation() { + assert!(SettingId::FindDeviceLeft.requires_confirmation()); + assert!(SettingId::FindDeviceRight.requires_confirmation()); + assert!(!SettingId::SleepMode.requires_confirmation()); + } +} diff --git a/lib/src/devices/soundcore/a6611/modules.rs b/lib/src/devices/soundcore/a6611/modules.rs new file mode 100644 index 000000000..f0babfe22 --- /dev/null +++ b/lib/src/devices/soundcore/a6611/modules.rs @@ -0,0 +1,24 @@ +use openscq30_lib_has::Has; + +use crate::devices::soundcore::{ + a6611::structures::{BluetoothMode, FindDevice}, + common::device::SoundcoreDeviceBuilder, +}; + +mod find_device; +mod sleep_mode; + +impl SoundcoreDeviceBuilder +where + StateType: Has + Has + Clone + Send + Sync + 'static, +{ + pub fn a6611_find_device(&mut self) { + let packet_io = self.packet_io_controller().clone(); + self.module_collection().add_a6611_find_device(packet_io); + } + + pub fn a6611_sleep_mode(&mut self) { + let packet_io = self.packet_io_controller().clone(); + self.module_collection().add_a6611_sleep_mode(packet_io); + } +} diff --git a/lib/src/devices/soundcore/a6611/modules/find_device.rs b/lib/src/devices/soundcore/a6611/modules/find_device.rs new file mode 100644 index 000000000..5b25bfd0a --- /dev/null +++ b/lib/src/devices/soundcore/a6611/modules/find_device.rs @@ -0,0 +1,109 @@ +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::{ + a6611::{packets::outbound::set_find_device, structures::FindDevice}, + common::{ + modules::ModuleCollection, + packet::PacketIOController, + settings_manager::{SettingHandler, SettingHandlerResult}, + state_modifier::StateModifier, + }, + }, + macros::enum_subset, +}; + +enum_subset! { + SettingId, + #[derive(EnumString, EnumIter, IntoStaticStr)] + enum FindDeviceSetting { + FindDeviceLeft, + FindDeviceRight, + } +} + +impl ModuleCollection +where + T: Has + Clone + Send + Sync, +{ + pub fn add_a6611_find_device(&mut self, packet_io: Arc) { + self.setting_manager + .add_handler(CategoryId::Miscellaneous, FindDeviceSettingHandler); + self.state_modifiers + .push(Box::new(FindDeviceStateModifier { packet_io })); + } +} + +struct FindDeviceSettingHandler; + +#[async_trait] +impl SettingHandler for FindDeviceSettingHandler +where + T: Has + Send, +{ + fn settings(&self) -> Vec { + FindDeviceSetting::iter().map(Into::into).collect() + } + + fn get(&self, state: &T, setting_id: &SettingId) -> Option { + let find_device: &FindDevice = state.get(); + let value = match FindDeviceSetting::try_from(*setting_id).ok()? { + FindDeviceSetting::FindDeviceLeft => find_device.left, + FindDeviceSetting::FindDeviceRight => find_device.right, + }; + Some(Setting::Toggle { value }) + } + + async fn set( + &self, + state: &mut T, + setting_id: &SettingId, + value: Value, + ) -> SettingHandlerResult<()> { + let setting: FindDeviceSetting = (*setting_id) + .try_into() + .expect("already filtered to valid values only by SettingsManager"); + let enabled = value.try_as_bool()?; + let find_device: &mut FindDevice = state.get_mut(); + match setting { + FindDeviceSetting::FindDeviceLeft => find_device.left = enabled, + FindDeviceSetting::FindDeviceRight => find_device.right = enabled, + } + Ok(()) + } +} + +struct FindDeviceStateModifier { + packet_io: Arc, +} + +#[async_trait] +impl StateModifier for FindDeviceStateModifier +where + T: Has + Send + Sync, +{ + async fn move_to_state( + &self, + state_sender: &watch::Sender, + target_state: &T, + ) -> device::Result<()> { + let current: FindDevice = *state_sender.borrow().get(); + let target: FindDevice = *target_state.get(); + if current != target { + self.packet_io + .send_with_response(&set_find_device(target)) + .await?; + state_sender.send_modify(|state| *state.get_mut() = target); + } + Ok(()) + } +} diff --git a/lib/src/devices/soundcore/a6611/modules/sleep_mode.rs b/lib/src/devices/soundcore/a6611/modules/sleep_mode.rs new file mode 100644 index 000000000..b74750da5 --- /dev/null +++ b/lib/src/devices/soundcore/a6611/modules/sleep_mode.rs @@ -0,0 +1,134 @@ +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::{ + a6611::{ + packets::{inbound::BLUETOOTH_MODE_UPDATE_COMMAND, outbound::set_bluetooth_mode}, + structures::BluetoothMode, + }, + common::{ + modules::ModuleCollection, + packet::{self, PacketIOController}, + packet_manager::PacketHandler, + settings_manager::{SettingHandler, SettingHandlerResult}, + state_modifier::StateModifier, + }, + }, + macros::enum_subset, +}; + +enum_subset! { + SettingId, + #[derive(EnumString, EnumIter, IntoStaticStr)] + enum SleepModeSetting { + SleepMode, + } +} + +impl ModuleCollection +where + T: Has + Clone + Send + Sync, +{ + pub fn add_a6611_sleep_mode(&mut self, packet_io: Arc) { + self.setting_manager + .add_handler(CategoryId::Miscellaneous, SleepModeSettingHandler); + self.state_modifiers + .push(Box::new(SleepModeStateModifier { packet_io })); + self.packet_handlers.set_handler( + BLUETOOTH_MODE_UPDATE_COMMAND, + Box::new(BluetoothModeUpdateHandler), + ); + } +} + +struct SleepModeSettingHandler; + +#[async_trait] +impl SettingHandler for SleepModeSettingHandler +where + T: Has + Send, +{ + fn settings(&self) -> Vec { + SleepModeSetting::iter().map(Into::into).collect() + } + + fn get(&self, state: &T, setting_id: &SettingId) -> Option { + let _: SleepModeSetting = (*setting_id).try_into().ok()?; + let bluetooth_mode: &BluetoothMode = state.get(); + Some(Setting::Toggle { + value: !bluetooth_mode.0, + }) + } + + async fn set( + &self, + state: &mut T, + _setting_id: &SettingId, + value: Value, + ) -> SettingHandlerResult<()> { + let is_sleep_mode = value.try_as_bool()?; + let bluetooth_mode: &mut BluetoothMode = state.get_mut(); + bluetooth_mode.0 = !is_sleep_mode; + Ok(()) + } +} + +struct SleepModeStateModifier { + packet_io: Arc, +} + +#[async_trait] +impl StateModifier for SleepModeStateModifier +where + T: Has + Send + Sync, +{ + async fn move_to_state( + &self, + state_sender: &watch::Sender, + target_state: &T, + ) -> device::Result<()> { + let current: BluetoothMode = *state_sender.borrow().get(); + let target: BluetoothMode = *target_state.get(); + if current != target { + self.packet_io + .send_with_response(&set_bluetooth_mode(target)) + .await?; + state_sender.send_modify(|state| *state.get_mut() = target); + } + Ok(()) + } +} + +struct BluetoothModeUpdateHandler; + +#[async_trait] +impl PacketHandler for BluetoothModeUpdateHandler +where + T: Has + Send + Sync, +{ + async fn handle_packet( + &self, + state: &watch::Sender, + packet: &packet::Inbound, + ) -> device::Result<()> { + if let Some(&value) = packet.body.first() { + let reported = BluetoothMode(value != 0); + state.send_if_modified(|state| { + let current: &mut BluetoothMode = state.get_mut(); + let modified = *current != reported; + *current = reported; + modified + }); + } + Ok(()) + } +} diff --git a/lib/src/devices/soundcore/a6611/packets.rs b/lib/src/devices/soundcore/a6611/packets.rs new file mode 100644 index 000000000..5756441b7 --- /dev/null +++ b/lib/src/devices/soundcore/a6611/packets.rs @@ -0,0 +1,2 @@ +pub mod inbound; +pub mod outbound; diff --git a/lib/src/devices/soundcore/a6611/packets/inbound.rs b/lib/src/devices/soundcore/a6611/packets/inbound.rs new file mode 100644 index 000000000..b811e8670 --- /dev/null +++ b/lib/src/devices/soundcore/a6611/packets/inbound.rs @@ -0,0 +1,120 @@ +use nom::{ + IResult, Parser, + bytes::complete::take, + combinator::{map, rest}, + error::{ContextError, ParseError, context}, +}; + +use crate::devices::soundcore::{ + a6611::{state::A6611State, structures::BluetoothMode}, + common::{ + macros::state_update_packet_module, + packet::{self, Command, inbound::FromPacketBody, outbound::ToPacket}, + structures::{DualBatteryLevel, DualFirmwareVersion, SerialNumber, TwsStatus}, + }, +}; + +// Sent by the earbuds whenever the mode changes, including from a double tap. Body is the same as the set command. +pub const BLUETOOTH_MODE_UPDATE_COMMAND: packet::Command = packet::Command([0x01, 0x14]); + +// Equalizer, volume adjustments, buttons and next alarm, see tools/soundcore-device-faker/devices/a6611.toml +const UNDECODED_BEFORE_BLUETOOTH_MODE_LEN: usize = 35; + +// Layout reference: tools/soundcore-device-faker/devices/a6611.toml +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct A6611StateUpdatePacket { + pub tws_status: TwsStatus, + pub battery: DualBatteryLevel, + pub firmware_version: DualFirmwareVersion, + pub serial_number: SerialNumber, + pub undecoded_before_bluetooth_mode: Vec, + pub bluetooth_mode: BluetoothMode, + // Undecoded remainder (sleep settings, prompts, case battery) + pub unknown: Vec, +} + +impl Default for A6611StateUpdatePacket { + fn default() -> Self { + Self { + tws_status: Default::default(), + battery: Default::default(), + firmware_version: Default::default(), + serial_number: Default::default(), + undecoded_before_bluetooth_mode: vec![0; UNDECODED_BEFORE_BLUETOOTH_MODE_LEN], + bluetooth_mode: Default::default(), + unknown: Vec::new(), + } + } +} + +impl FromPacketBody for A6611StateUpdatePacket { + type DirectionMarker = packet::InboundMarker; + + fn take<'a, E: ParseError<&'a [u8]> + ContextError<&'a [u8]>>( + input: &'a [u8], + ) -> IResult<&'a [u8], Self, E> { + context( + "a6611 state update packet", + map( + ( + TwsStatus::take, + DualBatteryLevel::take, + DualFirmwareVersion::take, + SerialNumber::take, + take(UNDECODED_BEFORE_BLUETOOTH_MODE_LEN), + BluetoothMode::take, + rest, + ), + |( + tws_status, + battery, + firmware_version, + serial_number, + undecoded_before_bluetooth_mode, + bluetooth_mode, + unknown, + ): ( + TwsStatus, + DualBatteryLevel, + DualFirmwareVersion, + SerialNumber, + &[u8], + BluetoothMode, + &[u8], + )| Self { + tws_status, + battery, + firmware_version, + serial_number, + undecoded_before_bluetooth_mode: undecoded_before_bluetooth_mode.to_vec(), + bluetooth_mode, + unknown: unknown.to_vec(), + }, + ), + ) + .parse_complete(input) + } +} + +impl ToPacket for A6611StateUpdatePacket { + type DirectionMarker = packet::InboundMarker; + + fn command(&self) -> Command { + packet::inbound::STATE_COMMAND + } + + fn body(&self) -> Vec { + self.tws_status + .bytes() + .into_iter() + .chain(self.battery.bytes()) + .chain(self.firmware_version.bytes()) + .chain(self.serial_number.bytes()) + .chain(self.undecoded_before_bluetooth_mode.iter().copied()) + .chain(self.bluetooth_mode.bytes()) + .chain(self.unknown.iter().copied()) + .collect() + } +} + +state_update_packet_module!(A6611State, A6611StateUpdatePacket); diff --git a/lib/src/devices/soundcore/a6611/packets/outbound.rs b/lib/src/devices/soundcore/a6611/packets/outbound.rs new file mode 100644 index 000000000..6904ba286 --- /dev/null +++ b/lib/src/devices/soundcore/a6611/packets/outbound.rs @@ -0,0 +1,21 @@ +use crate::devices::soundcore::{ + a6611::structures::{BluetoothMode, FindDevice}, + common::packet, +}; + +// Body is [1] for Bluetooth mode, [0] for sleep mode +pub const SET_BLUETOOTH_MODE_COMMAND: packet::Command = packet::Command([0x01, 0xa9]); + +pub fn set_bluetooth_mode(bluetooth_mode: BluetoothMode) -> packet::Outbound { + packet::Outbound::new(SET_BLUETOOTH_MODE_COMMAND, bluetooth_mode.bytes().to_vec()) +} + +pub const SET_FIND_DEVICE_COMMAND: packet::Command = packet::Command([0x10, 0x89]); + +// Body is [left, right, 0], taken from a capture of the official app +pub fn set_find_device(find_device: FindDevice) -> packet::Outbound { + packet::Outbound::new( + SET_FIND_DEVICE_COMMAND, + vec![find_device.left as u8, find_device.right as u8, 0], + ) +} diff --git a/lib/src/devices/soundcore/a6611/state.rs b/lib/src/devices/soundcore/a6611/state.rs new file mode 100644 index 000000000..9cf386367 --- /dev/null +++ b/lib/src/devices/soundcore/a6611/state.rs @@ -0,0 +1,45 @@ +use openscq30_lib_macros::Has; + +use crate::devices::soundcore::{ + a6611::structures::{BluetoothMode, FindDevice}, + common::{ + self, + structures::{DualBatteryLevel, DualFirmwareVersion, SerialNumber, TwsStatus}, + }, +}; + +use super::packets::inbound::A6611StateUpdatePacket; + +#[derive(Debug, Clone, PartialEq, Eq, Has)] +pub struct A6611State { + tws_status: TwsStatus, + battery: DualBatteryLevel, + firmware_version: DualFirmwareVersion, + serial_number: SerialNumber, + bluetooth_mode: BluetoothMode, + find_device: FindDevice, +} + +impl A6611State { + pub fn new(packet: A6611StateUpdatePacket) -> Self { + Self { + tws_status: packet.tws_status, + battery: packet.battery, + firmware_version: packet.firmware_version, + serial_number: packet.serial_number, + bluetooth_mode: packet.bluetooth_mode, + find_device: FindDevice::default(), + } + } +} + +// Not From: the blanket Update impl would reset find_device on updates +impl common::state::Update for A6611State { + fn update(&mut self, packet: A6611StateUpdatePacket) { + self.tws_status = packet.tws_status; + self.battery = packet.battery; + self.firmware_version = packet.firmware_version; + self.serial_number = packet.serial_number; + self.bluetooth_mode = packet.bluetooth_mode; + } +} diff --git a/lib/src/devices/soundcore/a6611/structures.rs b/lib/src/devices/soundcore/a6611/structures.rs new file mode 100644 index 000000000..68b0d5a41 --- /dev/null +++ b/lib/src/devices/soundcore/a6611/structures.rs @@ -0,0 +1,10 @@ +use crate::devices::soundcore::common::structures::flag; + +// Raw value from the device: true in Bluetooth mode, false in sleep mode +flag!(BluetoothMode); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct FindDevice { + pub left: bool, + pub right: bool, +} diff --git a/lib/src/devices/soundcore/common/device.rs b/lib/src/devices/soundcore/common/device.rs index cc963faad..8d58c952e 100644 --- a/lib/src/devices/soundcore/common/device.rs +++ b/lib/src/devices/soundcore/common/device.rs @@ -777,10 +777,16 @@ where let mut change_notify = self.change_notify.clone(); // receiver will close when self is dropped, so this will clean itself up tokio::spawn(async move { + // change_notify closes as soon as no module holds a sender for it, which is normal for devices without + // such modules. State changes must keep being forwarded after that. + let mut is_change_notify_open = true; loop { select! { result = receiver.changed() => if result.is_err() { return }, - result = change_notify.changed() => if result.is_err() { return }, + result = change_notify.changed(), if is_change_notify_open => if result.is_err() { + is_change_notify_open = false; + continue; + }, } if change_sender.send(()).is_err() { return; @@ -922,6 +928,15 @@ pub mod test_utils { &self.device } + /// Delivers an unsolicited packet from the device and gives its handler time to run. + pub async fn receive_packet(&self, packet: packet::Inbound) { + self.inbound_sender + .send(packet.bytes(self.config.checksum_kind)) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + } + #[track_caller] pub fn assert_setting_values( &self, diff --git a/lib/src/devices/soundcore/common/packet/multi_queue.rs b/lib/src/devices/soundcore/common/packet/multi_queue.rs index 02cca5e64..791e42ba2 100644 --- a/lib/src/devices/soundcore/common/packet/multi_queue.rs +++ b/lib/src/devices/soundcore/common/packet/multi_queue.rs @@ -94,8 +94,11 @@ impl MultiQueue { .iter() .position(|entry| Arc::ptr_eq(entry, &handle.current)) { - // No need to close the semaphore since we were given ownership of the only outside reference to it - queue.remove(index); + // The task queued behind this one holds a clone as its preceeding entry and waits for it to close, so it + // must be closed or every later task with the same key waits forever + if let Some(removed) = queue.remove(index) { + removed.semaphore.close(); + } } } } @@ -170,6 +173,25 @@ mod tests { } } + #[tokio::test(start_paused = true)] + async fn test_cancel_releases_waiting_successor() { + let queues = MultiQueue::::new(); + let first = queues.add(0); + let second = queues.add(0); + queues.cancel(&0, first); + assert!( + tokio::time::timeout(Duration::from_millis(1), second.wait_for_start()) + .await + .is_ok(), + "a request queued behind a cancelled one must be able to start", + ); + queues.pop(&0, 2); + assert_eq!( + tokio::time::timeout(Duration::from_millis(1), second.wait_for_value()).await, + Ok(2) + ); + } + #[tokio::test(start_paused = true)] async fn test_cancel() { let queues = MultiQueue::::new();