use std::fmt;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::{CollectionName, Extra, Meta, Timestamp, de, impl_document, time};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum EventType {
Unspecified,
BgCheck,
SnackBolus,
MealBolus,
CorrectionBolus,
CarbCorrection,
ComboBolus,
BolusWizard,
Announcement,
Note,
Question,
Exercise,
SiteChange,
SensorStart,
SensorChange,
SensorStop,
PumpBatteryChange,
InsulinChange,
TempBasal,
TempBasalStart,
TempBasalEnd,
ProfileSwitch,
EffectiveProfileSwitch,
DadAlert,
TemporaryTarget,
TemporaryTargetCancel,
OpenApsOffline,
TemporaryOverride,
TemporaryOverrideCancel,
RemoteCarbsEntry,
RemoteBolusEntry,
SuspendPump,
ResumePump,
Other(String),
}
const EVENT_TYPES: &[(EventType, &str)] = &[
(EventType::Unspecified, "<none>"),
(EventType::BgCheck, "BG Check"),
(EventType::SnackBolus, "Snack Bolus"),
(EventType::MealBolus, "Meal Bolus"),
(EventType::CorrectionBolus, "Correction Bolus"),
(EventType::CarbCorrection, "Carb Correction"),
(EventType::ComboBolus, "Combo Bolus"),
(EventType::BolusWizard, "Bolus Wizard"),
(EventType::Announcement, "Announcement"),
(EventType::Note, "Note"),
(EventType::Question, "Question"),
(EventType::Exercise, "Exercise"),
(EventType::SiteChange, "Site Change"),
(EventType::SensorStart, "Sensor Start"),
(EventType::SensorChange, "Sensor Change"),
(EventType::SensorStop, "Sensor Stop"),
(EventType::PumpBatteryChange, "Pump Battery Change"),
(EventType::InsulinChange, "Insulin Change"),
(EventType::TempBasal, "Temp Basal"),
(EventType::TempBasalStart, "Temp Basal Start"),
(EventType::TempBasalEnd, "Temp Basal End"),
(EventType::ProfileSwitch, "Profile Switch"),
(
EventType::EffectiveProfileSwitch,
"Effective Profile Switch",
),
(EventType::DadAlert, "D.A.D. Alert"),
(EventType::TemporaryTarget, "Temporary Target"),
(EventType::TemporaryTargetCancel, "Temporary Target Cancel"),
(EventType::OpenApsOffline, "OpenAPS Offline"),
(EventType::TemporaryOverride, "Temporary Override"),
(
EventType::TemporaryOverrideCancel,
"Temporary Override Cancel",
),
(EventType::RemoteCarbsEntry, "Remote Carbs Entry"),
(EventType::RemoteBolusEntry, "Remote Bolus Entry"),
(EventType::SuspendPump, "Suspend Pump"),
(EventType::ResumePump, "Resume Pump"),
];
impl EventType {
#[must_use]
pub fn parse(s: &str) -> Self {
EVENT_TYPES
.iter()
.find(|(_, name)| *name == s)
.map_or_else(|| Self::Other(s.to_owned()), |(ty, _)| ty.clone())
}
#[must_use]
pub fn as_str(&self) -> &str {
if let Self::Other(s) = self {
return s;
}
EVENT_TYPES
.iter()
.find(|(ty, _)| ty == self)
.map_or("", |(_, name)| name)
}
#[must_use]
pub const fn is_bolus(&self) -> bool {
matches!(
self,
Self::SnackBolus
| Self::MealBolus
| Self::CorrectionBolus
| Self::ComboBolus
| Self::BolusWizard
)
}
}
impl fmt::Display for EventType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl Serialize for EventType {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for EventType {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
String::deserialize(d).map(|s| Self::parse(&s))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Treatment {
#[serde(flatten)]
pub meta: Meta,
#[serde(
rename = "eventType",
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "de::tolerant"
)]
pub event_type: Option<EventType>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "time::iso")]
pub created_at: Option<Timestamp>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "time::millis"
)]
pub date: Option<Timestamp>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "de::text")]
pub device: Option<String>,
#[serde(
rename = "enteredBy",
default,
skip_serializing_if = "Option::is_none",
with = "de::text"
)]
pub entered_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "de::text")]
pub notes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "de::text")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "de::num")]
pub glucose: Option<f64>,
#[serde(
rename = "glucoseType",
default,
skip_serializing_if = "Option::is_none",
with = "de::text"
)]
pub glucose_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "de::text")]
pub units: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "de::num")]
pub carbs: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "de::num")]
pub protein: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "de::num")]
pub fat: Option<f64>,
#[serde(
rename = "absorptionTime",
default,
skip_serializing_if = "Option::is_none",
with = "de::num"
)]
pub absorption_time: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "de::num")]
pub insulin: Option<f64>,
#[serde(
rename = "isSMB",
default,
skip_serializing_if = "Option::is_none",
with = "de::flag"
)]
pub is_smb: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "de::num")]
pub duration: Option<f64>,
#[serde(
rename = "durationInMilliseconds",
default,
skip_serializing_if = "Option::is_none",
with = "de::int"
)]
pub duration_in_milliseconds: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "de::num")]
pub absolute: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "de::num")]
pub rate: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "de::num")]
pub percent: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "de::num")]
pub relative: Option<f64>,
#[serde(
rename = "targetTop",
default,
skip_serializing_if = "Option::is_none",
with = "de::num"
)]
pub target_top: Option<f64>,
#[serde(
rename = "targetBottom",
default,
skip_serializing_if = "Option::is_none",
with = "de::num"
)]
pub target_bottom: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "de::text")]
pub profile: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "de::num")]
pub percentage: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none", with = "de::num")]
pub timeshift: Option<f64>,
#[serde(
rename = "profileJson",
default,
skip_serializing_if = "Option::is_none",
with = "de::text"
)]
pub profile_json: Option<String>,
#[serde(
rename = "splitNow",
default,
skip_serializing_if = "Option::is_none",
with = "de::num"
)]
pub split_now: Option<f64>,
#[serde(
rename = "splitExt",
default,
skip_serializing_if = "Option::is_none",
with = "de::num"
)]
pub split_ext: Option<f64>,
#[serde(
rename = "preBolus",
default,
skip_serializing_if = "Option::is_none",
with = "de::num"
)]
pub pre_bolus: Option<f64>,
#[serde(
rename = "isAnnouncement",
default,
skip_serializing_if = "Option::is_none",
with = "de::flag"
)]
pub is_announcement: Option<bool>,
#[serde(
rename = "sensorCode",
default,
skip_serializing_if = "Option::is_none",
with = "de::text"
)]
pub sensor_code: Option<String>,
#[serde(
rename = "transmitterId",
default,
skip_serializing_if = "Option::is_none",
with = "de::text"
)]
pub transmitter_id: Option<String>,
#[serde(
rename = "pumpId",
default,
skip_serializing_if = "Option::is_none",
with = "de::int"
)]
pub pump_id: Option<i64>,
#[serde(
rename = "pumpType",
default,
skip_serializing_if = "Option::is_none",
with = "de::text"
)]
pub pump_type: Option<String>,
#[serde(
rename = "pumpSerial",
default,
skip_serializing_if = "Option::is_none",
with = "de::text"
)]
pub pump_serial: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub boluscalc: Option<serde_json::Value>,
#[serde(flatten)]
pub extra: Extra,
}
impl Treatment {
#[must_use]
pub fn new(event_type: EventType) -> Self {
Self {
meta: Meta::default(),
event_type: Some(event_type),
created_at: Some(Timestamp::now()),
date: None,
device: None,
entered_by: None,
notes: None,
reason: None,
glucose: None,
glucose_type: None,
units: None,
carbs: None,
protein: None,
fat: None,
absorption_time: None,
insulin: None,
is_smb: None,
duration: None,
duration_in_milliseconds: None,
absolute: None,
rate: None,
percent: None,
relative: None,
target_top: None,
target_bottom: None,
profile: None,
percentage: None,
timeshift: None,
profile_json: None,
split_now: None,
split_ext: None,
pre_bolus: None,
is_announcement: None,
sensor_code: None,
transmitter_id: None,
pump_id: None,
pump_type: None,
pump_serial: None,
boluscalc: None,
extra: Extra::new(),
}
}
#[must_use]
pub fn bolus(units: f64) -> Self {
Self {
insulin: Some(units),
..Self::new(EventType::CorrectionBolus)
}
}
#[must_use]
pub fn meal_bolus(units: f64, carbs: f64) -> Self {
Self {
insulin: Some(units),
carbs: Some(carbs),
..Self::new(EventType::MealBolus)
}
}
#[must_use]
pub fn carbs(grams: f64) -> Self {
Self {
carbs: Some(grams),
..Self::new(EventType::CarbCorrection)
}
}
#[must_use]
pub fn temp_basal_absolute(rate: f64, minutes: f64) -> Self {
Self {
absolute: Some(rate),
duration: Some(minutes),
..Self::new(EventType::TempBasal)
}
}
#[must_use]
pub fn temp_basal_percent(percent: f64, minutes: f64) -> Self {
Self {
percent: Some(percent),
duration: Some(minutes),
..Self::new(EventType::TempBasal)
}
}
#[must_use]
pub fn temp_target(bottom: f64, top: f64, minutes: f64) -> Self {
Self {
target_bottom: Some(bottom),
target_top: Some(top),
duration: Some(minutes),
units: Some("mg/dl".into()),
..Self::new(EventType::TemporaryTarget)
}
}
#[must_use]
pub fn profile_switch(profile: impl Into<String>) -> Self {
Self {
profile: Some(profile.into()),
duration: Some(0.0),
..Self::new(EventType::ProfileSwitch)
}
}
#[must_use]
pub fn bg_check(glucose: f64, units: super::Units, finger: bool) -> Self {
Self {
glucose: Some(glucose),
glucose_type: Some(if finger { "Finger" } else { "Sensor" }.into()),
units: Some(units.as_str().into()),
..Self::new(EventType::BgCheck)
}
}
#[must_use]
pub fn note(text: impl Into<String>) -> Self {
Self {
notes: Some(text.into()),
..Self::new(EventType::Note)
}
}
#[must_use]
pub fn announcement(text: impl Into<String>) -> Self {
Self {
notes: Some(text.into()),
..Self::new(EventType::Announcement)
}
}
#[must_use]
pub fn exercise(minutes: f64) -> Self {
Self {
duration: Some(minutes),
..Self::new(EventType::Exercise)
}
}
#[must_use]
pub fn site_change() -> Self {
Self::new(EventType::SiteChange)
}
#[must_use]
pub fn sensor_start() -> Self {
Self::new(EventType::SensorStart)
}
#[must_use]
pub fn sensor_change() -> Self {
Self::new(EventType::SensorChange)
}
#[must_use]
pub fn insulin_change() -> Self {
Self::new(EventType::InsulinChange)
}
#[must_use]
pub fn pump_battery_change() -> Self {
Self::new(EventType::PumpBatteryChange)
}
#[must_use]
pub fn at(mut self, time: impl Into<Timestamp>) -> Self {
self.created_at = Some(time.into());
self.date = None;
self
}
#[must_use]
pub fn with_notes(mut self, notes: impl Into<String>) -> Self {
self.notes = Some(notes.into());
self
}
#[must_use]
pub fn with_entered_by(mut self, who: impl Into<String>) -> Self {
self.entered_by = Some(who.into());
self
}
#[must_use]
pub fn with_device(mut self, device: impl Into<String>) -> Self {
self.device = Some(device.into());
self
}
#[must_use]
pub const fn with_duration(mut self, minutes: f64) -> Self {
self.duration = Some(minutes);
self
}
#[must_use]
pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
self.reason = Some(reason.into());
self
}
#[must_use]
pub fn time(&self) -> Option<Timestamp> {
self.created_at.or(self.date)
}
#[must_use]
pub fn duration_minutes(&self) -> Option<f64> {
self.duration
.or_else(|| self.duration_in_milliseconds.map(|ms| ms as f64 / 60_000.0))
}
#[must_use]
pub fn kind(&self) -> TreatmentKind<'_> {
let Some(event) = &self.event_type else {
return self.untyped_kind();
};
match event {
EventType::TempBasal | EventType::TempBasalStart => TreatmentKind::TempBasal {
rate: self.absolute.or(self.rate),
percent: self.percent,
minutes: self.duration_minutes(),
},
EventType::TempBasalEnd => TreatmentKind::TempBasal {
rate: None,
percent: None,
minutes: Some(0.0),
},
EventType::TemporaryTarget => TreatmentKind::TempTarget {
bottom: self.target_bottom,
top: self.target_top,
minutes: self.duration_minutes(),
reason: self.reason.as_deref(),
},
EventType::TemporaryTargetCancel => TreatmentKind::TempTarget {
bottom: None,
top: None,
minutes: Some(0.0),
reason: self.reason.as_deref(),
},
EventType::ProfileSwitch | EventType::EffectiveProfileSwitch => {
TreatmentKind::ProfileSwitch {
profile: self.profile.as_deref(),
percentage: self.percentage,
timeshift: self.timeshift,
minutes: self.duration_minutes(),
}
}
EventType::BgCheck => TreatmentKind::BgCheck {
glucose: self.glucose,
units: self.units.as_deref(),
},
EventType::SiteChange => TreatmentKind::SiteChange,
EventType::SensorStart | EventType::SensorChange => TreatmentKind::SensorChange,
EventType::InsulinChange => TreatmentKind::InsulinChange,
EventType::PumpBatteryChange => TreatmentKind::PumpBatteryChange,
EventType::Note | EventType::Announcement | EventType::Question => {
TreatmentKind::Note {
text: self.notes.as_deref(),
}
}
EventType::Exercise => TreatmentKind::Exercise {
minutes: self.duration_minutes(),
},
_ => self.untyped_kind(),
}
}
fn untyped_kind(&self) -> TreatmentKind<'_> {
match (
self.insulin.filter(|i| *i > 0.0),
self.carbs.filter(|c| *c > 0.0),
) {
(Some(insulin), carbs) => TreatmentKind::Bolus {
insulin,
carbs,
smb: self.is_smb.unwrap_or(false),
},
(None, Some(grams)) => TreatmentKind::Carbs { grams },
(None, None) => TreatmentKind::Other,
}
}
fn fill_defaults(&mut self, app: &str) {
self.device.get_or_insert_with(|| app.to_owned());
match (self.created_at, self.date) {
(None, None) => {
let now = Timestamp::now();
self.created_at = Some(now);
self.date = Some(now);
}
(Some(t), None) => self.date = Some(t),
(None, Some(t)) => self.created_at = Some(t),
(Some(_), Some(_)) => {}
}
}
}
impl_document!(
Treatment,
CollectionName::Treatments,
timestamp = |s| s.time(),
device = |s| s.device.as_deref(),
event_type = |s| s.event_type.as_ref().map(EventType::as_str)
);
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum TreatmentKind<'a> {
Bolus {
insulin: f64,
carbs: Option<f64>,
smb: bool,
},
Carbs {
grams: f64,
},
TempBasal {
rate: Option<f64>,
percent: Option<f64>,
minutes: Option<f64>,
},
TempTarget {
bottom: Option<f64>,
top: Option<f64>,
minutes: Option<f64>,
reason: Option<&'a str>,
},
ProfileSwitch {
profile: Option<&'a str>,
percentage: Option<f64>,
timeshift: Option<f64>,
minutes: Option<f64>,
},
BgCheck {
glucose: Option<f64>,
units: Option<&'a str>,
},
SiteChange,
SensorChange,
InsulinChange,
PumpBatteryChange,
Note {
text: Option<&'a str>,
},
Exercise {
minutes: Option<f64>,
},
Other,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_types_round_trip() {
for (ty, name) in EVENT_TYPES {
assert_eq!(EventType::parse(name), *ty);
assert_eq!(ty.as_str(), *name);
}
assert_eq!(EventType::parse("Custom Thing").as_str(), "Custom Thing");
}
#[test]
fn aaps_smb_round_trips_losslessly() {
let raw = serde_json::json!({
"identifier": "4e1c9a3c-0b4b-4c1e-9d6a-3c3a1f2b9e11",
"eventType": "Correction Bolus",
"created_at": "2024-05-01T12:00:00.000Z",
"date": 1714564800000_i64,
"utcOffset": 120,
"app": "AAPS",
"insulin": 0.35,
"type": "SMB",
"isSMB": true,
"pumpId": 4102,
"pumpType": "OMNIPOD_DASH",
"pumpSerial": "4241",
"isValid": true,
"srvModified": 1714564801234_i64,
"srvCreated": 1714564801234_i64,
"subject": "aaps"
});
let t: Treatment = serde_json::from_value(raw.clone()).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(
t.kind(),
TreatmentKind::Bolus {
insulin: 0.35,
carbs: None,
smb: true
}
);
assert_eq!(serde_json::to_value(&t).ok(), Some(raw));
}
#[test]
fn careportal_nulls_do_not_break_decoding() {
let t: Treatment = serde_json::from_value(serde_json::json!({
"_id": "65f1c2a8e4b0a1b2c3d4e5f6",
"eventType": "Temp Basal",
"created_at": "2024-05-01T12:00:00.000Z",
"absolute": "0.85",
"duration": 30,
"carbs": null,
"insulin": null
}))
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(
t.kind(),
TreatmentKind::TempBasal {
rate: Some(0.85),
percent: None,
minutes: Some(30.0)
}
);
}
}