Data classes
Define the classes that hold the values of your measurements, and give each one the JSON type of its measure.
A data class holds the values of one measurement and serializes them to JSON. Each measure type has its own data class, a subclass of Data, whose JSON type must be the measure type. CARP Mobile Sensing (CAMS) stores, uploads and decodes measurements by that type.
Write the data classes
The example package has two data classes. AirPressure holds one barometer reading, and AirPressureSummary holds the count, mean, minimum and maximum of the readings in one sampling window. Replace the contents of lib/barometer_data.dart with both classes:
Now run dart run build_runner build in the package folder: the functions whose names start with _$ do not exist until the generator writes them. AirPressureSummary.fromReadings() expects at least one reading, and the summary probe never calls it with an empty list.
Members of a data class
Every data class in a CARP package has the same members:
| Member | What it does |
|---|---|
@JsonSerializable(includeIfNull: false, explicitToJson: true) | Makes json_serializable generate _$AirPressureFromJson and _$AirPressureToJson. These options are the CARP convention. See Generated code. |
A constructor that calls super() | Runs the Serializable constructor, which sets the __type value from jsonType. It must work with placeholder values, because the package registers a placeholder instance. |
jsonType | Returns the measure type, which becomes __type in the JSON and the data type of each measurement. |
fromJsonFunction | Returns the generated _$AirPressureFromJson, which FromJsonFactory stores and calls to decode this type. |
fromJson factory | Decodes through FromJsonFactory().fromJson<AirPressure>(json), so decoding an unregistered type throws a SerializationException. |
toJson() | Returns the generated _$AirPressureToJson(this). |
Most mistakes in these members compile and show only at runtime:
- No
fromJsonFunctionortoJson()override:Dataimplements both, so the class compiles. WithouttoJson(), its JSON holds only__type; withoutfromJsonFunction, its JSON decodes as a plainDataobject without the values. - A
fromJsonFunctionthat returns thefromJsonfactory:FromJsonFactorythen calls itself until the app throws aStackOverflowError. Return the generated function. - A
jsonTypethat reads a field: theSerializableconstructor readsjsonTypeonce, before the subclass constructor body runs. Later changes to the field do not change__type, so return a constant. - A plugin object as a field:
json_serializablecannot serialize a plugin class without JSON methods, and the placeholder instance would need a plugin object. Copy plugin values into plain fields, asAirPressurecopies thepressureof aBarometerEvent. Put a conversion in a named constructor, such asApp.fromAppInfoincarp_apps_package. - A field named like an inherited member, such as
jsonTypeorrecordId: the field replaces that member. AStringfield or astatic constnameddataTypedoes not compile at all. A field namedtypeworks, because the JSON key of the class type is__type.
Two optional members change how CAMS treats the data:
| Member | Default | Override it when |
|---|---|---|
equivalentTo() | Returns false | A SamplingEventTrigger must fire on specific values of your data. With the default, a trigger with a condition never fires. |
recordId | null | The source has a stable ID for each record. The SQLite data manager then drops later measurements with the same ID, so return only an ID that is unique per record. |
Return the measure type from jsonType
By default, jsonType is dk.cachet.carp. followed by the class name in lowercase. Without its override, AirPressure would have the type dk.cachet.carp.airpressure, not com.example.airpressure.
The jsonType string is written to __type and is the data type of every measurement that holds the object. CAMS and CARP Web Services (CAWS) match data by it, so a data type that differs from the measure type breaks four things:
- Upload to CAWS: the CAWS data manager uploads only data whose type is one of the measure types in the deployment. Data of another type stays in the buffer on the phone.
- Privacy functions and data formats: packages register them under the measure type, but CAMS looks them up by the data type. They never run.
- Triggers and app code: a
SamplingEventTriggerandmeasurementsByType()of the study controller filter by the data type, so they never see the data. - Local storage: the SQLite data manager stores each measurement under its data type.
Override jsonType in every data class to return its measure type constant, as both example classes do. Give each class its own type. FromJsonFactory keeps one decoder per type, so the JSON of two classes with the same type decodes as the class registered last.
A test catches a type that does not match. This one is from test/package_test.dart in the example package:
Choose a base class
| Base class | Use it for | Examples |
|---|---|---|
Data | Most measures | Apps in carp_apps_package, Noise in carp_audio_package |
SensorData | A reading from a sensor. It adds a sensorSpecificData field for extra values from one sensor model, left out of the JSON when it is null. | AirPressure, AmbientLight in CAMS, Acceleration in carp_core |
FileData | A file on the phone, such as a recording. The CAWS data manager also uploads the file, unless upload is false. | MediaData in carp_audio_package |
The example uses SensorData because both measures come from the barometer. With Data as the base class, the generated code has no sensorSpecificData field and works the same way.
Field types and units
- Units: keep the unit of the source and name it in the field's doc comment.
sensors_plusreports air pressure in hectopascals, so the probe copies the value without converting it. DateTime: the JSON holds an ISO 8601 string, with aZonly for a UTC value. A local value decodes as local time in the time zone of the device that decodes it. Convert values withtoUtc()when you create the object.Duration: the JSON holds a whole number of microseconds.- Enums: the JSON holds the value's name, and decoding an unknown name throws an
ArgumentError. Do not rename enum values after a release, and copy plugin enums into your own enum or a string. double:jsonEncodecannot encodeNaNor infinity, and the SQLite data manager then loses the measurement with an unhandled error. Do not create data with these values, for example a mean of zero readings.
Generated code
dart run build_runner build writes these two functions for AirPressure to lib/barometer_sampling_package.g.dart:
The two options of the annotation show in the highlighted lines:
includeIfNull: false: the?before a value leaves out its key when the value isnull. Anullfield such assensorSpecificDatatherefore does not appear in the JSON. These null-aware elements need Dart 3.8 or later.explicitToJson: true: nested objects are converted with their owntoJson(). Without it,toJson()returns the nested Dart objects;jsonEncodestill writes the same JSON, but code that reads the map gets objects instead of maps.
Decoding restores __type from the JSON and decodes sensorSpecificData through Data.fromJson(), which again needs a registered class. Do not edit the generated file; generate it again after each change to a data class.
Register the data classes
CAMS decodes a data class only after it is registered in FromJsonFactory. The package class registers both data classes, with placeholder values, in onRegister() of lib/barometer_package.dart:
Collection works without the registration, but reading the data back does not. The CAWS data manager, for example, decodes the buffered measurements of all measures before it uploads them. One unregistered data class therefore makes every upload attempt of the study fail with a SerializationException, while the phone keeps collecting and storing data.
For the rest of onRegister(), see Register the JSON classes and privacy functions.
Timestamps
A probe wraps each data object in a Measurement with its timestamps. sensorStartTime and the optional sensorEndTime are microseconds since the epoch, which do not depend on the time zone:
Measurement.fromData() sets sensorStartTime to the phone's current time, that is, when the reading reaches the probe. AirPressureProbe creates one measurement per reading:
Measurement.fromData() has no end time, so data that covers an interval uses the constructor. AirPressureSummaryProbe records the start and end of each window in microseconds:
Three rules apply to timestamps:
- When the source reports when it took a reading, pass that time in microseconds as the second argument of
Measurement.fromData(). A value in milliseconds would date the data to January 1970. - Use one clock for measures that belong together. The example uses the phone's time for both measures, so the times of readings and windows are comparable.
- Set
sensorEndTimefor every measure that the package declares asDataTimeType.TIME_SPAN. CAMS does not set it from the declaration: theBLUETOOTHmeasure ofcarp_connectivity_package, for example, declaresTIME_SPANbut sets only a start time.
Example data
Formatted, the measurements of the two measure types look like this:
For the measurement envelope around the data, see Measurements and data.
Next step
Probes
Choose a probe base class and collect the data from the sensor.