diff --git a/app/assets/images/pocket_device.png b/app/assets/images/pocket_device.png new file mode 100644 index 0000000000..6359a84319 Binary files /dev/null and b/app/assets/images/pocket_device.png differ 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