-
Notifications
You must be signed in to change notification settings - Fork 28
5. Extending CARP Mobile Sensing
The main purpose of the CARP Mobile Sensing framework is to allow for extension with domain-specific studies, tasks, measures, probes, and datums (i.e., data). This is done by implementing classes that inherits from the (abstract) classes in the library.
There are a couple of ways to extend the library according to the following scenarios:
-
Adding new sampling capabilities by implementing a
SamplingPackage
-
Adding a new data management and backend support by creating a
DataManager
. -
Creating data and privacy transformer schemas that can transform CARP data to other formats, including privacy protecting them, by implementing a
TransformerSchema
.
If you want to add new sensing capabilities you would basically create a new SamplingPackage
. A sampling package implements the following classes:
-
SamplingPackage
- the overall specification of what this package does. -
SamplingSchema
- specifies sampling configurations. -
Data
- specifies the data model of the data collected. -
Probe
- implements the runtime of data collection. -
DeviceManager
- specifies how an external device is managed (if any).
Not all sampling packages use an external device and are hence simpler to implement since they can extend the default SmartphoneDeviceManager
.
Let us use the DeviceSamplingPackage
as an example of a sampling package that does not use any external device.
The first step is that your new sampling package should implement the SamplingPackage
interface, including all the instance methods.
class DeviceSamplingPackage extends SmartphoneSamplingPackage {
/// Measure type for collection of basic device information.
static const String DEVICE_INFORMATION = '${CarpDataTypes.CARP_NAMESPACE}.deviceinformation';
/// Measure type for collection of free physical and virtual memory.
static const String FREE_MEMORY = '${CarpDataTypes.CARP_NAMESPACE}.freememory';
/// Measure type for collection of battery level and charging status.
static const String BATTERY_STATE = '${CarpDataTypes.CARP_NAMESPACE}.batterystate';
/// Measure type for collection of screen events (on/off/unlocked).
static const String SCREEN_EVENT = '${CarpDataTypes.CARP_NAMESPACE}.screenevent';
@override
DataTypeSamplingSchemeMap get samplingSchemes =>
DataTypeSamplingSchemeMap.from([
DataTypeSamplingScheme(DataTypeMetaData(
type: DEVICE_INFORMATION,
displayName: "Device Information",
timeType: DataTimeType.POINT,
)),
DataTypeSamplingScheme(
DataTypeMetaData(
type: FREE_MEMORY,
displayName: "Free Memory",
timeType: DataTimeType.POINT,
),
IntervalSamplingConfiguration(
interval: const Duration(minutes: 1),
)),
DataTypeSamplingScheme(DataTypeMetaData(
type: BATTERY_STATE,
displayName: "Battery State",
timeType: DataTimeType.POINT,
)),
DataTypeSamplingScheme(DataTypeMetaData(
type: SCREEN_EVENT,
displayName: "Screen Events",
timeType: DataTimeType.POINT,
)),
]);
@override
Probe? create(String type) {
switch (type) {
case DEVICE_INFORMATION:
return DeviceProbe();
case FREE_MEMORY:
return MemoryProbe();
case BATTERY_STATE:
return BatteryProbe();
case SCREEN_EVENT:
return (Platform.isAndroid) ? ScreenProbe() : null;
default:
return null;
}
}
@override
void onRegister() {
FromJsonFactory().register(DeviceInformation());
FromJsonFactory().register(BatteryState());
FromJsonFactory().register(FreeMemory());
FromJsonFactory().register(ScreenEvent());
}
}
Default sampling configurations can be specified as part of samplingSchemes
, like the IntervalSamplingConfiguration
specified for the FREE_MEMORY
measure type above.
There are a number of built-in sampling configurations available:
PersistentSamplingConfiguration
HistoricSamplingConfiguration
IntervalSamplingConfiguration
PeriodicSamplingConfiguration
But you may need to write your own sampling configuration that is tailored to a specific measure and hence how data should be collected in a probe. As an example of how to write a sampling configuration, the IntervalSamplingConfiguration
is shown below:
/// A sampling configuration that allows configuring the time [interval] in
/// between subsequent measurements.
@JsonSerializable(fieldRename: FieldRename.none, includeIfNull: false)
class IntervalSamplingConfiguration extends PersistentSamplingConfiguration {
/// Sampling interval (i.e., delay between sampling).
Duration interval;
IntervalSamplingConfiguration({required this.interval}) : super();
@override
Function get fromJsonFunction => _$IntervalSamplingConfigurationFromJson;
@override
Map<String, dynamic> toJson() => _$IntervalSamplingConfigurationToJson(this);
factory IntervalSamplingConfiguration.fromJson(Map<String, dynamic> json) =>
FromJsonFactory().fromJson(json) as IntervalSamplingConfiguration;
}
Note that a sampling configuration must extend from one of the sampling configuration types (e.g., SamplingConfiguration
or PersistentSamplingConfiguration
).
Sampling configurations can be part of a study protocol where you can override the sampling configuration of a measure. If you want your own sampling configurations to be serializable to JSON, you need to add JSON serialization according to the carp_serializable
package to them. This entails
- annotated with
@JsonSerializable
- implement the three JSON serializable methods;
fromJsonFunction
,fromJson
,toJson
- register the configuration's
fromJsonFunction
in theFromJsonFactory
, e.g. in theonRegistry()
callback function as shown below:
@override
void onRegister() {
FromJsonFactory()
.register(IntervalSamplingConfiguration(interval: Duration(days: 1)));
}
See the carp_serializable
package for details on serialization.
Next, you should create the package-specific Datum
objects that the package collects.
Here is the example of the collected FreeMemoryDatum
;
/// Holds information about free memory on the phone.
@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false)
class FreeMemoryDatum extends Datum {
@override
DataFormat get format => DataFormat.fromString(DeviceSamplingPackage.MEMORY);
/// Amount of free physical memory in bytes.
int? freePhysicalMemory;
/// Amount of free virtual memory in bytes.
int? freeVirtualMemory;
FreeMemoryDatum([this.freePhysicalMemory, this.freeVirtualMemory]) : super();
factory FreeMemoryDatum.fromJson(Map<String, dynamic> json) => _$FreeMemoryDatumFromJson(json);
@override
Map<String, dynamic> toJson() => _$FreeMemoryDatumToJson(this);
@override
String toString() => '${super.toString()}, physical: $freePhysicalMemory, virtual: $freeVirtualMemory';
}
Again note that;
- a datum must always extend
Datum
(unless you are writing your own customDataManager
). - must be annotated with the
@JsonSerializable
- must implement the json serialization methods;
fromJson
andtoJson
A small note on privacy; it is possible to add privacy protection of collected data to the package. See how to create data privacy for how to do this.
The next step is to implement probes for each measure type. A probe implements how a measure type is collected, collects the data and returns it as datum object(s).
The generic Probe
interface explains how to use a probe. But in order to create your own probes, CAMS have a set of predefined, abstract probes to extend from:
-
AbstractProbe
- the most generic probe. -
DatumProbe
- a probe collecting only one piece of data (datum) and then stops. -
StreamProbe
- a probe listening on a stream of events. -
PeriodicDatumProbe
- a probe collecting a datum on a regular basis. -
PeriodicStreamProbe
- a probe listening on a stream with a certain frequency and duration. -
BufferingPeriodicProbe
- a probe which can sample data into a buffer, every frequency for a period of duration. -
BufferingPeriodicStreamProbe
- a probe which can sample data from a buffering stream, every frequency for a period of duration. -
BufferingStreamProbe
- a probe which can be used to buffer data from a stream and collect data every frequency.
The DeviceProbe
is an example of a simple DatumProbe
that collects device information about the phone using the the device_info_plus Flutter package and map this to a DeviceDatum
.
As shown below, the only thing this probe does it to implement the getDatum()
method, which return the device info mapped to a DeviceDatum
.
/// A probe that collects the device info about this device.
class DeviceProbe extends DatumProbe {
@override
Future<Datum?> getDatum() async => DeviceDatum(
DeviceInfo().platform,
DeviceInfo().deviceID,
deviceName: DeviceInfo().deviceName,
deviceModel: DeviceInfo().deviceModel,
deviceManufacturer: DeviceInfo().deviceManufacturer,
operatingSystem: DeviceInfo().operatingSystem,
hardware: DeviceInfo().hardware,
);
}
The ScreenProbe
is an example of a StreamProbe
that collects screen activity data by implementing the get stream
property, which maps screen events from the screen_state plugin to ScreenDatum
datum objects.
/// A probe collecting screen events:
/// - SCREEN ON
/// - SCREEN OFF
/// - SCREEN UNLOCK
/// which are stored as a [ScreenDatum].
///
/// This probe is only available on Android.
class ScreenProbe extends StreamProbe {
Screen screen = Screen();
@override
Stream<Datum>? get stream => (screen.screenStateStream != null)
? screen.screenStateStream!
.map((event) => ScreenDatum.fromScreenStateEvent(event))
: null;
}
Now that all packages classes have been designed, the final steps are to generate the json serialization helper classes (from the carp_serializable
Dart package) and to bundle the package together.
The files used in json serialization can be generated using build_runner
, by running the following command in the root of your Flutter project:
flutter packages pub run build_runner build --delete-conflicting-outputs
This will generate the necessary .g.dart
files.
Finally, all files should be bundled together in a Dart library. For example, the carp_device_package
package looks like this;
/// A library containing a sampling package for collecting information from the device hardware:
/// - device info
/// - battery
/// - screen
/// - free memory
library carp_device_package;
import 'dart:async';
import 'dart:io' show Platform;
import 'package:carp_core/carp_core.dart';
import 'package:carp_mobile_sensing/carp_mobile_sensing.dart';
import 'package:battery_plus/battery_plus.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:screen_state/screen_state.dart';
import 'package:system_info2/system_info2.dart';
part 'carp_device_package.g.dart';
part 'device_datum.dart';
part 'device_package.dart';
part 'device_probes.dart';
To use a sampling package, import it into your app together with the
carp_mobile_sensing
package:
import 'package:carp_core/carp_core.dart';
import 'package:carp_mobile_sensing/carp_mobile_sensing.dart';
import 'package: carp_device_package/carp_device_package.dart';
Before creating a study and running it, register this package in the SamplingPackageRegistry.
SamplingPackageRegistry().register(DeviceSamplingPackage());
A package can also be released as a Dart Package on Pub. We already provide a list of different sampling packages - both using the onboard phone sensors as well as external wearable devices and online services.
If a sampling package handles (i.e., collects data from) an external device (or service), then it should be able to specify what type of device it supports and provide corresponding DeviceManager
and DeviceDescriptor
classes.
For example, in the eSense sampling package, the ESenseSamplingPackage
implements the following two methods:
class ESenseSamplingPackage implements SamplingPackage {
static const String ESENSE_DEVICE_TYPE = 'esense';
// other parts of the package omitted
// the type of device supported
String get deviceType => ESENSE_DEVICE_TYPE;
// a device manager for this device
DeviceManager get deviceManager => ESenseDeviceManager();
}
The ESenseDevice device descriptor describes how an eSense device is to be configured:
/// A [DeviceDescriptor] for an eSense device used in a [StudyProtocol].
@JsonSerializable(fieldRename: FieldRename.none, includeIfNull: false)
class ESenseDevice extends DeviceDescriptor {
/// The type of a eSense device.
static const String DEVICE_TYPE =
'${DeviceDescriptor.DEVICE_NAMESPACE}.ESenseDevice';
/// The default rolename for a eSense device.
static const String DEFAULT_ROLENAME = 'esense';
/// The name of the eSense device.
/// Used for connecting to the eSense hardware device over BTLE.
/// eSense devices are typically named `eSense-xxxx`.
String? deviceName;
/// The sampling rate in Hz of getting sensor data from the device.
int? samplingRate;
ESenseDevice({
super.roleName = ESenseDevice.DEFAULT_ROLENAME,
this.deviceName,
this.samplingRate,
super.supportedDataTypes,
}) : super(
isMasterDevice: false,
);
@override
Function get fromJsonFunction => _$ESenseDeviceFromJson;
factory ESenseDevice.fromJson(Map<String, dynamic> json) => FromJsonFactory().fromJson(json) as ESenseDevice;
@override
Map<String, dynamic> toJson() => _$ESenseDeviceToJson(this);
}
Note again that since a device descriptor can be part of a study protocol, it needs to support JSON serialization.
The ESenseDeviceManager then implements the DeviceManager interface.
/// A [DeviceManager] for the eSense device.
class ESenseDeviceManager extends HardwareDeviceManager<DeviceRegistration, ESenseDevice> {
// the last known voltage level of the eSense device
double? _voltageLevel;
/// A handle to the [ESenseManager] plugin.
ESenseManager? manager;
@override
String get id => deviceDescriptor?.deviceName ?? 'eSense-????';
/// A estimate of the battery level of the eSense device.
@override
int? get batteryLevel => (_voltageLevel != null)
? ((1.19 * _voltageLevel! - 3.91) * 100).toInt()
: null;
@override
void onInitialize(DeviceDescriptor descriptor) {
if (deviceDescriptor?.deviceName == null ||
deviceDescriptor!.deviceName!.isEmpty) {
warning(
'Cannot initialize an $runtimeType with a null or empty device name. '
"Please specify a valid device name, typically on the form 'eSense-1234'");
}
}
@override
Future<bool> canConnect() async => (deviceDescriptor?.deviceName != null &&
deviceDescriptor!.deviceName!.isNotEmpty);
@override
Future<DeviceStatus> onConnect() async {
if (deviceDescriptor?.deviceName == null ||
deviceDescriptor!.deviceName!.isEmpty) return DeviceStatus.error;
manager = ESenseManager(id);
// listen for connection events
manager?.connectionEvents.listen((event) {
debug('$runtimeType - $event');
switch (event.type) {
case ConnectionType.connected:
status = DeviceStatus.connected;
// this is a hack! - don't know why, but the sensorEvents stream
// needs a kick in the ass to get started...
manager?.sensorEvents.listen(null);
// when connected, listen for battery events
manager!.eSenseEvents.listen((event) {
if (event is BatteryRead) {
_voltageLevel = event.voltage ?? 4;
}
});
// set up a timer that asks for the voltage level
Timer.periodic(const Duration(minutes: 2), (_) {
if (status == DeviceStatus.connected) {
manager?.getBatteryVoltage();
}
});
break;
case ConnectionType.unknown:
status = DeviceStatus.unknown;
break;
case ConnectionType.device_found:
status = DeviceStatus.paired;
break;
case ConnectionType.device_not_found:
case ConnectionType.disconnected:
status = DeviceStatus.disconnected;
_voltageLevel = null;
// _eventSubscription?.cancel();
break;
}
});
manager?.connect();
return DeviceStatus.connecting;
}
@override
Future<bool> onDisconnect() async => await manager?.disconnect() ?? false;
}
Once the eSense device manager is in place, the eSense probe is a straightforward stream probe.
/// Collects eSense button pressed events. It generates an [ESenseButtonDatum]
/// every time the button is pressed or released.
class ESenseButtonProbe extends StreamProbe {
@override
Stream<Datum>? get stream => (deviceManager.isConnected)
? deviceManager.manager!.eSenseEvents
.where((event) => event.runtimeType == ButtonEventChanged)
.map((event) => ESenseButtonDatum(
deviceName: deviceManager.manager!.deviceName,
pressed: (event as ButtonEventChanged).pressed))
.asBroadcastStream()
: null;
}
Both the ESenseDeviceManager
and the ESenseProbe
makes use of the ESenseManager
from the esense_flutter
Flutter Plugin.
CAMS comes with a set of build-in and external data managers - see Appendix C for an overview.
A new data manager can be created by implementing the DataManager
interface.
/// The [DataManager] interface is used to upload [DataPoint] objects to any
/// data manager that implements this interface.
abstract class DataManager {
/// The deployment using this data manager
MasterDeviceDeployment get deployment;
/// The ID of the study deployment that this manager is handling.
String get studyDeploymentId;
/// The type of this data manager as enumerated in [DataEndPointType].
String get type;
/// Initialize the data manager by specifying the study [deployment], the
/// [dataEndPoint], and the stream of [data] events to handle.
Future initialize(
MasterDeviceDeployment deployment,
DataEndPoint dataEndPoint,
Stream<DataPoint> data,
);
/// Close the data manager (e.g. closing connections).
Future close();
/// Stream of data manager events.
Stream<DataManagerEvent> get events;
/// On each data event from the data stream, the [onDataPoint] handler is called.
void onDataPoint(DataPoint dataPoint);
/// When the data stream closes, the [onDone] handler is called.
void onDone();
/// When an error event is send on the stream, the [onError] handler is called.
void onError(error);
}
The methods of how to handle data in the onDataPoint(DataPoint dataPoint)
method as well as errors and closing event has to be implemented.
The AbstractDataManager
class provides a useful class to start from.
For example, the ConsoleDataManager
provides a very simple example of a data manager that just prints the data.
/// A very simple data manager that just "uploads" the data to the
/// console (i.e., prints it). Used mainly for testing and debugging purposes.
class ConsoleDataManager extends AbstractDataManager {
@override
String get type => DataEndPointTypes.PRINT;
@override
Future<void> onDataPoint(DataPoint dataPoint) async =>
print(jsonEncode(dataPoint));
@override
Future<void> onDone() async {}
@override
Future<void> onError(error) async => print('ERROR >> $error');
@override
String toString() => 'JSON Print Data Manager';
}
When creating a new data manager, a data manager type must be defined (it can be any string). This type is used when specifying the DataEndPoint
type in the study protocol.
In order to lookup the right data manager based on the data endpoint type, the data manager needs to be registered in the DataManagerRegistry
.
DataManagerRegistry().register(ConsoleDataManager());
This should happen at app start-up and before the protocol is loaded and sensing is started.
Data transformation is supported by the DatumTransformerSchema
class and can be implemented by implementing the namespace
getter and the onRegister()
callback function.
As an example, the implementation of the Open mHealth transformer schema is shown below:
/// A default [DatumTransformerSchema] for Open mHealth (OMH) transformers
class OMHTransformerSchema extends DatumTransformerSchema {
String get namespace => NameSpace.OMH;
void onRegister() {}
}
Each transformer schema must be registered in the TransformerSchemaRegistry
(which is a singleton).
Hence, add the following line to you setup up part of the app:
TransformerSchemaRegistry().register(OMHTransformerSchema());
Once the schema is registered, transformers for each data type can be created.
Data transformation is a transformation of one type of datum to another type of datum. I.e. data transformation is defined by the DatumTransformer
typedef.
typedef DatumTransformer = Datum Function(Datum);
For each Datum
you want to transform, you need to define a new class that extends Datum
and implements TransformedDatum
.
For example the following OMHGeopositionDatum
class represents a OMH Geoposition
.
import 'package:openmhealth_schemas/openmhealth_schemas.dart' as omh;
class OMHGeopositionDatum extends Datum implements TransformedDatum {
static const DataFormat DATA_FORMAT = DataFormat(
omh.SchemaSupport.OMH_NAMESPACE, omh.SchemaSupport.GEOPOSITION);
DataFormat get format => DATA_FORMAT;
omh.Geoposition geoposition;
OMHGeopositionDatum(this.geoposition);
factory OMHGeopositionDatum.fromLocationDatum(LocationDatum location) =>
OMHGeopositionDatum(omh.Geoposition(
latitude: omh.PlaneAngleUnitValue(
unit: omh.PlaneAngleUnit.DEGREE_OF_ARC, value: location.latitude),
longitude: omh.PlaneAngleUnitValue(
unit: omh.PlaneAngleUnit.DEGREE_OF_ARC,
value: location.longitude),
positioningSystem: omh.PositioningSystem.GPS));
factory OMHGeopositionDatum.fromJson(Map<String, dynamic> json) =>
OMHGeopositionDatum(omh.Geoposition.fromJson(json));
Map<String, dynamic> toJson() => geoposition.toJson();
static DatumTransformer get transformer => ((datum) =>
OMHGeopositionDatum.fromLocationDatum(datum as LocationDatum));
}
The most important functions to implements is the transformer
getter and the toJson()
method. The transfomer returns a function that can transform one type of Datum
to this type of datum. The mapping between the two types of datums happens in the fromLocationDatum
factory. The toJson
method is needed in order to serialize and store the data. Also note that the namespace of the DataFormat
of this OMHGeopositionDatum
is OMH
.
Once the mapper Datum class is created, it must be added to the schema. This is done by calling;
TransformerSchemaRegistry()
.lookup(NameSpace.OMH)!
.add(LOCATION, OMHGeopositionDatum.transformer);
In the carp_context_package
, this is done in the onRegister()
method in the ContextSamplingPackage
.
Transformation of data can be done "manually" by looking up a specific transformer schema and apply its transformer. For example, the following code transform a LocationDatum
into a OMHGeopositionDatum
.
LocationDatum loc = LocationDatum()
..longitude = 12.23342
..latitude = 3.34224;
OMHGeopositionDatum geo = TransformerSchemaRegistry().lookup(NameSpace.OMH).transform(loc);
However, a more general and common use of transformers is to specify what data format the DataEndPoint
should use.
This is done via the dataFormat
property, which specified the namespace, like NameSpace.OMH
.
The data endpoint can then be added to the study runtime, when started:
await controller.configure(
dataEndPoint: FileDataEndPoint(
bufferSize: 50 * 1000,
dataFormat: NameSpace.OMH,
),
privacySchemaName: PrivacySchema.DEFAULT,
);
It is also possible to specify the data endpoint and its format as part of the study protocol:
SmartphoneStudyProtocol protocol = SmartphoneStudyProtocol(
ownerId: 'user@dtu.dk',
name: 'Track patient movement',
dataEndPoint: FileDataEndPoint(
bufferSize: 50 * 1000,
dataFormat: NameSpace.OMH,
),
);
A special instance of a data transformer schema is the PrivacySchema
which basically takes a datum and privacy protects relevant data properties. CARP Mobile Sensing comes with a built-in schema with a transformer namespace specified in PrivacySchema.DEFAULT
.
Hence, in order to add privacy protection, two things has to be implemented;
(i) a transformer function that knows how to transform a datum so that data is privacy protected, and
(ii) register this funtion in the TransformerSchemaRegistry
.
A function that anonymize a text message datum may look like this:
part of communication;
/// A [TextMessage] anonymizer function. Anonymizes:
/// - address
/// - body
TextMessage text_message_anoymizer(TextMessage msg) {
msg.address = sha1.convert(utf8.encode(msg.address)).toString();
msg.body = sha1.convert(utf8.encode(msg.body)).toString();
return msg;
}
This function can be put anywhere in the sampling package, but a typical place to to put it is in a separate file (if you have many functions) or in the package dart file (if you only have a few).
Registering of the anonymizer function is done with this statement:
TransformerSchemaRegistry().lookup(PrivacySchema.DEFAULT).add(TEXT_MESSAGE, text_message_datum_anoymizer);
This is done as part of the onRegister()
callback in the package, which would look like this:
void onRegister() {
FromJsonFactory().registerFromJsonFunction("PhoneLogMeasure", PhoneLogMeasure.fromJsonFunction);
FromJsonFactory().registerFromJsonFunction("CalendarMeasure", CalendarMeasure.fromJsonFunction);
TransformerSchemaRegistry().lookup(PrivacySchema.DEFAULT).add(TEXT_MESSAGE, text_message_datum_anoymizer);
TransformerSchemaRegistry().lookup(PrivacySchema.DEFAULT).add(TEXT_MESSAGE_LOG, text_message_log_anoymizer);
TransformerSchemaRegistry().lookup(PrivacySchema.DEFAULT).add(PHONE_LOG, phone_log_anoymizer);
TransformerSchemaRegistry().lookup(PrivacySchema.DEFAULT).add(CALENDAR, calendar_anoymizer);
}
The example above is implemented as part of the carp_communication_package
package.
It is good practice to consider if your data sampling packages needs to supply privacy protecting functions, and if so, add these to the default privacy schema.