Skip to content
36 changes: 36 additions & 0 deletions LoopFollow/Charts/BGChartModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ final class BGChartModel: ObservableObject {

@Published var bg: [BGPoint] = []
@Published var bgRuns: [BGRun] = []
@Published var smoothedBg: [BGPoint] = []
@Published var yesterday: [BGPoint] = []
@Published var prediction: [BGPoint] = []
@Published var ztPrediction: [BGPoint] = []
Expand Down Expand Up @@ -171,9 +172,11 @@ final class BGChartModel: ObservableObject {
private(set) var generation: Int = 0

private var rebuildScheduled = false
private var smoothedBgHistory: [SmoothedBgPoint] = []

@Published var showLines: Bool = true
@Published var showDots: Bool = true
@Published var showSmoothedBg: Bool = false
@Published var showDIA: Bool = true
@Published var show30Min: Bool = false
@Published var show90Min: Bool = false
Expand Down Expand Up @@ -215,6 +218,14 @@ final class BGChartModel: ObservableObject {
pillTimeFormatter.string(from: date)
}

func smoothedBgValue(near date: Date, tolerance: TimeInterval = 150) -> Double? {
SmoothedBgSeries.nearestValue(
in: smoothedBgHistory,
to: date.timeIntervalSince1970,
tolerance: tolerance
)
}

/// Nightscout remote-command error notes embed a JSON payload after
/// the human-readable message ("Error text {\"bolus-entry\": 1.5, ...}").
/// Returns the message plus a compact summary of the payload, or nil when
Expand Down Expand Up @@ -406,6 +417,10 @@ final class BGChartModel: ObservableObject {

showLines = Storage.shared.showLines.value
showDots = Storage.shared.showDots.value
showSmoothedBg = Storage.shared.displaySmoothedBG.value
&& IsNightscoutEnabled()
&& Storage.shared.device.value != "Loop"
&& !vc.smoothedBgData.isEmpty
showDIA = Storage.shared.showDIALines.value
show30Min = Storage.shared.show30MinLine.value
show90Min = Storage.shared.show90MinLine.value
Expand Down Expand Up @@ -433,6 +448,27 @@ final class BGChartModel: ObservableObject {
bg = vc.bgData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: clampSgv($0.sgv), color: colorFor($0.sgv, thresholds: thresholds)) }
bgRuns = Self.makeRuns(bg)

if showSmoothedBg,
let firstBgTime = vc.bgData.first?.date,
let lastBgTime = vc.bgData.last?.date
{
smoothedBgHistory = vc.smoothedBgData
smoothedBg = SmoothedBgSeries.chartPoints(
from: smoothedBgHistory,
startingAt: firstBgTime,
endingAt: lastBgTime + 150
).map {
BGPoint(
date: Date(timeIntervalSince1970: $0.time),
value: min(max($0.bgMgdl, Double(minDisplay)), Double(maxDisplay)),
color: .cyan
)
}
} else {
smoothedBgHistory = []
smoothedBg = []
}

// Yesterday comparison overlay (#665): already +24h shifted, dimmed gray, no dots.
if Storage.shared.showYesterdayLine.value {
yesterday = vc.yesterdayBGData.map {
Expand Down
30 changes: 27 additions & 3 deletions LoopFollow/Charts/BGChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,14 @@ private struct MainBGChart: View {

/// Pill entry for a BG reading. Shared by the scrub lookup and the tap hit test.
private func bgPillText(for point: BGChartModel.BGPoint) -> String {
"BG\n\(Localizer.toDisplayUnits(String(Int(point.value))))\n\(model.pillTimeString(for: point.date))"
let rawBg = Localizer.toDisplayUnits(String(Int(point.value)))
let time = model.pillTimeString(for: point.date)
if model.showSmoothedBg,
let smoothed = model.smoothedBgValue(near: point.date)
{
return "✨ \(Localizer.toDisplayUnits(String(smoothed))) ✨\n\(rawBg)\n\(time)"
}
return "BG\n\(rawBg)\n\(time)"
}

private func bandPillTexts(at date: Date) -> [String] {
Expand Down Expand Up @@ -1089,6 +1096,7 @@ private struct BGChartCanvas: View, Equatable {
yesterdayMarks
}
bgLineMarks
smoothedBgMarks
bgPointsMark
predictionLineMark
predictionVariantMarks
Expand Down Expand Up @@ -1278,7 +1286,7 @@ private struct BGChartCanvas: View, Equatable {

@ChartContentBuilder
private var bgLineMarks: some ChartContent {
if model.showLines {
if model.showLines, isSmall || !model.showSmoothedBg {
ForEach(model.bgRuns) { run in
if let first = run.points.first, let last = run.points.last,
last.date >= windowStart, first.date <= windowEnd
Expand All @@ -1298,9 +1306,25 @@ private struct BGChartCanvas: View, Equatable {
}
}

@ChartContentBuilder
private var smoothedBgMarks: some ChartContent {
if model.showSmoothedBg, !isSmall {
ForEach(windowedLine(model.smoothedBg) { $0.date }) { point in
LineMark(
x: .value("time", point.date),
y: .value("bg", point.value),
series: .value("series", "smoothed-bg")
)
.foregroundStyle(.cyan)
.lineStyle(StrokeStyle(lineWidth: 1.5))
.interpolationMethod(.linear)
}
}
}

@ChartContentBuilder
private var bgPointsMark: some ChartContent {
if model.showDots {
if model.showDots || (model.showSmoothedBg && !isSmall) {
ForEach(windowed(model.bg) { $0.date }) { pt in
PointMark(
x: .value("time", pt.date),
Expand Down
13 changes: 13 additions & 0 deletions LoopFollow/Controllers/Nightscout/BGData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,19 @@ extension MainViewController {

let latestReading = data[0]
let sensorTimestamp = latestReading.date

// If this is a brand-new reading (newer than what we last processed), pull
// devicestatus right away so the smoothed BG for this dot lands on the chart
// alongside the dot itself, not on the next 5-min devicestatus poll.
let previouslyProcessedBgTime = Storage.shared.lastBgReadingTimeSeconds.value ?? 0
if Storage.shared.displaySmoothedBG.value,
Storage.shared.device.value != "Loop",
sensorTimestamp > previouslyProcessedBgTime,
IsNightscoutEnabled()
{
// Tiny buffer so the loop has a moment to write its devicestatus record.
TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date().addingTimeInterval(1))
}
let now = dateTimeUtils.getNowTimeIntervalUTC()
// secondsAgo is how old the newest reading is
let secondsAgo = now - sensorTimestamp
Expand Down
42 changes: 40 additions & 2 deletions LoopFollow/Controllers/Nightscout/DeviceStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ extension MainViewController {
func updateDeviceStatusDisplay(jsonDeviceStatus: [[String: AnyObject]]) {
let previousIOBText = Observable.shared.iobText.value
let previousDeviceWasLoop = Storage.shared.device.value == "Loop"
infoManager.clearInfoData(types: [.iob, .cob, .battery, .pump, .pumpBattery, .target, .isf, .carbRatio, .updated, .recBolus, .tdd])
infoManager.clearInfoData(types: [.iob, .cob, .battery, .pump, .pumpBattery, .target, .isf, .carbRatio, .updated, .recBolus, .tdd, .smoothedBg])

// For Loop, clear the current override here - For Trio, it is handled using treatments
if Storage.shared.device.value == "Loop" {
Expand Down Expand Up @@ -200,8 +200,11 @@ extension MainViewController {
}

// OpenAPS - handle new data
var processedOpenAPS = false
var parsedOpenAPSTimestamp = false
if let lastLoopRecord = lastDeviceStatus?["openaps"] as! [String: AnyObject]? {
DeviceStatusOpenAPS(formatter: formatter, lastDeviceStatus: lastDeviceStatus, lastLoopRecord: lastLoopRecord)
processedOpenAPS = true
parsedOpenAPSTimestamp = DeviceStatusOpenAPS(formatter: formatter, lastDeviceStatus: lastDeviceStatus, lastLoopRecord: lastLoopRecord)
}

// If the active looping system flipped (Loop ⇄ Trio/OpenAPS), drop the previous
Expand All @@ -219,7 +222,35 @@ extension MainViewController {
let now = dateTimeUtils.getNowTimeIntervalUTC()
let secondsAgo = now - (Observable.shared.alertLastLoopTime.value ?? 0)

// Trio can upload a thin devicestatus record between full loop records.
// While the newest BG is fresh, poll quickly if that record did not
// repopulate the loop timestamp or if its matching smoothed value has
// not arrived yet. Keep this OpenAPS-only so Loop users never inherit
// the smoothing retry cadence.
let latestBgTime = bgData.last?.date ?? Storage.shared.lastBgReadingTimeSeconds.value
let latestBgAge = latestBgTime.map { max(0, now - $0) } ?? .infinity
let smoothingRetryEnabled = processedOpenAPS && Storage.shared.displaySmoothedBG.value
let recordIsSparse = smoothingRetryEnabled && !parsedOpenAPSTimestamp
let needsSmoothedBgRetry: Bool = {
guard smoothingRetryEnabled,
let latestBg = bgData.last,
latestBgAge < 300
else { return false }
return smoothedBg(near: latestBg.date) == nil
}()
let needsSparseRecordRetry = recordIsSparse && latestBgAge < 300
let needsRetry = needsSmoothedBgRetry || needsSparseRecordRetry
let retryDelay: TimeInterval = latestBgAge < 60 ? 3 : 15

DispatchQueue.main.async {
if needsRetry {
TaskScheduler.shared.rescheduleTask(
id: .deviceStatus,
to: Date().addingTimeInterval(retryDelay)
)
return
}

var interval: Double
if secondsAgo >= (20 * 60) {
interval = 5 * 60
Expand Down Expand Up @@ -249,6 +280,13 @@ extension MainViewController {
// Mark device status as loaded for initial loading state
markDataLoaded("deviceStatus")

if processedOpenAPS,
Storage.shared.displaySmoothedBG.value,
!hasFetchedSmoothedBgHistory
{
webLoadNSSmoothedBgHistory()
}

if Storage.shared.contactEnabled.value, Storage.shared.contactIOB.value != .off,
Observable.shared.iobText.value != previousIOBText
{
Expand Down
65 changes: 59 additions & 6 deletions LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,42 @@ import Foundation
import HealthKit

extension MainViewController {
func DeviceStatusOpenAPS(formatter: ISO8601DateFormatter, lastDeviceStatus: [String: AnyObject]?, lastLoopRecord: [String: AnyObject]) {
func DeviceStatusOpenAPS(formatter: ISO8601DateFormatter, lastDeviceStatus: [String: AnyObject]?, lastLoopRecord: [String: AnyObject]) -> Bool {
Storage.shared.device.value = lastDeviceStatus?["device"] as? String ?? ""
if lastLoopRecord["failureReason"] != nil {
Observable.shared.loopStatusText.value = "X"
latestLoopStatusString = "X"
return false
} else {
guard let enactedOrSuggested = lastLoopRecord["suggested"] as? [String: AnyObject] ?? lastLoopRecord["enacted"] as? [String: AnyObject] else {
// Suggested is the current loop's view, while enacted can carry
// fields such as TDD that are omitted when no new action was needed.
// Merge both and prefer suggested values on collisions.
let suggested = lastLoopRecord["suggested"] as? [String: AnyObject] ?? [:]
let enacted = lastLoopRecord["enacted"] as? [String: AnyObject] ?? [:]
guard !suggested.isEmpty || !enacted.isEmpty else {
Observable.shared.loopStatusText.value = "↻"
latestLoopStatusString = "↻"
return
return false
}
let enactedOrSuggested = enacted.merging(suggested) { _, suggestedValue in suggestedValue }

var updatedTime: TimeInterval?

if let timestamp = enactedOrSuggested["deliverAt"] as? String ?? enactedOrSuggested["timestamp"] as? String,
let parsedTime = formatter.date(from: timestamp)?.timeIntervalSince1970
{
// Prefer the current suggestion, then the outer Nightscout record,
// and finally the potentially older enacted timestamp. parseDate
// tolerates fractional seconds and the common trailing Z.
let timestampCandidates: [String?] = [
suggested["deliverAt"] as? String,
suggested["timestamp"] as? String,
lastDeviceStatus?["created_at"] as? String,
enacted["deliverAt"] as? String,
enacted["timestamp"] as? String,
]
let parsedTime = timestampCandidates
.compactMap { $0.flatMap { SmoothedBgSeries.parseDate($0) } }
.first?
.timeIntervalSince1970
if let parsedTime {
updatedTime = parsedTime
let formattedTime = Localizer.formatTimestampToLocalString(parsedTime)
infoManager.updateInfoData(type: .updated, value: formattedTime)
Expand Down Expand Up @@ -121,6 +140,39 @@ extension MainViewController {
Observable.shared.deviceRecBolus.value = nil
}

let smoothedBgPoint: SmoothedBgPoint? = {
if let bg = suggested["bg"] as? Double {
return SmoothedBgSeries.point(
bg: bg,
timestampCandidates: [
suggested["deliverAt"] as? String,
suggested["timestamp"] as? String,
lastDeviceStatus?["created_at"] as? String,
enacted["deliverAt"] as? String,
enacted["timestamp"] as? String,
]
)
}
if let bg = enacted["bg"] as? Double {
return SmoothedBgSeries.point(
bg: bg,
timestampCandidates: [
enacted["deliverAt"] as? String,
enacted["timestamp"] as? String,
lastDeviceStatus?["created_at"] as? String,
]
)
}
return nil
}()
if Storage.shared.displaySmoothedBG.value, let smoothedBgPoint {
appendSmoothedBgPoint(time: smoothedBgPoint.time, bgMgdl: smoothedBgPoint.bgMgdl)
infoManager.updateInfoData(
type: .smoothedBg,
value: Localizer.toDisplayUnits(String(smoothedBgPoint.bgMgdl))
)
}

// Eventual BG
if let eventualBGValue = enactedOrSuggested["eventualBG"] as? Double {
let eventualBGQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: eventualBGValue)
Expand Down Expand Up @@ -241,6 +293,7 @@ extension MainViewController {
// Live Activity storage
Storage.shared.lastIOB.value = latestIOB?.value
Storage.shared.lastCOB.value = latestCOB?.value
return updatedTime != nil
}
}
}
Loading
Loading