From 506cfe12536ddbd7c1610dcf01efabd6824c9ce1 Mon Sep 17 00:00:00 2001 From: "atlas-agent[bot]" Date: Sat, 7 Mar 2026 04:16:34 -0500 Subject: [PATCH] feat: Add HeyPocket (Pocket) device integration Add support for HeyPocket 'Pocket' wearable devices as a third-party device in the Omi app, enabling audio transcription via BLE. ## Device Details - BLE name prefix: PKT - Service UUID: 001120a0-2233-4455-6677-889912345678 - Text-based command protocol (APP&/MCU& prefix) - Audio codec: Opus (16kHz mono) - BLE UUIDs and protocol discovered via BLE packet analysis ## Changes - New PocketDeviceConnection class with full BLE command protocol - DeviceType.pocket enum value - Device detection via PKT name prefix and service UUID - Battery polling via APP&BAT command (with timer cleanup on disconnect) - Firmware version query via APP&FW command - Device time sync on connect - Command serialization lock to prevent response race conditions - Audio subscription onCancel handler to stop device recording - Factory registration in DeviceConnectionFactory - Device product image asset - Comprehensive unit tests ## Protocol - APP&STA/APP&STO: Start/stop recording - MCU&REC&CON/MCU&REC&CALL: Recording mode confirmation - APP&BAT -> MCU&BAT&{level}: Battery level - APP&FW -> MCU&FW&{version}: Firmware version - APP&T&{datetime} -> MCU&T&OK: Time sync ## Notes - UUID-to-characteristic mapping inferred from BLE analysis; a1/a2/a3 may need minor swap during testing with physical device - Audio packet header format TBD (may need stripping) - Binding password (16-char user-entered) not yet implemented in UI --- app/assets/images/pocket_device.png | Bin 0 -> 46470 bytes .../backend/schema/bt_device/bt_device.dart | 51 +++ app/lib/gen/assets.gen.dart | 5 + app/lib/providers/capture_provider.dart | 2 + .../services/devices/device_connection.dart | 3 + app/lib/services/devices/models.dart | 2 + .../services/devices/pocket_connection.dart | 328 ++++++++++++++++++ app/lib/utils/device.dart | 2 + .../devices/pocket_connection_test.dart | 184 ++++++++++ backend/models/conversation.py | 8 +- .../conversations/process_conversation.py | 10 +- 11 files changed, 588 insertions(+), 7 deletions(-) create mode 100644 app/assets/images/pocket_device.png create mode 100644 app/lib/services/devices/pocket_connection.dart create mode 100644 app/test/services/devices/pocket_connection_test.dart diff --git a/app/assets/images/pocket_device.png b/app/assets/images/pocket_device.png new file mode 100644 index 0000000000000000000000000000000000000000..6359a84319a822b9af2f6f66557afb845c9c7526 GIT binary patch literal 46470 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ÐRUU*cN( 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

y7|GcK1A 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 U^mIwL(~ba8k95_FA6mcpf8&j09RL6T literal 0 HcmV?d00001 diff --git a/app/lib/backend/schema/bt_device/bt_device.dart b/app/lib/backend/schema/bt_device/bt_device.dart index 42eb848ea0..e244311b7d 100644 --- a/app/lib/backend/schema/bt_device/bt_device.dart +++ b/app/lib/backend/schema/bt_device/bt_device.dart @@ -15,6 +15,7 @@ import 'package:omi/services/devices/models.dart'; import 'package:omi/services/devices/omi_connection.dart'; import 'package:omi/services/devices/omiglass_connection.dart'; import 'package:omi/services/devices/plaud_connection.dart'; +import 'package:omi/services/devices/pocket_connection.dart'; import 'package:omi/utils/logger.dart'; enum ImageOrientation { @@ -206,6 +207,8 @@ Future getTypeOfBluetoothDevice(BluetoothDevice device) async { deviceType = DeviceType.friendPendant; } else if (BtDevice.isLimitlessDeviceFromDevice(device)) { deviceType = DeviceType.limitless; + } else if (BtDevice.isPocketDeviceFromDevice(device)) { + deviceType = DeviceType.pocket; } else if (BtDevice.isOmiDeviceFromDevice(device)) { // Check if the device has the image data stream characteristic final hasImageStream = device.servicesList @@ -235,6 +238,7 @@ enum DeviceType { fieldy, friendPendant, limitless, + pocket, } Map cachedDevicesMap = {}; @@ -379,6 +383,8 @@ class BtDevice { return await _getDeviceInfoFromFrame(conn as FrameDeviceConnection); } else if (type == DeviceType.appleWatch) { return await _getDeviceInfoFromAppleWatch(conn as AppleWatchDeviceConnection); + } else if (type == DeviceType.pocket) { + return await _getDeviceInfoFromPocket(conn as PocketDeviceConnection); } else { return await _getDeviceInfoFromOmi(conn); } @@ -610,6 +616,31 @@ class BtDevice { ); } + Future _getDeviceInfoFromPocket(PocketDeviceConnection conn) async { + var modelNumber = 'Pocket'; + var firmwareRevision = '1.0.0'; + var hardwareRevision = 'HeyPocket Hardware'; + var manufacturerName = 'HeyPocket'; + + try { + final deviceInfo = await conn.getDeviceInfo(); + modelNumber = deviceInfo['modelNumber'] ?? modelNumber; + firmwareRevision = deviceInfo['firmwareRevision'] ?? firmwareRevision; + hardwareRevision = deviceInfo['hardwareRevision'] ?? hardwareRevision; + manufacturerName = deviceInfo['manufacturerName'] ?? manufacturerName; + } catch (e) { + Logger.error('Error getting Pocket device info: $e'); + } + + return copyWith( + modelNumber: modelNumber, + firmwareRevision: firmwareRevision, + hardwareRevision: hardwareRevision, + manufacturerName: manufacturerName, + type: DeviceType.pocket, + ); + } + /// Returns firmware warning title for this device type /// Empty string means no warning needed String getFirmwareWarningTitle() { @@ -619,6 +650,8 @@ class BtDevice { case DeviceType.fieldy: case DeviceType.friendPendant: case DeviceType.limitless: + case DeviceType.pocket: + // TODO: Extract all firmware warning strings to l10n .arb files return 'Compatibility Note'; case DeviceType.omi: case DeviceType.openglass: @@ -653,6 +686,10 @@ class BtDevice { return 'Your $name\'s current firmware works great with Omi.\n\n' 'We recommend keeping your current firmware and not updating through the Limitless app, as newer versions may affect compatibility.'; + case DeviceType.pocket: + return 'Your $name\'s current firmware works great with Omi.\n\n' + 'We recommend keeping your current firmware and not updating through the HeyPocket app, as newer versions may affect compatibility.'; + case DeviceType.omi: case DeviceType.openglass: case DeviceType.frame: @@ -679,6 +716,7 @@ class BtDevice { isFieldyDevice(result) || isFriendPendantDevice(result) || isLimitlessDevice(result) || + isPocketDevice(result) || isOmiDevice(result) || isFrameDevice(result); } @@ -769,6 +807,17 @@ class BtDevice { device.servicesList.any((s) => s.uuid.toString().toLowerCase() == limitlessServiceUuid.toLowerCase()); } + static bool isPocketDevice(ScanResult result) { + return result.device.platformName.toUpperCase().startsWith('PKT') || + result.advertisementData.serviceUuids + .any((uuid) => uuid.toString().toLowerCase() == pocketServiceUuid.toLowerCase()); + } + + static bool isPocketDeviceFromDevice(BluetoothDevice device) { + return device.platformName.toUpperCase().startsWith('PKT') || + device.servicesList.any((s) => s.uuid.toString().toLowerCase() == pocketServiceUuid.toLowerCase()); + } + static bool isOmiDevice(ScanResult result) { return result.advertisementData.serviceUuids.contains(Guid(omiServiceUuid)); } @@ -799,6 +848,8 @@ class BtDevice { deviceType = DeviceType.friendPendant; } else if (isLimitlessDevice(result)) { deviceType = DeviceType.limitless; + } else if (isPocketDevice(result)) { + deviceType = DeviceType.pocket; } else if (isOmiDevice(result)) { deviceType = DeviceType.omi; } else if (isFrameDevice(result)) { diff --git a/app/lib/gen/assets.gen.dart b/app/lib/gen/assets.gen.dart index ad331cadaa..7e2c43ff84 100644 --- a/app/lib/gen/assets.gen.dart +++ b/app/lib/gen/assets.gen.dart @@ -294,6 +294,10 @@ class $AssetsImagesGen { AssetGenImage get plaudNotePin => const AssetGenImage('assets/images/plaud_note_pin.webp'); + /// File path: assets/images/pocket_device.png + AssetGenImage get pocketDevice => + const AssetGenImage('assets/images/pocket_device.png'); + /// File path: assets/images/recording_green_circle_icon.png AssetGenImage get recordingGreenCircleIcon => const AssetGenImage('assets/images/recording_green_circle_icon.png'); @@ -403,6 +407,7 @@ class $AssetsImagesGen { onboardingBg6, onboarding, plaudNotePin, + pocketDevice, recordingGreenCircleIcon, slackLogo, speaker0Icon, diff --git a/app/lib/providers/capture_provider.dart b/app/lib/providers/capture_provider.dart index a39559fe49..6d83b15e92 100644 --- a/app/lib/providers/capture_provider.dart +++ b/app/lib/providers/capture_provider.dart @@ -261,6 +261,8 @@ class CaptureProvider extends ChangeNotifier return 'apple_watch'; case DeviceType.limitless: return 'limitless'; + case DeviceType.pocket: + return 'pocket'; } } diff --git a/app/lib/services/devices/device_connection.dart b/app/lib/services/devices/device_connection.dart index 79b129e061..a10fee8b18 100644 --- a/app/lib/services/devices/device_connection.dart +++ b/app/lib/services/devices/device_connection.dart @@ -17,6 +17,7 @@ import 'package:omi/services/devices/models.dart'; import 'package:omi/services/devices/omi_connection.dart'; import 'package:omi/services/devices/omiglass_connection.dart'; import 'package:omi/services/devices/plaud_connection.dart'; +import 'package:omi/services/devices/pocket_connection.dart'; import 'package:omi/services/devices/wifi_sync_error.dart'; import 'package:omi/main.dart'; import 'package:omi/services/notifications.dart'; @@ -88,6 +89,8 @@ class DeviceConnectionFactory { return FriendPendantDeviceConnection(device, transport); case DeviceType.limitless: return LimitlessDeviceConnection(device, transport); + case DeviceType.pocket: + return PocketDeviceConnection(device, transport); } } } diff --git a/app/lib/services/devices/models.dart b/app/lib/services/devices/models.dart index 00aeb32b91..0bf2e23d25 100644 --- a/app/lib/services/devices/models.dart +++ b/app/lib/services/devices/models.dart @@ -68,6 +68,8 @@ const String limitlessServiceUuid = "632de001-604c-446b-a80f-7963e950f3fb"; const String limitlessTxCharUuid = "632de002-604c-446b-a80f-7963e950f3fb"; const String limitlessRxCharUuid = "632de003-604c-446b-a80f-7963e950f3fb"; +const String pocketServiceUuid = '001120a0-2233-4455-6677-889912345678'; + // OmiGlass OTA Service UUIDs const String omiGlassOtaServiceUuid = "19b10010-e8f2-537e-4f6c-d104768a1214"; const String omiGlassOtaControlCharacteristicUuid = "19b10011-e8f2-537e-4f6c-d104768a1214"; diff --git a/app/lib/services/devices/pocket_connection.dart b/app/lib/services/devices/pocket_connection.dart new file mode 100644 index 0000000000..eade925ec7 --- /dev/null +++ b/app/lib/services/devices/pocket_connection.dart @@ -0,0 +1,328 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:omi/backend/schema/bt_device/bt_device.dart'; +import 'package:omi/services/devices.dart'; +import 'package:omi/services/devices/device_connection.dart'; +import 'package:omi/services/devices/models.dart'; +import 'package:omi/utils/logger.dart'; + +// BLE Characteristic UUIDs discovered via BLE packet analysis of HeyPocket device +// Service UUID is in models.dart as pocketServiceUuid +const String pocketAudioCharacteristicUuid = '001120a1-2233-4455-6677-889912345678'; +const String pocketCommandCharacteristicUuid = '001120a2-2233-4455-6677-889912345678'; +// Secondary write channel — reserved for future use (e.g. firmware OTA, WiFi config) +const String pocketCommandWriteCharacteristicUuid = '001120a3-2233-4455-6677-889912345678'; + +/// Device connection for HeyPocket (Pocket) wearable devices. +/// +/// The Pocket device uses a text-based BLE command protocol: +/// - APP→MCU: Commands sent as ASCII strings (e.g. "APP&STA" to start recording) +/// - MCU→APP: Responses as ASCII strings (e.g. "MCU&BAT&85" for battery level) +/// - Audio: Streamed via a dedicated notify characteristic (likely Opus encoded) +/// +/// Commands are serialized through a Completer-based lock to prevent concurrent +/// commands from consuming each other's responses on the shared broadcast stream. +class PocketDeviceConnection extends DeviceConnection { + final _audioController = StreamController>.broadcast(); + final _commandResponseController = StreamController.broadcast(); + + StreamSubscription? _commandNotifySub; + StreamSubscription? _audioNotifySub; + bool _isRecording = false; + Timer? _batteryTimer; + + /// Lock to serialize BLE commands — prevents concurrent commands from + /// consuming each other's responses on the shared broadcast stream. + Completer? _commandLock; + + PocketDeviceConnection(super.device, super.transport); + + // --- Connection Lifecycle --- + + @override + Future connect({ + Function(String deviceId, DeviceConnectionState state)? onConnectionStateChanged, + }) async { + await super.connect(onConnectionStateChanged: onConnectionStateChanged); + await Future.delayed(const Duration(milliseconds: 500)); + + // Subscribe to command responses (MCU→APP) + _commandNotifySub = transport + .getCharacteristicStream(pocketServiceUuid, pocketCommandCharacteristicUuid) + .listen((data) { + try { + final response = utf8.decode(data, allowMalformed: true); + Logger.debug('[Pocket] MCU response: $response'); + _commandResponseController.add(response); + } catch (e) { + Logger.error('[Pocket] Error decoding command response: $e'); + } + }); + + // Subscribe to audio stream + _audioNotifySub = transport + .getCharacteristicStream(pocketServiceUuid, pocketAudioCharacteristicUuid) + .listen((data) { + if (data.isNotEmpty) { + _audioController.add(data); + } + }); + + // Set device time on connect + await _setDeviceTime(); + + Logger.debug('[Pocket] Connected and subscribed to characteristics'); + } + + @override + Future disconnect() async { + // Stop recording if active + if (_isRecording) { + try { + await _sendCommand('APP&STO'); + } catch (_) {} + _isRecording = false; + } + + // Cancel battery polling timer + _batteryTimer?.cancel(); + _batteryTimer = null; + + await _commandNotifySub?.cancel(); + await _audioNotifySub?.cancel(); + await _audioController.close(); + await _commandResponseController.close(); + await super.disconnect(); + } + + // --- Command Protocol --- + + /// Send a text command to the Pocket MCU via BLE write. + /// Throws on write failure so callers can handle immediately + /// instead of waiting for a response timeout. + Future _sendCommand(String command) async { + await transport.writeCharacteristic( + pocketServiceUuid, + pocketCommandCharacteristicUuid, + utf8.encode(command), + ); + Logger.debug('[Pocket] Sent: $command'); + } + + /// Send a command and wait for a response starting with the given prefix. + /// Commands are serialized via a lock to prevent concurrent commands from + /// consuming each other's responses on the shared broadcast stream. + /// Returns null if the write fails or no matching response arrives within timeout. + Future _sendCommandWithResponse( + String command, { + required String expectPrefix, + Duration timeout = const Duration(seconds: 5), + }) async { + // Wait for any in-flight command to complete + while (_commandLock != null) { + await _commandLock!.future; + } + _commandLock = Completer(); + + try { + await _sendCommand(command); + final response = await _commandResponseController.stream + .where((r) => r.startsWith(expectPrefix)) + .first + .timeout(timeout, onTimeout: () => ''); + return response; + } catch (e) { + Logger.error('[Pocket] Command "$command" failed: $e'); + return null; + } finally { + final lock = _commandLock; + _commandLock = null; + lock?.complete(); + } + } + + /// Set device time to current time. + Future _setDeviceTime() async { + final now = DateTime.now(); + final timeStr = + '${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}' + '${now.hour.toString().padLeft(2, '0')}${now.minute.toString().padLeft(2, '0')}${now.second.toString().padLeft(2, '0')}'; + await _sendCommandWithResponse('APP&T&$timeStr', expectPrefix: 'MCU&T&'); + } + + /// Stop recording on the device and reset state. + Future _stopRecording() async { + if (!_isRecording) return; + try { + await _sendCommand('APP&STO'); + } catch (_) {} + _isRecording = false; + Logger.debug('[Pocket] Recording stopped'); + } + + // --- Audio --- + + @override + Future performGetAudioCodec() async { + // Pocket device stores .opus files and streams audio for Deepgram transcription. + // Most likely Opus at 16kHz mono. + return BleAudioCodec.opus; + } + + @override + Future performGetBleAudioBytesListener({ + required void Function(List) onAudioBytesReceived, + }) async { + // Start recording on the device + final response = await _sendCommandWithResponse('APP&STA', expectPrefix: 'MCU&REC&'); + if (response != null && response.isNotEmpty) { + _isRecording = true; + Logger.debug('[Pocket] Recording started: $response'); + } else { + Logger.warning('[Pocket] No confirmation for start recording, proceeding anyway'); + _isRecording = true; + } + + // When the last listener is cancelled (e.g. app backgrounded, capture stopped), + // send stop recording command to the device to prevent battery drain. + // onCancel on a broadcast StreamController fires when listener count drops to zero. + _audioController.onCancel = () => _stopRecording(); + + return _audioController.stream.listen(onAudioBytesReceived); + } + + // --- Battery --- + + @override + Future performRetrieveBatteryLevel() async { + final response = await _sendCommandWithResponse('APP&BAT', expectPrefix: 'MCU&BAT&'); + if (response != null && response.startsWith('MCU&BAT&')) { + final levelStr = response.substring('MCU&BAT&'.length).trim(); + return int.tryParse(levelStr) ?? -1; + } + return -1; + } + + @override + Future>?> performGetBleBatteryLevelListener({ + void Function(int)? onBatteryLevelChange, + }) async { + if (onBatteryLevelChange == null) return null; + + // Pocket uses polling for battery (no standard BLE Battery Service). + // Store timer reference so disconnect() can cancel it. + final controller = StreamController>(); + int? lastLevel; + + _batteryTimer?.cancel(); + _batteryTimer = Timer.periodic(const Duration(minutes: 5), (timer) async { + try { + final level = await performRetrieveBatteryLevel(); + if (level >= 0 && level != lastLevel) { + lastLevel = level; + onBatteryLevelChange(level); + } + } catch (e) { + Logger.debug('[Pocket] Battery poll failed (device may be disconnected): $e'); + } + }); + + controller.onCancel = () { + _batteryTimer?.cancel(); + _batteryTimer = null; + controller.close(); + }; + + // Read initial battery level + final initialLevel = await performRetrieveBatteryLevel(); + if (initialLevel >= 0) { + lastLevel = initialLevel; + onBatteryLevelChange(initialLevel); + } + + return controller.stream.listen(null); + } + + // --- Device Info --- + + Future> getDeviceInfo() async { + String firmwareVersion = 'Unknown'; + try { + final fwResponse = await _sendCommandWithResponse('APP&FW', expectPrefix: 'MCU&FW&'); + if (fwResponse != null && fwResponse.startsWith('MCU&FW&')) { + firmwareVersion = fwResponse.substring('MCU&FW&'.length).trim(); + } + } catch (e) { + Logger.error('[Pocket] Error getting firmware version: $e'); + } + + return { + 'modelNumber': 'Pocket', + 'firmwareRevision': firmwareVersion, + 'hardwareRevision': 'HeyPocket Hardware', + 'manufacturerName': 'HeyPocket', + }; + } + + // --- Storage Info --- + + /// Query device storage space. + /// Returns (total, free) in bytes, or null on failure. + Future<(int, int)?> getStorageInfo() async { + final response = await _sendCommandWithResponse('APP&SPACE', expectPrefix: 'MCU&SPA&'); + if (response != null && response.startsWith('MCU&SPA&')) { + final parts = response.substring('MCU&SPA&'.length).split('&'); + if (parts.length >= 2) { + final total = int.tryParse(parts[0]); + final free = int.tryParse(parts[1]); + if (total != null && free != null) return (total, free); + } + } + return null; + } + + // --- Stubs for unsupported features --- + + @override + Future> performGetButtonState() async => []; + + @override + Future performGetBleStorageBytesListener({ + required void Function(List) onStorageBytesReceived, + }) async => null; + + @override + Future performCameraStartPhotoController() async {} + + @override + Future performCameraStopPhotoController() async {} + + @override + Future performHasPhotoStreamingCharacteristic() async => false; + + @override + Future performGetImageListener({ + required void Function(OrientedImage orientedImage) onImageReceived, + }) async => null; + + @override + Future>?> performGetAccelListener({ + void Function(int)? onAccelChange, + }) async => null; + + @override + Future performGetFeatures() async => 0; + + @override + Future performSetLedDimRatio(int ratio) async {} + + @override + Future performGetLedDimRatio() async => null; + + @override + Future performSetMicGain(int gain) async {} + + @override + Future performGetMicGain() async => null; +} diff --git a/app/lib/utils/device.dart b/app/lib/utils/device.dart index 8cbaa48b66..45b3e5fa4d 100644 --- a/app/lib/utils/device.dart +++ b/app/lib/utils/device.dart @@ -73,6 +73,8 @@ class DeviceUtils { return Assets.images.fieldy.path; case DeviceType.friendPendant: return Assets.images.friendPendant.path; + case DeviceType.pocket: + return Assets.images.pocketDevice.path; case DeviceType.omi: // For omi type, need to check model/name to distinguish between devkit and regular omi if (modelNumber != null && modelNumber.isNotEmpty && modelNumber.toUpperCase() != 'UNKNOWN') { diff --git a/app/test/services/devices/pocket_connection_test.dart b/app/test/services/devices/pocket_connection_test.dart new file mode 100644 index 0000000000..fd2eabafae --- /dev/null +++ b/app/test/services/devices/pocket_connection_test.dart @@ -0,0 +1,184 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:omi/backend/schema/bt_device/bt_device.dart'; +import 'package:omi/services/devices/models.dart'; +import 'package:omi/services/devices/pocket_connection.dart' + show + pocketAudioCharacteristicUuid, + pocketCommandCharacteristicUuid, + pocketCommandWriteCharacteristicUuid; + +void main() { + group('Pocket device detection', () { + test('detects PKT prefix as Pocket device', () { + // Verify the static detection method recognizes PKT-prefixed names + expect('PKT-12345'.toUpperCase().startsWith('PKT'), isTrue); + expect('PKT'.toUpperCase().startsWith('PKT'), isTrue); + expect('pkt-device'.toUpperCase().startsWith('PKT'), isTrue); + }); + + test('does not detect non-PKT names', () { + expect('Omi'.toUpperCase().startsWith('PKT'), isFalse); + expect('Friend_v2'.toUpperCase().startsWith('PKT'), isFalse); + expect('PLAUD'.toUpperCase().startsWith('PKT'), isFalse); + }); + + test('DeviceType.pocket exists in enum', () { + expect(DeviceType.pocket, isNotNull); + expect(DeviceType.pocket.index, greaterThan(0)); + expect(DeviceType.values.contains(DeviceType.pocket), isTrue); + }); + }); + + group('Pocket BLE UUIDs', () { + test('service UUID is correct format', () { + expect(pocketServiceUuid, equals('001120a0-2233-4455-6677-889912345678')); + // Verify it's a valid UUID format + final uuidRegex = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); + expect(uuidRegex.hasMatch(pocketServiceUuid), isTrue); + expect(uuidRegex.hasMatch(pocketAudioCharacteristicUuid), isTrue); + expect(uuidRegex.hasMatch(pocketCommandCharacteristicUuid), isTrue); + expect(uuidRegex.hasMatch(pocketCommandWriteCharacteristicUuid), isTrue); + }); + + test('UUIDs share same base with different suffixes', () { + // All Pocket UUIDs share the same base pattern with a0-a3 suffix + const base = '-2233-4455-6677-889912345678'; + expect(pocketServiceUuid.endsWith(base), isTrue); + expect(pocketAudioCharacteristicUuid.endsWith(base), isTrue); + expect(pocketCommandCharacteristicUuid.endsWith(base), isTrue); + expect(pocketCommandWriteCharacteristicUuid.endsWith(base), isTrue); + }); + + test('UUIDs are distinct', () { + final uuids = { + pocketServiceUuid, + pocketAudioCharacteristicUuid, + pocketCommandCharacteristicUuid, + pocketCommandWriteCharacteristicUuid, + }; + expect(uuids.length, equals(4)); + }); + + test('service UUID from models.dart matches expected value', () { + // Verify the constant exported from models.dart has the correct value + expect(pocketServiceUuid, equals('001120a0-2233-4455-6677-889912345678')); + }); + }); + + group('Pocket command protocol', () { + test('APP commands encode correctly as UTF-8 bytes', () { + final startCmd = utf8.encode('APP&STA'); + expect(startCmd, equals([65, 80, 80, 38, 83, 84, 65])); + + final stopCmd = utf8.encode('APP&STO'); + expect(stopCmd, equals([65, 80, 80, 38, 83, 84, 79])); + + final batCmd = utf8.encode('APP&BAT'); + expect(batCmd, equals([65, 80, 80, 38, 66, 65, 84])); + + final fwCmd = utf8.encode('APP&FW'); + expect(fwCmd, equals([65, 80, 80, 38, 70, 87])); + }); + + test('MCU battery response parses correctly', () { + const response = 'MCU&BAT&85'; + expect(response.startsWith('MCU&BAT&'), isTrue); + final levelStr = response.substring('MCU&BAT&'.length).trim(); + final level = int.tryParse(levelStr); + expect(level, equals(85)); + }); + + test('MCU battery response handles edge cases', () { + // Full battery + const full = 'MCU&BAT&100'; + expect(int.tryParse(full.substring('MCU&BAT&'.length).trim()), equals(100)); + + // Empty battery + const empty = 'MCU&BAT&0'; + expect(int.tryParse(empty.substring('MCU&BAT&'.length).trim()), equals(0)); + + // Malformed + const bad = 'MCU&BAT&xyz'; + expect(int.tryParse(bad.substring('MCU&BAT&'.length).trim()), isNull); + }); + + test('MCU firmware response parses correctly', () { + const response = 'MCU&FW&T19'; + expect(response.startsWith('MCU&FW&'), isTrue); + final version = response.substring('MCU&FW&'.length).trim(); + expect(version, equals('T19')); + }); + + test('MCU recording modes parse correctly', () { + const convResponse = 'MCU&REC&CON'; + const callResponse = 'MCU&REC&CALL'; + expect(convResponse.startsWith('MCU&REC&'), isTrue); + expect(callResponse.startsWith('MCU&REC&'), isTrue); + + final convMode = convResponse.substring('MCU&REC&'.length); + final callMode = callResponse.substring('MCU&REC&'.length); + expect(convMode, equals('CON')); + expect(callMode, equals('CALL')); + }); + + test('MCU storage response parses correctly', () { + const response = 'MCU&SPA&16384&8192'; + expect(response.startsWith('MCU&SPA&'), isTrue); + final parts = response.substring('MCU&SPA&'.length).split('&'); + expect(parts.length, equals(2)); + expect(int.tryParse(parts[0]), equals(16384)); + expect(int.tryParse(parts[1]), equals(8192)); + }); + + test('time sync command formats correctly', () { + final now = DateTime(2026, 3, 6, 15, 30, 45); + final timeStr = + '${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}' + '${now.hour.toString().padLeft(2, '0')}${now.minute.toString().padLeft(2, '0')}${now.second.toString().padLeft(2, '0')}'; + expect(timeStr, equals('20260306153045')); + expect('APP&T&$timeStr', equals('APP&T&20260306153045')); + }); + }); + + group('Pocket BtDevice integration', () { + test('BtDevice can be created with pocket type', () { + final device = BtDevice( + name: 'PKT-ABC123', + id: 'AA:BB:CC:DD:EE:FF', + type: DeviceType.pocket, + rssi: -65, + ); + expect(device.type, equals(DeviceType.pocket)); + expect(device.name, equals('PKT-ABC123')); + }); + + test('BtDevice pocket type serializes/deserializes correctly', () { + final device = BtDevice( + name: 'PKT-TEST', + id: '11:22:33:44:55:66', + type: DeviceType.pocket, + rssi: -70, + ); + final json = device.toJson(); + final restored = BtDevice.fromJson(json); + expect(restored.type, equals(DeviceType.pocket)); + expect(restored.name, equals('PKT-TEST')); + expect(restored.id, equals('11:22:33:44:55:66')); + }); + + test('firmware warning message is set for pocket', () { + final device = BtDevice( + name: 'PKT-TEST', + id: '11:22:33:44:55:66', + type: DeviceType.pocket, + rssi: -70, + ); + expect(device.getFirmwareWarningTitle(), equals('Compatibility Note')); + expect(device.getFirmwareWarningMessage(), contains('HeyPocket')); + }); + }); +} diff --git a/backend/models/conversation.py b/backend/models/conversation.py index 42a1190b4d..582d4cd266 100644 --- a/backend/models/conversation.py +++ b/backend/models/conversation.py @@ -404,10 +404,10 @@ def conversations_to_string( return "\n\n---------------------\n\n".join(result).strip() - def get_transcript(self, include_timestamps: bool, people: List[Person] = None) -> str: + def get_transcript(self, include_timestamps: bool, people: List[Person] = None, user_name: str = None) -> str: # Warn: missing transcript for workflow source, external integration source return TranscriptSegment.segments_as_string( - self.transcript_segments, include_timestamps=include_timestamps, people=people + self.transcript_segments, include_timestamps=include_timestamps, user_name=user_name, people=people ) def get_photos_descriptions(self, include_timestamps: bool = False) -> str: @@ -450,9 +450,9 @@ class CreateConversation(BaseModel): processing_conversation_id: Optional[str] = None calendar_meeting_context: Optional[CalendarMeetingContext] = None - def get_transcript(self, include_timestamps: bool, people: List[Person] = None) -> str: + def get_transcript(self, include_timestamps: bool, people: List[Person] = None, user_name: str = None) -> str: return TranscriptSegment.segments_as_string( - self.transcript_segments, include_timestamps=include_timestamps, people=people + self.transcript_segments, include_timestamps=include_timestamps, user_name=user_name, people=people ) def get_person_ids(self) -> List[str]: diff --git a/backend/utils/conversations/process_conversation.py b/backend/utils/conversations/process_conversation.py index 95146867d2..c85412edbb 100644 --- a/backend/utils/conversations/process_conversation.py +++ b/backend/utils/conversations/process_conversation.py @@ -81,6 +81,7 @@ def _get_structured( conversation: Union[Conversation, CreateConversation, ExternalIntegrationCreateConversation], force_process: bool = False, people: List[Person] = None, + user_name: str = None, ) -> Tuple[Structured, bool]: try: tz = notification_db.get_user_time_zone(uid) @@ -139,7 +140,7 @@ def _get_structured( # not supported conversation source raise HTTPException(status_code=400, detail=f'Invalid conversation source: {conversation.text_source}') - transcript_text = conversation.get_transcript(False, people=people) + transcript_text = conversation.get_transcript(False, people=people, user_name=user_name) # For re-processing, we don't discard, just re-structure. if force_process: @@ -345,7 +346,7 @@ def _trigger_apps( def execute_app(app): with track_usage(uid, Features.CONVERSATION_APPS): result = get_app_result( - conversation.get_transcript(False, people=people), conversation.photos, app, language_code=language_code + conversation.get_transcript(False, people=people, user_name=user_name), conversation.photos, app, language_code=language_code ).strip() conversation.apps_results.append(AppResult(app_id=app.id, content=result)) if not is_reprocess: @@ -631,7 +632,10 @@ def process_conversation( people_data = users_db.get_people_by_ids(uid, list(set(person_ids))) people = [Person(**p) for p in people_data] - structured, discarded = _get_structured(uid, language_code, conversation, force_process, people=people) + from database.auth import get_user_name + user_name = get_user_name(uid) + + structured, discarded = _get_structured(uid, language_code, conversation, force_process, people=people, user_name=user_name) conversation = _get_conversation_obj(uid, structured, conversation) # AI-based folder assignment