From 568a851d070d388f0e09592d807fb643ac52c372 Mon Sep 17 00:00:00 2001 From: dvdrw Date: Sat, 16 May 2026 17:42:57 +0200 Subject: [PATCH] feat: implement ble tag scanning --- android/app/src/main/AndroidManifest.xml | 20 ++- .../repositories/phoenix_tag_repository.dart | 64 ++++++- lib/data/repositories/tag_repository.dart | 12 +- lib/data/sources/ble/tag_scanner.dart | 169 ++++++++++++++++++ lib/data/sources/localiser/tag_client.dart | 5 +- lib/domain/models/tag.dart | 9 +- 6 files changed, 257 insertions(+), 22 deletions(-) create mode 100644 lib/data/sources/ble/tag_scanner.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0fe0ecd..6bc39a4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,15 +7,21 @@ - + + + + + + + + - - - - + + + + > getTags() => throw UnimplementedError(); + Future> getTags() async { + final list = await tagClient.getTags(); + return list.map((j) => Tag.fromJson(j as Map)).toList(); + } @override - Future getTag(String id) => throw UnimplementedError(); + Future getTag(int id) async => + Tag.fromJson(await tagClient.getTag(id)); @override - Future createTag({required String id, required String name}) => - throw UnimplementedError(); + Future createTag({required String tag_id, required String name}) async => + Tag.fromJson(await tagClient.createTag({'tag_id': tag_id, 'name': name})); @override - Future updateTag(String id, {String? name}) => throw UnimplementedError(); + Future updateTag(int id, {String? name}) async { + final params = {}; + if (name != null) params['name'] = name; + return Tag.fromJson(await tagClient.updateTag(id, params)); + } @override - Future deleteTag(String id) => throw UnimplementedError(); + Future deleteTag(int id) => tagClient.deleteTag(id); @override - Stream> watchPositions() => throw UnimplementedError(); + Stream> watchPositions() => + realtime.channel('tags').map((payload) { + final list = payload['positions'] as List? ?? []; + return list.map((j) { + final m = j as Map; + return TagPosition( + tagId: m['tag_id'] as String, + name: m['name'] as String, + color: m['color'] as String, + roomId: m['room_id'] as int?, + ); + }).toList(); + }); @override - Stream> watchParticleCloud() => throw UnimplementedError(); + Stream> watchParticleCloud() => + realtime.channel('localiserd').map((payload) { + final list = payload['particles'] as List? ?? []; + return list + .map((j) => Particle.fromJson(j as Map)) + .toList(); + }); + + @override + Stream>> watchRoomOccupancy() async* { + final state = >{}; + + // Seed from the HTTP snapshot so the UI isn't blank until the first push. + final snapshot = await tagClient.getOccupancy(); + for (final entry in snapshot.cast>()) { + final roomId = entry['room_id'] as int?; + if (roomId != null) { + state.putIfAbsent(roomId, () => []).add(entry['tag_id'] as String); + } + } + yield Map>.from(state); + + await for (final payload in realtime.channel('rooms:occupancy')) { + final roomId = payload['room_id'] as int; + final occupants = (payload['occupants'] as List).cast(); + state[roomId] = occupants; + yield Map>.from(state); + } + } } diff --git a/lib/data/repositories/tag_repository.dart b/lib/data/repositories/tag_repository.dart index 16d7645..fec5142 100644 --- a/lib/data/repositories/tag_repository.dart +++ b/lib/data/repositories/tag_repository.dart @@ -3,14 +3,18 @@ import '../../domain/models/particle.dart'; abstract class TagRepository { Future> getTags(); - Future getTag(String id); - Future createTag({required String id, required String name}); - Future updateTag(String id, {String? name}); - Future deleteTag(String id); + Future getTag(int id); + Future createTag({required String tag_id, required String name}); + Future updateTag(int id, {String? name}); + Future deleteTag(int id); /// Live stream of all tag positions, pushed by localiserd over Phoenix channel. Stream> watchPositions(); /// Live stream of particle filter cloud snapshots. Stream> watchParticleCloud(); + + /// Room occupancy delta stream. + /// room_id -> list of tag_id strings currently in that room. + Stream>> watchRoomOccupancy(); } diff --git a/lib/data/sources/ble/tag_scanner.dart b/lib/data/sources/ble/tag_scanner.dart new file mode 100644 index 0000000..4088edd --- /dev/null +++ b/lib/data/sources/ble/tag_scanner.dart @@ -0,0 +1,169 @@ +import 'dart:async'; + +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:permission_handler/permission_handler.dart'; + +enum BeaconType { iBeacon, altBeacon, eddystoneUid } + +class TagScanResult { + const TagScanResult._({ + required this.type, + required this.deviceId, + required this.rssi, + this.uuid, + this.major, + this.minor, + this.namespace, + this.instance, + this.beaconId, + }); + + final BeaconType type; + final String deviceId; + final int rssi; + final String? uuid; // iBeacon proximity UUID + final int? major; // iBeacon + final int? minor; // iBeacon + final String? namespace; // Eddystone-UID 10-byte hex + final String? instance; // Eddystone-UID 6-byte hex + final String? beaconId; // AltBeacon 20-byte hex + + String get tagId => switch (type) { + BeaconType.iBeacon => 'ibeacon:${uuid!.toLowerCase()}-$major-$minor', + BeaconType.eddystoneUid => 'eddystone:$namespace$instance', + BeaconType.altBeacon => 'altbeacon:$beaconId', + }; + + String get displayLabel => switch (type) { + BeaconType.iBeacon => '${uuid!} [$major/$minor]', + BeaconType.eddystoneUid => '$namespace / $instance', + BeaconType.altBeacon => beaconId!, + }; + + String get typeName => switch (type) { + BeaconType.iBeacon => 'iBeacon', + BeaconType.eddystoneUid => 'Eddystone-UID', + BeaconType.altBeacon => 'AltBeacon', + }; +} + +class TagScanner { + static final _eddystoneGuid = Guid('feaa'); + + // Continuously scans for iBeacon / AltBeacon / Eddystone-UID, restarting + // each time the scan window closes, until the returned stream is cancelled. + Stream scan() { + StreamSubscription>? resultsSub; + final seen = {}; // tagId -> last rssi, for dedup + late StreamController controller; + + Future startScan() async { + if (controller.isClosed) return; + try { + await FlutterBluePlus.startScan( + androidUsesFineLocation: true, + ); + } catch (_) {} + } + + controller = StreamController( + onListen: () async { + try { + await [ + Permission.bluetoothScan, + Permission.bluetoothConnect, + Permission.locationWhenInUse, + ].request(); + } catch (e) { + controller.addError(e); + await controller.close(); + return; + } + + resultsSub = FlutterBluePlus.scanResults.listen((results) { + for (final r in results) { + final parsed = _parse(r); + if (parsed == null) continue; + final id = parsed.tagId; + if (seen[id] == parsed.rssi) continue; + seen[id] = parsed.rssi; + if (!controller.isClosed) controller.add(parsed); + } + }); + + if (!FlutterBluePlus.isScanningNow) await startScan(); + }, + onCancel: () { + resultsSub?.cancel(); + FlutterBluePlus.stopScan(); + controller.close(); + }, + ); + + return controller.stream; + } + + Future stopScan() => FlutterBluePlus.stopScan(); + + TagScanResult? _parse(ScanResult r) { + final deviceId = r.device.remoteId.str; + final mfr = r.advertisementData.manufacturerData; + final svc = r.advertisementData.serviceData; + + // iBeacon: Apple company ID 0x004C + type/length header [0x02, 0x15] + final appleData = mfr[0x004C]; + if (appleData != null && + appleData.length >= 23 && + appleData[0] == 0x02 && + appleData[1] == 0x15) { + return TagScanResult._( + type: BeaconType.iBeacon, + deviceId: deviceId, + rssi: r.rssi, + uuid: _formatUuid(appleData.sublist(2, 18)), + major: (appleData[18] << 8) | appleData[19], + minor: (appleData[20] << 8) | appleData[21], + ); + } + + // AltBeacon: beacon code [0xBE, 0xAC] at the start of any manufacturer data + for (final data in mfr.values) { + if (data.length >= 22 && data[0] == 0xBE && data[1] == 0xAC) { + return TagScanResult._( + type: BeaconType.altBeacon, + deviceId: deviceId, + rssi: r.rssi, + beaconId: _toHex(data.sublist(2, 22)), + ); + } + } + + // Eddystone-UID: service UUID 0xFEAA, frame type byte 0x00 + for (final entry in svc.entries) { + if (entry.key == _eddystoneGuid) { + final data = entry.value; + if (data.length >= 18 && data[0] == 0x00) { + return TagScanResult._( + type: BeaconType.eddystoneUid, + deviceId: deviceId, + rssi: r.rssi, + namespace: _toHex(data.sublist(2, 12)), + instance: _toHex(data.sublist(12, 18)), + ); + } + } + } + + return null; + } + + static String _formatUuid(List bytes) { + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-' + '${hex.substring(12, 16)}-${hex.substring(16, 20)}-' + '${hex.substring(20)}'; + } + + static String _toHex(List bytes) => + bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); +} diff --git a/lib/data/sources/localiser/tag_client.dart b/lib/data/sources/localiser/tag_client.dart index cb3dc32..71d46ec 100644 --- a/lib/data/sources/localiser/tag_client.dart +++ b/lib/data/sources/localiser/tag_client.dart @@ -6,6 +6,9 @@ class TagClient extends LocaliserdClient { Future> getTags() async => await get('/api/tags') as List; + Future> getOccupancy() async => + await get('/api/tags/occupancy') as List; + Future> getTag(int id) async => await get('/api/tags/$id') as Map; @@ -17,4 +20,4 @@ class TagClient extends LocaliserdClient { await patch('/api/tags/$id', params) as Map; Future deleteTag(int id) => delete('/api/tags/$id'); -} +} \ No newline at end of file diff --git a/lib/domain/models/tag.dart b/lib/domain/models/tag.dart index c950d7f..c01dc31 100644 --- a/lib/domain/models/tag.dart +++ b/lib/domain/models/tag.dart @@ -3,13 +3,15 @@ import 'position.dart'; class Tag { const Tag({ required this.id, + required this.tagId, required this.name, this.currentRoomId, this.lastPosition, this.lastSeen, }); - final String id; + final int id; + final String tagId; // BLE beacon advertised identifier final String name; final String? currentRoomId; final Position? lastPosition; @@ -18,6 +20,7 @@ class Tag { Tag copyWith({String? name, String? currentRoomId, Position? lastPosition, DateTime? lastSeen}) => Tag( id: id, + tagId: tagId, name: name ?? this.name, currentRoomId: currentRoomId ?? this.currentRoomId, lastPosition: lastPosition ?? this.lastPosition, @@ -26,6 +29,7 @@ class Tag { Map toJson() => { 'id': id, + 'tag_id': tagId, 'name': name, 'current_room_id': currentRoomId, 'last_position': lastPosition?.toJson(), @@ -33,7 +37,8 @@ class Tag { }; factory Tag.fromJson(Map json) => Tag( - id: json['id'] as String, + id: json['id'] as int, + tagId: json['tag_id'] as String, name: json['name'] as String, currentRoomId: json['current_room_id'] as String?, lastPosition: json['last_position'] == null