feat: implement ble tag scanning
This commit is contained in:
@@ -7,15 +7,21 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
|
||||
<!-- BLE — legacy flags for API ≤ 30 -->
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||
|
||||
<!-- New Bluetooth permissions in Android 12
|
||||
https://developer.android.com/about/versions/12/features/bluetooth-permissions -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
|
||||
<!-- legacy for Android 11 or lower -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
|
||||
<!-- BLE — API 31+ -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
<!-- legacy for Android 9 or lower -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="28" />
|
||||
|
||||
<application
|
||||
android:label="companion"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -14,24 +14,72 @@ class PhoenixTagRepository implements TagRepository {
|
||||
final RealtimeDataClient realtime;
|
||||
|
||||
@override
|
||||
Future<List<Tag>> getTags() => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<Tag> getTag(String id) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<Tag> createTag({required String id, required String name}) =>
|
||||
throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<Tag> updateTag(String id, {String? name}) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<void> deleteTag(String id) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Stream<List<TagPosition>> watchPositions() => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Stream<List<Particle>> watchParticleCloud() => throw UnimplementedError();
|
||||
Future<List<Tag>> getTags() async {
|
||||
final list = await tagClient.getTags();
|
||||
return list.map((j) => Tag.fromJson(j as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Tag> getTag(int id) async =>
|
||||
Tag.fromJson(await tagClient.getTag(id));
|
||||
|
||||
@override
|
||||
Future<Tag> createTag({required String tag_id, required String name}) async =>
|
||||
Tag.fromJson(await tagClient.createTag({'tag_id': tag_id, 'name': name}));
|
||||
|
||||
@override
|
||||
Future<Tag> updateTag(int id, {String? name}) async {
|
||||
final params = <String, dynamic>{};
|
||||
if (name != null) params['name'] = name;
|
||||
return Tag.fromJson(await tagClient.updateTag(id, params));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteTag(int id) => tagClient.deleteTag(id);
|
||||
|
||||
@override
|
||||
Stream<List<TagPosition>> watchPositions() =>
|
||||
realtime.channel('tags').map((payload) {
|
||||
final list = payload['positions'] as List<dynamic>? ?? [];
|
||||
return list.map((j) {
|
||||
final m = j as Map<String, dynamic>;
|
||||
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<List<Particle>> watchParticleCloud() =>
|
||||
realtime.channel('localiserd').map((payload) {
|
||||
final list = payload['particles'] as List<dynamic>? ?? [];
|
||||
return list
|
||||
.map((j) => Particle.fromJson(j as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
@override
|
||||
Stream<Map<int, List<String>>> watchRoomOccupancy() async* {
|
||||
final state = <int, List<String>>{};
|
||||
|
||||
// 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<Map<String, dynamic>>()) {
|
||||
final roomId = entry['room_id'] as int?;
|
||||
if (roomId != null) {
|
||||
state.putIfAbsent(roomId, () => []).add(entry['tag_id'] as String);
|
||||
}
|
||||
}
|
||||
yield Map<int, List<String>>.from(state);
|
||||
|
||||
await for (final payload in realtime.channel('rooms:occupancy')) {
|
||||
final roomId = payload['room_id'] as int;
|
||||
final occupants = (payload['occupants'] as List<dynamic>).cast<String>();
|
||||
state[roomId] = occupants;
|
||||
yield Map<int, List<String>>.from(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,18 @@ import '../../domain/models/particle.dart';
|
||||
|
||||
abstract class TagRepository {
|
||||
Future<List<Tag>> getTags();
|
||||
Future<Tag> getTag(String id);
|
||||
Future<Tag> createTag({required String id, required String name});
|
||||
Future<Tag> updateTag(String id, {String? name});
|
||||
Future<void> deleteTag(String id);
|
||||
Future<Tag> getTag(int id);
|
||||
Future<Tag> createTag({required String tag_id, required String name});
|
||||
Future<Tag> updateTag(int id, {String? name});
|
||||
Future<void> deleteTag(int id);
|
||||
|
||||
/// Live stream of all tag positions, pushed by localiserd over Phoenix channel.
|
||||
Stream<List<TagPosition>> watchPositions();
|
||||
|
||||
/// Live stream of particle filter cloud snapshots.
|
||||
Stream<List<Particle>> watchParticleCloud();
|
||||
|
||||
/// Room occupancy delta stream.
|
||||
/// room_id -> list of tag_id strings currently in that room.
|
||||
Stream<Map<int, List<String>>> watchRoomOccupancy();
|
||||
}
|
||||
|
||||
@@ -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<TagScanResult> scan() {
|
||||
StreamSubscription<List<ScanResult>>? resultsSub;
|
||||
final seen = <String, int>{}; // tagId -> last rssi, for dedup
|
||||
late StreamController<TagScanResult> controller;
|
||||
|
||||
Future<void> startScan() async {
|
||||
if (controller.isClosed) return;
|
||||
try {
|
||||
await FlutterBluePlus.startScan(
|
||||
androidUsesFineLocation: true,
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
controller = StreamController<TagScanResult>(
|
||||
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<void> 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<int> 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<int> bytes) =>
|
||||
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
@@ -6,6 +6,9 @@ class TagClient extends LocaliserdClient {
|
||||
Future<List<dynamic>> getTags() async =>
|
||||
await get('/api/tags') as List<dynamic>;
|
||||
|
||||
Future<List<dynamic>> getOccupancy() async =>
|
||||
await get('/api/tags/occupancy') as List<dynamic>;
|
||||
|
||||
Future<Map<String, dynamic>> getTag(int id) async =>
|
||||
await get('/api/tags/$id') as Map<String, dynamic>;
|
||||
|
||||
|
||||
@@ -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<String, dynamic> 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<String, dynamic> 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
|
||||
|
||||
Reference in New Issue
Block a user