diff --git a/carp_mobile_sensing/analysis_options.yaml b/carp_mobile_sensing/analysis_options.yaml index 07e99cb85..e30fa1b76 100644 --- a/carp_mobile_sensing/analysis_options.yaml +++ b/carp_mobile_sensing/analysis_options.yaml @@ -4,6 +4,8 @@ include: package:flutter_lints/flutter.yaml analyzer: + errors: + deprecated_member_use_from_same_package: ignore exclude: [build/**] language: strict-casts: true @@ -17,3 +19,5 @@ linter: depend_on_referenced_packages: true avoid_print: false use_string_in_part_of_directives: true + deprecated_member_use_from_same_package: false + \ No newline at end of file diff --git a/carp_mobile_sensing/example/lib/example.dart b/carp_mobile_sensing/example/lib/example.dart index 35ad14b68..d4c4cb06c 100644 --- a/carp_mobile_sensing/example/lib/example.dart +++ b/carp_mobile_sensing/example/lib/example.dart @@ -1,3 +1,5 @@ +// ignore_for_file: unused_local_variable + /* * Copyright 2018-2024 the Technical University of Denmark (DTU). * Use of this source code is governed by a MIT-style license that can be diff --git a/packages/carp_connectivity_package/CHANGELOG.md b/packages/carp_connectivity_package/CHANGELOG.md index 110fea769..c7758f698 100644 --- a/packages/carp_connectivity_package/CHANGELOG.md +++ b/packages/carp_connectivity_package/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.9.0 + +* added support for scanning iBeacons using the [dchs_flutter_beacon](https://pub.dev/packages/dchs_flutter_beacon) plugin +* use the `BEACON` measure type for collecting `BeaconData` within a set of specific regions as specified in a `BeaconRangingPeriodicSamplingConfiguration` + ## 1.8.1+1 * added `BluetoothScanPeriodicSamplingConfiguration` diff --git a/packages/carp_connectivity_package/README.md b/packages/carp_connectivity_package/README.md index ea3264a69..b5f9fac7d 100644 --- a/packages/carp_connectivity_package/README.md +++ b/packages/carp_connectivity_package/README.md @@ -12,6 +12,7 @@ This packages supports sampling of the following [`Measure`](https://github.com/ * `dk.cachet.carp.wifi` * `dk.cachet.carp.connectivity` * `dk.cachet.carp.bluetooth` +* `dk.cachet.carp.beacon` See the [wiki](https://github.com/cph-cachet/carp.sensing-flutter/wiki) for further documentation, particularly on available [measure types](https://github.com/cph-cachet/carp.sensing-flutter/wiki/A.-Measure-Types). See the [CARP Mobile Sensing App](https://github.com/cph-cachet/carp.sensing-flutter/tree/master/apps/carp_mobile_sensing_app) for an example of how to build a mobile sensing app in Flutter. @@ -55,7 +56,7 @@ Add the following to your app's `AndroidManifest.xml` file located in `android/a - + ```` @@ -70,8 +71,10 @@ and here for the [iOS](https://developer.apple.com/documentation/systemconfigura To enable bluetooth tracking, add these permissions in the `Info.plist` file located in `ios/Runner`: ````xml + + NSBluetoothAlwaysUsageDescription -Bluetooth needed +Reason why app needs bluetooth UIBackgroundModes bluetooth-central @@ -81,7 +84,23 @@ To enable bluetooth tracking, add these permissions in the `Info.plist` file loc ```` -> Note that on iOS, it is [impossible to do a general Bluetooth scan when the screen is off or the app is in background](https://developer.apple.com/forums/thread/652592). This will simply result in an empty scan. Hence, bluetooth devices are only collected when the app is in the foreground. +> [!NOTE] +> On iOS, it is [impossible to do a general Bluetooth scan when the screen is off or the app is in background](https://developer.apple.com/forums/thread/652592). This will simply result in an empty scan. Hence, bluetooth devices are only collected when the app is in the foreground. + +To collect iBeacon measurements, please follow the setup described in the [dchs_flutter_beacon](https://pub.dev/packages/dchs_flutter_beacon) plugin. Especially, for iOS you need permissions to access location information in the `Info.plist` file: + +````xml + +NSLocationWhenInUseUsageDescription +Reason why app needs location + + +NSLocationAlwaysAndWhenInUseUsageDescription +Reason why app needs location + +NSLocationAlwaysUsageDescription +Reason why app needs location +```` ## Using it @@ -116,13 +135,13 @@ Smartphone phone = Smartphone(); protocol.addPrimaryDevice(phone); // Add an automatic task that immediately starts collecting connectivity, -// nearby bluetooth devices, and wifi information. +// wifi information, and nearby bluetooth devices. protocol.addTaskControl( ImmediateTrigger(), BackgroundTask(measures: [ Measure(type: ConnectivitySamplingPackage.CONNECTIVITY), - Measure(type: ConnectivitySamplingPackage.BLUETOOTH), Measure(type: ConnectivitySamplingPackage.WIFI), + Measure(type: ConnectivitySamplingPackage.BLUETOOTH), ]), phone); ``` @@ -136,11 +155,42 @@ protocol.addTaskControl( Measure( type: ConnectivitySamplingPackage.BLUETOOTH, samplingConfiguration: BluetoothScanPeriodicSamplingConfiguration( - interval: const Duration(minutes: 10), - duration: const Duration(seconds: 10), + interval: const Duration(minutes: 20), + duration: const Duration(seconds: 15), withRemoteIds: ['123', '456'], withServices: ['service1', 'service2'], )) ]), phone); ``` + +The default configuration scans every 10 minutes for 10 seconds, and does not specify any remote IDs or services. + +If you want to collect iBeacon measurements, you need to configure the scanning by setting a [`BeaconRangingPeriodicSamplingConfiguration`](https://pub.dev/documentation/carp_connectivity_package/latest/connectivity/BeaconRangingPeriodicSamplingConfiguration-class.html). +The following example will scan for iBeacons in the specified regions which are closer than 2 meters. The regions are specified by their identifier and UUID. See the [dchs_flutter_beacon](https://pub.dev/packages/dchs_flutter_beacon) plugin for more information on how to set up iBeacon regions. + +> [!NOTE] +> There is no default sampling configuration for iBeacons. You need to specify at least one region to scan for. + +```dart + protocol.addTaskControl( + ImmediateTrigger(), + BackgroundTask(measures: [ + Measure( + type: ConnectivitySamplingPackage.BEACON, + samplingConfiguration: BeaconRangingPeriodicSamplingConfiguration( + beaconDistance: 2, + beaconRegions: [ + BeaconRegion( + identifier: 'region1', + uuid: '12345678-1234-1234-1234-123456789012', + ), + BeaconRegion( + identifier: 'region2', + uuid: '12345678-1234-1234-1234-123456789012', + ), + ], + )) + ]), + phone); +``` diff --git a/packages/carp_connectivity_package/example/lib/example.dart b/packages/carp_connectivity_package/example/lib/example.dart index 827979575..7f4e08fb2 100644 --- a/packages/carp_connectivity_package/example/lib/example.dart +++ b/packages/carp_connectivity_package/example/lib/example.dart @@ -1,3 +1,5 @@ +// ignore_for_file: unused_local_variable + import 'package:carp_core/carp_core.dart'; import 'package:carp_mobile_sensing/carp_mobile_sensing.dart'; import 'package:carp_connectivity_package/connectivity.dart'; @@ -22,13 +24,59 @@ void main() async { protocol.addPrimaryDevice(phone); // Add an automatic task that immediately starts collecting connectivity, - // nearby bluetooth devices, and wifi information. + // wifi information, and nearby bluetooth devices. protocol.addTaskControl( ImmediateTrigger(), BackgroundTask(measures: [ Measure(type: ConnectivitySamplingPackage.CONNECTIVITY), - Measure(type: ConnectivitySamplingPackage.BLUETOOTH), Measure(type: ConnectivitySamplingPackage.WIFI), + Measure(type: ConnectivitySamplingPackage.BLUETOOTH), + ]), + phone); + + // If you want to scan for nearby bluetooth devices, you can use a + // [BluetoothScanPeriodicSamplingConfiguration] to configure the scan. + // This will scan for bluetooth devices every 10 minutes for 10 seconds. + // You can also filter by remoteIds and services. + protocol.addTaskControl( + ImmediateTrigger(), + BackgroundTask(measures: [ + Measure( + type: ConnectivitySamplingPackage.BLUETOOTH, + samplingConfiguration: BluetoothScanPeriodicSamplingConfiguration( + interval: const Duration(minutes: 20), + duration: const Duration(seconds: 15), + withRemoteIds: ['123', '456'], + withServices: ['service1', 'service2'], + )) + ]), + phone); + + // If you want to collect iBeacon measurements, you can use a + // [BeaconRangingPeriodicSamplingConfiguration] to configure the scan. + // This will scan for iBeacons in the specified regions which are closer than + // 2 meters. The regions are specified by their identifier and UUID. + // + // See the dchs_flutter_beacon plugin for more information on how to set up + // iBeacon regions. + protocol.addTaskControl( + ImmediateTrigger(), + BackgroundTask(measures: [ + Measure( + type: ConnectivitySamplingPackage.BEACON, + samplingConfiguration: BeaconRangingPeriodicSamplingConfiguration( + beaconDistance: 2, + beaconRegions: [ + BeaconRegion( + identifier: 'region1', + uuid: '12345678-1234-1234-1234-123456789012', + ), + BeaconRegion( + identifier: 'region2', + uuid: '12345678-1234-1234-1234-123456789012', + ), + ], + )) ]), phone); } diff --git a/packages/carp_connectivity_package/lib/connectivity.dart b/packages/carp_connectivity_package/lib/connectivity.dart index 87460d824..4095965b8 100644 --- a/packages/carp_connectivity_package/lib/connectivity.dart +++ b/packages/carp_connectivity_package/lib/connectivity.dart @@ -7,6 +7,7 @@ library; import 'dart:async'; import 'dart:convert'; +import 'package:dchs_flutter_beacon/dchs_flutter_beacon.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:connectivity_plus/connectivity_plus.dart' as connectivity; diff --git a/packages/carp_connectivity_package/lib/connectivity.g.dart b/packages/carp_connectivity_package/lib/connectivity.g.dart index 8d9c25524..e9cdd6e45 100644 --- a/packages/carp_connectivity_package/lib/connectivity.g.dart +++ b/packages/carp_connectivity_package/lib/connectivity.g.dart @@ -84,6 +84,48 @@ Map _$WifiToJson(Wifi instance) => { if (instance.ip case final value?) 'ip': value, }; +BeaconData _$BeaconDataFromJson(Map json) => BeaconData( + region: json['region'] as String, + ) + ..$type = json['__type'] as String? + ..scanResult = (json['scanResult'] as List) + .map((e) => BeaconDevice.fromJson(e as Map)) + .toList(); + +Map _$BeaconDataToJson(BeaconData instance) => + { + if (instance.$type case final value?) '__type': value, + 'region': instance.region, + 'scanResult': instance.scanResult.map((e) => e.toJson()).toList(), + }; + +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(), + proximity: $enumDecodeNullable(_$ProximityEnumMap, json['proximity']), + ); + +Map _$BeaconDeviceToJson(BeaconDevice instance) => + { + 'uuid': instance.uuid, + 'rssi': instance.rssi, + if (instance.major case final value?) 'major': value, + if (instance.minor case final value?) 'minor': value, + if (instance.accuracy case final value?) 'accuracy': value, + if (_$ProximityEnumMap[instance.proximity] case final value?) + 'proximity': value, + }; + +const _$ProximityEnumMap = { + Proximity.unknown: 'unknown', + Proximity.immediate: 'immediate', + Proximity.near: 'near', + Proximity.far: 'far', +}; + BluetoothScanPeriodicSamplingConfiguration _$BluetoothScanPeriodicSamplingConfigurationFromJson( Map json) => @@ -115,3 +157,37 @@ Map _$BluetoothScanPeriodicSamplingConfigurationToJson( 'withServices': instance.withServices, 'withRemoteIds': instance.withRemoteIds, }; + +BeaconRangingPeriodicSamplingConfiguration + _$BeaconRangingPeriodicSamplingConfigurationFromJson( + Map json) => + BeaconRangingPeriodicSamplingConfiguration( + beaconRegions: (json['beaconRegions'] as List?) + ?.map((e) => BeaconRegion.fromJson(e as Map)) + .toList() ?? + const [], + beaconDistance: (json['beaconDistance'] as num?)?.toInt() ?? 2, + )..$type = json['__type'] as String?; + +Map _$BeaconRangingPeriodicSamplingConfigurationToJson( + BeaconRangingPeriodicSamplingConfiguration instance) => + { + if (instance.$type case final value?) '__type': value, + 'beaconRegions': instance.beaconRegions.map((e) => e.toJson()).toList(), + 'beaconDistance': instance.beaconDistance, + }; + +BeaconRegion _$BeaconRegionFromJson(Map json) => BeaconRegion( + identifier: json['identifier'] as String, + uuid: json['uuid'] as String, + major: (json['major'] as num?)?.toInt(), + minor: (json['minor'] as num?)?.toInt(), + ); + +Map _$BeaconRegionToJson(BeaconRegion instance) => + { + 'identifier': instance.identifier, + 'uuid': instance.uuid, + if (instance.major case final value?) 'major': value, + if (instance.minor case final value?) 'minor': value, + }; diff --git a/packages/carp_connectivity_package/lib/connectivity_data.dart b/packages/carp_connectivity_package/lib/connectivity_data.dart index c36bf80d9..d53bbd12a 100644 --- a/packages/carp_connectivity_package/lib/connectivity_data.dart +++ b/packages/carp_connectivity_package/lib/connectivity_data.dart @@ -114,6 +114,13 @@ class Bluetooth extends Data { } } + void addBluetoothDevicesFromRangingResults( + Beacon result, + String beaconName, + ) { + addBluetoothDevice(BluetoothDevice.fromRangingResult(result, beaconName)); + } + @override Function get fromJsonFunction => _$BluetoothFromJson; factory Bluetooth.fromJson(Map json) => @@ -164,6 +171,16 @@ class BluetoothDevice { rssi: result.rssi, ); + factory BluetoothDevice.fromRangingResult(Beacon result, String beaconName) => + BluetoothDevice( + bluetoothDeviceId: beaconName, + bluetoothDeviceName: beaconName, + connectable: false, + txPowerLevel: result.txPower, + advertisementName: beaconName, + rssi: result.rssi, + ); + factory BluetoothDevice.fromJson(Map json) => _$BluetoothDeviceFromJson(json); Map toJson() => _$BluetoothDeviceToJson(this); @@ -211,3 +228,111 @@ class Wifi extends Data { String toString() => '${super.toString()}, SSID: $ssid, BSSID: $bssid, IP: $ip'; } + +/// A [Data] holding information of nearby Beacon devices. +@JsonSerializable(includeIfNull: false, explicitToJson: true) +class BeaconData extends Data { + static const dataType = ConnectivitySamplingPackage.BEACON; + + /// The unique identifier of the region that this beacon belongs to. + String region; + + /// A map of [BeaconDevice] indexed by their UUID 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))); + + /// Creates a [BeaconData] instance with the specified region. + BeaconData({required this.region}) : super(); + + /// Creates a [BeaconData] instance from a region and a list of beacons. + BeaconData.fromRegionAndBeacons({ + required this.region, + required List beacons, + }) : super() { + scanResult = beacons + .map((beacon) => BeaconDevice( + uuid: beacon.proximityUUID, + rssi: beacon.rssi, + major: beacon.major, + minor: beacon.minor, + accuracy: beacon.accuracy, + proximity: beacon.proximity, + )) + .toList(); + } + + void addBeaconDevice(BeaconDevice device) => + _scanResult[device.uuid] = device; + + void addBeaconDevicesFromRangingResults(RangingResult result) { + region = result.region.identifier; + for (var beacon in result.beacons) { + addBeaconDevice(BeaconDevice.fromRegionAndBeacon(beacon)); + } + } + + @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 device data. +@JsonSerializable(includeIfNull: false, explicitToJson: true) +class BeaconDevice { + /// The proximity UUID of beacon. + String uuid; + + /// The RSSI signal strength to the device. + int rssi; + + /// Major value (for iBeacon). + int? major; + + /// Minor value (for iBeacon). + int? minor; + + /// The accuracy of distance of beacon in meter. + double? accuracy; + + /// The proximity of beacon. + final Proximity? proximity; + + BeaconDevice({ + required this.rssi, + required this.uuid, + this.major, + this.minor, + this.accuracy, + this.proximity, + }) : super(); + + BeaconDevice.fromRegionAndBeacon(Beacon beacon) + : this( + rssi: beacon.rssi, + uuid: beacon.proximityUUID, + major: beacon.major, + minor: beacon.minor, + accuracy: beacon.accuracy, + proximity: beacon.proximity, + ); + + 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'; +} diff --git a/packages/carp_connectivity_package/lib/connectivity_package.dart b/packages/carp_connectivity_package/lib/connectivity_package.dart index 6ec45b2b9..5792b5f74 100644 --- a/packages/carp_connectivity_package/lib/connectivity_package.dart +++ b/packages/carp_connectivity_package/lib/connectivity_package.dart @@ -11,7 +11,7 @@ class ConnectivitySamplingPackage extends SmartphoneSamplingPackage { /// Measure type for collection of nearby Bluetooth devices on a regular basis. /// * Event-based (Periodic) measure - default every 10 minutes for 10 seconds. /// * Uses the [Smartphone] master device for data collection. - /// * Use a [PeriodicSamplingConfiguration] for configuration. + /// * Use a [BluetoothScanPeriodicSamplingConfiguration] for configuration. static const String BLUETOOTH = "${NameSpace.CARP}.bluetooth"; /// Measure type for collection of wifi information (SSID, BSSID, IP). @@ -20,6 +20,15 @@ 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 collects beacon identifiers (UUID, major, minor) and + /// estimated distance or RSSI. + /// * Event-based (Interval) measure - default every 10 minutes. + /// * Uses the [Smartphone] master device for data collection. + /// * Use a [BeaconRangingPeriodicSamplingConfiguration] for configuration. + static const String BEACON = "${NameSpace.CARP}.beacon"; + @override DataTypeSamplingSchemeMap get samplingSchemes => DataTypeSamplingSchemeMap.from([ @@ -50,6 +59,15 @@ class ConnectivitySamplingPackage extends SmartphoneSamplingPackage { IntervalSamplingConfiguration( interval: const Duration(minutes: 10), )), + DataTypeSamplingScheme( + CamsDataTypeMetaData( + type: BEACON, + displayName: "Ranging iBeacons", + timeType: DataTimeType.POINT, + permissions: [Permission.bluetoothScan, Permission.locationAlways], + ), + BeaconRangingPeriodicSamplingConfiguration(), + ), ]); @override @@ -61,6 +79,8 @@ class ConnectivitySamplingPackage extends SmartphoneSamplingPackage { return BluetoothProbe(); case WIFI: return WifiProbe(); + case BEACON: + return BeaconProbe(); default: return null; } @@ -73,10 +93,13 @@ class ConnectivitySamplingPackage extends SmartphoneSamplingPackage { Connectivity(), Bluetooth(), Wifi(), + BeaconData(region: ''), BluetoothScanPeriodicSamplingConfiguration( interval: const Duration(minutes: 10), duration: const Duration(seconds: 10), ), + BeaconRangingPeriodicSamplingConfiguration( + beaconDistance: 2, beaconRegions: []) ]); // registering default privacy functions @@ -126,3 +149,79 @@ class BluetoothScanPeriodicSamplingConfiguration FromJsonFactory() .fromJson(json); } + +/// A sampling configuration specifying how to scan for iBeacon devices. +/// +/// The regions of the beacons to monitor are specified in the [beaconRegions] +/// list. The [beaconDistance] is used to determine the proximity to the beacon, +/// with a default value of 2 meters. +@JsonSerializable(includeIfNull: false, explicitToJson: true) +class BeaconRangingPeriodicSamplingConfiguration extends SamplingConfiguration { + /// List of beacon regions to monitor and range. + List beaconRegions; + + /// The distance in meters to consider a beacon as "in range". + int beaconDistance; + + BeaconRangingPeriodicSamplingConfiguration({ + this.beaconRegions = const [], + this.beaconDistance = 2, + }) : super(); + + @override + Map toJson() => + _$BeaconRangingPeriodicSamplingConfigurationToJson(this); + @override + Function get fromJsonFunction => + _$BeaconRangingPeriodicSamplingConfigurationFromJson; + factory BeaconRangingPeriodicSamplingConfiguration.fromJson( + Map json) => + FromJsonFactory() + .fromJson(json); +} + +/// Beacon region to use when scanning for beacons. +@JsonSerializable(includeIfNull: false, explicitToJson: true) +class BeaconRegion { + /// A unique identifier for the beacon region. + /// Used to distinguish between different regions being monitored. + String identifier; + + /// The proximity UUID of the beacon. + /// This is a 128-bit value used to identify a group of related beacons. + String uuid; + + /// The major value of the beacon region (optional). + /// Used to further distinguish a subset of beacons within the same UUID. + int? major; + + /// The minor value of the beacon region (optional). + /// Provides a finer granularity within a group of beacons identified by + /// the same UUID and major value. + int? minor; + + BeaconRegion({ + required this.identifier, + required this.uuid, + this.major, + this.minor, + }); + + Region toRegion() { + return Region( + identifier: identifier, + proximityUUID: uuid, + major: major, + minor: minor, + ); + } + + factory BeaconRegion.fromJson(Map json) => + _$BeaconRegionFromJson(json); + + 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_probes.dart b/packages/carp_connectivity_package/lib/connectivity_probes.dart index 0649113ba..e4b09c6c9 100644 --- a/packages/carp_connectivity_package/lib/connectivity_probes.dart +++ b/packages/carp_connectivity_package/lib/connectivity_probes.dart @@ -75,7 +75,6 @@ class BluetoothProbe extends BufferingPeriodicStreamProbe { // if a BT-specific sampling configuration is used, we need to // extract the services and remoteIds from it so FlutterBluePlus can // perform filtered scanning - List get services => (samplingConfiguration is BluetoothScanPeriodicSamplingConfiguration) ? (samplingConfiguration as BluetoothScanPeriodicSamplingConfiguration) @@ -96,10 +95,11 @@ class BluetoothProbe extends BufferingPeriodicStreamProbe { try { FlutterBluePlus.startScan( - withServices: services, - withRemoteIds: remoteIds, - timeout: samplingConfiguration?.duration ?? - const Duration(milliseconds: DEFAULT_TIMEOUT)); + withServices: services, + withRemoteIds: remoteIds, + timeout: samplingConfiguration?.duration ?? + const Duration(milliseconds: DEFAULT_TIMEOUT), + ); } catch (error) { FlutterBluePlus.stopScan(); _data = Error(message: 'Error scanning for bluetooth - $error'); @@ -109,6 +109,7 @@ class BluetoothProbe extends BufferingPeriodicStreamProbe { @override void onSamplingEnd() { FlutterBluePlus.stopScan(); + if (_data is Bluetooth) (_data as Bluetooth).endScan = DateTime.now(); } @@ -119,3 +120,74 @@ class BluetoothProbe extends BufferingPeriodicStreamProbe { } } } + +/// A Probe that constantly scans for nearby and visible iBeacon devices and collects a +/// [BeaconData] measurement that lists each [BeaconDevice] found during the scan. +/// +/// Uses a [BeaconRangingPeriodicSamplingConfiguration] for configuration the +/// [beaconRegions] to include and the [beaconDistance]. +class BeaconProbe extends StreamProbe { + @override + BeaconRangingPeriodicSamplingConfiguration? get samplingConfiguration => + super.samplingConfiguration as BeaconRangingPeriodicSamplingConfiguration; + + List get beaconRegions => + samplingConfiguration?.beaconRegions + .map((region) => region.toRegion()) + .toList() ?? + []; + + int get beaconDistance => samplingConfiguration?.beaconDistance ?? 2; + + @override + bool onInitialize() { + super.onInitialize(); + if (beaconRegions.isEmpty) { + warning( + '$runtimeType - No beacon regions specified for monitoring. Will not start monitoring.'); + return false; + } + + try { + info('$runtimeType - Initializing iBeacon scanning...'); + flutterBeacon.initializeScanning.then((_) { + info('$runtimeType - Initialized.'); + return true; + }, onError: (Object error) { + warning('$runtimeType - Error while initializing scanner - $error'); + return false; + }); + } catch (error) { + warning('$runtimeType - Error while initializing scanner - $error'); + return false; + } + return true; + } + + @override + Stream get stream async* { + await for (final monitoringResult + in flutterBeacon.monitoring(beaconRegions)) { + if (monitoringResult.monitoringState == MonitoringState.inside) { + debug( + '$runtimeType - Entered region: ${monitoringResult.region.identifier}'); + + yield* flutterBeacon.ranging(beaconRegions).map( + (rangingResult) { + final closeBeacons = rangingResult.beacons + .where((beacon) => beacon.accuracy <= beaconDistance) + .toList(); + + return Measurement.fromData(BeaconData.fromRegionAndBeacons( + region: rangingResult.region.identifier, + beacons: closeBeacons, + )); + }, + ); + } else if (monitoringResult.monitoringState == MonitoringState.outside) { + debug( + '$runtimeType - Exited region: ${monitoringResult.region.identifier}'); + } + } + } +} diff --git a/packages/carp_connectivity_package/pubspec.yaml b/packages/carp_connectivity_package/pubspec.yaml index 4e90cb066..f3577bad7 100644 --- a/packages/carp_connectivity_package/pubspec.yaml +++ b/packages/carp_connectivity_package/pubspec.yaml @@ -1,6 +1,6 @@ name: carp_connectivity_package description: CARP connectivity sampling package. Samples connectivity status, bluetooth devices, and wifi access points. -version: 1.8.1+1 +version: 1.9.0 homepage: https://github.com/cph-cachet/carp.sensing-flutter/tree/master/packages/carp_connectivity_package environment: @@ -20,8 +20,10 @@ dependencies: connectivity_plus: ^6.0.0 # connectivity events network_info_plus: ^6.0.0 # wifi ssid name flutter_blue_plus: ^1.35.0 # bluetooth scan + dchs_flutter_beacon: ^0.6.4 # collecting nearby iBeacon devices crypto: ^3.0.0 # hashing sensitive data - permission_handler: '>=11.0.0 <13.0.0' + permission_handler: '>=11.0.0 <13.0.0' + # Overriding carp libraries to use the local copy # Remove this before release of package diff --git a/packages/carp_connectivity_package/test/carp_connectivity_package_test.dart b/packages/carp_connectivity_package/test/carp_connectivity_package_test.dart index 7805a0f36..1b850967e 100644 --- a/packages/carp_connectivity_package/test/carp_connectivity_package_test.dart +++ b/packages/carp_connectivity_package/test/carp_connectivity_package_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:carp_connectivity_package/connectivity.dart'; +import 'package:dchs_flutter_beacon/dchs_flutter_beacon.dart'; import 'package:test/test.dart'; import 'package:carp_serializable/carp_serializable.dart'; @@ -11,64 +12,88 @@ void main() { late StudyProtocol protocol; Smartphone phone; - setUp(() { - // Initialization of serialization - CarpMobileSensing(); - - // register the context sampling package - SamplingPackageRegistry().register(ConnectivitySamplingPackage()); - - // Create a new study protocol. - protocol = StudyProtocol( - ownerId: 'alex@uni.dk', - name: 'Connectivity package test', - ); - - // Define which devices are used for data collection. - phone = Smartphone(); - - protocol.addPrimaryDevice(phone); - - // adding all available measures to one one trigger and one task - protocol.addTaskControl( - ImmediateTrigger(), - BackgroundTask() - ..measures = SamplingPackageRegistry() - .dataTypes - .map((type) => Measure(type: type.type)) - .toList(), - phone, - ); - - // also add a PeriodicSamplingConfiguration - protocol.addTaskControl( + setUp( + () { + // Initialization of serialization + CarpMobileSensing.ensureInitialized(); + + // register the context sampling package + SamplingPackageRegistry().register(ConnectivitySamplingPackage()); + + // Create a new study protocol. + protocol = StudyProtocol( + ownerId: 'alex@uni.dk', + name: 'Connectivity package test', + ); + + // Define which devices are used for data collection. + phone = Smartphone(); + + protocol.addPrimaryDevice(phone); + + // adding all available measures to one one trigger and one task + protocol.addTaskControl( + ImmediateTrigger(), + BackgroundTask() + ..measures = SamplingPackageRegistry() + .dataTypes + .map((type) => Measure(type: type.type)) + .toList(), + phone, + ); + + // also add a PeriodicSamplingConfiguration + protocol.addTaskControl( + ImmediateTrigger(), + BackgroundTask( + measures: [ + Measure(type: ConnectivitySamplingPackage.BLUETOOTH) + ..overrideSamplingConfiguration = PeriodicSamplingConfiguration( + interval: const Duration(minutes: 10), + duration: const Duration(seconds: 10), + ), + ], + ), + phone); + + // also add a BluetoothScanPeriodicSamplingConfiguration + protocol.addTaskControl( + ImmediateTrigger(), + BackgroundTask(measures: [ + Measure( + type: ConnectivitySamplingPackage.BLUETOOTH, + samplingConfiguration: + BluetoothScanPeriodicSamplingConfiguration( + interval: const Duration(minutes: 10), + duration: const Duration(seconds: 10), + withRemoteIds: ['123', '456'], + withServices: ['service1', 'service2'], + )) + ]), + phone); + + protocol.addTaskControl( ImmediateTrigger(), BackgroundTask( measures: [ - Measure(type: ConnectivitySamplingPackage.BLUETOOTH) - ..overrideSamplingConfiguration = PeriodicSamplingConfiguration( - interval: const Duration(minutes: 10), - duration: const Duration(seconds: 10), + Measure( + type: ConnectivitySamplingPackage.BEACON, + samplingConfiguration: BeaconRangingPeriodicSamplingConfiguration( + beaconDistance: 2, + beaconRegions: [ + BeaconRegion( + identifier: 'TestB1', + uuid: 'fda50693-a4e2-4fb1-afcf-c6eb07647825', + ), + ], ), + ), ], ), - phone); - - // also add a BluetoothScanPeriodicSamplingConfiguration - protocol.addTaskControl( - ImmediateTrigger(), - BackgroundTask(measures: [ - Measure( - type: ConnectivitySamplingPackage.BLUETOOTH, - samplingConfiguration: BluetoothScanPeriodicSamplingConfiguration( - interval: const Duration(minutes: 10), - duration: const Duration(seconds: 10), - withRemoteIds: ['123', '456'], - withServices: ['service1', 'service2'], - )) - ]), - phone); - }); + phone, + ); + }, + ); test('CAMSStudyProtocol -> JSON', () async { print(protocol); @@ -113,6 +138,22 @@ void main() { print(toJsonString(measurement)); }); + + test('Beacon -> JSON', () async { + BeaconData data = BeaconData(region: "TestB1") + ..addBeaconDevice( + BeaconDevice( + uuid: 'fda50693-a4e2-4fb1-afcf-c6eb07647825', + rssi: -60, + proximity: Proximity.near, + ), + ); + + final measurement = Measurement.fromData(data); + + print(toJsonString(measurement)); + }); + test('Connectivity -> JSON', () async { Connectivity data = Connectivity() ..connectivityStatus = [ConnectivityStatus.bluetooth]; diff --git a/packages/carp_connectivity_package/test/json/study_protocol.json b/packages/carp_connectivity_package/test/json/study_protocol.json index 2b321b4f4..b52883f38 100644 --- a/packages/carp_connectivity_package/test/json/study_protocol.json +++ b/packages/carp_connectivity_package/test/json/study_protocol.json @@ -1,6 +1,6 @@ { - "id": "62c13ff1-cffd-4b18-bc04-58d7b882dd77", - "createdOn": "2025-06-03T06:38:28.601596Z", + "id": "aec1d280-eb33-4694-a83c-8e356cca3292", + "createdOn": "2025-07-25T10:34:06.769594Z", "version": 0, "ownerId": "alex@uni.dk", "name": "Connectivity package test", @@ -81,6 +81,10 @@ { "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", "type": "dk.cachet.carp.wifi" + }, + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.beacon" } ] }, @@ -121,6 +125,30 @@ } } ] + }, + { + "__type": "dk.cachet.carp.common.application.tasks.BackgroundTask", + "name": "Task #10", + "measures": [ + { + "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", + "type": "dk.cachet.carp.beacon", + "overrideSamplingConfiguration": { + "__type": "dk.cachet.carp.common.application.sampling.BeaconRangingPeriodicSamplingConfiguration", + "beaconRegions": [ + { + "identifier": "region1", + "uuid": "12345678-1234-1234-1234-123456789012" + }, + { + "identifier": "region2", + "uuid": "12345678-1234-1234-1234-123456789012" + } + ], + "beaconDistance": 2 + } + } + ] } ], "triggers": { @@ -135,6 +163,10 @@ "2": { "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", "sourceDeviceRoleName": "Primary Phone" + }, + "3": { + "__type": "dk.cachet.carp.common.application.triggers.ImmediateTrigger", + "sourceDeviceRoleName": "Primary Phone" } }, "taskControls": [ @@ -155,6 +187,12 @@ "taskName": "Task #9", "destinationDeviceRoleName": "Primary Phone", "control": "Start" + }, + { + "triggerId": 3, + "taskName": "Task #10", + "destinationDeviceRoleName": "Primary Phone", + "control": "Start" } ], "expectedParticipantData": [] diff --git a/packages/carp_esense_package/example/ios/Podfile b/packages/carp_esense_package/example/ios/Podfile new file mode 100644 index 000000000..d97f17e22 --- /dev/null +++ b/packages/carp_esense_package/example/ios/Podfile @@ -0,0 +1,44 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '12.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end