diff --git a/packages/carp_connectivity_package/lib/connectivity.g.dart b/packages/carp_connectivity_package/lib/connectivity.g.dart index 7a65467c..89739dcd 100644 --- a/packages/carp_connectivity_package/lib/connectivity.g.dart +++ b/packages/carp_connectivity_package/lib/connectivity.g.dart @@ -71,6 +71,23 @@ Map _$BluetoothDeviceToJson(BluetoothDevice instance) => 'rssi': instance.rssi, }; +BeaconDevice _$BeaconDeviceFromJson(Map json) => BeaconDevice( + rssi: (json['rssi'] as num).toInt(), + uuid: json['uuid'] as String?, + major: (json['major'] as num?)?.toInt(), + minor: (json['minor'] as num?)?.toInt(), + accuracy: (json['accuracy'] as num?)?.toDouble(), + ); + +Map _$BeaconDeviceToJson(BeaconDevice instance) => + { + 'rssi': instance.rssi, + if (instance.uuid case final value?) 'uuid': value, + if (instance.major case final value?) 'major': value, + if (instance.minor case final value?) 'minor': value, + if (instance.accuracy case final value?) 'accuracy': value, + }; + Wifi _$WifiFromJson(Map json) => Wifi( ssid: json['ssid'] as String?, bssid: json['bssid'] as String?, @@ -141,3 +158,34 @@ Map _$BluetoothScanPeriodicSamplingConfigurationToJson( 'beaconRegions': instance.beaconRegions.map((e) => e?.toJson()).toList(), 'beaconDistance': instance.beaconDistance, }; + +BeaconRangingPeriodicSamplingConfiguration + _$BeaconRangingPeriodicSamplingConfigurationFromJson( + Map json) => + BeaconRangingPeriodicSamplingConfiguration( + interval: Duration(microseconds: (json['interval'] as num).toInt()), + duration: Duration(microseconds: (json['duration'] as num).toInt()), + beaconRegions: (json['beaconRegions'] as List?) + ?.map((e) => e == null + ? null + : BeaconRegion.fromJson(e as Map)) + .toList() ?? + const [], + beaconDistance: (json['beaconDistance'] as num?)?.toInt() ?? 2, + ) + ..$type = json['__type'] as String? + ..lastTime = json['lastTime'] == null + ? null + : DateTime.parse(json['lastTime'] as String); + +Map _$BeaconRangingPeriodicSamplingConfigurationToJson( + BeaconRangingPeriodicSamplingConfiguration instance) => + { + if (instance.$type case final value?) '__type': value, + if (instance.lastTime?.toIso8601String() case final value?) + 'lastTime': value, + 'interval': instance.interval.inMicroseconds, + 'duration': instance.duration.inMicroseconds, + 'beaconRegions': instance.beaconRegions.map((e) => e?.toJson()).toList(), + 'beaconDistance': instance.beaconDistance, + }; diff --git a/packages/carp_connectivity_package/lib/connectivity_data.dart b/packages/carp_connectivity_package/lib/connectivity_data.dart index 272a614b..5b2f2f38 100644 --- a/packages/carp_connectivity_package/lib/connectivity_data.dart +++ b/packages/carp_connectivity_package/lib/connectivity_data.dart @@ -183,6 +183,48 @@ class BluetoothDevice { ', rssi: $rssi'; } +/// Beacon device data. +@JsonSerializable(includeIfNull: false, explicitToJson: true) +class BeaconDevice { + /// The RSSI signal strength to the device. + int rssi; + + /// Beacon UUID (for iBeacon). + String? uuid; + + /// Major value (for iBeacon). + int? major; + + /// Minor value (for iBeacon). + int? minor; + + double? accuracy; + + BeaconDevice({ + required this.rssi, + this.uuid, + this.major, + this.minor, + this.accuracy, + }) : super(); + + factory BeaconDevice.fromRangingResult(BeaconDevice result) => BeaconDevice( + uuid: result.uuid, + major: result.major, + minor: result.minor, + accuracy: result.accuracy, + rssi: result.rssi, + ); + + factory BeaconDevice.fromJson(Map json) => _$BeaconDeviceFromJson(json); + Map toJson() => _$BeaconDeviceToJson(this); + + @override + String toString() => '$runtimeType - ' + ', uuid: $uuid, major: $major, minor: $minor, accuracy: $accuracy' + ', rssi: $rssi'; +} + /// A [Data] holding wifi connectivity status in terms of connected SSID /// and BSSID. /// @@ -216,9 +258,55 @@ class Wifi extends Data { String toString() => '${super.toString()}, SSID: $ssid, BSSID: $bssid, IP: $ip'; } +/// A [Data] holding information of nearby Bluetooth devices. +@JsonSerializable(includeIfNull: false, explicitToJson: true) +class BeaconData extends Data { + static const dataType = ConnectivitySamplingPackage.BLUETOOTH; + + /// Timestamp of scan start. + late DateTime startScan; + + /// Timestamp of scan end, if available. + DateTime? endScan; + + /// A map of [BeaconDevice] indexed by their [bluetoothDeviceId] to make + /// sure that the same device only appears once. + final Map _scanResult = {}; + + /// The list of [BeaconDevice] found in a scan. + List get scanResult => _scanResult.values.toList(); + set scanResult(List devices) => _scanResult.addEntries( + devices.map( + (device) => MapEntry( + device.uuid ?? '', + device, + ), + ), + ); + + BeaconData({DateTime? startScan, this.endScan}) : super() { + this.startScan = startScan ?? DateTime.now(); + } + + void addBluetoothDevice(BeaconDevice device) => _scanResult[device.uuid ?? ''] = device; + + void addBluetoothDevicesFromRangingResults(BeaconDevice result) { + addBluetoothDevice(BeaconDevice.fromRangingResult(result)); + } + + @override + Function get fromJsonFunction => _$BeaconDataFromJson; + factory BeaconData.fromJson(Map json) => FromJsonFactory().fromJson(json); + @override + Map toJson() => _$BeaconDataToJson(this); + + @override + String toString() => '${super.toString()}, scanResult: $scanResult'; +} + /// Beacon Region to use when monitoring for beacons. @JsonSerializable(includeIfNull: false, explicitToJson: true) -class BeaconRegion { +class BeaconRegion extends Data { /// A unique identifier for the beacon region. /// Used to distinguish between different regions being monitored. String identifier; @@ -251,6 +339,11 @@ class BeaconRegion { ); } + factory BeaconRegion.fromJson(Map json) => FromJsonFactory().fromJson(json); + + @override + Map toJson() => _$BeaconRegionToJson(this); + @override String toString() => '${super.toString()}, Identifier: $identifier, UUID: $uuid, Major: $major, Minor: $minor'; } diff --git a/packages/carp_connectivity_package/lib/connectivity_package.dart b/packages/carp_connectivity_package/lib/connectivity_package.dart index df71576d..8357987b 100644 --- a/packages/carp_connectivity_package/lib/connectivity_package.dart +++ b/packages/carp_connectivity_package/lib/connectivity_package.dart @@ -20,6 +20,13 @@ class ConnectivitySamplingPackage extends SmartphoneSamplingPackage { /// * Use a [IntervalSamplingConfiguration] for configuration. static const String WIFI = "${NameSpace.CARP}.wifi"; + /// Measure type for Beacon ranging to detect and estimate proximity to + /// Bluetooth beacons (e.g., iBeacon, Eddystone). + /// * Typically returns beacon identifiers (UUID, major, minor) and + /// estimated distance or RSSI. + /// * Use a [PeriodicSamplingConfiguration] for configuration. + static const String BEACON = "${NameSpace.CARP}.beacon"; + @override DataTypeSamplingSchemeMap get samplingSchemes => DataTypeSamplingSchemeMap.from([ DataTypeSamplingScheme( @@ -49,6 +56,18 @@ class ConnectivitySamplingPackage extends SmartphoneSamplingPackage { IntervalSamplingConfiguration( interval: const Duration(minutes: 10), )), + DataTypeSamplingScheme( + CamsDataTypeMetaData( + type: BEACON, + displayName: "Ranging beacons in proximity", + timeType: DataTimeType.POINT, + permissions: [Permission.bluetoothScan, Permission.locationAlways], + ), + PeriodicSamplingConfiguration( + interval: const Duration(minutes: 10), + duration: const Duration(seconds: 10), + ), + ), ]); @override @@ -60,6 +79,8 @@ class ConnectivitySamplingPackage extends SmartphoneSamplingPackage { return BluetoothProbe(); case WIFI: return WifiProbe(); + case BEACON: + return BeaconProbe(); default: return null; } @@ -102,32 +123,42 @@ class BluetoothScanPeriodicSamplingConfiguration extends PeriodicSamplingConfigu /// List of remote device IDs to filter the scan results. List withRemoteIds; - /// Use Package `flutter_beacon` to enable beacon monitoring while the app is in background. - bool useBeaconMonitoring; + BluetoothScanPeriodicSamplingConfiguration({ + required super.interval, + required super.duration, + this.withServices = const [], + this.withRemoteIds = const [], + }); + + @override + Map toJson() => _$BluetoothScanPeriodicSamplingConfigurationToJson(this); + @override + Function get fromJsonFunction => _$BluetoothScanPeriodicSamplingConfigurationFromJson; + factory BluetoothScanPeriodicSamplingConfiguration.fromJson(Map json) => + FromJsonFactory().fromJson(json); +} + +@JsonSerializable(includeIfNull: false, explicitToJson: true) +class BeaconRangingPeriodicSamplingConfiguration extends PeriodicSamplingConfiguration { /// List of beacon regions to monitor and/or range using the `flutter_beacon` package. - /// - /// When [useBeaconMonitoring] is true, the app will monitor these regions, potentially in the background if platform permissions and conditions allow. List beaconRegions; /// When a device is within this distance from the beacon, a predefined event is triggered. /// Defaults to 2 meters. int beaconDistance; - BluetoothScanPeriodicSamplingConfiguration({ + BeaconRangingPeriodicSamplingConfiguration({ required super.interval, required super.duration, - this.withServices = const [], - this.withRemoteIds = const [], this.beaconRegions = const [], - this.useBeaconMonitoring = false, this.beaconDistance = 2, }); @override - Map toJson() => _$BluetoothScanPeriodicSamplingConfigurationToJson(this); + Map toJson() => _$BeaconRangingPeriodicSamplingConfigurationToJson(this); @override - Function get fromJsonFunction => _$BluetoothScanPeriodicSamplingConfigurationFromJson; - factory BluetoothScanPeriodicSamplingConfiguration.fromJson(Map json) => - FromJsonFactory().fromJson(json); + Function get fromJsonFunction => _$BeaconRangingPeriodicSamplingConfigurationFromJson; + factory BeaconRangingPeriodicSamplingConfiguration.fromJson(Map json) => + FromJsonFactory().fromJson(json); } diff --git a/packages/carp_connectivity_package/lib/connectivity_probes.dart b/packages/carp_connectivity_package/lib/connectivity_probes.dart index 6d774efe..43a9a5f5 100644 --- a/packages/carp_connectivity_package/lib/connectivity_probes.dart +++ b/packages/carp_connectivity_package/lib/connectivity_probes.dart @@ -81,36 +81,16 @@ class BluetoothProbe extends BufferingPeriodicStreamProbe { ? (samplingConfiguration as BluetoothScanPeriodicSamplingConfiguration).withRemoteIds : []; - bool get useBeaconMonitoring => (samplingConfiguration is BluetoothScanPeriodicSamplingConfiguration) - ? (samplingConfiguration as BluetoothScanPeriodicSamplingConfiguration).useBeaconMonitoring - : false; - - List get beaconRegions => (samplingConfiguration is BluetoothScanPeriodicSamplingConfiguration) - ? (samplingConfiguration as BluetoothScanPeriodicSamplingConfiguration).beaconRegions - : []; - - int get beaconDistance => (samplingConfiguration is BluetoothScanPeriodicSamplingConfiguration) - ? (samplingConfiguration as BluetoothScanPeriodicSamplingConfiguration).beaconDistance - : 2; - - StreamSubscription? _streamMonitoring; - StreamSubscription? _streamRanging; - @override void onSamplingStart() { _data = Bluetooth(); try { - if (useBeaconMonitoring) { - info('Using beacon monitoring.'); - _startMonitoring(); - } else { - FlutterBluePlus.startScan( - withServices: services, - withRemoteIds: remoteIds, - timeout: samplingConfiguration?.duration ?? const Duration(milliseconds: DEFAULT_TIMEOUT), - ); - } + FlutterBluePlus.startScan( + withServices: services, + withRemoteIds: remoteIds, + timeout: samplingConfiguration?.duration ?? const Duration(milliseconds: DEFAULT_TIMEOUT), + ); } catch (error) { FlutterBluePlus.stopScan(); _data = Error(message: 'Error scanning for bluetooth - $error'); @@ -119,12 +99,7 @@ class BluetoothProbe extends BufferingPeriodicStreamProbe { @override void onSamplingEnd() { - if (useBeaconMonitoring) { - info('stopping monitoring'); - _stopMonitoring(); - } else { - FlutterBluePlus.stopScan(); - } + FlutterBluePlus.stopScan(); if (_data is Bluetooth) (_data as Bluetooth).endScan = DateTime.now(); } @@ -135,6 +110,23 @@ class BluetoothProbe extends BufferingPeriodicStreamProbe { (_data as Bluetooth).addBluetoothDevicesFromScanResults(event); } } +} + +class BeaconProbe extends StreamProbe { + /// Default timeout for bluetooth scan - 4 secs + static const DEFAULT_TIMEOUT = 4 * 1000; + Data? _data; + + List get beaconRegions => (samplingConfiguration is BeaconRangingPeriodicSamplingConfiguration) + ? (samplingConfiguration as BeaconRangingPeriodicSamplingConfiguration).beaconRegions + : []; + + int get beaconDistance => (samplingConfiguration is BeaconRangingPeriodicSamplingConfiguration) + ? (samplingConfiguration as BeaconRangingPeriodicSamplingConfiguration).beaconDistance + : 2; + + StreamSubscription? _streamMonitoring; + StreamSubscription? _streamRanging; Future _startMonitoring() async { info('start monitoring & initializing scanning.'); @@ -164,17 +156,23 @@ class BluetoothProbe extends BufferingPeriodicStreamProbe { } void _startRanging(Region region) { - _streamRanging = flutterBeacon.ranging([region]).listen((RangingResult result) { - final closeBeacons = result.beacons.where((beacon) => beacon.accuracy <= beaconDistance); - - for (var beacon in closeBeacons) { - info('✅ beacon in range: ${beacon.proximityUUID}, ${beacon.accuracy} m'); - (_data as Bluetooth).addBluetoothDevicesFromRangingResults( - beacon, - result.region.identifier, - ); - } - }); + _streamRanging = flutterBeacon.ranging([region]).listen( + (RangingResult result) { + final closeBeacons = result.beacons.where((beacon) => beacon.accuracy <= beaconDistance); + + for (var beacon in closeBeacons) { + info('✅ beacon in range: ${beacon.proximityUUID}, ${beacon.accuracy} m'); + (_data as BeaconData).addBluetoothDevicesFromRangingResults( + BeaconDevice( + rssi: beacon.rssi, + major: beacon.major, + minor: beacon.minor, + accuracy: beacon.accuracy, + ), + ); + } + }, + ); } void _stopMonitoring() { @@ -183,4 +181,34 @@ class BluetoothProbe extends BufferingPeriodicStreamProbe { _streamMonitoring?.cancel(); _streamMonitoring = null; } + + @override + Future onStart() async { + _data = BeaconData(); + + _startMonitoring(); + + return super.onStart(); + } + + @override + Stream get stream => flutterBeacon.ranging(beaconRegions.map((e) => e!.toRegion()).toList()).map( + (RangingResult result) { + final closeBeacons = result.beacons.where((beacon) => beacon.accuracy <= beaconDistance); + if (closeBeacons.isNotEmpty) { + return Measurement.fromData(BeaconData() + ..startScan = DateTime.now() + ..scanResult = closeBeacons + .map((beacon) => BeaconDevice( + rssi: beacon.rssi, + uuid: beacon.proximityUUID, + major: beacon.major, + minor: beacon.minor, + accuracy: beacon.accuracy, + )) + .toList()); + } + return Measurement.fromData(BeaconData()); + }, + ); }