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.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_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" android:maxSdkVersion="30" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" 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+ -->
|
<!-- legacy for Android 9 or lower -->
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="28" />
|
||||||
android:usesPermissionFlags="neverForLocation"
|
|
||||||
tools:targetApi="s" />
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
|
||||||
<application
|
<application
|
||||||
android:label="companion"
|
android:label="companion"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@@ -14,24 +14,72 @@ class PhoenixTagRepository implements TagRepository {
|
|||||||
final RealtimeDataClient realtime;
|
final RealtimeDataClient realtime;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Tag>> getTags() => 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
|
@override
|
||||||
Future<Tag> getTag(String id) => throw UnimplementedError();
|
Future<Tag> getTag(int id) async =>
|
||||||
|
Tag.fromJson(await tagClient.getTag(id));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Tag> createTag({required String id, required String name}) =>
|
Future<Tag> createTag({required String tag_id, required String name}) async =>
|
||||||
throw UnimplementedError();
|
Tag.fromJson(await tagClient.createTag({'tag_id': tag_id, 'name': name}));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Tag> updateTag(String id, {String? name}) => throw UnimplementedError();
|
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
|
@override
|
||||||
Future<void> deleteTag(String id) => throw UnimplementedError();
|
Future<void> deleteTag(int id) => tagClient.deleteTag(id);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<List<TagPosition>> watchPositions() => throw UnimplementedError();
|
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
|
@override
|
||||||
Stream<List<Particle>> watchParticleCloud() => throw UnimplementedError();
|
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 {
|
abstract class TagRepository {
|
||||||
Future<List<Tag>> getTags();
|
Future<List<Tag>> getTags();
|
||||||
Future<Tag> getTag(String id);
|
Future<Tag> getTag(int id);
|
||||||
Future<Tag> createTag({required String id, required String name});
|
Future<Tag> createTag({required String tag_id, required String name});
|
||||||
Future<Tag> updateTag(String id, {String? name});
|
Future<Tag> updateTag(int id, {String? name});
|
||||||
Future<void> deleteTag(String id);
|
Future<void> deleteTag(int id);
|
||||||
|
|
||||||
/// Live stream of all tag positions, pushed by localiserd over Phoenix channel.
|
/// Live stream of all tag positions, pushed by localiserd over Phoenix channel.
|
||||||
Stream<List<TagPosition>> watchPositions();
|
Stream<List<TagPosition>> watchPositions();
|
||||||
|
|
||||||
/// Live stream of particle filter cloud snapshots.
|
/// Live stream of particle filter cloud snapshots.
|
||||||
Stream<List<Particle>> watchParticleCloud();
|
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 =>
|
Future<List<dynamic>> getTags() async =>
|
||||||
await get('/api/tags') as List<dynamic>;
|
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 =>
|
Future<Map<String, dynamic>> getTag(int id) async =>
|
||||||
await get('/api/tags/$id') as Map<String, dynamic>;
|
await get('/api/tags/$id') as Map<String, dynamic>;
|
||||||
|
|
||||||
@@ -17,4 +20,4 @@ class TagClient extends LocaliserdClient {
|
|||||||
await patch('/api/tags/$id', params) as Map<String, dynamic>;
|
await patch('/api/tags/$id', params) as Map<String, dynamic>;
|
||||||
|
|
||||||
Future<void> deleteTag(int id) => delete('/api/tags/$id');
|
Future<void> deleteTag(int id) => delete('/api/tags/$id');
|
||||||
}
|
}
|
||||||
@@ -3,13 +3,15 @@ import 'position.dart';
|
|||||||
class Tag {
|
class Tag {
|
||||||
const Tag({
|
const Tag({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
required this.tagId,
|
||||||
required this.name,
|
required this.name,
|
||||||
this.currentRoomId,
|
this.currentRoomId,
|
||||||
this.lastPosition,
|
this.lastPosition,
|
||||||
this.lastSeen,
|
this.lastSeen,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final int id;
|
||||||
|
final String tagId; // BLE beacon advertised identifier
|
||||||
final String name;
|
final String name;
|
||||||
final String? currentRoomId;
|
final String? currentRoomId;
|
||||||
final Position? lastPosition;
|
final Position? lastPosition;
|
||||||
@@ -18,6 +20,7 @@ class Tag {
|
|||||||
Tag copyWith({String? name, String? currentRoomId, Position? lastPosition, DateTime? lastSeen}) =>
|
Tag copyWith({String? name, String? currentRoomId, Position? lastPosition, DateTime? lastSeen}) =>
|
||||||
Tag(
|
Tag(
|
||||||
id: id,
|
id: id,
|
||||||
|
tagId: tagId,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
currentRoomId: currentRoomId ?? this.currentRoomId,
|
currentRoomId: currentRoomId ?? this.currentRoomId,
|
||||||
lastPosition: lastPosition ?? this.lastPosition,
|
lastPosition: lastPosition ?? this.lastPosition,
|
||||||
@@ -26,6 +29,7 @@ class Tag {
|
|||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
|
'tag_id': tagId,
|
||||||
'name': name,
|
'name': name,
|
||||||
'current_room_id': currentRoomId,
|
'current_room_id': currentRoomId,
|
||||||
'last_position': lastPosition?.toJson(),
|
'last_position': lastPosition?.toJson(),
|
||||||
@@ -33,7 +37,8 @@ class Tag {
|
|||||||
};
|
};
|
||||||
|
|
||||||
factory Tag.fromJson(Map<String, dynamic> json) => 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,
|
name: json['name'] as String,
|
||||||
currentRoomId: json['current_room_id'] as String?,
|
currentRoomId: json['current_room_id'] as String?,
|
||||||
lastPosition: json['last_position'] == null
|
lastPosition: json['last_position'] == null
|
||||||
|
|||||||
Reference in New Issue
Block a user