@@ -5,12 +5,14 @@ import io.aaps.copilot.data.repository.HistoryJson
55import io.aaps.copilot.data.repository.LocalNightscoutV3Store
66import io.aaps.copilot.data.repository.LocalV3RetainedSnapshot
77import java.security.MessageDigest
8+ import java.time.Instant
89import java.util.Collections
910import kotlinx.serialization.json.*
1011
1112enum class AapsV3ObservedInsulinStatus { UNCHECKED , OBSERVED , PARTIAL_CONTEXT , NOT_OBSERVED , INVALID , CONFLICTING , LATE_EVIDENCE , LIMIT_REACHED , ERROR }
1213
1314internal enum class AapsV3TempBasalType { NORMAL , EMULATED_PUMP_SUSPEND , PUMP_SUSPEND , SUPERBOLUS , FAKE_EXTENDED }
15+ internal enum class AapsV3InsulinAnchorKind { UNSPECIFIED_EVENT , COMBO_BOLUS_START_UNVERIFIED , REPORTED_INTERVAL_START }
1416
1517internal sealed class AapsV3InsulinDose {
1618 data class Bolus (val units : Double , val type : String , val basalInsulin : Boolean ,
@@ -25,7 +27,9 @@ internal sealed class AapsV3InsulinDose {
2527internal data class AapsV3InsulinObservation (
2628 val identifier : String , val eventAtMs : Long , val availableAtMs : Long ,
2729 val createdRevision : Long , val modifiedRevision : Long , val invalidated : Boolean ,
28- val pumpRecordIdentity : String? , val pumpEndId : Long? , val dose : AapsV3InsulinDose
30+ val pumpRecordIdentity : String? , val pumpEndId : Long? , val dose : AapsV3InsulinDose ,
31+ val recordedUtcOffsetMinutes : Int? = null ,
32+ val anchorKind : AapsV3InsulinAnchorKind = AapsV3InsulinAnchorKind .UNSPECIFIED_EVENT
2933) {
3034 val endAtMs: Long get() = eventAtMs + when (dose) {
3135 is AapsV3InsulinDose .Bolus -> 0
@@ -44,7 +48,8 @@ internal class AapsV3InsulinRead(val status: AapsV3ObservedInsulinStatus, val re
4448
4549internal class AapsV3InsulinInventoryTimeline (snapshot : LocalV3RetainedSnapshot ) {
4650 val revision = snapshot.collectionRevision
47- private data class Entry (val atMs : Long , val observation : AapsV3InsulinObservation ? )
51+ private data class Entry (val atMs : Long , val observation : AapsV3InsulinObservation ? , val conflictingTime : Boolean = false )
52+ private data class RecordedClock (val atMs : Long , val offsetMinutes : Int? )
4853 private val entries: List <Entry >
4954 private val failure: AapsV3ObservedInsulinStatus ?
5055
@@ -68,8 +73,17 @@ internal class AapsV3InsulinInventoryTimeline(snapshot: LocalV3RetainedSnapshot)
6873 val dose = dose(body) ? : return @mapNotNull null
6974 val identityBody = if (dose is AapsV3InsulinDose .Extended && string(body, " eventType" ) == " Temp Basal" )
7075 HistoryJson .obj(body.getValue(" extendedEmulated" )) else body
71- val at = HistoryJson .long(body.getValue(" date" ))
72- require(at == row.eventAtMs && at in 1 .. LocalNightscoutV3Store .MAX_TIME )
76+ val clock = try {
77+ val outer = recordedClock(body)
78+ require(outer.atMs == row.eventAtMs)
79+ if (identityBody == = body) outer else recordedClock(identityBody).also { child ->
80+ require(child.atMs == outer.atMs)
81+ require(child.offsetMinutes == null || outer.offsetMinutes == null || child.offsetMinutes == outer.offsetMinutes)
82+ }
83+ } catch (_: Exception ) {
84+ return @mapNotNull Entry (checkNotNull(row.eventAtMs), null , conflictingTime = true )
85+ }
86+ val at = clock.atMs
7387 val duration = when (dose) {
7488 is AapsV3InsulinDose .Bolus -> 0
7589 is AapsV3InsulinDose .TempBasal -> dose.durationMs
@@ -79,7 +93,12 @@ internal class AapsV3InsulinInventoryTimeline(snapshot: LocalV3RetainedSnapshot)
7993 Entry (at, AapsV3InsulinObservation (row.identifier, at, row.modifiedRevision,
8094 row.createdRevision, row.modifiedRevision, row.deleted || boolean(body, " isValid" ) == false ||
8195 boolean(identityBody, " isValid" ) == false ,
82- pumpIdentity(identityBody, dose), identityBody[" endId" ]?.takeUnless { it == JsonNull }?.let (HistoryJson ::long), dose))
96+ pumpIdentity(identityBody, dose), identityBody[" endId" ]?.takeUnless { it == JsonNull }?.let (HistoryJson ::long), dose,
97+ clock.offsetMinutes, when {
98+ dose !is AapsV3InsulinDose .Bolus -> AapsV3InsulinAnchorKind .REPORTED_INTERVAL_START
99+ string(identityBody, " pumpType" ) == " ACCU_CHEK_COMBO" -> AapsV3InsulinAnchorKind .COMBO_BOLUS_START_UNVERIFIED
100+ else -> AapsV3InsulinAnchorKind .UNSPECIFIED_EVENT
101+ }))
83102 } catch (_: Exception ) {
84103 Entry (checkNotNull(row.eventAtMs), null )
85104 }
@@ -93,6 +112,8 @@ internal class AapsV3InsulinInventoryTimeline(snapshot: LocalV3RetainedSnapshot)
93112 failure?.let { return unavailable(it) }
94113 // Latest retained versions cannot recover a record moved outside this earlier query.
95114 if (revision > knownAtMs) return unavailable(AapsV3ObservedInsulinStatus .LATE_EVIDENCE )
115+ // A contradictory anchor cannot establish that its delivery lies outside this window.
116+ if (entries.any { it.conflictingTime }) return unavailable(AapsV3ObservedInsulinStatus .INVALID )
96117 val relevant = entries.filter { it.atMs <= atMs &&
97118 (it.observation == null || it.atMs >= fromMs || it.observation.endAtMs > fromMs) }
98119 if (relevant.any { it.observation == null }) return unavailable(AapsV3ObservedInsulinStatus .INVALID )
@@ -195,6 +216,22 @@ internal class AapsV3InsulinInventoryTimeline(snapshot: LocalV3RetainedSnapshot)
195216 return MessageDigest .getInstance(" SHA-256" ).digest(identity.toByteArray()).joinToString(" " ) { " %02x" .format(it.toInt() and 255 ) }
196217 }
197218
219+ private fun recordedClock (body : JsonObject ): RecordedClock {
220+ val at = HistoryJson .long(body.getValue(" date" ))
221+ require(at in 1 .. LocalNightscoutV3Store .MAX_TIME )
222+ for (key in listOf (" mills" , " timestamp" )) {
223+ body[key]?.takeUnless { it == JsonNull }?.let { require(HistoryJson .long(it) == at) }
224+ }
225+ string(body, " created_at" )?.let {
226+ val instant = Instant .parse(it)
227+ require(instant.nano % 1_000_000 == 0 && instant.toEpochMilli() == at)
228+ }
229+ val offset = body[" utcOffset" ]?.takeUnless { it == JsonNull }?.let {
230+ HistoryJson .signedLong(it).also { value -> require(value in - 1440 .. 1440 ) }.toInt()
231+ }
232+ return RecordedClock (at, offset)
233+ }
234+
198235 private fun number (body : JsonObject , key : String ) = body[key]?.takeUnless { it == JsonNull }?.let (HistoryJson ::double)
199236 private fun string (body : JsonObject , key : String ) = body[key]?.takeUnless { it == JsonNull }?.let (HistoryJson ::string)
200237 private fun boolean (body : JsonObject , key : String ) = body[key]?.takeUnless { it == JsonNull }?.let (HistoryJson ::bool)
0 commit comments