diff --git a/lib/src/devices/soundcore/common/device.rs b/lib/src/devices/soundcore/common/device.rs index cc963faad..be6b0a47b 100644 --- a/lib/src/devices/soundcore/common/device.rs +++ b/lib/src/devices/soundcore/common/device.rs @@ -267,6 +267,10 @@ where self.change_notify.clone() } + pub fn state_sender(&self) -> &watch::Sender { + &self.state_sender + } + pub fn sound_modes(&mut self, available_sound_modes: AvailableSoundModes) where StateType: Has, @@ -836,13 +840,42 @@ pub mod test_utils { config: SoundcoreDeviceConfig, delays: HashMap, ) -> Self + where + StateType: Clone + Send + Sync + 'static, + SoundcoreDeviceRegistry: BuildDevice, + { + let database = Arc::new(OpenSCQ30Database::new_in_memory().await.unwrap()); + Self::new_with_database( + constructor, + device_model, + packet_responses, + config, + delays, + database, + ) + .await + } + + /// Allows sharing a database between multiple connections, for testing things that are + /// persisted across reconnects. + pub async fn new_with_database( + constructor: fn( + Arc, + Arc, + DeviceModel, + ) -> SoundcoreDeviceRegistry, + device_model: DeviceModel, + packet_responses: HashMap, + config: SoundcoreDeviceConfig, + delays: HashMap, + database: Arc, + ) -> Self where StateType: Clone + Send + Sync + 'static, SoundcoreDeviceRegistry: BuildDevice, { let (inbound_sender, inbound_receiver) = mpsc::channel(100); let (outbound_sender, mut outbound_receiver) = mpsc::channel(100); - let database = Arc::new(OpenSCQ30Database::new_in_memory().await.unwrap()); let registry = constructor( Arc::new(MockRfcommBackend::new(inbound_receiver, outbound_sender)), diff --git a/lib/src/devices/soundcore/d1301.rs b/lib/src/devices/soundcore/d1301.rs index 6da40ede0..b7f5eb447 100644 --- a/lib/src/devices/soundcore/d1301.rs +++ b/lib/src/devices/soundcore/d1301.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use crate::{ devices::soundcore::{ common::{ + self, macros::soundcore_device, modules::{ button_configuration::{ @@ -11,7 +12,7 @@ use crate::{ dual_battery_level::DualBatteryLevelConfiguration, }, packet::{ - inbound::TryToPacket, + inbound::{DualConnectionsDevicePacket, TryToPacket}, outbound::{RequestState, ToPacket}, }, structures::button_configuration::{ @@ -43,10 +44,15 @@ soundcore_device!( .send_with_response(&packets::outbound::request_alarms()) .await? .try_to_packet()?; + // Unlike other models, the D1301 answers this even while dual connections is disabled, and the + // state update packet has no enabled flag, so always ask. + let dual_connections_devices = + common::modules::dual_connections::take_dual_connection_devices(&packet_io).await?; Ok(state::D1301State::new( state_update_packet, auto_stop_timer_packet.0, alarms_packet.0, + modules::dual_connections::initial_dual_connections(dual_connections_devices), )) }, async |builder| { @@ -60,6 +66,8 @@ soundcore_device!( builder.d1301_alarms(); + builder.d1301_dual_connections().await; + // misc builder.d1301_auto_stop_timer(); builder.d1301_auto_switch_once_asleep(); @@ -96,6 +104,10 @@ soundcore_device!( packets::inbound::AlarmsPacket::COMMAND, packets::inbound::AlarmsPacket(Vec::new()).to_packet(), ), + ( + DualConnectionsDevicePacket::COMMAND, + DualConnectionsDevicePacket::demo().to_packet(), + ), ]) }, ); @@ -337,4 +349,145 @@ mod tests { device.assert_setting_values(vec![(SettingId::Alarm1Volume, 51.into())]); } + + mod dual_connections { + use std::{sync::Arc, time::Duration}; + + use macaddr::MacAddr6; + + use super::*; + use crate::{ + devices::soundcore::common::structures::DualConnectionsDevice, + storage::OpenSCQ30Database, + }; + + fn packet_responses( + dual_connections_devices: Vec, + ) -> HashMap { + HashMap::from([ + ( + RequestState::COMMAND, + D1301StateUpdatePacket::default().to_packet(), + ), + ( + packets::inbound::AutoStopTimerPacket::COMMAND, + packets::inbound::AutoStopTimerPacket(structures::AutoStopTimer::default()) + .to_packet(), + ), + ( + packets::inbound::AlarmsPacket::COMMAND, + packets::inbound::AlarmsPacket(Vec::new()).to_packet(), + ), + ( + DualConnectionsDevicePacket::COMMAND, + DualConnectionsDevicePacket { + total_packets: 1, + current_packet_index: 1, + devices: dual_connections_devices, + } + .to_packet(), + ), + // captured from a real device: echoes the value followed by a 0 status byte + ( + packet::Command([0x0b, 0x84]), + packet::Inbound::new(packet::Command([0x0b, 0x84]), vec![1, 0]), + ), + ]) + } + + fn host(last_octet: u8, is_connected: bool) -> DualConnectionsDevice { + DualConnectionsDevice { + is_connected, + mac_address: MacAddr6::new(0, 0, 0, 0, 0, last_octet), + name: format!("Device {last_octet}"), + } + } + + async fn connect( + database: Arc, + dual_connections_devices: Vec, + ) -> TestSoundcoreDevice { + TestSoundcoreDevice::new_with_database( + super::super::device_registry, + DeviceModel::SoundcoreD1301, + packet_responses(dual_connections_devices), + SoundcoreDeviceConfig::default(), + HashMap::::new(), + database, + ) + .await + } + + async fn database() -> Arc { + Arc::new(OpenSCQ30Database::new_in_memory().await.unwrap()) + } + + #[tokio::test(start_paused = true)] + async fn is_off_when_unknown() { + let device = connect(database().await, vec![host(1, true), host(2, false)]).await; + device.assert_setting_values([(SettingId::DualConnections, false.into())]); + } + + #[tokio::test(start_paused = true)] + async fn is_on_when_two_devices_are_connected() { + let device = connect(database().await, vec![host(1, true), host(2, true)]).await; + device.assert_setting_values([(SettingId::DualConnections, true.into())]); + } + + #[tokio::test(start_paused = true)] + async fn enabling_sends_packet() { + let mut device = connect(database().await, vec![host(1, true)]).await; + device + .assert_set_settings_response( + vec![(SettingId::DualConnections, true.into())], + vec![packet::outbound::set_dual_connections_enabled(true)], + ) + .await; + device.assert_setting_values([(SettingId::DualConnections, true.into())]); + } + + #[tokio::test(start_paused = true)] + async fn enabled_is_reapplied_on_reconnect() { + let database = database().await; + let mut device = connect(database.clone(), vec![host(1, true)]).await; + device + .set_settings(vec![(SettingId::DualConnections, true.into())]) + .await; + drop(device); + + // simulate the earbuds having been powered off, which disables dual connections + let device = connect(database, vec![host(1, true)]).await; + device.assert_setting_values([(SettingId::DualConnections, true.into())]); + } + + #[tokio::test(start_paused = true)] + async fn enabling_while_already_on_is_reapplied_on_reconnect() { + let database = database().await; + // two connected devices: already shown as enabled, but nothing has been saved yet + let mut device = connect(database.clone(), vec![host(1, true), host(2, true)]).await; + device + .set_settings(vec![(SettingId::DualConnections, true.into())]) + .await; + drop(device); + + let device = connect(database, vec![host(1, true)]).await; + device.assert_setting_values([(SettingId::DualConnections, true.into())]); + } + + #[tokio::test(start_paused = true)] + async fn disabled_is_not_reapplied_on_reconnect() { + let database = database().await; + let mut device = connect(database.clone(), vec![host(1, true)]).await; + device + .set_settings(vec![(SettingId::DualConnections, true.into())]) + .await; + device + .set_settings(vec![(SettingId::DualConnections, false.into())]) + .await; + drop(device); + + let device = connect(database, vec![host(1, true)]).await; + device.assert_setting_values([(SettingId::DualConnections, false.into())]); + } + } } diff --git a/lib/src/devices/soundcore/d1301/modules.rs b/lib/src/devices/soundcore/d1301/modules.rs index 94c09d884..8f64bcc30 100644 --- a/lib/src/devices/soundcore/d1301/modules.rs +++ b/lib/src/devices/soundcore/d1301/modules.rs @@ -3,6 +3,7 @@ use crate::devices::soundcore::{common::device::SoundcoreDeviceBuilder, d1301::s mod alarms; mod auto_stop_timer; mod auto_switch_once_asleep; +pub mod dual_connections; mod flags; mod listening_mode; diff --git a/lib/src/devices/soundcore/d1301/modules/dual_connections.rs b/lib/src/devices/soundcore/d1301/modules/dual_connections.rs new file mode 100644 index 000000000..74bfa2ea0 --- /dev/null +++ b/lib/src/devices/soundcore/d1301/modules/dual_connections.rs @@ -0,0 +1,134 @@ +//! The D1301 supports the standard dual connections commands (0x0b family), but unlike other models +//! it neither reports whether dual connections is enabled nor remembers it after powering off. To +//! make up for that, the last value set through openscq30 is stored and reapplied on connect. + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use openscq30_lib_has::Has; +use tokio::sync::watch; + +use crate::{ + api::{device, settings::SettingId}, + devices::{ + DeviceModel, + soundcore::{ + common::{ + device::SoundcoreDeviceBuilder, + modules::ModuleCollection, + packet, + state_modifier::StateModifier, + structures::{DualConnections, DualConnectionsDevice}, + }, + d1301::state::D1301State, + }, + }, + storage::OpenSCQ30Database, +}; + +/// The D1301 does not report whether dual connections is enabled, but if more than one device is +/// connected, it must be. +pub fn initial_dual_connections(devices: Vec) -> DualConnections { + DualConnections { + is_enabled: devices.iter().filter(|device| device.is_connected).count() > 1, + devices, + } +} + +impl SoundcoreDeviceBuilder { + pub async fn d1301_dual_connections(&mut self) { + let database = self.database(); + let device_model = self.device_model(); + + let saved = match database + .fetch_device_model_setting(device_model, SettingId::DualConnections) + .await + { + Ok(value) => value.and_then(|value| value.as_bool()), + Err(err) => { + tracing::warn!("failed to fetch saved dual connections setting: {err:?}"); + None + } + }; + let is_enabled = Has::::get(&*self.state_sender().borrow()).is_enabled; + if saved == Some(true) && !is_enabled { + match self + .packet_io_controller() + .send_with_response(&packet::outbound::set_dual_connections_enabled(true)) + .await + { + Ok(_) => self.state_sender().send_modify(|state| { + Has::::get_mut(state).is_enabled = true; + }), + Err(err) => tracing::warn!("failed to reapply dual connections: {err:?}"), + } + } + + self.dual_connections(); + self.module_collection() + .add_d1301_dual_connections_persistence(database, device_model, saved); + } +} + +impl ModuleCollection +where + T: Has + Clone + Send + Sync, +{ + /// Must be added after the dual connections module so that only values that were successfully + /// sent to the device get saved. + fn add_d1301_dual_connections_persistence( + &mut self, + database: Arc, + device_model: DeviceModel, + saved: Option, + ) { + self.state_modifiers + .push(Box::new(PersistDualConnectionsStateModifier { + database, + device_model, + saved: Mutex::new(saved), + })); + } +} + +struct PersistDualConnectionsStateModifier { + database: Arc, + device_model: DeviceModel, + /// Compared against the database's value rather than the device's state, since the state can + /// already be enabled (two devices connected) without that ever having been saved. + saved: Mutex>, +} + +#[async_trait] +impl StateModifier for PersistDualConnectionsStateModifier +where + StateT: Has + Send + Sync, +{ + async fn move_to_state( + &self, + state_sender: &watch::Sender, + target_state: &StateT, + ) -> device::Result<()> { + let is_enabled = target_state.get().is_enabled; + // Only save what the dual connections module actually applied to the device. + if state_sender.borrow().get().is_enabled != is_enabled + || *self.saved.lock().unwrap() == Some(is_enabled) + { + return Ok(()); + } + // Failing to save shouldn't fail setting the value on the device, which already happened. + match self + .database + .upsert_device_model_setting( + self.device_model, + SettingId::DualConnections, + is_enabled.into(), + ) + .await + { + Ok(()) => *self.saved.lock().unwrap() = Some(is_enabled), + Err(err) => tracing::warn!("failed to save dual connections setting: {err:?}"), + } + Ok(()) + } +} diff --git a/lib/src/devices/soundcore/d1301/state.rs b/lib/src/devices/soundcore/d1301/state.rs index d6d48a3ef..def9c6c7b 100644 --- a/lib/src/devices/soundcore/d1301/state.rs +++ b/lib/src/devices/soundcore/d1301/state.rs @@ -5,8 +5,9 @@ use crate::devices::soundcore::{ modules::reset_button_configuration::ResetButtonConfigurationPending, state::Update, structures::{ - DualBatteryLevel, DualFirmwareVersion, FirmwareVersion, LowBatteryPrompt, SerialNumber, - TwsStatus, button_configuration::ButtonStatusCollection, + DualBatteryLevel, DualConnections, DualFirmwareVersion, FirmwareVersion, + LowBatteryPrompt, SerialNumber, TwsStatus, + button_configuration::ButtonStatusCollection, }, }, d1301, @@ -37,6 +38,9 @@ pub struct D1301State { auto_stop_timer: d1301::structures::AutoStopTimer, alarms: Vec, + // Not part of the state update packet: the D1301 accepts the dual connections commands but never + // reports whether the feature is enabled, so this is tracked separately. See d1301/modules/dual_connections.rs + dual_connections: DualConnections, } impl D1301State { @@ -44,6 +48,7 @@ impl D1301State { state_update_packet: D1301StateUpdatePacket, auto_stop_timer: d1301::structures::AutoStopTimer, alarms: Vec, + dual_connections: DualConnections, ) -> Self { Self { tws_status: state_update_packet.tws_status, @@ -67,6 +72,7 @@ impl D1301State { button_reset_pending: ResetButtonConfigurationPending::default(), auto_stop_timer, alarms, + dual_connections, } } } diff --git a/lib/src/storage/device_model_setting.rs b/lib/src/storage/device_model_setting.rs new file mode 100644 index 000000000..d78c8af27 --- /dev/null +++ b/lib/src/storage/device_model_setting.rs @@ -0,0 +1,84 @@ +use rusqlite::{Connection, OptionalExtension}; + +use crate::{api::settings::SettingId, devices::DeviceModel}; + +use super::{Error, type_conversions::SqliteDeviceModel}; + +pub fn fetch( + connection: &Connection, + model: DeviceModel, + setting_id: SettingId, +) -> Result, Error> { + let setting_id: &'static str = setting_id.into(); + let json: Option = connection + .prepare_cached( + r#"SELECT value FROM device_model_setting WHERE device_model = ?1 AND setting_id = ?2"#, + )? + .query_row((SqliteDeviceModel(model), setting_id), |row| row.get(0)) + .optional()?; + json.map(|json| serde_json::from_str(&json).map_err(Error::from)) + .transpose() +} + +pub fn upsert( + connection: &Connection, + model: DeviceModel, + setting_id: SettingId, + value: serde_json::Value, +) -> Result<(), Error> { + let setting_id: &'static str = setting_id.into(); + let json = serde_json::to_string(&value).map_err(Error::from)?; + connection.execute( + r#"INSERT INTO device_model_setting (device_model, setting_id, value) + VALUES (?1, ?2, json(?3)) + ON CONFLICT(device_model, setting_id) DO UPDATE SET + value = excluded.value + "#, + (SqliteDeviceModel(model), setting_id, json), + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::{api::settings::SettingId, devices::DeviceModel, storage::OpenSCQ30Database}; + + #[tokio::test] + async fn missing_setting_is_none() { + let database = OpenSCQ30Database::new_in_memory().await.unwrap(); + let value = database + .fetch_device_model_setting(DeviceModel::SoundcoreD1301, SettingId::DualConnections) + .await + .unwrap(); + assert_eq!(value, None); + } + + #[tokio::test] + async fn upsert_overwrites_and_is_per_model() { + let database = OpenSCQ30Database::new_in_memory().await.unwrap(); + for value in [true, false] { + database + .upsert_device_model_setting( + DeviceModel::SoundcoreD1301, + SettingId::DualConnections, + value.into(), + ) + .await + .unwrap(); + } + assert_eq!( + database + .fetch_device_model_setting(DeviceModel::SoundcoreD1301, SettingId::DualConnections) + .await + .unwrap(), + Some(false.into()), + ); + assert_eq!( + database + .fetch_device_model_setting(DeviceModel::SoundcoreA3040, SettingId::DualConnections) + .await + .unwrap(), + None, + ); + } +} diff --git a/lib/src/storage/migration.rs b/lib/src/storage/migration.rs index fd8ef91b9..efcc0df5c 100644 --- a/lib/src/storage/migration.rs +++ b/lib/src/storage/migration.rs @@ -11,7 +11,7 @@ macro_rules! migration_file { }; } -pub const MIGRATIONS: &[Migration] = &[migration_file!("0.sql")]; +pub const MIGRATIONS: &[Migration] = &[migration_file!("0.sql"), migration_file!("1.sql")]; #[instrument(skip(connection, migrations))] pub fn migrate( diff --git a/lib/src/storage/migrations/1.sql b/lib/src/storage/migrations/1.sql new file mode 100644 index 000000000..619d38c58 --- /dev/null +++ b/lib/src/storage/migrations/1.sql @@ -0,0 +1,9 @@ +-- Settings that a device accepts but cannot report back or does not persist itself, +-- so openscq30 remembers the last value and reapplies it on connect. +CREATE TABLE device_model_setting ( + device_model TEXT NOT NULL, + setting_id TEXT NOT NULL, + value TEXT NOT NULL CHECK(json_valid(value)), + created_at INTEGER NOT NULL DEFAULT (strftime('%s')) +) STRICT; +CREATE UNIQUE INDEX idx_device_model_setting_device_model_setting_id ON device_model_setting (device_model, setting_id); diff --git a/lib/src/storage/mod.rs b/lib/src/storage/mod.rs index d7471a2ce..da3010c11 100644 --- a/lib/src/storage/mod.rs +++ b/lib/src/storage/mod.rs @@ -1,3 +1,4 @@ +mod device_model_setting; mod equalizer_profile; mod migration; mod paired_device; @@ -249,4 +250,13 @@ commands!( name: Vec<(String, Vec)>, ) -> Result<()>; equalizer_profile::delete => fn delete_equalizer_profile(model: DeviceModel, name: String) -> Result<()>; + device_model_setting::fetch => fn fetch_device_model_setting( + model: DeviceModel, + setting_id: SettingId, + ) -> Result>; + device_model_setting::upsert => fn upsert_device_model_setting( + model: DeviceModel, + setting_id: SettingId, + value: serde_json::Value, + ) -> Result<()>; );