diff --git a/README.md b/README.md index 82c4b53..a34dfa2 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Fake pod implementation * This version also mimics a behavior we see in some DASH pods where the pod disconnects every 3 minutes; this can be used with iOS hooks to make a heartbeat to run Loop in situations where a BLE CGM is not available. -* It serves a control panel at http://:8080/ (`-api` changes the address): the pod's reservoir, delivery, age, alerts and fault, with controls to set the reservoir and pod age, raise alerts, trigger or clear a fault, and crash on the next command to test unacknowledged commands. The panel drives a JSON API: `GET /status`, and `POST /command` with `{"command": "changeReservoir", "value": 45}` (also `setActiveTime` in minutes, `setAlerts` as a slot mask, `setFault` with a fault code, 0 to clear, `crashNextCommand` with `"beforeProcessing": true|false`, `ignoreNextMessages` with a count, which keeps the link up but leaves that many messages unanswered, as a pod that didn't hear them would, and `failNextConnects` with a count and an optional `hangUpAfter` in seconds, 7 by default, which leaves service discovery unanswered on that many connects and then hangs up with CoreBluetooth's "peripheral disconnected" error, as some pods do in the field; this needs `-transport impossible` and an ImpossiBLE that passes `errorDomain` and `errorCode` through on a disconnect). A fault stops a bolus in progress, and the pod reports the part left undelivered however late it is read, as a real pod does. +* It serves a control panel at http://:8080/ (`-api` changes the address): the pod's reservoir, delivery, age, alerts and fault, with controls to set the reservoir and pod age, raise alerts, trigger or clear a fault, and crash on the next command to test unacknowledged commands. The panel drives a JSON API: `GET /status`, and `POST /command` with `{"command": "changeReservoir", "value": 45}` (also `setActiveTime` in minutes, `setAlerts` as a slot mask, `setFault` with a fault code, 0 to clear, `crashNextCommand` with `"beforeProcessing": true|false`, `ignoreNextMessages` with a count, which keeps the link up but leaves that many messages unanswered, as a pod that didn't hear them would, and `failNextConnects` with a count and an optional `hangUpAfter` in seconds, 7 by default, which leaves service discovery unanswered on that many connects and then hangs up with CoreBluetooth's "peripheral disconnected" error, as some pods do in the field; this needs `-transport impossible` and an ImpossiBLE that passes `errorDomain` and `errorCode` through on a disconnect). A fault stops a bolus in progress, and the pod reports the part left undelivered however late it is read, as a real pod does. A reset fault (0x0D, 0x0E, 0x0F, 0x10, 0x12, 0x22, 0x34, 0x3B, 0xAA, 0xD6 or 0xD7) reports what a reset pod does instead: no fault time, nothing delivered or left undelivered, and minutes active counted from the reset. * The websocket API at `/ws`, used by the separate [NodeJS/React frontend](https://github.com/ps2/pod_simulator_frontend), is still there. diff --git a/pkg/pod/pod.go b/pkg/pod/pod.go index 42e9af5..55fe4f9 100644 --- a/pkg/pod/pod.go +++ b/pkg/pod/pod.go @@ -598,6 +598,7 @@ func (p *Pod) makeGeneralStatusResponse() response.Response { func (p *Pod) makeDetailedStatusResponse() response.Response { var now = time.Now() + delivered, bolusRemaining := p.state.ReportedDelivery() return &response.DetailedStatusResponse{ LastProgSeqNum: p.state.LastProgSeqNum, @@ -608,9 +609,9 @@ func (p *Pod) makeDetailedStatusResponse() response.Response { BasalActive: p.state.BasalActive, ExtendedBolusActive: p.state.ExtendedBolusActive, PodProgress: p.state.PodProgress, - Delivered: p.state.Delivered, - BolusRemaining: p.state.BolusRemaining(), - MinutesActive: p.state.MinutesActive(), + Delivered: delivered, + BolusRemaining: bolusRemaining, + MinutesActive: p.state.ReportedMinutesActive(), FaultEvent: p.state.FaultEvent, FaultEventTime: p.state.FaultTime, } @@ -628,7 +629,7 @@ func (p *Pod) makeType3StatusResponse() response.Response { return &response.Type3StatusResponse{ FaultEvent: p.state.FaultEvent, FaultEventTime: p.state.FaultTime, - MinutesActive: p.state.MinutesActive(), + MinutesActive: p.state.ReportedMinutesActive(), } } diff --git a/pkg/pod/state.go b/pkg/pod/state.go index 4b7756d..7f4bd9b 100644 --- a/pkg/pod/state.go +++ b/pkg/pod/state.go @@ -39,6 +39,9 @@ type PODState struct { // pod keeps reporting it, however late it is read. FaultBolusRemaining uint16 `toml:"fault_bolus_remaining"` + // ResetAt is when a reset fault restarted the pod's counters. + ResetAt time.Time `toml:"reset_at"` + TriggerTimes [8]uint16 `toml:"trigger_times"` // At some point these could be replaced with details @@ -134,6 +137,42 @@ func (p *PODState) SetFault(code uint8) { } p.FaultEvent = code p.FaultTime = p.MinutesActive() + if IsResetFault(code) { + if p.ResetAt.IsZero() { + p.ResetAt = time.Now() + } + p.FaultTime = 0 + } else { + p.ResetAt = time.Time{} + } +} + +// IsResetFault is a fault that resets the pod. Its status then holds no +// fault time, nothing delivered or left undelivered, and minutes counted +// from the reset, as every reset in field reports does. +func IsResetFault(code uint8) bool { + switch code { + case 0x0D, 0x0E, 0x0F, 0x10, 0x12, 0x22, 0x34, 0x3B, 0xAA, 0xD6, 0xD7: + return true + } + return false +} + +// ReportedMinutesActive is the pod's minute counter, which a reset restarts. +func (p *PODState) ReportedMinutesActive() uint16 { + if !p.ResetAt.IsZero() { + return uint16(time.Now().Sub(p.ResetAt).Round(time.Minute).Minutes()) + } + return p.MinutesActive() +} + +// ReportedDelivery is the delivered total and bolus remaining the pod +// reports, both of which a reset clears. +func (p *PODState) ReportedDelivery() (delivered uint16, bolusRemaining uint16) { + if !p.ResetAt.IsZero() { + return 0, 0 + } + return p.Delivered, p.BolusRemaining() } // NOTE: only handles immediate boluses; any extended bolus is not accounted for diff --git a/pkg/pod/state_test.go b/pkg/pod/state_test.go index ca88874..a77726c 100644 --- a/pkg/pod/state_test.go +++ b/pkg/pod/state_test.go @@ -1,6 +1,7 @@ package pod import ( + "encoding/hex" "testing" "time" @@ -55,3 +56,55 @@ func TestAFaultWithNoBolusLeavesNothingUndelivered(t *testing.T) { t.Fatalf("remaining %d, delivered %d; want 0 and 1080", got, s.Delivered) } } + +func TestAResetFaultClearsWhatThePodReports(t *testing.T) { + // 3 U (60 pulses) bolus, 20 s in, on a pod 18 minutes old + s := &PODState{ + PodProgress: response.PodProgressRunningAbove50U, + ActivationTime: time.Now().Add(-18 * time.Minute), + Delivered: 149, + Reservoir: 2851, + BolusEnd: time.Now().Add(100 * time.Second), + } + s.SetFault(0x10) + + p := &Pod{state: s} + raw, _ := p.makeDetailedStatusResponse().Marshal() + got := hex.EncodeToString(raw) + // no bolus remaining, nothing delivered, fault 0x10 at no time, 0 minutes active + want := "0216020d00000000000010000003ff0000" + if got[:len(want)] != want { + t.Fatalf("reset fault status %s, want it to start %s", got, want) + } + + // The pod's own books still have the bolus stopped at the fault + if s.Delivered != 99 || s.FaultBolusRemaining != 50 { + t.Fatalf("delivered %d, fault bolus remaining %d; want 99 and 50", s.Delivered, s.FaultBolusRemaining) + } + + s.ResetAt = time.Now().Add(-3 * time.Minute) + if got := s.ReportedMinutesActive(); got != 3 { + t.Fatalf("minutes active 3 minutes after the reset = %d, want 3", got) + } + + s.SetFault(0) + if !s.ResetAt.IsZero() || s.ReportedMinutesActive() != 18 { + t.Fatalf("clearing the fault left reset at %v, minutes active %d", s.ResetAt, s.ReportedMinutesActive()) + } +} + +func TestANonResetFaultKeepsItsTimeAndCounters(t *testing.T) { + s := &PODState{ + PodProgress: response.PodProgressRunningAbove50U, + ActivationTime: time.Now().Add(-100 * time.Minute), + Delivered: 1080, + } + s.SetFault(0x18) + + if s.FaultTime != 100 || !s.ResetAt.IsZero() { + t.Fatalf("fault time %d, reset at %v; want 100 and none", s.FaultTime, s.ResetAt) + } + if delivered, _ := s.ReportedDelivery(); delivered != 1080 || s.ReportedMinutesActive() != 100 { + t.Fatalf("reported delivered %d, minutes %d; want 1080 and 100", delivered, s.ReportedMinutesActive()) + } +}