| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368 |
- import Combine
- import CoreData
- import Foundation
- import LoopKitUI
- import Swinject
- import UIKit
- protocol NightscoutManager: GlucoseSource {
- func fetchGlucose(since date: Date) async -> [BloodGlucose]
- func fetchCarbs() async -> [CarbsEntry]
- func fetchTempTargets() async -> [TempTarget]
- func deleteCarbs(withID id: String) async
- func deleteInsulin(withID id: String) async
- func deleteGlucose(withID id: String, withDate date: Date) async
- func uploadDeviceStatus() async throws
- func uploadGlucose() async
- func uploadCarbs() async
- func uploadPumpHistory() async
- func uploadOverrides() async
- func uploadTempTargets() async
- func uploadProfiles() async throws
- func uploadNoteTreatment(note: String) async
- func importSettings() async -> ScheduledNightscoutProfile?
- var cgmURL: URL? { get }
- }
- final class BaseNightscoutManager: NightscoutManager, Injectable {
- @Injected() private var keychain: Keychain!
- @Injected() private var determinationStorage: DeterminationStorage!
- @Injected() var glucoseStorage: GlucoseStorage!
- @Injected() private var tempTargetsStorage: TempTargetsStorage!
- @Injected() private var overridesStorage: OverrideStorage!
- @Injected() private var carbsStorage: CarbsStorage!
- @Injected() private var pumpHistoryStorage: PumpHistoryStorage!
- @Injected() private var storage: FileStorage!
- @Injected() private var settingsManager: SettingsManager!
- @Injected() private var broadcaster: Broadcaster!
- @Injected() private var reachabilityManager: ReachabilityManager!
- @Injected() var healthkitManager: HealthKitManager!
- @Injected() private var bolusCalculationManager: BolusCalculationManager!
- @Injected() private var apsManager: APSManager!
- private let processQueue = DispatchQueue(label: "BaseNetworkManager.processQueue")
- private var ping: TimeInterval?
- // Queue where upload pipelines run.
- let uploadPipelineQueue = DispatchQueue(label: "NightscoutManager.uploadPipelines", qos: .utility)
- // Background Core Data context for fetches used by upload tasks.
- var backgroundContext = CoreDataStack.shared.newTaskContext()
- /// Throttle window (seconds) per upload pipeline. Any requests inside this window
- /// coalesce into a single upload run for that pipeline.
- let uploadPipelineInterval: [NightscoutUploadPipeline: TimeInterval] = [
- .carbs: 2, .pumpHistory: 2, .overrides: 2, .tempTargets: 2,
- .glucose: 2, .deviceStatus: 2
- ]
- /// Subjects used to request an upload pipeline. The pipeline applies a throttle so
- /// close calls don’t double-upload.
- var uploadPipelineSubjects: [NightscoutUploadPipeline: PassthroughSubject<Void, Never>] = {
- var d: [NightscoutUploadPipeline: PassthroughSubject<Void, Never>] = [:]
- NightscoutUploadPipeline.allCases.forEach { d[$0] = PassthroughSubject<Void, Never>() }
- return d
- }()
- /// Request an upload for a pipeline (enqueue work). Safe to call from anywhere.
- func requestUpload(_ uploadPipeline: NightscoutUploadPipeline) {
- uploadPipelineSubjects[uploadPipeline]?.send(())
- }
- /// Build the Combine pipelines for all upload pipelines: subject → throttle → upload.
- /// Must be called once during init().
- func setupLanePipelines() {
- for pipeline in NightscoutUploadPipeline.allCases {
- guard let subject = uploadPipelineSubjects[pipeline], let window = uploadPipelineInterval[pipeline] else { continue }
- subject
- .receive(on: uploadPipelineQueue)
- .throttle(for: .seconds(window), scheduler: uploadPipelineQueue, latest: false)
- .sink { [weak self] in
- guard let self else { return }
- Task(priority: .utility) { await self.runUploadPipeline(pipeline) }
- }
- .store(in: &subscriptions)
- }
- }
- /// Runs the actual upload for a single upload pipeline.
- /// Called by the throttled pipeline, not directly by callers.
- func runUploadPipeline(_ uploadPipeline: NightscoutUploadPipeline) async {
- switch uploadPipeline {
- case .carbs: await uploadCarbs()
- case .pumpHistory: await uploadPumpHistory()
- case .overrides: await uploadOverrides()
- case .tempTargets: await uploadTempTargets()
- case .glucose: await uploadGlucose()
- case .deviceStatus:
- do { try await uploadDeviceStatus() }
- catch { debug(.nightscout, "deviceStatus upload failed: \(error)") }
- }
- }
- private var isNetworkReachable: Bool {
- reachabilityManager.isReachable
- }
- private var isUploadEnabled: Bool {
- settingsManager.settings.isUploadEnabled
- }
- private var isDownloadEnabled: Bool {
- settingsManager.settings.isDownloadEnabled
- }
- private var isUploadGlucoseEnabled: Bool {
- settingsManager.settings.uploadGlucose
- }
- private var nightscoutAPI: NightscoutAPI? {
- guard let urlString = keychain.getValue(String.self, forKey: NightscoutConfig.Config.urlKey),
- let url = URL(string: urlString),
- let secret = keychain.getValue(String.self, forKey: NightscoutConfig.Config.secretKey)
- else {
- return nil
- }
- return NightscoutAPI(url: url, secret: secret)
- }
- private var lastEnactedDetermination: Determination?
- private var lastSuggestedDetermination: Determination?
- // Queue for handling Core Data change notifications
- let queue = DispatchQueue(label: "BaseNightscoutManager.queue", qos: .utility)
- /// Emits changed Core Data object IDs from the app. We filter by entity names
- /// and request upload pipelines based on what changed.
- var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
- /// Bag for Combine subscriptions owned by this manager.
- var subscriptions = Set<AnyCancellable>()
- init(resolver: Resolver) {
- injectServices(resolver)
- subscribe()
- coreDataPublisher =
- changedObjectsOnManagedObjectContextDidSavePublisher()
- .receive(on: queue)
- .share()
- .eraseToAnyPublisher()
- setupNotification()
- setupLanePipelines()
- wireSubscribers()
- /// Ensure that Nightscout Manager holds the `lastEnactedDetermination`, if one exists, on initialization.
- /// We have to set this here in `init()`, so there's a `lastEnactedDetermination` available after an app restart
- /// for `uploadDeviceStatus()`, as within that fuction `lastEnactedDetermination` is reassigned at the very end of the function.
- /// This way, we ensure the latest enacted determination is always part of `devicestatus` and avoid having instances
- /// where the first uploaded non-enacted determination (i.e., "suggested"), lacks the "enacted" data.
- Task {
- do {
- let lastEnactedDeterminationID = try await determinationStorage
- .fetchLastDeterminationObjectID(predicate: NSPredicate.enactedDetermination)
- self.lastEnactedDetermination = await determinationStorage
- .getOrefDeterminationNotYetUploadedToNightscout(lastEnactedDeterminationID)
- } catch {
- debug(
- .default,
- "\(DebuggingIdentifiers.failed) failed to fetch last enacted determination: \(error)"
- )
- }
- }
- }
- private func subscribe() {
- _ = reachabilityManager.startListening(onQueue: processQueue) { status in
- debug(.nightscout, "Network status: \(status)")
- }
- }
- func setupNotification() {
- Foundation.NotificationCenter.default.publisher(for: .willUpdateOverrideConfiguration)
- .sink { [weak self] _ in
- guard let self = self else { return }
- Task {
- await self.uploadOverrides()
- // Post a notification indicating that the upload has finished and that we can end the background task in the OverridePresetsIntentRequest
- Foundation.NotificationCenter.default.post(name: .didUpdateOverrideConfiguration, object: nil)
- }
- }
- .store(in: &subscriptions)
- Foundation.NotificationCenter.default.publisher(for: .willUpdateTempTargetConfiguration)
- .sink { [weak self] _ in
- guard let self = self else { return }
- Task {
- await self.uploadTempTargets()
- // Post a notification indicating that the upload has finished and that we can end the background task in the TempTargetPresetsIntentRequest
- Foundation.NotificationCenter.default.post(name: .didUpdateTempTargetConfiguration, object: nil)
- }
- }
- .store(in: &subscriptions)
- }
- func sourceInfo() -> [String: Any]? {
- if let ping = ping {
- return [GlucoseSourceKey.nightscoutPing.rawValue: ping]
- }
- return nil
- }
- var cgmURL: URL? {
- if let url = settingsManager.settings.cgm.appURL {
- return url
- }
- let useLocal = settingsManager.settings.useLocalGlucoseSource
- let maybeNightscout = useLocal
- ? NightscoutAPI(url: URL(string: "http://127.0.0.1:\(settingsManager.settings.localGlucosePort)")!)
- : nightscoutAPI
- return maybeNightscout?.url
- }
- func fetchGlucose(since date: Date) async -> [BloodGlucose] {
- let useLocal = settingsManager.settings.useLocalGlucoseSource
- ping = nil
- if !useLocal {
- guard isNetworkReachable else {
- return []
- }
- }
- let maybeNightscout = useLocal
- ? NightscoutAPI(url: URL(string: "http://127.0.0.1:\(settingsManager.settings.localGlucosePort)")!)
- : nightscoutAPI
- guard let nightscout = maybeNightscout else {
- return []
- }
- let startDate = Date()
- do {
- let glucose = try await nightscout.fetchLastGlucose(sinceDate: date)
- if glucose.isNotEmpty {
- ping = Date().timeIntervalSince(startDate)
- }
- return glucose
- } catch {
- print(error.localizedDescription)
- return []
- }
- }
- // MARK: - GlucoseSource
- var glucoseManager: FetchGlucoseManager?
- var cgmManager: CGMManagerUI?
- func fetch(_: DispatchTimer?) -> AnyPublisher<[BloodGlucose], Never> {
- Future { promise in
- Task {
- let glucoseData = await self.fetchGlucose(since: self.glucoseStorage.syncDate())
- promise(.success(glucoseData))
- }
- }
- .eraseToAnyPublisher()
- }
- func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
- fetch(nil)
- }
- func fetchCarbs() async -> [CarbsEntry] {
- guard let nightscout = nightscoutAPI, isNetworkReachable, isDownloadEnabled else {
- return []
- }
- let since = carbsStorage.syncDate()
- do {
- let carbs = try await nightscout.fetchCarbs(sinceDate: since)
- return carbs
- } catch {
- debug(.nightscout, "Error fetching carbs: \(error)")
- return []
- }
- }
- func fetchTempTargets() async -> [TempTarget] {
- guard let nightscout = nightscoutAPI, isNetworkReachable, isDownloadEnabled else {
- return []
- }
- let since = tempTargetsStorage.syncDate()
- do {
- let tempTargets = try await nightscout.fetchTempTargets(sinceDate: since)
- return tempTargets
- } catch {
- debug(.nightscout, "Error fetching temp targets: \(error)")
- return []
- }
- }
- func deleteCarbs(withID id: String) async {
- guard let nightscout = nightscoutAPI, isUploadEnabled else { return }
- do {
- try await nightscout.deleteCarbs(withId: id)
- debug(.nightscout, "Carbs deleted")
- } catch {
- debug(
- .nightscout,
- "\(DebuggingIdentifiers.failed) Failed to delete Carbs from Nightscout with error: \(error)"
- )
- }
- }
- func deleteInsulin(withID id: String) async {
- guard let nightscout = nightscoutAPI, isUploadEnabled else { return }
- do {
- try await nightscout.deleteInsulin(withId: id)
- debug(.nightscout, "Insulin deleted")
- } catch {
- debug(
- .nightscout,
- "\(DebuggingIdentifiers.failed) Failed to delete Insulin from Nightscout with error: \(error)"
- )
- }
- }
- func deleteGlucose(withID id: String, withDate date: Date) async {
- guard let nightscout = nightscoutAPI, isUploadEnabled else { return }
- do {
- try await nightscout.deleteGlucose(withId: id, withDate: date)
- debug(.nightscout, "Glucose deleted")
- } catch {
- debug(
- .nightscout,
- "\(DebuggingIdentifiers.failed) Failed to delete Glucose from Nightscout with error: \(error)"
- )
- }
- }
- private func fetchBattery() async -> Battery {
- await backgroundContext.perform {
- do {
- let results = try self.backgroundContext.fetch(OpenAPS_Battery.fetch(NSPredicate.predicateFor30MinAgo))
- if let last = results.first {
- let percent: Int? = Int(last.percent)
- let voltage: Decimal? = last.voltage as Decimal?
- let status: String? = last.status
- let display: Bool? = last.display
- if let status {
- debugPrint(
- "NightscoutManager: \(#function) \(DebuggingIdentifiers.succeeded) setup battery from core data successfully"
- )
- return Battery(
- percent: percent,
- voltage: voltage,
- string: BatteryState(rawValue: status) ?? BatteryState.unknown,
- display: display
- )
- }
- }
- debugPrint(
- "NightscoutManager: \(#function) \(DebuggingIdentifiers.succeeded) successfully fetched; but no battery data available. Returning fallback default."
- )
- return Battery(percent: nil, voltage: nil, string: BatteryState.error, display: nil)
- } catch {
- debugPrint(
- "NightscoutManager: \(#function) \(DebuggingIdentifiers.failed) failed to setup battery from core data"
- )
- return Battery(percent: nil, voltage: nil, string: BatteryState.error, display: nil)
- }
- }
- }
- /// Asynchronously uploads the current status to Nightscout, including OpenAPS status, pump status, and uploader details.
- ///
- /// This function gathers and processes various pieces of information such as the "enacted" and "suggested" determinations,
- /// pump battery and reservoir levels, insulin-on-board (IOB), and the uploader's battery status. It ensures that only
- /// valid determinations are uploaded by filtering out duplicates and handling unit conversions based on the user's
- /// settings. If the status upload is successful, it updates the determination storage to mark them as uploaded.
- ///
- /// Key steps:
- /// - Fetch the last unuploaded enacted and suggested determinations from the storage.
- /// - Retrieve pump-related data such as battery, reservoir, and status.
- /// - Parse determinations to ensure they are properly formatted for Nightscout, including unit conversions if needed.
- /// - Construct an `OpenAPSStatus` object with relevant information for upload.
- /// - Construct a `NightscoutStatus` object with all gathered data.
- /// - Attempt to upload the status to Nightscout. On success, update the storage to mark determinations as uploaded.
- /// - Schedule a task to upload pod age data separately.
- ///
- /// - Note: Ensure `nightscoutAPI` is initialized and `isUploadEnabled` is set to `true` before invoking this function.
- /// - Returns: Nothing.
- func uploadDeviceStatus() async throws {
- guard let nightscout = nightscoutAPI, isUploadEnabled else {
- debug(.nightscout, "NS API not available or upload disabled. Aborting NS Status upload.")
- return
- }
- let results = try await CoreDataStack.shared.fetchEntitiesAsync(
- ofType: TDDStored.self,
- onContext: backgroundContext,
- predicate: NSPredicate.predicateFor30MinAgo,
- key: "date",
- ascending: false,
- fetchLimit: 1
- )
- let tdd: Decimal? = await backgroundContext.perform {
- (results as? [TDDStored])?.first?.total as? Decimal
- }
- // Suggested / Enacted
- async let enactedDeterminationID = determinationStorage
- .fetchLastDeterminationObjectID(predicate: NSPredicate.enactedDeterminationsNotYetUploadedToNightscout)
- async let suggestedDeterminationID = determinationStorage
- .fetchLastDeterminationObjectID(predicate: NSPredicate.suggestedDeterminationsNotYetUploadedToNightscout)
- // OpenAPS Status
- async let fetchedBattery = fetchBattery()
- async let fetchedReservoir = Decimal(from: storage.retrieveRawAsync(OpenAPS.Monitor.reservoir) ?? "0")
- async let fetchedIOBEntry = storage.retrieveAsync(OpenAPS.Monitor.iob, as: [IOBEntry].self)
- async let fetchedPumpStatus = storage.retrieveAsync(OpenAPS.Monitor.status, as: PumpStatus.self)
- var (fetchedEnactedDetermination, fetchedSuggestedDetermination) = try await (
- determinationStorage.getOrefDeterminationNotYetUploadedToNightscout(enactedDeterminationID),
- determinationStorage.getOrefDeterminationNotYetUploadedToNightscout(suggestedDeterminationID)
- )
- // Guard to ensure both determinations are not nil
- guard fetchedEnactedDetermination != nil || fetchedSuggestedDetermination != nil else {
- debug(
- .nightscout,
- "Both fetchedEnactedDetermination and fetchedSuggestedDetermination are nil. Aborting NS Status upload."
- )
- return
- }
- // Unwrap fetchedSuggestedDetermination and manipulate the timestamp field to ensure deliverAt and timestamp for a suggestion truly match!
- var modifiedSuggestedDetermination = fetchedSuggestedDetermination
- if var suggestion = fetchedSuggestedDetermination {
- suggestion.timestamp = suggestion.deliverAt
- if settingsManager.settings.units == .mmolL {
- suggestion.reason = parseReasonGlucoseValuesToMmolL(suggestion.reason)
- // TODO: verify that these parsings are needed for 3rd party apps, e.g., LoopFollow
- suggestion.current_target = suggestion.current_target?.asMmolL
- suggestion.minGuardBG = suggestion.minGuardBG?.asMmolL
- suggestion.minPredBG = suggestion.minPredBG?.asMmolL
- suggestion.threshold = suggestion.threshold?.asMmolL
- }
- suggestion.reason = injectTDD(into: suggestion.reason, tdd: tdd)
- suggestion.tdd = tdd
- // Check whether the last suggestion that was uploaded is the same that is fetched again when we are attempting to upload the enacted determination
- // Apparently we are too fast; so the flag update is not fast enough to have the predicate filter last suggestion out
- // If this check is truthy, set suggestion to nil so it's not uploaded again
- if let lastSuggested = lastSuggestedDetermination, lastSuggested.deliverAt == suggestion.deliverAt {
- modifiedSuggestedDetermination = nil
- } else {
- modifiedSuggestedDetermination = suggestion
- }
- }
- if var enacted = fetchedEnactedDetermination {
- if settingsManager.settings.units == .mmolL {
- enacted.reason = parseReasonGlucoseValuesToMmolL(enacted.reason)
- // TODO: verify that these parsings are needed for 3rd party apps, e.g., LoopFollow
- enacted.current_target = enacted.current_target?.asMmolL
- enacted.minGuardBG = enacted.minGuardBG?.asMmolL
- enacted.minPredBG = enacted.minPredBG?.asMmolL
- enacted.threshold = enacted.threshold?.asMmolL
- }
- enacted.reason = injectTDD(into: enacted.reason, tdd: tdd)
- enacted.tdd = tdd
- fetchedEnactedDetermination = enacted
- }
- // Calculate recommended bolus
- var recommendedBolus: Decimal = 0
- if let latest = fetchedSuggestedDetermination ?? fetchedEnactedDetermination {
- let minPredBG = latest.minPredBGFromReason ?? 0
- let simulatedCOB: Int16? = latest.cob.map { Int16(truncating: NSDecimalNumber(decimal: $0)) }
- let result = await bolusCalculationManager.handleBolusCalculation(
- carbs: 0,
- useFattyMealCorrection: false,
- useSuperBolus: false,
- lastLoopDate: apsManager.lastLoopDate,
- minPredBG: minPredBG,
- simulatedCOB: simulatedCOB,
- isBackdated: false
- )
- recommendedBolus = apsManager.roundBolus(amount: result.insulinCalculated)
- }
- // Bolus increment
- let bolusIncrement = settingsManager.preferences.bolusIncrement
- // Gather all relevant data for OpenAPS Status
- let iob = await fetchedIOBEntry
- let suggestedToUpload = modifiedSuggestedDetermination ?? lastSuggestedDetermination
- let enactedToUpload = fetchedEnactedDetermination ?? lastEnactedDetermination
- let openapsStatus = OpenAPSStatus(
- iob: iob?.first,
- suggested: suggestedToUpload,
- enacted: settingsManager.settings.closedLoop ? enactedToUpload : nil,
- version: Bundle.main.releaseVersionNumber ?? "Unknown",
- recommendedBolus: recommendedBolus
- )
- debug(.nightscout, "To be uploaded openapsStatus: \(openapsStatus)")
- // Gather all relevant data for NS Status
- let battery = await fetchedBattery
- let reservoir = await fetchedReservoir
- let pumpStatus = await fetchedPumpStatus
- let pump = NSPumpStatus(
- clock: Date(),
- battery: battery,
- reservoir: reservoir != 0xDEAD_BEEF ? reservoir : nil,
- status: pumpStatus,
- bolusIncrement: bolusIncrement
- )
- let batteryLevel = await UIDevice.current.batteryLevel
- let batteryState = await UIDevice.current.batteryState
- let uploader = Uploader(
- batteryVoltage: nil,
- battery: Int(batteryLevel * 100),
- isCharging: batteryState == .charging || batteryState == .full
- )
- let status = NightscoutStatus(
- device: NightscoutTreatment.local,
- openaps: openapsStatus,
- pump: pump,
- uploader: uploader
- )
- do {
- try await nightscout.uploadDeviceStatus(status)
- debug(.nightscout, "NSDeviceStatus with Determination uploaded")
- if let enacted = fetchedEnactedDetermination {
- await updateOrefDeterminationAsUploaded([enacted])
- debug(.nightscout, "Flagged last fetched enacted determination as uploaded")
- }
- if let suggested = fetchedSuggestedDetermination {
- await updateOrefDeterminationAsUploaded([suggested])
- debug(.nightscout, "Flagged last fetched suggested determination as uploaded")
- }
- if let lastEnactedDetermination = fetchedEnactedDetermination {
- self.lastEnactedDetermination = lastEnactedDetermination
- }
- if let lastSuggestedDetermination = fetchedSuggestedDetermination {
- self.lastSuggestedDetermination = lastSuggestedDetermination
- }
- } catch {
- debug(.nightscout, String(describing: error))
- }
- }
- private func updateOrefDeterminationAsUploaded(_ determination: [Determination]) async {
- await backgroundContext.perform {
- let ids = determination.map(\.id) as NSArray
- let fetchRequest: NSFetchRequest<OrefDetermination> = OrefDetermination.fetchRequest()
- fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
- do {
- let results = try self.backgroundContext.fetch(fetchRequest)
- for result in results {
- result.isUploadedToNS = true
- }
- guard self.backgroundContext.hasChanges else { return }
- try self.backgroundContext.save()
- } catch let error as NSError {
- debugPrint(
- "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
- )
- }
- }
- }
- func uploadProfiles() async throws {
- if isUploadEnabled {
- do {
- guard let sensitivities = await storage.retrieveAsync(
- OpenAPS.Settings.insulinSensitivities,
- as: InsulinSensitivities.self
- ) else {
- debug(.nightscout, "NightscoutManager uploadProfile: error loading insulinSensitivities")
- return
- }
- guard let targets = await storage.retrieveAsync(OpenAPS.Settings.bgTargets, as: BGTargets.self) else {
- debug(.nightscout, "NightscoutManager uploadProfile: error loading bgTargets")
- return
- }
- guard let carbRatios = await storage.retrieveAsync(OpenAPS.Settings.carbRatios, as: CarbRatios.self) else {
- debug(.nightscout, "NightscoutManager uploadProfile: error loading carbRatios")
- return
- }
- guard let basalProfile = await storage.retrieveAsync(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self)
- else {
- debug(.nightscout, "NightscoutManager uploadProfile: error loading basalProfile")
- return
- }
- let shouldParseToMmolL = settingsManager.settings.units == .mmolL
- let sens = sensitivities.sensitivities.map { item in
- NightscoutTimevalue(
- time: String(item.start.prefix(5)),
- value: !shouldParseToMmolL ? item.sensitivity : item.sensitivity.asMmolL,
- timeAsSeconds: item.offset * 60
- )
- }
- let targetLow = targets.targets.map { item in
- NightscoutTimevalue(
- time: String(item.start.prefix(5)),
- value: !shouldParseToMmolL ? item.low : item.low.asMmolL,
- timeAsSeconds: item.offset * 60
- )
- }
- let targetHigh = targets.targets.map { item in
- NightscoutTimevalue(
- time: String(item.start.prefix(5)),
- value: !shouldParseToMmolL ? item.high : item.high.asMmolL,
- timeAsSeconds: item.offset * 60
- )
- }
- let cr = carbRatios.schedule.map { item in
- NightscoutTimevalue(
- time: String(item.start.prefix(5)),
- value: item.ratio,
- timeAsSeconds: item.offset * 60
- )
- }
- let basal = basalProfile.map { item in
- NightscoutTimevalue(
- time: String(item.start.prefix(5)),
- value: item.rate,
- timeAsSeconds: item.minutes * 60
- )
- }
- let nsUnits: String = {
- switch settingsManager.settings.units {
- case .mgdL:
- return "mg/dl"
- case .mmolL:
- return "mmol"
- }
- }()
- var carbsHr: Decimal = 0
- if let isf = sensitivities.sensitivities.map(\.sensitivity).first,
- let cr = carbRatios.schedule.map(\.ratio).first,
- isf > 0, cr > 0
- {
- carbsHr = settingsManager.preferences.min5mCarbimpact * 12 / isf * cr
- carbsHr = Decimal(round(Double(carbsHr) * 10.0)) / 10
- }
- let scheduledProfile = ScheduledNightscoutProfile(
- dia: settingsManager.pumpSettings.insulinActionCurve,
- carbs_hr: Int(carbsHr),
- delay: 0,
- timezone: TimeZone.current.identifier,
- target_low: targetLow,
- target_high: targetHigh,
- sens: sens,
- basal: basal,
- carbratio: cr,
- units: nsUnits
- )
- let defaultProfile = "default"
- let now = Date()
- let bundleIdentifier = Bundle.main.bundleIdentifier ?? ""
- let deviceToken = UserDefaults.standard.string(forKey: "deviceToken") ?? ""
- let isAPNSProduction = UserDefaults.standard.bool(forKey: "isAPNSProduction")
- let presetOverrides = try await overridesStorage.getPresetOverridesForNightscout()
- let teamID = Bundle.main.object(forInfoDictionaryKey: "TeamID") as? String ?? ""
- let expireDate = BuildDetails.shared.calculateExpirationDate()
- let profileStore = NightscoutProfileStore(
- defaultProfile: defaultProfile,
- startDate: now,
- mills: Int(now.timeIntervalSince1970) * 1000,
- units: nsUnits,
- enteredBy: NightscoutTreatment.local,
- store: [defaultProfile: scheduledProfile],
- bundleIdentifier: bundleIdentifier,
- deviceToken: deviceToken,
- isAPNSProduction: isAPNSProduction,
- overridePresets: presetOverrides,
- teamID: teamID,
- expirationDate: expireDate
- )
- guard let nightscout = nightscoutAPI, isNetworkReachable else {
- if !isNetworkReachable {
- debug(.nightscout, "Network issues; aborting upload")
- }
- debug(.nightscout, "Nightscout API service not available; aborting upload")
- return
- }
- try await nightscout.uploadProfile(profileStore)
- BuildDetails.shared.recordUploadedExpireDate(expireDate: expireDate)
- debug(.nightscout, "Profile uploaded")
- } catch {
- debug(.nightscout, "NightscoutManager uploadProfile: \(error)")
- throw error
- }
- } else {
- debug(.nightscout, "Upload to NS disabled; aborting profile uploaded")
- }
- }
- func importSettings() async -> ScheduledNightscoutProfile? {
- guard let nightscout = nightscoutAPI else {
- debug(.nightscout, "NS API not available. Aborting NS Status upload.")
- return nil
- }
- do {
- return try await nightscout.importSettings()
- } catch {
- debug(.nightscout, String(describing: error))
- return nil
- }
- }
- func uploadGlucose() async {
- do {
- try await uploadGlucose(glucoseStorage.getGlucoseNotYetUploadedToNightscout())
- try await uploadNonCoreDataTreatments(glucoseStorage.getCGMStateNotYetUploadedToNightscout())
- } catch {
- debug(
- .nightscout,
- "\(DebuggingIdentifiers.failed) failed to upload glucose with error: \(error)"
- )
- }
- }
- func uploadPumpHistory() async {
- do {
- try await uploadPumpHistory(pumpHistoryStorage.getPumpHistoryNotYetUploadedToNightscout())
- } catch {
- debug(
- .nightscout,
- "\(DebuggingIdentifiers.failed) failed to upload pump history with error: \(error)"
- )
- }
- }
- func uploadCarbs() async {
- do {
- try await uploadCarbs(carbsStorage.getCarbsNotYetUploadedToNightscout())
- try await uploadCarbs(carbsStorage.getFPUsNotYetUploadedToNightscout())
- } catch {
- debug(
- .nightscout,
- "\(DebuggingIdentifiers.failed) failed to upload carbs with error: \(error)"
- )
- }
- }
- func uploadOverrides() async {
- do {
- try await uploadOverrides(overridesStorage.getOverridesNotYetUploadedToNightscout())
- try await uploadOverrideRuns(overridesStorage.getOverrideRunsNotYetUploadedToNightscout())
- } catch {
- debug(
- .nightscout,
- "\(DebuggingIdentifiers.failed) failed to upload overrides with error: \(error)"
- )
- }
- }
- func uploadTempTargets() async {
- do {
- try await uploadTempTargets(await tempTargetsStorage.getTempTargetsNotYetUploadedToNightscout())
- try await uploadTempTargetRuns(await tempTargetsStorage.getTempTargetRunsNotYetUploadedToNightscout())
- } catch {
- debug(
- .nightscout,
- "\(DebuggingIdentifiers.failed) failed to upload temp targets with error: \(error)"
- )
- }
- }
- private func uploadGlucose(_ glucose: [BloodGlucose]) async {
- guard !glucose.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled, isUploadGlucoseEnabled else {
- return
- }
- do {
- // Upload in Batches of 100
- for chunk in glucose.chunks(ofCount: 100) {
- try await nightscout.uploadGlucose(Array(chunk))
- }
- // If successful, update the isUploadedToNS property of the GlucoseStored objects
- await updateGlucoseAsUploaded(glucose)
- debug(.nightscout, "Glucose uploaded")
- } catch {
- debug(.nightscout, "Upload of glucose failed: \(error)")
- }
- }
- private func updateGlucoseAsUploaded(_ glucose: [BloodGlucose]) async {
- await backgroundContext.perform {
- let ids = glucose.map(\.id) as NSArray
- let fetchRequest: NSFetchRequest<GlucoseStored> = GlucoseStored.fetchRequest()
- fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
- do {
- let results = try self.backgroundContext.fetch(fetchRequest)
- for result in results {
- result.isUploadedToNS = true
- }
- guard self.backgroundContext.hasChanges else { return }
- try self.backgroundContext.save()
- } catch let error as NSError {
- debugPrint(
- "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
- )
- }
- }
- }
- private func uploadNonCoreDataTreatments(_ treatments: [NightscoutTreatment]) async {
- guard !treatments.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
- return
- }
- do {
- for chunk in treatments.chunks(ofCount: 100) {
- try await nightscout.uploadTreatments(Array(chunk))
- }
- debug(.nightscout, "Treatments uploaded")
- } catch {
- debug(.nightscout, String(describing: error))
- }
- }
- private func uploadPumpHistory(_ treatments: [NightscoutTreatment]) async {
- guard !treatments.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
- return
- }
- do {
- for chunk in treatments.chunks(ofCount: 100) {
- try await nightscout.uploadTreatments(Array(chunk))
- }
- await updatePumpEventStoredsAsUploaded(treatments)
- debug(.nightscout, "Treatments uploaded")
- } catch {
- debug(.nightscout, String(describing: error))
- }
- }
- private func updatePumpEventStoredsAsUploaded(_ treatments: [NightscoutTreatment]) async {
- await backgroundContext.perform {
- let ids = treatments.map(\.id) as NSArray
- let fetchRequest: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
- fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
- do {
- let results = try self.backgroundContext.fetch(fetchRequest)
- for result in results {
- result.isUploadedToNS = true
- }
- guard self.backgroundContext.hasChanges else { return }
- try self.backgroundContext.save()
- } catch let error as NSError {
- debugPrint(
- "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
- )
- }
- }
- }
- private func uploadCarbs(_ treatments: [NightscoutTreatment]) async {
- guard !treatments.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
- return
- }
- do {
- for chunk in treatments.chunks(ofCount: 100) {
- try await nightscout.uploadTreatments(Array(chunk))
- }
- // If successful, update the isUploadedToNS property of the CarbEntryStored objects
- await updateCarbsAsUploaded(treatments)
- debug(.nightscout, "Treatments uploaded")
- } catch {
- debug(.nightscout, String(describing: error))
- }
- }
- private func updateCarbsAsUploaded(_ treatments: [NightscoutTreatment]) async {
- await backgroundContext.perform {
- let ids = treatments.map(\.id) as NSArray
- let fetchRequest: NSFetchRequest<CarbEntryStored> = CarbEntryStored.fetchRequest()
- fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
- do {
- let results = try self.backgroundContext.fetch(fetchRequest)
- for result in results {
- result.isUploadedToNS = true
- }
- guard self.backgroundContext.hasChanges else { return }
- try self.backgroundContext.save()
- } catch let error as NSError {
- debugPrint(
- "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
- )
- }
- }
- }
- private func uploadOverrides(_ overrides: [NightscoutExercise]) async {
- guard !overrides.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
- return
- }
- do {
- var processedOverrides: [NightscoutExercise] = []
- for override in overrides {
- guard let createdAtString = override.created_at as? String else {
- continue
- }
- /// Check for an existing stored override and delete if needed
- /// This is neccessary to delete original entry in NS when a running override gets customized with a new duration.
- try await overridesStorage.checkIfShouldDeleteNightscoutOverrideEntry(
- forCreatedAt: createdAtString,
- newDuration: override.duration,
- using: nightscout
- )
- processedOverrides.append(override)
- }
- for chunk in processedOverrides.chunks(ofCount: 100) {
- try await nightscout.uploadOverrides(Array(chunk))
- }
- // If successful, update the isUploadedToNS property of the OverrideStored objects
- await updateOverridesAsUploaded(processedOverrides)
- debug(.nightscout, "Overrides uploaded")
- } catch {
- debug(.nightscout, String(describing: error))
- }
- }
- private func updateOverridesAsUploaded(_ overrides: [NightscoutExercise]) async {
- await backgroundContext.perform {
- let ids = overrides.map(\.id) as NSArray
- let fetchRequest: NSFetchRequest<OverrideStored> = OverrideStored.fetchRequest()
- fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
- do {
- let results = try self.backgroundContext.fetch(fetchRequest)
- for result in results {
- result.isUploadedToNS = true
- }
- guard self.backgroundContext.hasChanges else { return }
- try self.backgroundContext.save()
- } catch let error as NSError {
- debugPrint(
- "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
- )
- }
- }
- }
- private func uploadOverrideRuns(_ overrideRuns: [NightscoutExercise]) async {
- guard !overrideRuns.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
- return
- }
- do {
- var processedOverrideRuns: [NightscoutExercise] = []
- for overrideRun in overrideRuns {
- guard let createdAtString = overrideRun.created_at as? String else {
- continue
- }
- /// Check for an existing stored override and delete if needed
- /// This is neccessary when a running override is cancelled, or replaced with a new override, before its duration is over.
- try await overridesStorage.checkIfShouldDeleteNightscoutOverrideEntry(
- forCreatedAt: createdAtString,
- newDuration: overrideRun.duration,
- using: nightscout
- )
- processedOverrideRuns.append(overrideRun)
- }
- for chunk in processedOverrideRuns.chunks(ofCount: 100) {
- try await nightscout.uploadOverrides(Array(chunk))
- }
- // If successful, update the isUploadedToNS property of the OverrideRunStored objects
- await updateOverrideRunsAsUploaded(overrideRuns)
- debug(.nightscout, "Overrides uploaded")
- } catch {
- debug(.nightscout, String(describing: error))
- }
- }
- private func updateOverrideRunsAsUploaded(_ overrideRuns: [NightscoutExercise]) async {
- await backgroundContext.perform {
- let ids = overrideRuns.map(\.id) as NSArray
- let fetchRequest: NSFetchRequest<OverrideRunStored> = OverrideRunStored.fetchRequest()
- fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
- do {
- let results = try self.backgroundContext.fetch(fetchRequest)
- for result in results {
- result.isUploadedToNS = true
- }
- guard self.backgroundContext.hasChanges else { return }
- try self.backgroundContext.save()
- } catch let error as NSError {
- debugPrint(
- "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS: \(error.userInfo)"
- )
- }
- }
- }
- private func uploadTempTargets(_ tempTargets: [NightscoutTreatment]) async {
- guard !tempTargets.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
- return
- }
- do {
- for chunk in tempTargets.chunks(ofCount: 100) {
- try await nightscout.uploadTreatments(Array(chunk))
- }
- // If successful, update the isUploadedToNS property of the TempTargetStored objects
- await updateTempTargetsAsUploaded(tempTargets)
- debug(.nightscout, "Temp Targets uploaded")
- } catch {
- debug(.nightscout, String(describing: error))
- }
- }
- private func updateTempTargetsAsUploaded(_ tempTargets: [NightscoutTreatment]) async {
- await backgroundContext.perform {
- let ids = tempTargets.map(\.id) as NSArray
- let fetchRequest: NSFetchRequest<TempTargetStored> = TempTargetStored.fetchRequest()
- fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
- do {
- let results = try self.backgroundContext.fetch(fetchRequest)
- for result in results {
- result.isUploadedToNS = true
- }
- guard self.backgroundContext.hasChanges else { return }
- try self.backgroundContext.save()
- } catch let error as NSError {
- debugPrint(
- "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS for TempTargetStored: \(error.userInfo)"
- )
- }
- }
- }
- private func uploadTempTargetRuns(_ tempTargetRuns: [NightscoutTreatment]) async {
- guard !tempTargetRuns.isEmpty, let nightscout = nightscoutAPI, isUploadEnabled else {
- return
- }
- do {
- for chunk in tempTargetRuns.chunks(ofCount: 100) {
- try await nightscout.uploadTreatments(Array(chunk))
- }
- // If successful, update the isUploadedToNS property of the TempTargetRunStored objects
- await updateTempTargetRunsAsUploaded(tempTargetRuns)
- debug(.nightscout, "Temp Target Runs uploaded")
- } catch {
- debug(.nightscout, String(describing: error))
- }
- }
- private func updateTempTargetRunsAsUploaded(_ tempTargetRuns: [NightscoutTreatment]) async {
- await backgroundContext.perform {
- let ids = tempTargetRuns.map(\.id) as NSArray
- let fetchRequest: NSFetchRequest<TempTargetRunStored> = TempTargetRunStored.fetchRequest()
- fetchRequest.predicate = NSPredicate(format: "id IN %@", ids)
- do {
- let results = try self.backgroundContext.fetch(fetchRequest)
- for result in results {
- result.isUploadedToNS = true
- }
- guard self.backgroundContext.hasChanges else { return }
- try self.backgroundContext.save()
- } catch let error as NSError {
- debugPrint(
- "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update isUploadedToNS for TempTargetRunStored: \(error.userInfo)"
- )
- }
- }
- }
- // TODO: have this checked; this has never actually written anything to file; the entire logic of this function seems broken
- func uploadNoteTreatment(note: String) async {
- let uploadedNotes = storage.retrieve(OpenAPS.Nightscout.uploadedNotes, as: [NightscoutTreatment].self) ?? []
- let now = Date()
- if uploadedNotes.last?.notes != note || (uploadedNotes.last?.createdAt ?? .distantPast) != now {
- let noteTreatment = NightscoutTreatment(
- eventType: .nsNote,
- createdAt: now,
- enteredBy: NightscoutTreatment.local,
- notes: note,
- targetTop: nil,
- targetBottom: nil
- )
- await uploadNonCoreDataTreatments([noteTreatment])
- // TODO: fix/adjust, if necessary
- // await uploadTreatments([noteTreatment], fileToSave: OpenAPS.Nightscout.uploadedNotes)
- }
- }
- }
- extension Array {
- func chunks(ofCount count: Int) -> [[Element]] {
- stride(from: 0, to: self.count, by: count).map {
- Array(self[$0 ..< Swift.min($0 + count, self.count)])
- }
- }
- }
- extension BaseNightscoutManager {
- /**
- Converts glucose-related values in the given `reason` string to mmol/L, including ranges (e.g., `ISF: 54→54`), comparisons (e.g., `maxDelta 37 > 20% of BG 95`), and both positive and negative values (e.g., `Dev: -36`).
- - Parameters:
- - reason: The string containing glucose-related values to be converted.
- - Returns:
- A string with glucose values converted to mmol/L.
- - Glucose tags handled: `ISF:`, `Target:`, `minPredBG`, `minGuardBG`, `IOBpredBG`, `COBpredBG`, `UAMpredBG`, `Dev:`, `maxDelta`, `BGI`.
- */
- // TODO: Consolidate all mmol parsing methods (in TagCloudView, NightscoutManager and HomeRootView) to one central func
- func parseReasonGlucoseValuesToMmolL(_ reason: String) -> String {
- let patterns = [
- "(?:ISF|Target):\\s*-?\\d+\\.?\\d*(?:→-?\\d+\\.?\\d*)+",
- // ISF or Target with any number of “→value” segments after the first number
- "Dev:\\s*-?\\d+\\.?\\d*", // Dev pattern
- "BGI:\\s*-?\\d+\\.?\\d*", // BGI pattern
- "Target:\\s*-?\\d+\\.?\\d*", // Target pattern
- "(?:minPredBG|minGuardBG|IOBpredBG|COBpredBG|UAMpredBG)\\s+-?\\d+\\.?\\d*(?:<-?\\d+\\.?\\d*)?", // minPredBG, etc.
- "minGuardBG\\s+-?\\d+\\.?\\d*<-?\\d+\\.?\\d*", // minGuardBG x<y
- "Eventual BG\\s+-?\\d+\\.?\\d*\\s*>=\\s*-?\\d+\\.?\\d*", // Eventual BG x >= target
- "Eventual BG\\s+-?\\d+\\.?\\d*\\s*<\\s*-?\\d+\\.?\\d*", // Eventual BG x < target
- "\\S+\\s+\\d+\\s*>\\s*\\d+%\\s+of\\s+BG\\s+\\d+" // maxDelta x > y% of BG z
- ]
- let pattern = patterns.joined(separator: "|")
- let regex = try! NSRegularExpression(pattern: pattern)
- func convertToMmolL(_ value: String) -> String {
- if let glucoseValue = Double(value.replacingOccurrences(of: "[^\\d.-]", with: "", options: .regularExpression)) {
- let mmolValue = Decimal(glucoseValue).asMmolL
- return mmolValue.description
- }
- return value
- }
- let matches = regex.matches(in: reason, range: NSRange(reason.startIndex..., in: reason))
- var updatedReason = reason
- for match in matches.reversed() {
- guard let range = Range(match.range, in: reason) else { continue }
- let glucoseValueString = String(reason[range])
- if glucoseValueString.contains("→") {
- // Handle ISF: X→Y… or Target: X→Y→Z…
- let parts = glucoseValueString.components(separatedBy: ":")
- guard parts.count == 2 else { continue }
- let targetOrISF = parts[0].trimmingCharacters(in: .whitespaces)
- let values = parts[1]
- .components(separatedBy: "→")
- .map { $0.trimmingCharacters(in: .whitespaces) }
- let convertedValues = values.map { convertToMmolL($0) }
- let joined = convertedValues.joined(separator: "→")
- let rebuilt = "\(targetOrISF): \(joined)"
- updatedReason.replaceSubrange(range, with: rebuilt)
- } else if glucoseValueString.contains("Eventual BG"), glucoseValueString.contains("<") {
- // Handle Eventual BG XX < target
- let parts = glucoseValueString.components(separatedBy: "<")
- if parts.count == 2 {
- let bgPart = parts[0].replacingOccurrences(of: "Eventual BG", with: "").trimmingCharacters(in: .whitespaces)
- let targetValue = parts[1].trimmingCharacters(in: .whitespaces)
- let formattedBGPart = convertToMmolL(bgPart)
- let formattedTargetValue = convertToMmolL(targetValue)
- let formattedString = "Eventual BG \(formattedBGPart)<\(formattedTargetValue)"
- updatedReason.replaceSubrange(range, with: formattedString)
- }
- } else if glucoseValueString.contains("<") {
- // Handle minGuardBG (or minPredBG, etc.) x < y
- let parts = glucoseValueString.components(separatedBy: "<")
- if parts.count == 2 {
- let firstValue = parts[0].trimmingCharacters(in: .whitespaces)
- let secondValue = parts[1].trimmingCharacters(in: .whitespaces)
- let formattedFirstValue = convertToMmolL(firstValue)
- let formattedSecondValue = convertToMmolL(secondValue)
- let formattedString = "minGuardBG \(formattedFirstValue)<\(formattedSecondValue)"
- updatedReason.replaceSubrange(range, with: formattedString)
- }
- } else if glucoseValueString.contains(">=") {
- // Handle "Eventual BG X >= Y"
- let parts = glucoseValueString.components(separatedBy: " >= ")
- if parts.count == 2 {
- let firstValue = parts[0].replacingOccurrences(of: "Eventual BG", with: "")
- .trimmingCharacters(in: .whitespaces)
- let secondValue = parts[1].trimmingCharacters(in: .whitespaces)
- let formattedFirstValue = convertToMmolL(firstValue)
- let formattedSecondValue = convertToMmolL(secondValue)
- let formattedString = "Eventual BG \(formattedFirstValue) >= \(formattedSecondValue)"
- updatedReason.replaceSubrange(range, with: formattedString)
- }
- } else if glucoseValueString.contains(">"), glucoseValueString.contains("BG") {
- // Handle "maxDelta 37 > 20% of BG 95" style
- let localPattern = "(\\d+) > (\\d+)% of BG (\\d+)"
- let localRegex = try! NSRegularExpression(pattern: localPattern)
- let localMatches = localRegex.matches(
- in: glucoseValueString,
- range: NSRange(glucoseValueString.startIndex..., in: glucoseValueString)
- )
- if let localMatch = localMatches.first, localMatch.numberOfRanges == 4 {
- let range1 = Range(localMatch.range(at: 1), in: glucoseValueString)!
- let range2 = Range(localMatch.range(at: 2), in: glucoseValueString)!
- let range3 = Range(localMatch.range(at: 3), in: glucoseValueString)!
- let firstValue = convertToMmolL(String(glucoseValueString[range1]))
- let thirdValue = convertToMmolL(String(glucoseValueString[range3]))
- let oldSnippet =
- "\(glucoseValueString[range1]) > \(glucoseValueString[range2])% of BG \(glucoseValueString[range3])"
- let newSnippet = "\(firstValue) > \(glucoseValueString[range2])% of BG \(thirdValue)"
- let replaced = glucoseValueString.replacingOccurrences(of: oldSnippet, with: newSnippet)
- updatedReason.replaceSubrange(range, with: replaced)
- }
- } else {
- // Handle everything else, e.g., "minPredBG 39", "Dev: 5", etc.
- let parts = glucoseValueString.components(separatedBy: .whitespaces)
- if parts.count >= 2 {
- var metric = parts[0]
- let value = parts[1]
- // Add ":" to the metric only if it doesn't already end with ":"
- if !metric.hasSuffix(":") {
- metric += ":"
- }
- let formattedValue = convertToMmolL(value)
- let formattedString = "\(metric) \(formattedValue)"
- updatedReason.replaceSubrange(range, with: formattedString)
- }
- }
- }
- return updatedReason
- }
- }
- extension BaseNightscoutManager {
- /// Injects TDD into the provided `reason` string if TDD is available.
- ///
- /// - Parameters:
- /// - reason: The raw reason string (e.g., "minPredBG=5.2, IOBpredBG=102").
- /// - tdd: The total daily dose of insulin.
- /// - Returns: A modified reason string that includes "TDD: x U" appended
- /// after the last matched prediction term, or at the end if no match is found.
- func injectTDD(into reason: String, tdd: Decimal?) -> String {
- guard let tdd = tdd else { return reason }
- let tddString = ", TDD: \(tdd) U"
- // Regex that matches any of the keywords followed by an optional colon, whitespace, then a number.
- let pattern = "(minPredBG|minGuardBG|IOBpredBG|COBpredBG|UAMpredBG):?\\s*(-?\\d+(?:\\.\\d+)?)"
- guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
- return reason + tddString
- }
- // Split the reason at the first semicolon (if present)
- let components = reason.split(separator: ";", maxSplits: 1, omittingEmptySubsequences: false)
- let mainPart = String(components[0])
- let tailPart = components.count > 1 ? ";" + components[1] : ""
- // Search only in the main part for the keywords
- let nsRange = NSRange(mainPart.startIndex ..< mainPart.endIndex, in: mainPart)
- let matches = regex.matches(in: mainPart, options: [], range: nsRange)
- // If found, insert TDD after the last occurrence in the main part.
- if let lastMatch = matches.last, let matchRange = Range(lastMatch.range, in: mainPart) {
- var modifiedMainPart = mainPart
- modifiedMainPart.insert(contentsOf: tddString, at: matchRange.upperBound)
- return modifiedMainPart + tailPart
- }
- // If no match is found, append TDD at the end of the original reason string.
- return reason + tddString
- }
- }
|