feat: implement ble tag scanning

This commit is contained in:
2026-05-16 17:42:57 +02:00
parent f37176cce5
commit 568a851d07
6 changed files with 257 additions and 22 deletions
+13 -7
View File
@@ -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();
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(String id) => throw UnimplementedError();
Future<Tag> getTag(int id) async =>
Tag.fromJson(await tagClient.getTag(id));
@override
Future<Tag> createTag({required String id, required String name}) =>
throw UnimplementedError();
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(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
Future<void> deleteTag(String id) => throw UnimplementedError();
Future<void> deleteTag(int id) => tagClient.deleteTag(id);
@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
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);
}
}
}
+8 -4
View File
@@ -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();
}
+169
View File
@@ -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>;
+7 -2
View File
@@ -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