// LoopFollow // BGData.swift import Foundation import UIKit extension MainViewController { /// Number of days of BG history to request from the source. One extra day is /// added when the "Show Yesterday's BG" overlay is enabled (Nightscout only), /// so the overlay can display the same clock time from the day before. var bgFetchDays: Int { let extraDay = (Storage.shared.showYesterdayLine.value && IsNightscoutEnabled()) ? 1 : 0 return Storage.shared.downloadDays.value + extraDay } // Dex Share Web Call func webLoadDexShare() { // Dexcom Share only returns 24 hrs of data as of now // Requesting more just for consistency with NS let graphHours = 24 * bgFetchDays let count = graphHours * 12 dexShare?.fetchData(count) { err, result in if let error = err { LogManager.shared.log(category: .dexcom, message: "Error fetching Dexcom data: \(error.localizedDescription)", limitIdentifier: "Error fetching Dexcom data") self.webLoadNSBGData() return } guard let data = result, !data.isEmpty else { LogManager.shared.log(category: .dexcom, message: "Received empty data array from Dexcom", limitIdentifier: "Received empty data array from Dexcom") self.webLoadNSBGData() return } // If Dex data is old, load from NS instead let latestDate = data[0].date let now = dateTimeUtils.getNowTimeIntervalUTC() if (latestDate + 330) < now, IsNightscoutEnabled() { LogManager.shared.log(category: .dexcom, message: "Dexcom data is old, loading from NS instead", limitIdentifier: "Dexcom data is old, loading from NS instead") self.webLoadNSBGData() return } // Dexcom Share can return duplicate readings when multiple uploaders // write to the same Dexcom account. Dedup before any further use. let dedupedData = self.deduplicateBGReadings(data) // Supplement with NS if Dex data doesn't cover the full requested window. let dexCutoff = dateTimeUtils.getNowTimeIntervalUTC() - Double(graphHours) * 3600 let dexCoversFull = dedupedData.last.map { $0.date <= dexCutoff } ?? false if !dexCoversFull, IsNightscoutEnabled() { self.webLoadNSBGData(dexData: dedupedData) } else { self.ProcessDexBGData(data: dedupedData, sourceName: "Dexcom") } } } // NS BG Data Web call func webLoadNSBGData(dexData: [ShareGlucoseData] = []) { // This kicks it out in the instance where dexcom fails but they aren't using NS && if !IsNightscoutEnabled() { Storage.shared.lastBGChecked.value = Date() return } var parameters: [String: String] = [:] let date = Calendar.current.date(byAdding: .day, value: -1 * bgFetchDays, to: Date())! parameters["count"] = "\(bgFetchDays * globalVariables.maxExpectedUploaders * 24 * 60 / 5)" parameters["find[date][$gte]"] = "\(Int(date.timeIntervalSince1970 * 1000))" // Exclude 'cal' entries parameters["find[type][$ne]"] = "cal" NightscoutUtils.executeRequest(eventType: .sgv, parameters: parameters) { (result: Result<[ShareGlucoseData], Error>) in switch result { case let .success(entriesResponse): var nsData = entriesResponse DispatchQueue.main.async { // transform NS data to look like Dex data for i in 0 ..< nsData.count { // convert the NS timestamp to seconds instead of milliseconds nsData[i].date /= 1000 nsData[i].date.round(FloatingPointRoundingRule.toNearestOrEven) } var nsData2 = self.deduplicateBGReadings(nsData) // merge NS and Dex data if needed; use recent Dex data and older NS data var sourceName = "Nightscout" if !dexData.isEmpty { let oldestDexDate = dexData[dexData.count - 1].date var itemsToRemove = 0 while itemsToRemove < nsData2.count, nsData2[itemsToRemove].date >= oldestDexDate { itemsToRemove += 1 } nsData2.removeFirst(itemsToRemove) nsData2 = dexData + nsData2 sourceName = "Dexcom" } // trigger the processor for the data after downloading. self.ProcessDexBGData(data: nsData2, sourceName: sourceName) } case let .failure(error): LogManager.shared.log(category: .nightscout, message: "Failed to fetch bg data: \(error)", limitIdentifier: "Failed to fetch bg data") DispatchQueue.main.async { TaskScheduler.shared.rescheduleTask( id: .fetchBG, to: Date().addingTimeInterval(10) ) } // if we have Dex data, use it if !dexData.isEmpty { self.ProcessDexBGData(data: dexData, sourceName: "Dexcom") } else { Storage.shared.lastBGChecked.value = Date() } return } } } /// Removes consecutive duplicate readings (same SGV within 30 s). Expects newest-first input. func deduplicateBGReadings(_ readings: [ShareGlucoseData]) -> [ShareGlucoseData] { var result: [ShareGlucoseData] = [] var lastTime = Double.infinity var lastSGV: Int? for reading in readings { if lastSGV == nil || lastSGV != reading.sgv || lastTime - reading.date >= 30 { result.append(reading) lastTime = reading.date lastSGV = reading.sgv } } return result } /// Processes incoming BG data. func ProcessDexBGData(data: [ShareGlucoseData], sourceName: String) { let graphHours = 24 * Storage.shared.downloadDays.value guard !data.isEmpty else { LogManager.shared.log(category: .nightscout, message: "No bg data received. Skipping processing.", limitIdentifier: "No bg data received. Skipping processing.") Storage.shared.lastBGChecked.value = Date() return } let latestReading = data[0] let sensorTimestamp = latestReading.date let now = dateTimeUtils.getNowTimeIntervalUTC() // secondsAgo is how old the newest reading is let secondsAgo = now - sensorTimestamp // Compute the current sensor schedule offset let currentOffset = CycleHelper.cycleOffset(for: sensorTimestamp, interval: 5 * 60) if Storage.shared.sensorScheduleOffset.value != currentOffset { Storage.shared.sensorScheduleOffset.value = currentOffset LogManager.shared.log(category: .nightscout, message: "Sensor schedule offset: \(currentOffset) seconds.", isDebug: true) } // Determine the next polling delay. var delayToSchedule: Double = 0 DispatchQueue.main.async { // Fallback scheduling for older readings. if secondsAgo >= (20 * 60) { delayToSchedule = 5 * 60 LogManager.shared.log(category: .nightscout, message: "Reading is very old (\(secondsAgo) sec). Scheduling next fetch in 5 minutes.", isDebug: true) } else if secondsAgo >= (10 * 60) { delayToSchedule = 60 LogManager.shared.log(category: .nightscout, message: "Reading is moderately old (\(secondsAgo) sec). Scheduling next fetch in 60 seconds.", isDebug: true) } else if secondsAgo >= (7 * 60) { delayToSchedule = 30 LogManager.shared.log(category: .nightscout, message: "Reading is a bit old (\(secondsAgo) sec). Scheduling next fetch in 30 seconds.", isDebug: true) } else if secondsAgo >= (5 * 60) { delayToSchedule = 5 LogManager.shared.log(category: .nightscout, message: "Reading is close to 5 minutes old (\(secondsAgo) sec). Scheduling next fetch in 5 seconds.", isDebug: true) } else { delayToSchedule = 300 - secondsAgo + Double(Storage.shared.bgUpdateDelay.value) LogManager.shared.log(category: .nightscout, message: "Fresh reading. Scheduling next fetch in \(delayToSchedule) seconds.", isDebug: true) TaskScheduler.shared.rescheduleTask(id: .alarmCheck, to: Date().addingTimeInterval(3)) } if NightscoutSocketManager.shared.connectionState == .authenticated { delayToSchedule = max(delayToSchedule * 3, 60) } TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: Date().addingTimeInterval(delayToSchedule)) // Evaluate speak conditions if there is a previous value. if data.count > 1 { self.evaluateSpeakConditions(currentValue: data[0].sgv, previousValue: data[1].sgv) } } // Process data for graph display. bgData.removeAll() for i in 0 ..< data.count { let readingTimestamp = data[data.count - 1 - i].date if readingTimestamp >= dateTimeUtils.getTimeIntervalNHoursAgo(N: graphHours) { let sgvValue = data[data.count - 1 - i].sgv // Skip outlier values (e.g. first reading of a new sensor might be abnormally high). if sgvValue > 600 { LogManager.shared.log(category: .nightscout, message: "Skipping reading with sgv \(sgvValue) as it exceeds threshold.", isDebug: true) continue } let reading = ShareGlucoseData(sgv: sgvValue, date: readingTimestamp, direction: data[data.count - 1 - i].direction) bgData.append(reading) } } LogManager.shared.log(category: .nightscout, message: "Graph data updated with \(bgData.count) entries.", isDebug: true) // Build the optional "yesterday" comparison overlay. Every fetched reading is // shifted +24h so it lines up with the same clock time today; the extra day of // history pulled by bgFetchDays provides the portion that falls inside the // visible window. The overlay is capped to "now + hours of prediction" so it // never extends further into the future than the prediction line. yesterdayBGData.removeAll() if Storage.shared.showYesterdayLine.value, IsNightscoutEnabled() { let cutoff = dateTimeUtils.getTimeIntervalNHoursAgo(N: 24 * bgFetchDays) let futureLimit = dateTimeUtils.getNowTimeIntervalUTC() + Storage.shared.predictionToLoad.value * 3600 for i in 0 ..< data.count { let reading = data[data.count - 1 - i] guard reading.date >= cutoff, reading.sgv <= 600 else { continue } let shiftedDate = reading.date + 24 * 60 * 60 guard shiftedDate <= futureLimit else { continue } yesterdayBGData.append(ShareGlucoseData(sgv: reading.sgv, date: shiftedDate, direction: reading.direction)) } } viewUpdateNSBG(sourceName: sourceName) } func updateServerText(with serverText: String? = nil) { if Storage.shared.showDisplayName.value, let displayName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String { Observable.shared.serverText.value = displayName } else if let serverText = serverText { Observable.shared.serverText.value = serverText } } // NS BG Data Front end updater func viewUpdateNSBG(sourceName: String) { DispatchQueue.main.async { TaskScheduler.shared.rescheduleTask(id: .minAgoUpdate, to: Date()) let entries = self.bgData if entries.count < 2 { // Protect index out of bounds Storage.shared.lastBGChecked.value = Date() return } self.updateBGGraph() self.updateStats() let latestEntryIndex = entries.count - 1 let latestBG = entries[latestEntryIndex].sgv let priorBG = entries[latestEntryIndex - 1].sgv let deltaBG = latestBG - priorBG let lastBGTime = entries[latestEntryIndex].date self.updateServerText(with: sourceName) // Set BGText with the latest BG value self.updateBGTextAppearance() if latestBG <= globalVariables.minDisplayGlucose { Observable.shared.bgText.value = "LOW" } else if latestBG >= globalVariables.maxDisplayGlucose { Observable.shared.bgText.value = "HIGH" } else { Observable.shared.bgText.value = Localizer.toDisplayUnits(String(latestBG)) } Observable.shared.bg.value = latestBG // Direction handling if let directionBG = entries[latestEntryIndex].direction { Observable.shared.directionText.value = self.bgDirectionGraphic(directionBG) } else { Observable.shared.directionText.value = "" } // Delta handling if deltaBG < 0 { Observable.shared.deltaText.value = Localizer.toDisplayUnits(String(deltaBG)) } else { Observable.shared.deltaText.value = "+" + Localizer.toDisplayUnits(String(deltaBG)) } // Live Activity storage Storage.shared.lastBgReadingTimeSeconds.value = lastBGTime Storage.shared.lastDeltaMgdl.value = Double(deltaBG) Storage.shared.lastTrendCode.value = entries[latestEntryIndex].direction // Mark BG data as loaded for initial loading state self.markDataLoaded("bg") // Live Activity update #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.refreshFromCurrentState(reason: "bg") #endif // Update contact if Storage.shared.contactEnabled.value { self.contactImageUpdater .updateContactImage( bgValue: Observable.shared.bgText.value, trend: Observable.shared.directionText.value, delta: Observable.shared.deltaText.value, iob: Observable.shared.iobText.value, stale: Observable.shared.bgStale.value ) } Storage.shared.lastBGChecked.value = Date() } } }