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
+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();
}