feat: implement ble tag scanning
This commit is contained in:
@@ -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>;
|
||||
|
||||
@@ -17,4 +20,4 @@ class TagClient extends LocaliserdClient {
|
||||
await patch('/api/tags/$id', params) as Map<String, dynamic>;
|
||||
|
||||
Future<void> deleteTag(int id) => delete('/api/tags/$id');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user