diff --git a/README.md b/README.md index 0d18e8e..19740cf 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,9 @@ To get started with the OpenEarable Flutter package, follow these steps: ### 7. Access sensor data In order to access sensor data, you need to check if the device is a `SensorManager`. Then you can access the sensor data streams by accessing the `sensors` property: ```dart - if (wearable is SensorManager) { - wearable.sensors.forEach((sensor) { + final sensorManager = wearable.getCapability(); + if (sensorManager != null) { + sensorManager.sensors.forEach((sensor) { sensor.sensorStream.listen((data) { // Handle sensor data }); @@ -110,5 +111,8 @@ To get started with the OpenEarable Flutter package, follow these steps: For most devices, the sensors have to be configured before they start sending data. You can learn more about configuring sensors in the chapter [Configuring Sensors](doc/SENSOR_CONFIG.md). + > [!WARNING] + > Checking for capabilities using `is ` is deprecated. Please use `hasCapability()` instead. You can learn more about capabilities in the [Capabilities](doc/CAPABILITIES.md) documentation. + ## Add custom Wearable Support Learn more about how to add support for your own wearable devices in the [Adding Custom Wearable Support](doc/ADD_CUSTOM_WEARABLE.md) documentation. \ No newline at end of file diff --git a/doc/CAPABILITIES.md b/doc/CAPABILITIES.md index 5ca7380..11ffc9e 100644 --- a/doc/CAPABILITIES.md +++ b/doc/CAPABILITIES.md @@ -2,7 +2,21 @@ Wearable functionality in the Open Earable Flutter package is modular and extensible through the use of **capabilities**. Capabilities are abstract interfaces that define specific features (like sensor access, battery info, button interaction, etc.). Each `Wearable` can implement any combination of these capabilities depending on its hardware and firmware support. -This guide outlines the most common capabilities and how to use them. +This guide outlines the most common capabilities and how to use them with the new capability lookup helpers. + +Use `hasCapability()` to check support and `getCapability()` or `requireCapability()` to fetch an instance: + +```dart +if (wearable.hasCapability()) { + final sensorManager = wearable.requireCapability(); + final sensors = sensorManager.sensors; +} +``` + +The difference between `getCapability()` and `requireCapability()` is that the latter throws an exception if the capability is not supported, while the former returns `null`. + +> [!WARNING] +> The old way of checking capabilities using `is ` is deprecated. Please use `hasCapability()` instead. --- @@ -15,8 +29,9 @@ Some of the most commonly used capabilities include: Enables access to available sensors on the wearable. ```dart -if (wearable is SensorManager) { - List sensors = wearable.sensors; +final sensorManager = wearable.getCapability(); +if (sensorManager != null) { + List sensors = sensorManager.sensors; } ``` @@ -27,8 +42,9 @@ if (wearable is SensorManager) { Allows configuration of the wearable’s sensors, including setting sampling rates or modes. ```dart -if (wearable is SensorConfigurationManager) { - List configurations = wearable.sensorConfigurations; +final configurationManager = wearable.getCapability(); +if (configurationManager != null) { + List configurations = configurationManager.sensorConfigurations; } ``` @@ -41,8 +57,9 @@ if (wearable is SensorConfigurationManager) { Provides access to battery energy data. ```dart -if (wearable is BatteryEnergyStatusService) { - BatteryEnergyStatus status = await wearable.readEnergyStatus(); +final energyStatusService = wearable.getCapability(); +if (energyStatusService != null) { + BatteryEnergyStatus status = await energyStatusService.readEnergyStatus(); } ``` @@ -51,8 +68,9 @@ if (wearable is BatteryEnergyStatusService) { Reads battery health and performance metrics. ```dart -if (wearable is BatteryHealthStatusService) { - BatteryHealthStatus healthStatus = await wearable.readHealthStatus(); +final healthStatusService = wearable.getCapability(); +if (healthStatusService != null) { + BatteryHealthStatus healthStatus = await healthStatusService.readHealthStatus(); } ``` @@ -61,8 +79,9 @@ if (wearable is BatteryHealthStatusService) { Gives the current battery level as a percentage or unit. ```dart -if (wearable is BatteryLevelStatusService) { - BatteryPowerStatus levelStatus = await wearable.readPowerStatus(); +final levelStatusService = wearable.getCapability(); +if (levelStatusService != null) { + BatteryPowerStatus levelStatus = await levelStatusService.readPowerStatus(); } ``` @@ -73,8 +92,9 @@ if (wearable is BatteryLevelStatusService) { Enables listening to hardware button events on the wearable. ```dart -if (wearable is ButtonManager) { - wearable.buttonEvents.listen((buttonEvent) { +final buttonManager = wearable.getCapability(); +if (buttonManager != null) { + buttonManager.buttonEvents.listen((buttonEvent) { // Handle button events }); } @@ -87,8 +107,9 @@ if (wearable is ButtonManager) { Controls on-device recording. You can specify filename prefixes or manage session behaviors. ```dart -if (wearable is EdgeRecorderManager) { - wearable.setFilePrefix("my_recording"); +final edgeRecorder = wearable.getCapability(); +if (edgeRecorder != null) { + edgeRecorder.setFilePrefix("my_recording"); } ``` @@ -99,9 +120,10 @@ if (wearable is EdgeRecorderManager) { Lets you select the active microphone (if the device has multiple). ```dart -if (wearable is MicrophoneManager) { - List microphones = wearable.availableMicrophones; - wearable.setMicrophone(microphones.first); +final microphoneManager = wearable.getCapability(); +if (microphoneManager != null) { + List microphones = microphoneManager.availableMicrophones; + microphoneManager.setMicrophone(microphones.first); } ``` @@ -112,9 +134,10 @@ if (wearable is MicrophoneManager) { Allows switching between different audio modes (e.g., mono, stereo, streaming). ```dart -if (wearable is AudioModeManager) { - List audioModes = wearable.availableAudioModes; - wearable.setAudioMode(audioModes.first); +final audioModeManager = wearable.getCapability(); +if (audioModeManager != null) { + List audioModes = audioModeManager.availableAudioModes; + audioModeManager.setAudioMode(audioModes.first); } ``` @@ -127,8 +150,9 @@ if (wearable is AudioModeManager) { Reads the current firmware version of the device. ```dart -if (wearable is DeviceFirmwareVersion) { - String firmwareVersion = await wearable.readDeviceFirmwareVersion(); +final firmwareVersionService = wearable.getCapability(); +if (firmwareVersionService != null) { + String firmwareVersion = await firmwareVersionService.readDeviceFirmwareVersion(); } ``` @@ -137,8 +161,9 @@ if (wearable is DeviceFirmwareVersion) { Reads the hardware version of the device. ```dart -if (wearable is DeviceHardwareVersion) { - String hardwareVersion = await wearable.readDeviceHardwareVersion(); +final hardwareVersionService = wearable.getCapability(); +if (hardwareVersionService != null) { + String hardwareVersion = await hardwareVersionService.readDeviceHardwareVersion(); } ``` @@ -147,8 +172,9 @@ if (wearable is DeviceHardwareVersion) { Retrieves the device’s unique ID. ```dart -if (wearable is DeviceIdentifier) { - String deviceId = await wearable.readDeviceIdentifier(); +final deviceIdentifierService = wearable.getCapability(); +if (deviceIdentifierService != null) { + String deviceId = await deviceIdentifierService.readDeviceIdentifier(); } ``` @@ -156,4 +182,4 @@ if (wearable is DeviceIdentifier) { ## Summary -Capabilities are the building blocks of wearable functionality. You can dynamically check for and use any supported capability through simple type checks (`if (wearable is SomeCapability)`). This enables modular development and ensures your app only uses features supported by the connected device. +Capabilities are the building blocks of wearable functionality. Use `hasCapability()` to check support and `getCapability()` to access a capability instance. This enables modular development and ensures your app only uses features supported by the connected device. diff --git a/doc/SENSOR_CONFIG.md b/doc/SENSOR_CONFIG.md index e58327c..8fc6b74 100644 --- a/doc/SENSOR_CONFIG.md +++ b/doc/SENSOR_CONFIG.md @@ -17,8 +17,9 @@ To configure sensors, you first need to access the `SensorConfiguration` you wan If you have a `Wearable` that implements `SensorConfigurationManager`, you can access the configurations like this: ```dart - if (wearable is SensorConfigurationManager) { - List configurations = wearable.sensorConfigurations; + final sensorConfigurationManager = wearable.getCapability(); + if (sensorConfigurationManager != null) { + List configurations = sensorConfigurationManager.sensorConfigurations; } ``` diff --git a/lib/src/managers/ble_gatt_manager.dart b/lib/src/managers/ble_gatt_manager.dart index 496e84e..d424619 100644 --- a/lib/src/managers/ble_gatt_manager.dart +++ b/lib/src/managers/ble_gatt_manager.dart @@ -3,6 +3,18 @@ abstract class BleGattManager { /// Check if a device is connected. bool isConnected(String deviceId); + /// Checks if a specific service is available on the connected device. + Future hasService({ + required String deviceId, + required String serviceId, + }); + + Future hasCharacteristic({ + required String deviceId, + required String serviceId, + required String characteristicId, + }); + /// Writes byte data to a specific characteristic of a device. Future write({ required String deviceId, diff --git a/lib/src/managers/ble_manager.dart b/lib/src/managers/ble_manager.dart index 4d9bb4d..cac6ec8 100644 --- a/lib/src/managers/ble_manager.dart +++ b/lib/src/managers/ble_manager.dart @@ -222,6 +222,50 @@ class BleManager extends BleGattManager { return completer.future; } + /// Checks if the connected device has a specific service. + @override + Future hasService({ + required String deviceId, + required String serviceId, + }) async { + + + if (!isConnected(deviceId)) { + throw Exception("Device is not connected"); + } + + List services = await UniversalBle.discoverServices(deviceId); + for (final service in services) { + if (service.uuid.toLowerCase() == serviceId.toLowerCase()) { + return true; + } + } + return false; + } + + /// Checks if the connected device has a specific characteristic. + @override + Future hasCharacteristic({ + required String deviceId, + required String serviceId, + required String characteristicId, + }) async { + if (!isConnected(deviceId)) { + throw Exception("Device is not connected"); + } + List services = await UniversalBle.discoverServices(deviceId); + for (final service in services) { + if (service.uuid.toLowerCase() == serviceId.toLowerCase()) { + for (final characteristic in service.characteristics) { + if (characteristic.uuid.toLowerCase() == characteristicId.toLowerCase()) { + return true; + } + } + } + } + return false; + } + /// Writes byte data to a specific characteristic of the connected Earable device. @override Future write({ diff --git a/lib/src/models/devices/battery_gatt_reader/battery_energy_status_gatt_reader.dart b/lib/src/models/devices/battery_gatt_reader/battery_energy_status_gatt_reader.dart new file mode 100644 index 0000000..8366382 --- /dev/null +++ b/lib/src/models/devices/battery_gatt_reader/battery_energy_status_gatt_reader.dart @@ -0,0 +1,88 @@ +import 'dart:async'; +import 'dart:math'; + +import '../../../../open_earable_flutter.dart' show logger; +import '../../capabilities/battery_energy_status.dart'; +import '../bluetooth_wearable.dart'; + +const String _batteryEnergyStatusCharacteristicUuid = "2BF0"; +const String _batteryServiceUuid = "180F"; + +mixin BatteryEnergyStatusGattReader on BluetoothWearable implements BatteryEnergyStatusService { + @override + Future readEnergyStatus() async { + List energyStatusList = await bleManager.read( + deviceId: discoveredDevice.id, + serviceId: _batteryServiceUuid, + characteristicId: _batteryEnergyStatusCharacteristicUuid, + ); + + logger.t("Battery energy status bytes: $energyStatusList"); + + if (energyStatusList.length != 7) { + throw StateError( + 'Battery energy status characteristic expected 7 values, but got ${energyStatusList.length}', + ); + } + + int rawVoltage = (energyStatusList[2] << 8) | energyStatusList[1]; + double voltage = _convertSFloat(rawVoltage); + + int rawAvailableCapacity = (energyStatusList[4] << 8) | energyStatusList[3]; + double availableCapacity = _convertSFloat(rawAvailableCapacity); + + int rawChargeRate = (energyStatusList[6] << 8) | energyStatusList[5]; + double chargeRate = _convertSFloat(rawChargeRate); + + BatteryEnergyStatus batteryEnergyStatus = BatteryEnergyStatus( + voltage: voltage, + availableCapacity: availableCapacity, + chargeRate: chargeRate, + ); + + logger.d('Battery energy status: $batteryEnergyStatus'); + + return batteryEnergyStatus; + } + + double _convertSFloat(int rawBits) { + int exponent = ((rawBits & 0xF000) >> 12) - 16; + int mantissa = rawBits & 0x0FFF; + + if (mantissa >= 0x800) { + mantissa = -((0x1000) - mantissa); + } + logger.t("Exponent: $exponent, Mantissa: $mantissa"); + double result = mantissa.toDouble() * pow(10.0, exponent.toDouble()); + return result; + } + + @override + Stream get energyStatusStream { + StreamController controller = + StreamController(); + Timer? energyPollingTimer; + + controller.onCancel = () { + energyPollingTimer?.cancel(); + }; + + controller.onListen = () { + energyPollingTimer = Timer.periodic(const Duration(seconds: 5), (timer) { + readEnergyStatus().then((energyStatus) { + controller.add(energyStatus); + }).catchError((e) { + logger.e('Error reading energy status: $e'); + }); + }); + + readEnergyStatus().then((energyStatus) { + controller.add(energyStatus); + }).catchError((e) { + logger.e('Error reading energy status: $e'); + }); + }; + + return controller.stream; + } +} diff --git a/lib/src/models/devices/battery_gatt_reader/battery_health_status_gatt_reader.dart b/lib/src/models/devices/battery_gatt_reader/battery_health_status_gatt_reader.dart new file mode 100644 index 0000000..d652537 --- /dev/null +++ b/lib/src/models/devices/battery_gatt_reader/battery_health_status_gatt_reader.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import '../../../../open_earable_flutter.dart' show logger; +import '../../capabilities/battery_health_status.dart'; +import '../bluetooth_wearable.dart'; + +const String _batteryHealthStatusCharacteristicUuid = "2BEA"; +const String _batteryServiceUuid = "180F"; + +mixin BatteryHealthStatusGattReader on BluetoothWearable implements BatteryHealthStatusService { + @override + Future readHealthStatus() async { + List healthStatusList = await bleManager.read( + deviceId: discoveredDevice.id, + serviceId: _batteryServiceUuid, + characteristicId: _batteryHealthStatusCharacteristicUuid, + ); + + logger.t("Battery health status bytes: $healthStatusList"); + + if (healthStatusList.length != 5) { + throw StateError( + 'Battery health status characteristic expected 5 values, but got ${healthStatusList.length}', + ); + } + + int healthSummary = healthStatusList[1]; + int cycleCount = (healthStatusList[2] << 8) | healthStatusList[3]; + int currentTemperature = healthStatusList[4]; + + BatteryHealthStatus batteryHealthStatus = BatteryHealthStatus( + healthSummary: healthSummary, + cycleCount: cycleCount, + currentTemperature: currentTemperature, + ); + + logger.d('Battery health status: $batteryHealthStatus'); + + return batteryHealthStatus; + } + + @override + Stream get healthStatusStream { + StreamController controller = + StreamController(); + Timer? healthPollingTimer; + + controller.onCancel = () { + healthPollingTimer?.cancel(); + }; + + controller.onListen = () { + healthPollingTimer = Timer.periodic(const Duration(seconds: 5), (timer) { + readHealthStatus().then((healthStatus) { + controller.add(healthStatus); + }).catchError((e) { + logger.e('Error reading health status: $e'); + }); + }); + + readHealthStatus().then((healthStatus) { + controller.add(healthStatus); + }).catchError((e) { + logger.e('Error reading health status: $e'); + }); + }; + + return controller.stream; + } +} diff --git a/lib/src/models/devices/battery_gatt_reader/battery_level_status_gatt_reader.dart b/lib/src/models/devices/battery_gatt_reader/battery_level_status_gatt_reader.dart new file mode 100644 index 0000000..e648520 --- /dev/null +++ b/lib/src/models/devices/battery_gatt_reader/battery_level_status_gatt_reader.dart @@ -0,0 +1,58 @@ +import 'dart:async'; + +import '../../../../open_earable_flutter.dart' show logger; +import '../../capabilities/battery_level.dart'; +import '../bluetooth_wearable.dart'; + +const String _batteryLevelCharacteristicUuid = "2A19"; +const String _batteryServiceUuid = "180F"; + +/// Mixin that implements [BatteryLevelStatus] according to the GATT specification. +mixin BatteryLevelStatusGattReader on BluetoothWearable implements BatteryLevelStatus { + @override + Future readBatteryPercentage() async { + List batteryLevelList = await bleManager.read( + deviceId: discoveredDevice.id, + serviceId: _batteryServiceUuid, + characteristicId: _batteryLevelCharacteristicUuid, + ); + + logger.t("Battery level bytes: $batteryLevelList"); + + if (batteryLevelList.length != 1) { + throw StateError( + 'Battery level characteristic expected 1 value, but got ${batteryLevelList.length}', + ); + } + + return batteryLevelList[0]; + } + + @override + Stream get batteryPercentageStream { + StreamController controller = StreamController(); + Timer? batteryPollingTimer; + + controller.onCancel = () { + batteryPollingTimer?.cancel(); + }; + + controller.onListen = () { + batteryPollingTimer = Timer.periodic(const Duration(seconds: 5), (timer) { + readBatteryPercentage().then((batteryPercentage) { + controller.add(batteryPercentage); + }).catchError((e) { + logger.e('Error reading battery percentage: $e'); + }); + }); + + readBatteryPercentage().then((batteryPercentage) { + controller.add(batteryPercentage); + }).catchError((e) { + logger.e('Error reading battery percentage: $e'); + }); + }; + + return controller.stream; + } +} diff --git a/lib/src/models/devices/battery_gatt_reader/battery_level_status_service_gatt_reader.dart b/lib/src/models/devices/battery_gatt_reader/battery_level_status_service_gatt_reader.dart new file mode 100644 index 0000000..57e66e3 --- /dev/null +++ b/lib/src/models/devices/battery_gatt_reader/battery_level_status_service_gatt_reader.dart @@ -0,0 +1,100 @@ +import 'dart:async'; + +import '../../../../open_earable_flutter.dart' show logger; +import '../../capabilities/battery_level_status.dart'; +import '../bluetooth_wearable.dart'; + +const String _batteryLevelStatusCharacteristicUuid = "2BED"; +const String _batteryServiceUuid = "180F"; + +mixin BatteryLevelStatusServiceGattReader on BluetoothWearable implements BatteryLevelStatusService { + @override + Future readPowerStatus() async { + List powerStateList = await bleManager.read( + deviceId: discoveredDevice.id, + serviceId: _batteryServiceUuid, + characteristicId: _batteryLevelStatusCharacteristicUuid, + ); + + int powerState = (powerStateList[1] << 8) | powerStateList[2]; + logger.d("Battery power status bits: ${powerState.toRadixString(2)}"); + + bool batteryPresent = powerState >> 15 & 0x1 != 0; + + int wiredExternalPowerSourceConnectedRaw = (powerState >> 13) & 0x3; + ExternalPowerSourceConnected wiredExternalPowerSourceConnected = + ExternalPowerSourceConnected + .values[wiredExternalPowerSourceConnectedRaw]; + + int wirelessExternalPowerSourceConnectedRaw = (powerState >> 11) & 0x3; + ExternalPowerSourceConnected wirelessExternalPowerSourceConnected = + ExternalPowerSourceConnected + .values[wirelessExternalPowerSourceConnectedRaw]; + + int chargeStateRaw = (powerState >> 9) & 0x3; + ChargeState chargeState = ChargeState.values[chargeStateRaw]; + + int chargeLevelRaw = (powerState >> 7) & 0x3; + BatteryChargeLevel chargeLevel = BatteryChargeLevel.values[chargeLevelRaw]; + + int chargingTypeRaw = (powerState >> 5) & 0x7; + BatteryChargingType chargingType = + BatteryChargingType.values[chargingTypeRaw]; + + int chargingFaultReasonRaw = (powerState >> 2) & 0x5; + List chargingFaultReason = []; + if ((chargingFaultReasonRaw & 0x1) != 0) { + chargingFaultReason.add(ChargingFaultReason.other); + } + if ((chargingFaultReasonRaw & 0x2) != 0) { + chargingFaultReason.add(ChargingFaultReason.externalPowerSource); + } + if ((chargingFaultReasonRaw & 0x4) != 0) { + chargingFaultReason.add(ChargingFaultReason.battery); + } + + BatteryPowerStatus batteryPowerStatus = BatteryPowerStatus( + batteryPresent: batteryPresent, + wiredExternalPowerSourceConnected: wiredExternalPowerSourceConnected, + wirelessExternalPowerSourceConnected: + wirelessExternalPowerSourceConnected, + chargeState: chargeState, + chargeLevel: chargeLevel, + chargingType: chargingType, + chargingFaultReason: chargingFaultReason, + ); + + logger.d('Battery power status: $batteryPowerStatus'); + + return batteryPowerStatus; + } + + @override + Stream get powerStatusStream { + StreamController controller = + StreamController(); + Timer? powerPollingTimer; + + controller.onCancel = () { + powerPollingTimer?.cancel(); + }; + + controller.onListen = () { + powerPollingTimer = Timer.periodic(const Duration(seconds: 5), (timer) { + readPowerStatus().then((powerStatus) { + controller.add(powerStatus); + }).catchError((e) { + logger.e('Error reading power status: $e'); + }); + }); + + readPowerStatus().then((powerStatus) { + controller.add(powerStatus); + }).catchError((e) { + logger.e('Error reading power status: $e'); + }); + }; + + return controller.stream; + } +} diff --git a/lib/src/models/devices/bluetooth_wearable.dart b/lib/src/models/devices/bluetooth_wearable.dart new file mode 100644 index 0000000..47bb64d --- /dev/null +++ b/lib/src/models/devices/bluetooth_wearable.dart @@ -0,0 +1,15 @@ +import '../../managers/ble_gatt_manager.dart'; +import 'discovered_device.dart'; +import 'wearable.dart'; + +abstract class BluetoothWearable extends Wearable { + BluetoothWearable({ + required super.name, + required super.disconnectNotifier, + required this.bleManager, + required this.discoveredDevice, + }); + + final BleGattManager bleManager; + final DiscoveredDevice discoveredDevice; +} diff --git a/lib/src/models/devices/open_earable_factory.dart b/lib/src/models/devices/open_earable_factory.dart index 74a1653..6a8409b 100644 --- a/lib/src/models/devices/open_earable_factory.dart +++ b/lib/src/models/devices/open_earable_factory.dart @@ -16,6 +16,7 @@ import '../capabilities/sensor_configuration_specializations/recordable_sensor_c import '../capabilities/sensor_configuration_specializations/sensor_configuration_open_earable_v2.dart'; import '../capabilities/sensor_configuration_specializations/streamable_sensor_configuration.dart'; import '../capabilities/system_device.dart'; +import '../capabilities/time_synchronizable.dart'; import 'discovered_device.dart'; import 'open_earable_v1.dart'; import 'open_earable_v2.dart'; @@ -69,7 +70,7 @@ class OpenEarableFactory extends WearableFactory { } else if (_v2Regex.hasMatch(firmwareVersion)) { (List, List) sensorInfo = await _initSensors(device); - return OpenEarableV2( + final wearable = OpenEarableV2( name: device.name, disconnectNotifier: disconnectNotifier!, sensors: sensorInfo.$1, @@ -87,6 +88,15 @@ class OpenEarableFactory extends WearableFactory { }, isConnectedViaSystem: options.contains(const ConnectedViaSystem()), ); + if (await bleManager!.hasService(deviceId: device.id, serviceId: timeSynchronizationServiceUuid)) { + wearable.registerCapability( + OpenEarableV2TimeSyncImp( + bleManager: bleManager!, + deviceId: device.id, + ), + ); + } + return wearable; } else { throw Exception('OpenEarable version is not supported'); } diff --git a/lib/src/models/devices/open_earable_v2.dart b/lib/src/models/devices/open_earable_v2.dart index d426e97..a7b58a7 100644 --- a/lib/src/models/devices/open_earable_v2.dart +++ b/lib/src/models/devices/open_earable_v2.dart @@ -1,19 +1,18 @@ import 'dart:async'; -import 'dart:math'; import 'dart:typed_data'; import 'package:open_earable_flutter/src/constants.dart'; +import 'package:open_earable_flutter/src/models/devices/bluetooth_wearable.dart'; import 'package:pub_semver/pub_semver.dart'; import '../../../open_earable_flutter.dart' hide Version; import '../../managers/v2_sensor_handler.dart'; import '../capabilities/device_firmware_version.dart'; import '../capabilities/sensor_configuration_specializations/sensor_configuration_open_earable_v2.dart'; - -const String _batteryLevelCharacteristicUuid = "2A19"; -const String _batteryLevelStatusCharacteristicUuid = "2BED"; -const String _batteryHealthStatusCharacteristicUuid = "2BEA"; -const String _batteryEnergyStatusCharacteristicUuid = "2BF0"; +import 'battery_gatt_reader/battery_energy_status_gatt_reader.dart'; +import 'battery_gatt_reader/battery_health_status_gatt_reader.dart'; +import 'battery_gatt_reader/battery_level_status_gatt_reader.dart'; +import 'battery_gatt_reader/battery_level_status_service_gatt_reader.dart'; const String _ledSetColorCharacteristic = "81040e7a-4819-11ee-be56-0242ac120002"; @@ -36,7 +35,7 @@ const String _audioModeCharacteristicUuid = const String _buttonServiceUuid = "29c10bdc-4773-11ee-be56-0242ac120002"; const String _buttonCharacteristicUuid = "29c10f38-4773-11ee-be56-0242ac120002"; -const String _timeSynchronizationServiceUuid = "2e04cbf7-939d-4be5-823e-271838b75259"; +const String timeSynchronizationServiceUuid = "2e04cbf7-939d-4be5-823e-271838b75259"; const String _timeSyncTimeMappingCharacteristicUuid = "2e04cbf8-939d-4be5-823e-271838b75259"; const String _timeSyncRttCharacteristicUuid = @@ -57,16 +56,18 @@ final VersionConstraint _versionConstraint = /// manage sensors, control LEDs, and retrieve battery and device information. /// The class also provides streams for monitoring battery and power status, /// as well as health and energy status. -class OpenEarableV2 extends Wearable - with DeviceFirmwareVersionNumberExt +class OpenEarableV2 extends BluetoothWearable + with + DeviceFirmwareVersionNumberExt, + BatteryLevelStatusGattReader, + BatteryLevelStatusServiceGattReader, + BatteryHealthStatusGattReader, + BatteryEnergyStatusGattReader implements SensorManager, SensorConfigurationManager, RgbLed, StatusLed, - BatteryLevelStatus, - BatteryLevelStatusService, - BatteryHealthStatusService, BatteryEnergyStatusService, DeviceIdentifier, DeviceFirmwareVersion, @@ -76,8 +77,7 @@ class OpenEarableV2 extends Wearable EdgeRecorderManager, ButtonManager, StereoDevice, - SystemDevice, - TimeSynchronizable { + SystemDevice { static const String deviceInfoServiceUuid = "45622510-6468-465a-b141-0b9b0f96b468"; static const String ledServiceUuid = "81040a2e-4819-11ee-be56-0242ac120002"; @@ -99,7 +99,7 @@ class OpenEarableV2 extends Wearable _sensorConfigSubscription?.cancel(); - _sensorConfigSubscription = _bleManager + _sensorConfigSubscription = bleManager .subscribe( deviceId: deviceId, serviceId: sensorServiceUuid, @@ -122,7 +122,7 @@ class OpenEarableV2 extends Wearable controller.onListen = () { // Immediately read the current sensor configuration - _bleManager + bleManager .read( deviceId: deviceId, serviceId: sensorServiceUuid, @@ -191,8 +191,6 @@ class OpenEarableV2 extends Wearable StreamSubscription? _sensorConfigSubscription; StreamSubscription? _buttonSubscription; - final BleGattManager _bleManager; - final DiscoveredDevice _discoveredDevice; @override final Set availableMicrophones; @@ -201,7 +199,7 @@ class OpenEarableV2 extends Wearable @override Future get filePrefix async { - List prefixBytes = await _bleManager.read( + List prefixBytes = await bleManager.read( deviceId: deviceId, serviceId: sensorServiceUuid, characteristicId: sensorEdgeRecorderFilePrefixCharacteristicUuid, @@ -215,7 +213,7 @@ class OpenEarableV2 extends Wearable _buttonSubscription?.cancel(); - _buttonSubscription = _bleManager + _buttonSubscription = bleManager .subscribe( deviceId: deviceId, serviceId: _buttonServiceUuid, @@ -245,7 +243,7 @@ class OpenEarableV2 extends Wearable controller.onListen = () { // Immediately read current button state - _bleManager + bleManager .read( deviceId: deviceId, serviceId: _buttonServiceUuid, @@ -274,19 +272,17 @@ class OpenEarableV2 extends Wearable required super.disconnectNotifier, required List sensors, required List sensorConfigurations, - required BleGattManager bleManager, - required DiscoveredDevice discoveredDevice, + required super.bleManager, + required super.discoveredDevice, this.availableMicrophones = const {}, this.availableAudioModes = const {}, bool isConnectedViaSystem = false, }) : _sensors = sensors, _sensorConfigurations = sensorConfigurations, - _bleManager = bleManager, - _discoveredDevice = discoveredDevice, _isConnectedViaSystem = isConnectedViaSystem; @override - String get deviceId => _discoveredDevice.id; + String get deviceId => discoveredDevice.id; @override String? getWearableIconPath({bool darkmode = false}) { @@ -313,8 +309,8 @@ class OpenEarableV2 extends Wearable data.setUint8(0, r); data.setUint8(1, g); data.setUint8(2, b); - await _bleManager.write( - deviceId: _discoveredDevice.id, + await bleManager.write( + deviceId: discoveredDevice.id, serviceId: ledServiceUuid, characteristicId: _ledSetColorCharacteristic, byteData: data.buffer.asUint8List(), @@ -325,8 +321,8 @@ class OpenEarableV2 extends Wearable Future showStatus(bool status) async { ByteData statusData = ByteData(1); statusData.setUint8(0, status ? 0 : 1); - await _bleManager.write( - deviceId: _discoveredDevice.id, + await bleManager.write( + deviceId: discoveredDevice.id, serviceId: ledServiceUuid, characteristicId: _ledSetStateCharacteristic, byteData: statusData.buffer.asUint8List(), @@ -340,8 +336,8 @@ class OpenEarableV2 extends Wearable /// Returns a `Future` that completes with the device identifier as a `String`. @override Future readDeviceIdentifier() async { - List deviceIdentifierBytes = await _bleManager.read( - deviceId: _discoveredDevice.id, + List deviceIdentifierBytes = await bleManager.read( + deviceId: discoveredDevice.id, serviceId: deviceInfoServiceUuid, characteristicId: _deviceIdentifierCharacteristicUuid, ); @@ -357,8 +353,8 @@ class OpenEarableV2 extends Wearable /// Returns a `Future` that completes with the device firmware version as a `String`. @override Future readDeviceFirmwareVersion() async { - List deviceGenerationBytes = await _bleManager.read( - deviceId: _discoveredDevice.id, + List deviceGenerationBytes = await bleManager.read( + deviceId: discoveredDevice.id, serviceId: deviceInfoServiceUuid, characteristicId: _deviceFirmwareVersionCharacteristicUuid, ); @@ -377,8 +373,8 @@ class OpenEarableV2 extends Wearable /// Returns a `Future` that completes with the device firmware version as a `String`. @override Future readDeviceHardwareVersion() async { - List hardwareGenerationBytes = await _bleManager.read( - deviceId: _discoveredDevice.id, + List hardwareGenerationBytes = await bleManager.read( + deviceId: discoveredDevice.id, serviceId: deviceInfoServiceUuid, characteristicId: _deviceHardwareVersionCharacteristicUuid, ); @@ -393,7 +389,7 @@ class OpenEarableV2 extends Wearable @override Future disconnect() { - return _bleManager.disconnect(_discoveredDevice.id); + return bleManager.disconnect(discoveredDevice.id); } @override @@ -403,282 +399,6 @@ class OpenEarableV2 extends Wearable @override List get sensors => List.unmodifiable(_sensors); - // MARK: Battery - - @override - Future readBatteryPercentage() async { - List batteryLevelList = await _bleManager.read( - deviceId: _discoveredDevice.id, - serviceId: batteryServiceUuid, - characteristicId: _batteryLevelCharacteristicUuid, - ); - - logger.t("Battery level bytes: $batteryLevelList"); - - if (batteryLevelList.length != 1) { - throw StateError( - 'Battery level characteristic expected 1 value, but got ${batteryLevelList.length}', - ); - } - - return batteryLevelList[0]; - } - - @override - Future readEnergyStatus() async { - List energyStatusList = await _bleManager.read( - deviceId: _discoveredDevice.id, - serviceId: batteryServiceUuid, - characteristicId: _batteryEnergyStatusCharacteristicUuid, - ); - - logger.t("Battery energy status bytes: $energyStatusList"); - - if (energyStatusList.length != 7) { - throw StateError( - 'Battery energy status characteristic expected 7 values, but got ${energyStatusList.length}', - ); - } - - int rawVoltage = (energyStatusList[2] << 8) | energyStatusList[1]; - double voltage = _convertSFloat(rawVoltage); - - int rawAvailableCapacity = (energyStatusList[4] << 8) | energyStatusList[3]; - double availableCapacity = _convertSFloat(rawAvailableCapacity); - - int rawChargeRate = (energyStatusList[6] << 8) | energyStatusList[5]; - double chargeRate = _convertSFloat(rawChargeRate); - - BatteryEnergyStatus batteryEnergyStatus = BatteryEnergyStatus( - voltage: voltage, - availableCapacity: availableCapacity, - chargeRate: chargeRate, - ); - - logger.d('Battery energy status: $batteryEnergyStatus'); - - return batteryEnergyStatus; - } - - double _convertSFloat(int rawBits) { - int exponent = ((rawBits & 0xF000) >> 12) - 16; - int mantissa = rawBits & 0x0FFF; - - if (mantissa >= 0x800) { - mantissa = -((0x1000) - mantissa); - } - logger.t("Exponent: $exponent, Mantissa: $mantissa"); - double result = mantissa.toDouble() * pow(10.0, exponent.toDouble()); - return result; - } - - @override - Future readHealthStatus() async { - List healthStatusList = await _bleManager.read( - deviceId: _discoveredDevice.id, - serviceId: batteryServiceUuid, - characteristicId: _batteryHealthStatusCharacteristicUuid, - ); - - logger.t("Battery health status bytes: $healthStatusList"); - - if (healthStatusList.length != 5) { - throw StateError( - 'Battery health status characteristic expected 5 values, but got ${healthStatusList.length}', - ); - } - - int healthSummary = healthStatusList[1]; - int cycleCount = (healthStatusList[2] << 8) | healthStatusList[3]; - int currentTemperature = healthStatusList[4]; - - BatteryHealthStatus batteryHealthStatus = BatteryHealthStatus( - healthSummary: healthSummary, - cycleCount: cycleCount, - currentTemperature: currentTemperature, - ); - - logger.d('Battery health status: $batteryHealthStatus'); - - return batteryHealthStatus; - } - - @override - Future readPowerStatus() async { - List powerStateList = await _bleManager.read( - deviceId: _discoveredDevice.id, - serviceId: batteryServiceUuid, - characteristicId: _batteryLevelStatusCharacteristicUuid, - ); - - int powerState = (powerStateList[1] << 8) | powerStateList[2]; - logger.d("Battery power status bits: ${powerState.toRadixString(2)}"); - - bool batteryPresent = powerState >> 15 & 0x1 != 0; - - int wiredExternalPowerSourceConnectedRaw = (powerState >> 13) & 0x3; - ExternalPowerSourceConnected wiredExternalPowerSourceConnected = - ExternalPowerSourceConnected - .values[wiredExternalPowerSourceConnectedRaw]; - - int wirelessExternalPowerSourceConnectedRaw = (powerState >> 11) & 0x3; - ExternalPowerSourceConnected wirelessExternalPowerSourceConnected = - ExternalPowerSourceConnected - .values[wirelessExternalPowerSourceConnectedRaw]; - - int chargeStateRaw = (powerState >> 9) & 0x3; - ChargeState chargeState = ChargeState.values[chargeStateRaw]; - - int chargeLevelRaw = (powerState >> 7) & 0x3; - BatteryChargeLevel chargeLevel = BatteryChargeLevel.values[chargeLevelRaw]; - - int chargingTypeRaw = (powerState >> 5) & 0x7; - BatteryChargingType chargingType = - BatteryChargingType.values[chargingTypeRaw]; - - int chargingFaultReasonRaw = (powerState >> 2) & 0x5; - List chargingFaultReason = []; - if ((chargingFaultReasonRaw & 0x1) != 0) { - chargingFaultReason.add(ChargingFaultReason.other); - } - if ((chargingFaultReasonRaw & 0x2) != 0) { - chargingFaultReason.add(ChargingFaultReason.externalPowerSource); - } - if ((chargingFaultReasonRaw & 0x4) != 0) { - chargingFaultReason.add(ChargingFaultReason.battery); - } - - BatteryPowerStatus batteryPowerStatus = BatteryPowerStatus( - batteryPresent: batteryPresent, - wiredExternalPowerSourceConnected: wiredExternalPowerSourceConnected, - wirelessExternalPowerSourceConnected: - wirelessExternalPowerSourceConnected, - chargeState: chargeState, - chargeLevel: chargeLevel, - chargingType: chargingType, - chargingFaultReason: chargingFaultReason, - ); - - logger.d('Battery power status: $batteryPowerStatus'); - - return batteryPowerStatus; - } - - @override - Stream get batteryPercentageStream { - StreamController controller = StreamController(); - Timer? batteryPollingTimer; - - controller.onCancel = () { - batteryPollingTimer?.cancel(); - }; - - controller.onListen = () { - batteryPollingTimer = Timer.periodic(const Duration(seconds: 5), (timer) { - readBatteryPercentage().then((batteryPercentage) { - controller.add(batteryPercentage); - }).catchError((e) { - logger.e('Error reading battery percentage: $e'); - }); - }); - - readBatteryPercentage().then((batteryPercentage) { - controller.add(batteryPercentage); - }).catchError((e) { - logger.e('Error reading battery percentage: $e'); - }); - }; - - return controller.stream; - } - - @override - Stream get powerStatusStream { - StreamController controller = - StreamController(); - Timer? powerPollingTimer; - - controller.onCancel = () { - powerPollingTimer?.cancel(); - }; - - controller.onListen = () { - powerPollingTimer = Timer.periodic(const Duration(seconds: 5), (timer) { - readPowerStatus().then((powerStatus) { - controller.add(powerStatus); - }).catchError((e) { - logger.e('Error reading power status: $e'); - }); - }); - - readPowerStatus().then((powerStatus) { - controller.add(powerStatus); - }).catchError((e) { - logger.e('Error reading power status: $e'); - }); - }; - - return controller.stream; - } - - @override - Stream get energyStatusStream { - StreamController controller = - StreamController(); - Timer? energyPollingTimer; - - controller.onCancel = () { - energyPollingTimer?.cancel(); - }; - - controller.onListen = () { - energyPollingTimer = Timer.periodic(const Duration(seconds: 5), (timer) { - readEnergyStatus().then((energyStatus) { - controller.add(energyStatus); - }).catchError((e) { - logger.e('Error reading energy status: $e'); - }); - }); - - readEnergyStatus().then((energyStatus) { - controller.add(energyStatus); - }).catchError((e) { - logger.e('Error reading energy status: $e'); - }); - }; - - return controller.stream; - } - - @override - Stream get healthStatusStream { - StreamController controller = - StreamController(); - Timer? healthPollingTimer; - - controller.onCancel = () { - healthPollingTimer?.cancel(); - }; - - controller.onListen = () { - healthPollingTimer = Timer.periodic(const Duration(seconds: 5), (timer) { - readHealthStatus().then((healthStatus) { - controller.add(healthStatus); - }).catchError((e) { - logger.e('Error reading health status: $e'); - }); - }); - - readHealthStatus().then((healthStatus) { - controller.add(healthStatus); - }).catchError((e) { - logger.e('Error reading health status: $e'); - }); - }; - - return controller.stream; - } - // MARK: MicrophoneManager @override @@ -687,7 +407,7 @@ class OpenEarableV2 extends Wearable throw ArgumentError('Microphone not available: ${microphone.key}'); } - _bleManager.write( + bleManager.write( deviceId: deviceId, serviceId: _audioConfigServiceUuid, characteristicId: _micSelectCharacteristicUuid, @@ -697,7 +417,7 @@ class OpenEarableV2 extends Wearable @override Future getMicrophone() async { - List microphoneBytes = await _bleManager.read( + List microphoneBytes = await bleManager.read( deviceId: deviceId, serviceId: _audioConfigServiceUuid, characteristicId: _micSelectCharacteristicUuid, @@ -721,7 +441,7 @@ class OpenEarableV2 extends Wearable throw ArgumentError('Audio mode not available: ${audioMode.key}'); } - _bleManager.write( + bleManager.write( deviceId: deviceId, serviceId: _audioConfigServiceUuid, characteristicId: _audioModeCharacteristicUuid, @@ -731,7 +451,7 @@ class OpenEarableV2 extends Wearable @override Future getAudioMode() async { - List audioModeBytes = await _bleManager.read( + List audioModeBytes = await bleManager.read( deviceId: deviceId, serviceId: _audioConfigServiceUuid, characteristicId: _audioModeCharacteristicUuid, @@ -751,7 +471,7 @@ class OpenEarableV2 extends Wearable @override Future setFilePrefix(String prefix) { - return _bleManager.write( + return bleManager.write( deviceId: deviceId, serviceId: sensorServiceUuid, characteristicId: sensorEdgeRecorderFilePrefixCharacteristicUuid, @@ -765,7 +485,7 @@ class OpenEarableV2 extends Wearable Future get position async { List positionBytes; try { - positionBytes = await _bleManager.read( + positionBytes = await bleManager.read( deviceId: deviceId, serviceId: "1410df95-5f68-4ebb-a7c7-5e0fb9ae7557", characteristicId: "1410df98-5f68-4ebb-a7c7-5e0fb9ae7557", @@ -816,8 +536,130 @@ class OpenEarableV2 extends Wearable _pairedDevice?.unpair(); _pairedDevice = null; } +} + +// MARK: OpenEarableV2Mic + +class OpenEarableV2Mic extends Microphone { + final int id; - // MARK: TimeSynchronizable + const OpenEarableV2Mic({ + required this.id, + required super.key, + }); +} + +class OpenEarableV2PairingRule extends PairingRule { + @override + Future isValidPair(OpenEarableV2 left, OpenEarableV2 right) async { + // Example rule: both devices must be OpenEarable V2 and have different positions + DevicePosition? leftPosition = await left.position; + DevicePosition? rightPosition = await right.position; + if (leftPosition == null || rightPosition == null) { + return false; + } + if (leftPosition == rightPosition) { + return false; + } + + return left.name == right.name; + } +} + +// MARK: OpenEarable Sync Time packet + +enum _TimeSyncOperation { + request(0x00), + response(0x01); + + final int value; + const _TimeSyncOperation(this.value); +} + +class _SyncTimePacket { + final int version; + final _TimeSyncOperation op; + final int seq; + final int timePhoneSend; + final int timeDeviceReceive; + final int timeDeviceSend; + + factory _SyncTimePacket.fromBytes(Uint8List bytes) { + if (bytes.length < 15) { + throw ArgumentError.value( + bytes, + 'bytes', + 'Byte array too short to be a valid SyncTimePacket', + ); + } + + ByteData bd = ByteData.sublistView(bytes); + int version = bd.getUint8(0); + _TimeSyncOperation op = + _TimeSyncOperation.values.firstWhere((e) => e.value == bd.getUint8(1)); + int seq = bd.getUint16(2, Endian.little); + int timePhoneSend = bd.getUint64(4, Endian.little); + int timeDeviceReceive = bd.getUint64(12, Endian.little); + int timeDeviceSend = bd.getUint64(20, Endian.little); + + return _SyncTimePacket( + version: version, + op: op, + seq: seq, + timePhoneSend: timePhoneSend, + timeDeviceReceive: timeDeviceReceive, + timeDeviceSend: timeDeviceSend, + ); + } + + const _SyncTimePacket({ + required this.version, + required this.op, + required this.seq, + required this.timePhoneSend, + required this.timeDeviceReceive, + required this.timeDeviceSend, + }); + + /// Serialize packet to bytes. + /// Layout (little-endian): + /// [0] : version (1 byte) + /// [1] : operation (1 byte) + /// [2] : sequence (2 byte) + /// [3..6] : timePhoneSend (uint64) + /// [7..10]: timeDeviceReceive (uint64) + /// [11..14]: timeDeviceSend (uint64) + Uint8List toBytes() { + if (seq < 0 || seq > 0xFFFF) { + throw ArgumentError.value(seq, 'seq', 'Must fit in two bytes (0..65535)'); + } + + final ByteData bd = ByteData(28); + bd.setUint8(0, version); + bd.setUint8(1, op.value); + bd.setUint16(2, seq, Endian.little); + bd.setUint64(4, timePhoneSend, Endian.little); + bd.setUint64(12, timeDeviceReceive, Endian.little); + bd.setUint64(20, timeDeviceSend, Endian.little); + return bd.buffer.asUint8List(); + } + + @override + String toString() { + return '_SyncTimePacket(version: $version, op: $op, seq: $seq, timePhoneSend: $timePhoneSend, timeDeviceReceive: $timeDeviceReceive, timeDeviceSend: $timeDeviceSend)'; + } +} + +// MARK: TimeSynchronizable + +class OpenEarableV2TimeSyncImp implements TimeSynchronizable { + final BleGattManager bleManager; + final String deviceId; + + OpenEarableV2TimeSyncImp({ + required this.bleManager, + required this.deviceId, + }); @override bool get isTimeSynchronized { @@ -840,10 +682,10 @@ class OpenEarableV2 extends Wearable // Subscribe to RTT responses late final StreamSubscription> rttSub; - rttSub = _bleManager + rttSub = bleManager .subscribe( deviceId: deviceId, - serviceId: _timeSynchronizationServiceUuid, + serviceId: timeSynchronizationServiceUuid, characteristicId: _timeSyncRttCharacteristicUuid, ) .listen( @@ -883,9 +725,9 @@ class OpenEarableV2 extends Wearable ..setInt64(0, medianOffset, Endian.little); // Write the final median offset to the device - await _bleManager.write( + await bleManager.write( deviceId: deviceId, - serviceId: _timeSynchronizationServiceUuid, + serviceId: timeSynchronizationServiceUuid, characteristicId: _timeSyncTimeMappingCharacteristicUuid, byteData: offsetBytes.buffer.asUint8List(), ); @@ -919,9 +761,9 @@ class OpenEarableV2 extends Wearable logger.d("Sending time sync request seq=$i, t1=$t1"); - await _bleManager.write( + await bleManager.write( deviceId: deviceId, - serviceId: _timeSynchronizationServiceUuid, + serviceId: timeSynchronizationServiceUuid, characteristicId: _timeSyncRttCharacteristicUuid, byteData: request.toBytes(), ); @@ -947,115 +789,3 @@ class OpenEarableV2 extends Wearable } } } - -// MARK: OpenEarableV2Mic - -class OpenEarableV2Mic extends Microphone { - final int id; - - const OpenEarableV2Mic({ - required this.id, - required super.key, - }); -} - -class OpenEarableV2PairingRule extends PairingRule { - @override - Future isValidPair(OpenEarableV2 left, OpenEarableV2 right) async { - // Example rule: both devices must be OpenEarable V2 and have different positions - DevicePosition? leftPosition = await left.position; - DevicePosition? rightPosition = await right.position; - if (leftPosition == null || rightPosition == null) { - return false; - } - if (leftPosition == rightPosition) { - return false; - } - - return left.name == right.name; - } -} - -// MARK: OpenEarable Sync Time packet - -enum _TimeSyncOperation { - request(0x00), - response(0x01); - - final int value; - const _TimeSyncOperation(this.value); -} - -class _SyncTimePacket { - final int version; - final _TimeSyncOperation op; - final int seq; - final int timePhoneSend; - final int timeDeviceReceive; - final int timeDeviceSend; - - factory _SyncTimePacket.fromBytes(Uint8List bytes) { - if (bytes.length < 15) { - throw ArgumentError.value( - bytes, - 'bytes', - 'Byte array too short to be a valid SyncTimePacket', - ); - } - - ByteData bd = ByteData.sublistView(bytes); - int version = bd.getUint8(0); - _TimeSyncOperation op = - _TimeSyncOperation.values.firstWhere((e) => e.value == bd.getUint8(1)); - int seq = bd.getUint16(2, Endian.little); - int timePhoneSend = bd.getUint64(4, Endian.little); - int timeDeviceReceive = bd.getUint64(12, Endian.little); - int timeDeviceSend = bd.getUint64(20, Endian.little); - - return _SyncTimePacket( - version: version, - op: op, - seq: seq, - timePhoneSend: timePhoneSend, - timeDeviceReceive: timeDeviceReceive, - timeDeviceSend: timeDeviceSend, - ); - } - - const _SyncTimePacket({ - required this.version, - required this.op, - required this.seq, - required this.timePhoneSend, - required this.timeDeviceReceive, - required this.timeDeviceSend, - }); - - /// Serialize packet to bytes. - /// Layout (little-endian): - /// [0] : version (1 byte) - /// [1] : operation (1 byte) - /// [2] : sequence (2 byte) - /// [3..6] : timePhoneSend (uint64) - /// [7..10]: timeDeviceReceive (uint64) - /// [11..14]: timeDeviceSend (uint64) - Uint8List toBytes() { - if (seq < 0 || seq > 0xFFFF) { - throw ArgumentError.value(seq, 'seq', 'Must fit in two bytes (0..65535)'); - } - - final ByteData bd = ByteData(28); - bd.setUint8(0, version); - bd.setUint8(1, op.value); - bd.setUint16(2, seq, Endian.little); - bd.setUint64(4, timePhoneSend, Endian.little); - bd.setUint64(12, timeDeviceReceive, Endian.little); - bd.setUint64(20, timeDeviceSend, Endian.little); - return bd.buffer.asUint8List(); - } - - @override - String toString() { - return '_SyncTimePacket(version: $version, op: $op, seq: $seq, timePhoneSend: $timePhoneSend, timeDeviceReceive: $timeDeviceReceive, timeDeviceSend: $timeDeviceSend)'; - } -} diff --git a/lib/src/models/devices/wearable.dart b/lib/src/models/devices/wearable.dart index a22d0e9..bb87f9f 100644 --- a/lib/src/models/devices/wearable.dart +++ b/lib/src/models/devices/wearable.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui'; import '../../managers/wearable_disconnect_notifier.dart'; @@ -5,6 +6,11 @@ import '../../managers/wearable_disconnect_notifier.dart'; abstract class Wearable { final String name; + final Map _capabilities = {}; + + final StreamController> _registeredCapabilityController = + StreamController>.broadcast(); + Wearable({ required this.name, required WearableDisconnectNotifier disconnectNotifier, @@ -12,6 +18,64 @@ abstract class Wearable { disconnectNotifier.addListener(_notifyDisconnectListeners); } + /// Checks if the wearable has a specific capability. + bool hasCapability() { + if (_capabilities.containsKey(T)) { + return true; + } + return this is T; + } + + /// Gets a specific capability of the wearable. + /// Returns null if the capability is not supported by this wearable. + T? getCapability() { + if (_capabilities.containsKey(T)) { + return _capabilities[T] as T; + } + if (this is T) { + return this as T; + } + return null; + } + + /// Gets a specific capability of the wearable, throwing a StateError if not supported. + T requireCapability() { + final capability = getCapability(); + if (capability != null) { + return capability; + } + throw StateError('Wearable does not have required capability: $T'); + } + + /// Registers a specific capability for the wearable. + /// Throws a StateError if the capability is already registered. + void registerCapability(T capability) { + if (hasCapability()) { + throw StateError('Wearable already has capability: $T'); + } + _capabilities[T] = capability as Object; + _registeredCapabilityController.add([T]); + } + + /// Stream that emits an event whenever a new capability is registered. + Stream> get capabilityRegistered => _registeredCapabilityController.stream; + + Stream capabilityAvailable() async* { + final cap = getCapability(); + if (cap != null) { + yield cap; + return; + } + + await for (final _ in capabilityRegistered) { + final next = getCapability(); + if (next != null) { + yield next; + return; + } + } + } + /// Gets path to an icon representing the wearable. /// Preferred type is SVG. /// Needs to be added to the asset section of the pubspec.yaml file.