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();