From f6310019b51684bc236ca7d87f7018f95a80b303 Mon Sep 17 00:00:00 2001 From: Ruben Lohberg Date: Mon, 18 May 2026 20:17:00 +0200 Subject: [PATCH] feat(power-saving-service): Support power saving service --- CHANGELOG.md | 4 + doc/CAPABILITIES.md | 16 +++ lib/open_earable_flutter.dart | 1 + lib/src/constants.dart | 6 + .../power_saving_mode_manager.dart | 39 ++++++ .../models/devices/open_earable_factory.dart | 23 ++++ lib/src/models/devices/open_earable_v2.dart | 115 ++++++++++++++++++ 7 files changed, 204 insertions(+) create mode 100644 lib/src/models/capabilities/power_saving_mode_manager.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b46e6620..fb76a5a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +* added dynamic power saving mode capability for OpenEarable v2 devices + ## 2.3.7 * added erase firmware image slot function for FOTA slot info capability diff --git a/doc/CAPABILITIES.md b/doc/CAPABILITIES.md index 17be381c..908cafea 100644 --- a/doc/CAPABILITIES.md +++ b/doc/CAPABILITIES.md @@ -143,6 +143,22 @@ if (audioModeManager != null) { --- +### PowerSavingModeManager + +Reads the power saving modes that the firmware currently supports, including +their display names, and applies the selected mode. + +```dart +final powerSaving = wearable.getCapability(); +if (powerSaving != null) { + final modes = await powerSaving.readSupportedPowerSavingModes(); + final currentMode = await powerSaving.readPowerSavingMode(); + await powerSaving.setPowerSavingMode(modes.first); +} +``` + +--- + ### ℹ️ Device Information Capabilities #### DeviceFirmwareVersion diff --git a/lib/open_earable_flutter.dart b/lib/open_earable_flutter.dart index c3262467..f768dfe1 100644 --- a/lib/open_earable_flutter.dart +++ b/lib/open_earable_flutter.dart @@ -61,6 +61,7 @@ export 'src/models/capabilities/audio_player_controls.dart'; export 'src/models/capabilities/storage_path_audio_player.dart'; export 'src/models/capabilities/audio_mode_manager.dart'; export 'src/models/capabilities/microphone_manager.dart'; +export 'src/models/capabilities/power_saving_mode_manager.dart'; export 'src/models/capabilities/stereo_device.dart'; export 'src/models/recorder.dart'; export 'src/models/devices/stereo_pairing/pairing_rule.dart'; diff --git a/lib/src/constants.dart b/lib/src/constants.dart index e20e21a4..30e4ee74 100644 --- a/lib/src/constants.dart +++ b/lib/src/constants.dart @@ -40,3 +40,9 @@ const String buttonStateCharacteristicUuid = const String ledServiceUuid = "81040a2e-4819-11ee-be56-0242ac120002"; const String ledSetStateCharacteristic = "81040e7a-4819-11ee-be56-0242ac120002"; + +const String powerSavingServiceUuid = "d63fd1f0-5f68-4ebb-a7c7-5e0fb9ae7557"; +const String powerSavingModeCharacteristicUuid = + "d63fd1f1-5f68-4ebb-a7c7-5e0fb9ae7557"; +const String powerSavingSupportedModesCharacteristicUuid = + "d63fd1f2-5f68-4ebb-a7c7-5e0fb9ae7557"; diff --git a/lib/src/models/capabilities/power_saving_mode_manager.dart b/lib/src/models/capabilities/power_saving_mode_manager.dart new file mode 100644 index 00000000..83a51188 --- /dev/null +++ b/lib/src/models/capabilities/power_saving_mode_manager.dart @@ -0,0 +1,39 @@ +/// Manages firmware-defined power saving modes. +/// +/// Implementations read the supported mode list from the wearable so apps can +/// show newly added firmware modes without package or app changes. +abstract class PowerSavingModeManager { + /// Reads all power saving modes supported by the wearable. + Future> readSupportedPowerSavingModes(); + + /// Reads the currently selected power saving mode. + Future readPowerSavingMode(); + + /// Applies [mode] as the current power saving mode. + Future setPowerSavingMode(PowerSavingMode mode); +} + +/// A selectable firmware-defined power saving mode. +class PowerSavingMode { + /// Stable firmware-defined mode identifier. + final int id; + + /// User-facing mode name supplied by the firmware. + final String name; + + /// Creates a power saving mode. + const PowerSavingMode({required this.id, required this.name}); + + @override + bool operator ==(Object other) { + return other is PowerSavingMode && other.id == id; + } + + @override + int get hashCode => id.hashCode; + + @override + String toString() { + return name; + } +} diff --git a/lib/src/models/devices/open_earable_factory.dart b/lib/src/models/devices/open_earable_factory.dart index 11dadebd..c2953433 100644 --- a/lib/src/models/devices/open_earable_factory.dart +++ b/lib/src/models/devices/open_earable_factory.dart @@ -7,11 +7,13 @@ import 'package:open_earable_flutter/src/utils/sensor_scheme_parser/v2_sensor_sc import 'package:universal_ble/universal_ble.dart'; import '../../../open_earable_flutter.dart' show logger; +import '../../constants.dart'; import '../../managers/v2_sensor_handler.dart'; import '../../utils/sensor_value_parser/v2_sensor_value_parser.dart'; import '../capabilities/audio_mode_manager.dart'; import '../capabilities/fota_capability.dart'; import '../capabilities/fota_slot_info_capability.dart'; +import '../capabilities/power_saving_mode_manager.dart'; import '../capabilities/sensor.dart'; import '../capabilities/sensor_configuration.dart'; import '../capabilities/sensor_configuration_specializations/recordable_sensor_configuration.dart'; @@ -113,6 +115,14 @@ class OpenEarableFactory extends WearableFactory { McuMgrFotaSlotInfoManager(deviceId: device.id), ); } + if (await _hasPowerSavingService(device)) { + wearable.registerCapability( + OpenEarableV2PowerSavingManager( + bleManager: bleManager!, + deviceId: device.id, + ), + ); + } return wearable; } else { throw Exception('OpenEarable version is not supported'); @@ -134,6 +144,19 @@ class OpenEarableFactory extends WearableFactory { return String.fromCharCodes(softwareGenerationBytes); } + Future _hasPowerSavingService(DiscoveredDevice device) async { + return await bleManager!.hasCharacteristic( + deviceId: device.id, + serviceId: powerSavingServiceUuid, + characteristicId: powerSavingModeCharacteristicUuid, + ) && + await bleManager!.hasCharacteristic( + deviceId: device.id, + serviceId: powerSavingServiceUuid, + characteristicId: powerSavingSupportedModesCharacteristicUuid, + ); + } + Future<(List, List)> _initSensors( DiscoveredDevice device, ) async { diff --git a/lib/src/models/devices/open_earable_v2.dart b/lib/src/models/devices/open_earable_v2.dart index e2b617d6..67da7dc9 100644 --- a/lib/src/models/devices/open_earable_v2.dart +++ b/lib/src/models/devices/open_earable_v2.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:typed_data'; import 'package:open_earable_flutter/src/constants.dart'; @@ -582,6 +583,120 @@ class OpenEarableV2PairingRule extends PairingRule { } } +// MARK: PowerSavingModeManager + +/// OpenEarable V2 implementation of [PowerSavingModeManager]. +class OpenEarableV2PowerSavingManager implements PowerSavingModeManager { + /// Creates a manager that talks to the OpenEarable V2 power saving service. + OpenEarableV2PowerSavingManager({ + required this.bleManager, + required this.deviceId, + }); + + /// GATT manager used for BLE communication. + final BleGattManager bleManager; + + /// Connected wearable id. + final String deviceId; + + List? _supportedModesCache; + + @override + Future> readSupportedPowerSavingModes() async { + final cachedModes = _supportedModesCache; + if (cachedModes != null) { + return cachedModes; + } + + final bytes = await bleManager.read( + deviceId: deviceId, + serviceId: powerSavingServiceUuid, + characteristicId: powerSavingSupportedModesCharacteristicUuid, + ); + + final modes = _decodeSupportedModes(bytes); + _supportedModesCache = modes; + return modes; + } + + @override + Future readPowerSavingMode() async { + final modeBytes = await bleManager.read( + deviceId: deviceId, + serviceId: powerSavingServiceUuid, + characteristicId: powerSavingModeCharacteristicUuid, + ); + + if (modeBytes.length != 1) { + throw StateError( + 'Power saving mode characteristic expected 1 value, but got ${modeBytes.length}', + ); + } + + final modeId = modeBytes[0]; + final supportedModes = await readSupportedPowerSavingModes(); + for (final mode in supportedModes) { + if (mode.id == modeId) { + return mode; + } + } + + throw StateError( + 'Current power saving mode $modeId is not advertised as supported', + ); + } + + @override + Future setPowerSavingMode(PowerSavingMode mode) { + if (mode.id < 0 || mode.id > 0xFF) { + throw RangeError.range(mode.id, 0, 0xFF, 'mode.id'); + } + + return bleManager.write( + deviceId: deviceId, + serviceId: powerSavingServiceUuid, + characteristicId: powerSavingModeCharacteristicUuid, + byteData: [mode.id], + ); + } + + List _decodeSupportedModes(List bytes) { + if (bytes.isEmpty) { + throw StateError( + 'Supported power saving modes characteristic is too short: ${bytes.length}', + ); + } + + final modeCount = bytes[0]; + var offset = 1; + final modes = []; + + for (var i = 0; i < modeCount; i++) { + if (offset + 2 > bytes.length) { + throw StateError( + 'Supported power saving modes ended before mode header $i', + ); + } + + final modeId = bytes[offset++]; + final nameLength = bytes[offset++]; + + if (offset + nameLength > bytes.length) { + throw StateError( + 'Supported power saving mode $modeId has an incomplete name', + ); + } + + final name = utf8.decode(bytes.sublist(offset, offset + nameLength)); + offset += nameLength; + + modes.add(PowerSavingMode(id: modeId, name: name)); + } + + return List.unmodifiable(modes); + } +} + // MARK: OpenEarable Sync Time packet enum _TimeSyncOperation {