From 5c9a47e549e1048100a6e85608a13b078b3e52f7 Mon Sep 17 00:00:00 2001 From: Robert Date: Sun, 19 May 2024 17:48:32 +0200 Subject: [PATCH] Merge pull request #67 from avouspierre/alpha Update with tide pool + calibration + CGM home screen --- .gitmodules | 3 + BuildDetails.plist | 8 + FreeAPS.xcodeproj/project.pbxproj | 68 +++ .../xcshareddata/swiftpm/Package.resolved | 123 ++--- .../xcshareddata/xcschemes/FreeAPS X.xcscheme | 30 +- FreeAPS.xcworkspace/contents.xcworkspacedata | 3 + .../xcshareddata/swiftpm/Package.resolved | 19 +- .../CGM/Calibrations/CalibrationService.swift | 119 +++++ FreeAPS/Sources/APS/CGM/PluginSource.swift | 9 +- FreeAPS/Sources/APS/FetchGlucoseManager.swift | 39 +- FreeAPS/Sources/APS/PluginManager.swift | 2 + FreeAPS/Sources/Assemblies/APSAssembly.swift | 1 + .../Sources/Assemblies/NetworkAssembly.swift | 1 + FreeAPS/Sources/Models/BloodGlucose.swift | 13 + FreeAPS/Sources/Models/CarbsEntry.swift | 23 + FreeAPS/Sources/Models/PumpHistoryEvent.swift | 27 ++ .../Sources/Modules/CGM/CGMStateModel.swift | 19 + .../Modules/CGM/View/CGMRootView.swift | 20 + .../Calibrations/CalibrationsDataFlow.swift | 13 + .../Calibrations/CalibrationsProvider.swift | 3 + .../Calibrations/CalibrationsStateModel.swift | 73 +++ .../Calibrations/View/CalibrationsChart.swift | 60 +++ .../View/CalibrationsRootView.swift | 109 +++++ .../Modules/DataTable/DataTableProvider.swift | 21 +- .../Sources/Modules/Home/HomeStateModel.swift | 16 +- .../Home/View/Header/CurrentGlucoseView.swift | 29 +- .../Modules/Home/View/HomeRootView.swift | 15 +- .../View/NightscoutConfigRootView.swift | 7 + .../Modules/Settings/SettingsProvider.swift | 4 +- .../Modules/Settings/SettingsStateModel.swift | 27 ++ .../Settings/View/SettingsRootView.swift | 28 ++ .../Settings/View/TidePoolConfigView.swift | 45 ++ FreeAPS/Sources/Router/Screen.swift | 8 +- .../Services/Network/TidepoolManager.swift | 426 ++++++++++++++++++ FreeAPSTests/CalibrationsTests.swift | 55 +++ FreeAPSTests/PluginManagerTests.swift | 71 +++ TidepoolService | 1 + scripts/swiftformat.sh | 2 +- 38 files changed, 1427 insertions(+), 113 deletions(-) create mode 100644 BuildDetails.plist create mode 100644 FreeAPS/Sources/APS/CGM/Calibrations/CalibrationService.swift create mode 100644 FreeAPS/Sources/Modules/Calibrations/CalibrationsDataFlow.swift create mode 100644 FreeAPS/Sources/Modules/Calibrations/CalibrationsProvider.swift create mode 100644 FreeAPS/Sources/Modules/Calibrations/CalibrationsStateModel.swift create mode 100644 FreeAPS/Sources/Modules/Calibrations/View/CalibrationsChart.swift create mode 100644 FreeAPS/Sources/Modules/Calibrations/View/CalibrationsRootView.swift create mode 100644 FreeAPS/Sources/Modules/Settings/View/TidePoolConfigView.swift create mode 100644 FreeAPS/Sources/Services/Network/TidepoolManager.swift create mode 100644 FreeAPSTests/CalibrationsTests.swift create mode 100644 FreeAPSTests/PluginManagerTests.swift create mode 160000 TidepoolService diff --git a/.gitmodules b/.gitmodules index 5e724b4e08..43705f607b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -34,3 +34,6 @@ path = LibreTransmitter url = https://github.com/LoopKit/LibreTransmitter.git branch = main +[submodule "TidepoolService"] + path = TidepoolService + url = https://github.com/LoopKit/TidepoolService.git diff --git a/BuildDetails.plist b/BuildDetails.plist new file mode 100644 index 0000000000..6a06c7327b --- /dev/null +++ b/BuildDetails.plist @@ -0,0 +1,8 @@ + + + + + TidepoolServiceClientId + diy-loop + + diff --git a/FreeAPS.xcodeproj/project.pbxproj b/FreeAPS.xcodeproj/project.pbxproj index b606d88dc8..25c6b2462a 100644 --- a/FreeAPS.xcodeproj/project.pbxproj +++ b/FreeAPS.xcodeproj/project.pbxproj @@ -325,6 +325,10 @@ CC41E29A2B1E1F460070974F /* HistoryLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC41E2992B1E1F460070974F /* HistoryLayout.swift */; }; CC6C406E2ACDD69E009B8058 /* RawFetchedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */; }; CD78BB94E43B249D60CC1A1B /* NotificationsConfigRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22963BD06A9C83959D4914E4 /* NotificationsConfigRootView.swift */; }; + CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */; }; + CE1F6DDB2BAE08B60064EB8D /* TidepoolManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1F6DDA2BAE08B60064EB8D /* TidepoolManager.swift */; }; + CE1F6DE72BAF1A180064EB8D /* BuildDetails.plist in Resources */ = {isa = PBXBuildFile; fileRef = CE1F6DE62BAF1A180064EB8D /* BuildDetails.plist */; }; + CE1F6DE92BAF37C90064EB8D /* TidePoolConfigView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1F6DE82BAF37C90064EB8D /* TidePoolConfigView.swift */; }; CE1F2B9A2B011CC0002EDCA0 /* AutoISFConfDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1F2B992B011CC0002EDCA0 /* AutoISFConfDataFlow.swift */; }; CE1F2B9C2B011CCF002EDCA0 /* AutoISFConfProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1F2B9B2B011CCF002EDCA0 /* AutoISFConfProvider.swift */; }; CE1F2B9E2B011CDE002EDCA0 /* AutoISFConfStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1F2B9D2B011CDE002EDCA0 /* AutoISFConfStateModel.swift */; }; @@ -376,6 +380,13 @@ CEB434E728B9053300B70274 /* LoopUIColorPalette+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB434E628B9053300B70274 /* LoopUIColorPalette+Default.swift */; }; CEB434FD28B90B7C00B70274 /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = CEB434FC28B90B7C00B70274 /* SwiftCharts */; }; CEB434FE28B90B8C00B70274 /* SwiftCharts in Embed Frameworks */ = {isa = PBXBuildFile; productRef = CEB434FC28B90B7C00B70274 /* SwiftCharts */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + CEE9A6552BBB418300EB5194 /* CalibrationsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE9A64F2BBB418300EB5194 /* CalibrationsProvider.swift */; }; + CEE9A6562BBB418300EB5194 /* CalibrationsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE9A6512BBB418300EB5194 /* CalibrationsRootView.swift */; }; + CEE9A6572BBB418300EB5194 /* CalibrationsChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE9A6522BBB418300EB5194 /* CalibrationsChart.swift */; }; + CEE9A6582BBB418300EB5194 /* CalibrationsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE9A6532BBB418300EB5194 /* CalibrationsStateModel.swift */; }; + CEE9A6592BBB418300EB5194 /* CalibrationsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE9A6542BBB418300EB5194 /* CalibrationsDataFlow.swift */; }; + CEE9A65C2BBB41C800EB5194 /* CalibrationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE9A65B2BBB41C800EB5194 /* CalibrationService.swift */; }; + CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */; }; CECCB4262BDBDCF7006E41C4 /* carbPresetResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = CECCB4252BDBDCF7006E41C4 /* carbPresetResult.swift */; }; CECCB4222BDB85BC006E41C4 /* ListCarbsPresetIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CECCB4212BDB85BC006E41C4 /* ListCarbsPresetIntent.swift */; }; D2165E9D78EFF692C1DED1C6 /* AddTempTargetDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8A42073A2D03A278914448 /* AddTempTargetDataFlow.swift */; }; @@ -900,6 +911,10 @@ CE1F2B9B2B011CCF002EDCA0 /* AutoISFConfProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoISFConfProvider.swift; sourceTree = ""; }; CE1F2B9D2B011CDE002EDCA0 /* AutoISFConfStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoISFConfStateModel.swift; sourceTree = ""; }; CE1F2BA02B011CF5002EDCA0 /* AutoISFConfRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoISFConfRootView.swift; sourceTree = ""; }; + CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManagerTests.swift; sourceTree = ""; }; + CE1F6DDA2BAE08B60064EB8D /* TidepoolManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TidepoolManager.swift; sourceTree = ""; }; + CE1F6DE62BAF1A180064EB8D /* BuildDetails.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = BuildDetails.plist; sourceTree = ""; }; + CE1F6DE82BAF37C90064EB8D /* TidePoolConfigView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TidePoolConfigView.swift; sourceTree = ""; }; CE2FAD39297D93F0001A872C /* BloodGlucoseExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloodGlucoseExtensions.swift; sourceTree = ""; }; CE398D012977349800DF218F /* CryptoKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoKit.framework; path = System/Library/Frameworks/CryptoKit.framework; sourceTree = SDKROOT; }; CE398D17297C9EE800DF218F /* G7SensorKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = G7SensorKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -952,6 +967,13 @@ CEC751D729D88262006E9D24 /* MinimedKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MinimedKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; CECCB4252BDBDCF7006E41C4 /* carbPresetResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = carbPresetResult.swift; sourceTree = ""; }; CECCB4212BDB85BC006E41C4 /* ListCarbsPresetIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListCarbsPresetIntent.swift; sourceTree = ""; }; + CEE9A64F2BBB418300EB5194 /* CalibrationsProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationsProvider.swift; sourceTree = ""; }; + CEE9A6512BBB418300EB5194 /* CalibrationsRootView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationsRootView.swift; sourceTree = ""; }; + CEE9A6522BBB418300EB5194 /* CalibrationsChart.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationsChart.swift; sourceTree = ""; }; + CEE9A6532BBB418300EB5194 /* CalibrationsStateModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationsStateModel.swift; sourceTree = ""; }; + CEE9A6542BBB418300EB5194 /* CalibrationsDataFlow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CalibrationsDataFlow.swift; sourceTree = ""; }; + CEE9A65B2BBB41C800EB5194 /* CalibrationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalibrationService.swift; sourceTree = ""; }; + CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalibrationsTests.swift; sourceTree = ""; }; CFCFE0781F9074C2917890E8 /* ManualTempBasalStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ManualTempBasalStateModel.swift; sourceTree = ""; }; D0BDC6993C1087310EDFC428 /* CREditorRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CREditorRootView.swift; sourceTree = ""; }; D295A3F870E826BE371C0BB5 /* AutotuneConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AutotuneConfigStateModel.swift; sourceTree = ""; }; @@ -1275,6 +1297,7 @@ F2159A472BA60A0300A0B716 /* ContactTrick */, 195D80B22AF696EE00D25097 /* Dynamic */, BD7DA9A32AE06DBA00601B20 /* BolusCalculatorConfig */, + CEE9A64D2BBB411C00EB5194 /* Calibrations */, 190EBCC229FF134900BA767D /* StatConfig */, 19F95FF129F10F9C00314DDC /* Stat */, CE94597C29E9E1CD0047C9C6 /* WatchConfig */, @@ -1403,6 +1426,7 @@ isa = PBXGroup; children = ( 3811DE3C25C9D4A100A708ED /* SettingsRootView.swift */, + CE1F6DE82BAF37C90064EB8D /* TidePoolConfigView.swift */, ); path = View; sourceTree = ""; @@ -1444,6 +1468,7 @@ 3811DE9725C9D88300A708ED /* NightscoutManager.swift */, 38FE826925CC82DB001FF17A /* NetworkService.swift */, 38FE826C25CC8461001FF17A /* NightscoutAPI.swift */, + CE1F6DDA2BAE08B60064EB8D /* TidepoolManager.swift */, ); path = Network; sourceTree = ""; @@ -1609,6 +1634,7 @@ 3856933F270B57A00002C50D /* CGM */ = { isa = PBXGroup; children = ( + CEE9A65A2BBB41AD00EB5194 /* Calibrations */, F816825F28DB441800054060 /* BluetoothTransmitter.swift */, F816825D28DB441200054060 /* HeartBeatManager.swift */, 38569346270B5DFB0002C50D /* AppGroupSource.swift */, @@ -1645,6 +1671,7 @@ 388E594F25AD948C0019842D = { isa = PBXGroup; children = ( + CE1F6DE62BAF1A180064EB8D /* BuildDetails.plist */, FEFA5C0D299F810B00765C17 /* Core_Data.xcdatamodeld */, 38F3783A2613555C009DB701 /* Config.xcconfig */, 3818AA42274BBC1100843DB3 /* ConfigOverride.xcconfig */, @@ -1962,6 +1989,8 @@ children = ( 38FCF3F125E9028E0078B0D1 /* Info.plist */, 38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */, + CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */, + CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */, ); path = FreeAPSTests; sourceTree = ""; @@ -2383,6 +2412,34 @@ path = Bluetooth; sourceTree = ""; }; + CEE9A64D2BBB411C00EB5194 /* Calibrations */ = { + isa = PBXGroup; + children = ( + CEE9A6542BBB418300EB5194 /* CalibrationsDataFlow.swift */, + CEE9A64F2BBB418300EB5194 /* CalibrationsProvider.swift */, + CEE9A6532BBB418300EB5194 /* CalibrationsStateModel.swift */, + CEE9A6502BBB418300EB5194 /* View */, + ); + path = Calibrations; + sourceTree = ""; + }; + CEE9A6502BBB418300EB5194 /* View */ = { + isa = PBXGroup; + children = ( + CEE9A6512BBB418300EB5194 /* CalibrationsRootView.swift */, + CEE9A6522BBB418300EB5194 /* CalibrationsChart.swift */, + ); + path = View; + sourceTree = ""; + }; + CEE9A65A2BBB41AD00EB5194 /* Calibrations */ = { + isa = PBXGroup; + children = ( + CEE9A65B2BBB41C800EB5194 /* CalibrationService.swift */, + ); + path = Calibrations; + sourceTree = ""; + }; D533BF261CDC1C3F871E7BFD /* NightscoutConfig */ = { isa = PBXGroup; children = ( @@ -2722,6 +2779,7 @@ buildActionMask = 2147483647; files = ( 198377D2266BFFF6004DE65E /* Localizable.strings in Resources */, + CE1F6DE72BAF1A180064EB8D /* BuildDetails.plist in Resources */, 38DF178D27733E6800B3528F /* snow.sks in Resources */, 388E597225AD9CF10019842D /* json in Resources */, 38DF178E27733E6800B3528F /* Assets.xcassets in Resources */, @@ -2870,6 +2928,7 @@ 383420D925FFEB3F002D46C1 /* Popup.swift in Sources */, 3811DE3025C9D49500A708ED /* HomeStateModel.swift in Sources */, 38BF021725E7CBBC00579895 /* PumpManagerExtensions.swift in Sources */, + CEE9A6552BBB418300EB5194 /* CalibrationsProvider.swift in Sources */, 19F95FF529F10FCF00314DDC /* StatProvider.swift in Sources */, 38F3B2EF25ED8E2A005C48AA /* TempTargetsStorage.swift in Sources */, 19B0EF2128F6D66200069496 /* Statistics.swift in Sources */, @@ -2890,6 +2949,7 @@ 382C133725F13A1E00715CE1 /* InsulinSensitivities.swift in Sources */, 19D466A529AA2BD4004D5F33 /* FPUConfigProvider.swift in Sources */, 383948D625CD4D8900E91849 /* FileStorage.swift in Sources */, + CEE9A6572BBB418300EB5194 /* CalibrationsChart.swift in Sources */, 3811DE4125C9D4A100A708ED /* SettingsRootView.swift in Sources */, 38192E04261B82FA0094D973 /* ReachabilityManager.swift in Sources */, 38E44539274E411700EC9A94 /* Disk+UIImage.swift in Sources */, @@ -2898,8 +2958,10 @@ CECCB4262BDBDCF7006E41C4 /* carbPresetResult.swift in Sources */, F2159A542BA6207F00A0B716 /* ContactTrickEntry.swift in Sources */, 38569348270B5DFB0002C50D /* GlucoseSource.swift in Sources */, + CEE9A6582BBB418300EB5194 /* CalibrationsStateModel.swift in Sources */, CEB434E328B8F9DB00B70274 /* BluetoothStateManager.swift in Sources */, 3811DE4225C9D4A100A708ED /* SettingsDataFlow.swift in Sources */, + CEE9A6562BBB418300EB5194 /* CalibrationsRootView.swift in Sources */, 3811DE2525C9D48300A708ED /* MainRootView.swift in Sources */, CE94598229E9E3D30047C9C6 /* WatchConfigProvider.swift in Sources */, 38E44535274E411700EC9A94 /* Disk+Data.swift in Sources */, @@ -2938,6 +3000,7 @@ 38569347270B5DFB0002C50D /* CGMType.swift in Sources */, 3821ED4C25DD18BA00BC42AD /* Constants.swift in Sources */, 384E803425C385E60086DB71 /* JavaScriptWorker.swift in Sources */, + CE1F6DE92BAF37C90064EB8D /* TidePoolConfigView.swift in Sources */, 3811DE5D25C9D4D500A708ED /* Publisher.swift in Sources */, E00EEC0727368630002FF094 /* APSAssembly.swift in Sources */, 38B4F3AF25E2979F00E76A18 /* IndexedCollection.swift in Sources */, @@ -2952,6 +3015,7 @@ CE95BF5A2BA62E4A00DC3DE3 /* PluginSource.swift in Sources */, 3811DE5C25C9D4D500A708ED /* Formatters.swift in Sources */, 3871F39F25ED895A0013ECB5 /* Decimal+Extensions.swift in Sources */, + CEE9A6592BBB418300EB5194 /* CalibrationsDataFlow.swift in Sources */, 3811DE3525C9D49500A708ED /* HomeRootView.swift in Sources */, 49CA5A182BDA385E001F0D3A /* KetoProtectConfRootView.swift in Sources */, 38E98A2925F52C9300C0CED0 /* Error+Extensions.swift in Sources */, @@ -3022,6 +3086,7 @@ CE7CA3532A064973004BE681 /* tempPresetEntity.swift in Sources */, D6DEC113821A7F1056C4AA1E /* NightscoutConfigDataFlow.swift in Sources */, 38E98A3025F52FF700C0CED0 /* Config.swift in Sources */, + CE1F6DDB2BAE08B60064EB8D /* TidepoolManager.swift in Sources */, BD2B464E0745FBE7B79913F4 /* NightscoutConfigProvider.swift in Sources */, 9825E5E923F0B8FA80C8C7C7 /* NightscoutConfigStateModel.swift in Sources */, CE1F2B9E2B011CDE002EDCA0 /* AutoISFConfStateModel.swift in Sources */, @@ -3088,6 +3153,7 @@ 69B9A368029F7EB39F525422 /* CREditorStateModel.swift in Sources */, 38E44538274E411700EC9A94 /* Disk+[Data].swift in Sources */, 98641AF4F92123DA668AB931 /* CREditorRootView.swift in Sources */, + CEE9A65C2BBB41C800EB5194 /* CalibrationService.swift in Sources */, 38E4453D274E411700EC9A94 /* Disk+Errors.swift in Sources */, 38E98A2325F52C9300C0CED0 /* Signpost.swift in Sources */, 495068AA2BDFEF1D0048FF3B /* CarbPresetIntentRequest.swift in Sources */, @@ -3216,6 +3282,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */, + CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */, 38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/FreeAPS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/FreeAPS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9c00041f21..44ce21ec11 100644 --- a/FreeAPS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/FreeAPS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,61 +1,68 @@ { - "object": { - "pins": [ - { - "package": "Alamofire", - "repositoryURL": "https://github.com/Alamofire/Alamofire", - "state": { - "branch": null, - "revision": "f96b619bcb2383b43d898402283924b80e2c4bae", - "version": "5.4.3" - } - }, - { - "package": "Disk", - "repositoryURL": "https://github.com/saoudrizwan/Disk", - "state": { - "branch": null, - "revision": "b0cb4fdf23e51849cc2460bdc6de795c3bcca99d", - "version": "0.6.4" - } - }, - { - "package": "swift-algorithms", - "repositoryURL": "https://github.com/apple/swift-algorithms", - "state": { - "branch": null, - "revision": "2327673b0e9c7e90e6b1826376526ec3627210e4", - "version": "0.2.1" - } - }, - { - "package": "swift-numerics", - "repositoryURL": "https://github.com/apple/swift-numerics", - "state": { - "branch": null, - "revision": "6583ac70c326c3ee080c1d42d9ca3361dca816cd", - "version": "0.1.0" - } - }, - { - "package": "SwiftDate", - "repositoryURL": "https://github.com/malcommac/SwiftDate", - "state": { - "branch": null, - "revision": "6190d0cefff3013e77ed567e6b074f324e5c5bf5", - "version": "6.3.1" - } - }, - { - "package": "Swinject", - "repositoryURL": "https://github.com/Swinject/Swinject", - "state": { - "branch": null, - "revision": "8a76d2c74bafbb455763487cc6a08e91bad1f78b", - "version": "2.7.1" - } + "pins" : [ + { + "identity" : "mkringprogressview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/maxkonovalov/MKRingProgressView.git", + "state" : { + "branch" : "master", + "revision" : "660888aab1d2ab0ed7eb9eb53caec12af4955fa7" } - ] - }, - "version": 1 + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms", + "state" : { + "revision" : "2327673b0e9c7e90e6b1826376526ec3627210e4", + "version" : "0.2.1" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics", + "state" : { + "revision" : "6583ac70c326c3ee080c1d42d9ca3361dca816cd", + "version" : "0.1.0" + } + }, + { + "identity" : "swiftcharts", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ivanschuetz/SwiftCharts.git", + "state" : { + "branch" : "master", + "revision" : "c354c1945bb35a1f01b665b22474f6db28cba4a2" + } + }, + { + "identity" : "swiftdate", + "kind" : "remoteSourceControl", + "location" : "https://github.com/malcommac/SwiftDate", + "state" : { + "revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5", + "version" : "6.3.1" + } + }, + { + "identity" : "swiftmessages", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SwiftKickMobile/SwiftMessages", + "state" : { + "revision" : "62e12e138fc3eedf88c7553dd5d98712aa119f40", + "version" : "9.0.9" + } + }, + { + "identity" : "swinject", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Swinject/Swinject", + "state" : { + "revision" : "8a76d2c74bafbb455763487cc6a08e91bad1f78b", + "version" : "2.7.1" + } + } + ], + "version" : 2 } diff --git a/FreeAPS.xcodeproj/xcshareddata/xcschemes/FreeAPS X.xcscheme b/FreeAPS.xcodeproj/xcshareddata/xcschemes/FreeAPS X.xcscheme index e35119541d..6aadc01288 100644 --- a/FreeAPS.xcodeproj/xcshareddata/xcschemes/FreeAPS X.xcscheme +++ b/FreeAPS.xcodeproj/xcshareddata/xcschemes/FreeAPS X.xcscheme @@ -216,6 +216,20 @@ ReferencedContainer = "container:OmniBLE/OmniBLE.xcodeproj"> + + + + + skipped = "YES"> + skipped = "YES"> + skipped = "YES"> + skipped = "YES"> + skipped = "YES"> + skipped = "YES"> + skipped = "YES"> + skipped = "YES"> + + diff --git a/FreeAPS.xcworkspace/xcshareddata/swiftpm/Package.resolved b/FreeAPS.xcworkspace/xcshareddata/swiftpm/Package.resolved index 85d11e0cf1..5709b4b655 100644 --- a/FreeAPS.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/FreeAPS.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -48,7 +48,7 @@ { "identity" : "swiftcharts", "kind" : "remoteSourceControl", - "location" : "https://github.com/ivanschuetz/SwiftCharts.git", + "location" : "https://github.com/ivanschuetz/SwiftCharts", "state" : { "branch" : "master", "revision" : "c354c1945bb35a1f01b665b22474f6db28cba4a2" @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SwiftKickMobile/SwiftMessages", "state" : { - "revision" : "b29dd21090b708aa0ae9ecbaf6e2d0487028dc3f", - "version" : "9.0.6" + "revision" : "62e12e138fc3eedf88c7553dd5d98712aa119f40", + "version" : "9.0.9" } }, { @@ -77,8 +77,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Swinject/Swinject", "state" : { - "revision" : "8bc503e60965298984fb58cf47b71c541449fe2a", - "version" : "2.8.3" + "revision" : "13d2d7065253eea1e9ce4d9263cf51c783fdf3f0", + "version" : "2.8.5" + } + }, + { + "identity" : "tidepoolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tidepool-org/TidepoolKit", + "state" : { + "branch" : "dev", + "revision" : "b8185353c0f46a055f6d5681bb3f18ec86a5b974" } } ], diff --git a/FreeAPS/Sources/APS/CGM/Calibrations/CalibrationService.swift b/FreeAPS/Sources/APS/CGM/Calibrations/CalibrationService.swift new file mode 100644 index 0000000000..99af475269 --- /dev/null +++ b/FreeAPS/Sources/APS/CGM/Calibrations/CalibrationService.swift @@ -0,0 +1,119 @@ +import Foundation +import LibreTransmitter +import Swinject + +struct Calibration: JSON, Hashable, Identifiable { + let x: Double + let y: Double + var date = Date() + + static let zero = Calibration(x: 0, y: 0) + + var id = UUID() +} + +protocol CalibrationService { + var slope: Double { get } + var intercept: Double { get } + var calibrations: [Calibration] { get } + + func addCalibration(_ calibration: Calibration) + func removeCalibration(_ calibration: Calibration) + func removeAllCalibrations() + func removeLast() + + func calibrate(value: Int) -> Double +} + +final class BaseCalibrationService: CalibrationService, Injectable { + private enum Config { + static let minSlope = 0.8 + static let maxSlope = 1.25 + static let minIntercept = -100.0 + static let maxIntercept = 100.0 + static let maxValue = 500.0 + static let minValue = 0.0 + } + + @Injected() var storage: FileStorage! + @Injected() var notificationCenter: NotificationCenter! + private var lifetime = Lifetime() + + private(set) var calibrations: [Calibration] = [] { + didSet { + storage.save(calibrations, as: OpenAPS.FreeAPS.calibrations) + } + } + + init(resolver: Resolver) { + injectServices(resolver) + calibrations = storage.retrieve(OpenAPS.FreeAPS.calibrations, as: [Calibration].self) ?? [] + subscribe() + } + + private func subscribe() { +// notificationCenter.publisher(for: .newSensorDetected) +// .sink { [weak self] _ in +// self?.removeAllCalibrations() +// } +// .store(in: &lifetime) + } + + var slope: Double { + guard calibrations.count >= 2 else { + return 1 + } + + let xs = calibrations.map(\.x) + let ys = calibrations.map(\.y) + let sum1 = average(multiply(xs, ys)) - average(xs) * average(ys) + let sum2 = average(multiply(xs, xs)) - pow(average(xs), 2) + let slope = sum1 / sum2 + + return min(max(slope, Config.minSlope), Config.maxSlope) + } + + var intercept: Double { + guard calibrations.count >= 1 else { + return 0 + } + let xs = calibrations.map(\.x) + let ys = calibrations.map(\.y) + + let intercept = average(ys) - slope * average(xs) + + return min(max(intercept, Config.minIntercept), Config.maxIntercept) + } + + func calibrate(value: Int) -> Double { + linearRegression(value) + } + + func addCalibration(_ calibration: Calibration) { + calibrations.append(calibration) + } + + func removeCalibration(_ calibration: Calibration) { + calibrations.removeAll { $0 == calibration } + } + + func removeAllCalibrations() { + calibrations.removeAll() + } + + func removeLast() { + calibrations.removeLast() + } + + private func average(_ input: [Double]) -> Double { + input.reduce(0, +) / Double(input.count) + } + + private func multiply(_ a: [Double], _ b: [Double]) -> [Double] { + zip(a, b).map(*) + } + + private func linearRegression(_ x: Int) -> Double { + (intercept + slope * Double(x)).clamped(Config.minValue ... Config.maxValue) + } +} diff --git a/FreeAPS/Sources/APS/CGM/PluginSource.swift b/FreeAPS/Sources/APS/CGM/PluginSource.swift index 3485b08790..07233bc9ca 100644 --- a/FreeAPS/Sources/APS/CGM/PluginSource.swift +++ b/FreeAPS/Sources/APS/CGM/PluginSource.swift @@ -103,7 +103,13 @@ extension PluginSource: CGMManagerDelegate { dispatchPrecondition(condition: .onQueue(processQueue)) // TODO: Events in APS ? // currently only display in log the date of the event - events.forEach { debug(.deviceManager, "events from CGM at \($0.date)") } + events.forEach { event in + debug(.deviceManager, "events from CGM at \(event.date)") + + if event.type == .sensorStart { + self.glucoseManager?.removeCalibrations() + } + } } func startDateToFilterNewData(for _: CGMManager) -> Date? { @@ -126,6 +132,7 @@ extension PluginSource: CGMManagerDelegate { } func cgmManager(_: CGMManager, didUpdate status: CGMManagerStatus) { + debug(.deviceManager, "DEBUG DID UPDATE STATE") processQueue.async { if self.cgmHasValidSensorSession != status.hasValidSensorSession { self.cgmHasValidSensorSession = status.hasValidSensorSession diff --git a/FreeAPS/Sources/APS/FetchGlucoseManager.swift b/FreeAPS/Sources/APS/FetchGlucoseManager.swift index 1b7821de7e..2b7b6e36ca 100644 --- a/FreeAPS/Sources/APS/FetchGlucoseManager.swift +++ b/FreeAPS/Sources/APS/FetchGlucoseManager.swift @@ -11,6 +11,7 @@ protocol FetchGlucoseManager: SourceInfoProvider { func refreshCGM() func updateGlucoseSource(cgmGlucoseSourceType: CGMType, cgmGlucosePluginId: String, newManager: CGMManagerUI?) func deleteGlucoseSource() + func removeCalibrations() var glucoseSource: GlucoseSource! { get } var cgmManager: CGMManagerUI? { get } var cgmGlucoseSourceType: CGMType? { get set } @@ -29,11 +30,13 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { private let processQueue = DispatchQueue(label: "BaseGlucoseManager.processQueue") @Injected() var glucoseStorage: GlucoseStorage! @Injected() var nightscoutManager: NightscoutManager! + @Injected() var tidePoolService: TidePoolManager! @Injected() var apsManager: APSManager! @Injected() var settingsManager: SettingsManager! @Injected() var healthKitManager: HealthKitManager! @Injected() var deviceDataManager: DeviceDataManager! @Injected() var pluginCGMManager: PluginManager! + @Injected() var calibrationService: CalibrationService! private var lifetime = Lifetime() private let timer = DispatchTimer(timeInterval: 1.minutes.timeInterval) @@ -67,6 +70,10 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { var glucoseSource: GlucoseSource! + func removeCalibrations() { + calibrationService.removeAllCalibrations() + } + func deleteGlucoseSource() { cgmManager = nil updateGlucoseSource( @@ -76,6 +83,11 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { } func updateGlucoseSource(cgmGlucoseSourceType: CGMType, cgmGlucosePluginId: String, newManager: CGMManagerUI?) { + // if changed, remove all calibrations + if self.cgmGlucoseSourceType != cgmGlucoseSourceType || self.cgmGlucosePluginId != cgmGlucosePluginId { + removeCalibrations() + } + self.cgmGlucoseSourceType = cgmGlucoseSourceType self.cgmGlucosePluginId = cgmGlucosePluginId @@ -87,12 +99,10 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { if let manager = newManager { cgmManager = manager + removeCalibrations() } else if self.cgmGlucoseSourceType == .plugin, cgmManager == nil, let rawCGMManager = rawCGMManager { cgmManager = cgmManagerFromRawValue(rawCGMManager) } -// } else if self.cgmGlucoseSourceType == .plugin, self.cgmGlucosePluginId != , self.cgmGlucosePluginId != cgmManager?.pluginIdentifier { -// cgmManager = nil -// } switch self.cgmGlucoseSourceType { case nil, @@ -153,7 +163,10 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { } private func glucoseStoreAndHeartDecision(syncDate: Date, glucose: [BloodGlucose], glucoseFromHealth: [BloodGlucose] = []) { - let allGlucose = glucose + glucoseFromHealth + // calibration add if required only for sensor + let newGlucose = overcalibrate(entries: glucose) + + let allGlucose = newGlucose + glucoseFromHealth var filteredByDate: [BloodGlucose] = [] var filtered: [BloodGlucose] = [] @@ -206,6 +219,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { deviceDataManager.heartbeat(date: Date()) nightscoutManager.uploadGlucose() + tidePoolService.uploadGlucose(device: cgmManager?.cgmManagerStatus.device) // end of the BG tasks if let backgroundTask = backGroundFetchBGTaskID { @@ -258,6 +272,23 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { func sourceInfo() -> [String: Any]? { glucoseSource.sourceInfo() } + + private func overcalibrate(entries: [BloodGlucose]) -> [BloodGlucose] { + // overcalibrate + var overcalibration: ((Int) -> (Double))? + processQueue.sync { overcalibration = calibrationService.calibrate } + + if let overcalibration = overcalibration { + return entries.map { entry in + var entry = entry + entry.glucose = Int(overcalibration(entry.glucose!)) + entry.sgv = Int(overcalibration(entry.sgv!)) + return entry + } + } else { + return entries + } + } } extension CGMManager { diff --git a/FreeAPS/Sources/APS/PluginManager.swift b/FreeAPS/Sources/APS/PluginManager.swift index 8f66f4f9a5..de6e211ece 100644 --- a/FreeAPS/Sources/APS/PluginManager.swift +++ b/FreeAPS/Sources/APS/PluginManager.swift @@ -6,8 +6,10 @@ import Swinject protocol PluginManager { var availablePumpManagers: [PumpManagerDescriptor] { get } var availableCGMManagers: [CGMManagerDescriptor] { get } + var availableServices: [ServiceDescriptor] { get } func getPumpManagerTypeByIdentifier(_ identifier: String) -> PumpManagerUI.Type? func getCGMManagerTypeByIdentifier(_ identifier: String) -> CGMManagerUI.Type? + func getServiceTypeByIdentifier(_ identifier: String) -> ServiceUI.Type? } class BasePluginManager: Injectable, PluginManager { diff --git a/FreeAPS/Sources/Assemblies/APSAssembly.swift b/FreeAPS/Sources/Assemblies/APSAssembly.swift index d5d3253ce2..4fc3eacb43 100644 --- a/FreeAPS/Sources/Assemblies/APSAssembly.swift +++ b/FreeAPS/Sources/Assemblies/APSAssembly.swift @@ -10,5 +10,6 @@ final class APSAssembly: Assembly { container.register(FetchAnnouncementsManager.self) { r in BaseFetchAnnouncementsManager(resolver: r) } container.register(BluetoothStateManager.self) { r in BaseBluetoothStateManager(resolver: r) } container.register(PluginManager.self) { r in BasePluginManager(resolver: r) } + container.register(CalibrationService.self) { r in BaseCalibrationService(resolver: r) } } } diff --git a/FreeAPS/Sources/Assemblies/NetworkAssembly.swift b/FreeAPS/Sources/Assemblies/NetworkAssembly.swift index 619b816695..d3c63eec7b 100644 --- a/FreeAPS/Sources/Assemblies/NetworkAssembly.swift +++ b/FreeAPS/Sources/Assemblies/NetworkAssembly.swift @@ -8,5 +8,6 @@ final class NetworkAssembly: Assembly { } container.register(NightscoutManager.self) { r in BaseNightscoutManager(resolver: r) } + container.register(TidePoolManager.self) { r in BaseTidePoolManager(resolver: r) } } } diff --git a/FreeAPS/Sources/Models/BloodGlucose.swift b/FreeAPS/Sources/Models/BloodGlucose.swift index ad4307f40c..094ff3e046 100644 --- a/FreeAPS/Sources/Models/BloodGlucose.swift +++ b/FreeAPS/Sources/Models/BloodGlucose.swift @@ -1,4 +1,6 @@ import Foundation +import HealthKit +import LoopKit struct BloodGlucose: JSON, Identifiable, Hashable { enum Direction: String, JSON { @@ -89,3 +91,14 @@ extension BloodGlucose: SavitzkyGolaySmoothable { } } } + +extension BloodGlucose { + func convertStoredGlucoseSample(device: HKDevice?) -> StoredGlucoseSample { + StoredGlucoseSample( + syncIdentifier: id, + startDate: dateString.date, + quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: Double(glucose!)), + device: device + ) + } +} diff --git a/FreeAPS/Sources/Models/CarbsEntry.swift b/FreeAPS/Sources/Models/CarbsEntry.swift index 0394644edb..43d479496e 100644 --- a/FreeAPS/Sources/Models/CarbsEntry.swift +++ b/FreeAPS/Sources/Models/CarbsEntry.swift @@ -1,4 +1,5 @@ import Foundation +import LoopKit struct CarbsEntry: JSON, Equatable, Hashable { let id: String? @@ -39,3 +40,25 @@ extension CarbsEntry { case fpuID } } + +extension CarbsEntry { + func convertSyncCarb(operation: LoopKit.Operation = .create) -> SyncCarbObject { + SyncCarbObject( + absorptionTime: nil, + createdByCurrentApp: true, + foodType: nil, + grams: Double(carbs), + startDate: createdAt, + uuid: UUID(uuidString: id!), + provenanceIdentifier: enteredBy ?? "", + syncIdentifier: id, + syncVersion: nil, + userCreatedDate: nil, + userUpdatedDate: nil, + userDeletedDate: nil, + operation: operation, + addedDate: nil, + supercededDate: nil + ) + } +} diff --git a/FreeAPS/Sources/Models/PumpHistoryEvent.swift b/FreeAPS/Sources/Models/PumpHistoryEvent.swift index f9c4cc6a4a..583a15639e 100644 --- a/FreeAPS/Sources/Models/PumpHistoryEvent.swift +++ b/FreeAPS/Sources/Models/PumpHistoryEvent.swift @@ -1,4 +1,5 @@ import Foundation +import LoopKit struct PumpHistoryEvent: JSON, Equatable { let id: String @@ -94,3 +95,29 @@ extension PumpHistoryEvent { case isExternal } } + +extension EventType { + func mapEventTypeToPumpEventType() -> PumpEventType? { + switch self { + case .prime: + return PumpEventType.prime + case .pumpResume: + return PumpEventType.resume + case .rewind: + return PumpEventType.rewind + case .pumpSuspend: + return PumpEventType.suspend + case .nsBatteryChange, + .pumpBattery: + return PumpEventType.replaceComponent(componentType: .pump) + case .nsInsulinChange: + return PumpEventType.replaceComponent(componentType: .reservoir) + case .nsSiteChange: + return PumpEventType.replaceComponent(componentType: .infusionSet) + case .pumpAlarm: + return PumpEventType.alarm + default: + return nil + } + } +} diff --git a/FreeAPS/Sources/Modules/CGM/CGMStateModel.swift b/FreeAPS/Sources/Modules/CGM/CGMStateModel.swift index c276b34b36..958854089b 100644 --- a/FreeAPS/Sources/Modules/CGM/CGMStateModel.swift +++ b/FreeAPS/Sources/Modules/CGM/CGMStateModel.swift @@ -23,6 +23,8 @@ extension CGM { @Injected() var cgmManager: FetchGlucoseManager! @Injected() var calendarManager: CalendarManager! @Injected() var pluginCGMManager: PluginManager! + @Injected() private var broadcaster: Broadcaster! + @Injected() var nightscoutManager: NightscoutManager! @Published var setupCGM: Bool = false @Published var cgmCurrent = cgmDefaultName @@ -35,6 +37,7 @@ extension CGM { @Persisted(key: "CalendarManager.currentCalendarID") var storedCalendarID: String? = nil @Published var cgmTransmitterDeviceAddress: String? = nil @Published var listOfCGM: [cgmName] = [] + @Published var url: URL? override func subscribe() { // collect the list of CGM available with plugins and CGMType defined manually @@ -67,6 +70,17 @@ extension CGM { ) } + url = nightscoutManager.cgmURL + switch url?.absoluteString { + case "http://127.0.0.1:1979": + url = URL(string: "spikeapp://")! + case "http://127.0.0.1:17580": + url = URL(string: "diabox://")! + // case CGMType.libreTransmitter.appURL?.absoluteString: + // showModal(for: .libreConfig) + default: break + } + currentCalendarID = storedCalendarID ?? "" calendarIDs = calendarManager.calendarIDs() cgmTransmitterDeviceAddress = UserDefaults.standard.cgmTransmitterDeviceAddress @@ -143,6 +157,11 @@ extension CGM.StateModel: CompletionDelegate { settingsManager.settings.uploadGlucose = cgmManager.shouldSyncToRemoteService // update if required the Glucose source + DispatchQueue.main.async { + self.broadcaster.notify(GlucoseObserver.self, on: .main) { + $0.glucoseDidUpdate([]) + } + } } } diff --git a/FreeAPS/Sources/Modules/CGM/View/CGMRootView.swift b/FreeAPS/Sources/Modules/CGM/View/CGMRootView.swift index 5f997fce38..369cdf4e09 100644 --- a/FreeAPS/Sources/Modules/CGM/View/CGMRootView.swift +++ b/FreeAPS/Sources/Modules/CGM/View/CGMRootView.swift @@ -5,6 +5,7 @@ import Swinject extension CGM { struct RootView: BaseView { let resolver: Resolver + let displayClose: Bool @StateObject var state = StateModel() @State private var setupCGM = false @@ -65,6 +66,24 @@ extension CGM { } } } + if state.cgmCurrent.type == .plugin && state.cgmCurrent.id.contains("Libre") { + Section(header: Text("Calibrations")) { + Text("Calibrations").navigationLink(to: .calibrations, from: self) + } + } + + if state.cgmCurrent.type == .nightscout { + Section(header: Text("Nightscout")) { + if state.url != nil { + Button(state.url!.absoluteString) { + UIApplication.shared.open(state.url!, options: [:], completionHandler: nil) + } + } else { + Text("You need to configure Nightscout URL") + } + } + } + Section(header: Text("Calendar")) { Toggle("Create Events in Calendar", isOn: $state.createCalendarEvents) if state.calendarIDs.isNotEmpty { @@ -99,6 +118,7 @@ extension CGM { .onAppear(perform: configureView) .navigationTitle("CGM") .navigationBarTitleDisplayMode(.automatic) + .navigationBarItems(leading: displayClose ? Button("Close", action: state.hideModal) : nil) .sheet(isPresented: $setupCGM) { if let cgmFetchManager = state.cgmManager, let cgmManager = cgmFetchManager.cgmManager, diff --git a/FreeAPS/Sources/Modules/Calibrations/CalibrationsDataFlow.swift b/FreeAPS/Sources/Modules/Calibrations/CalibrationsDataFlow.swift new file mode 100644 index 0000000000..2f385bdc06 --- /dev/null +++ b/FreeAPS/Sources/Modules/Calibrations/CalibrationsDataFlow.swift @@ -0,0 +1,13 @@ +enum Calibrations { + enum Config {} + + struct Item: Hashable, Identifiable { + let calibration: Calibration + + var id: String { + calibration.id.uuidString + } + } +} + +protocol CalibrationsProvider {} diff --git a/FreeAPS/Sources/Modules/Calibrations/CalibrationsProvider.swift b/FreeAPS/Sources/Modules/Calibrations/CalibrationsProvider.swift new file mode 100644 index 0000000000..28e75281fc --- /dev/null +++ b/FreeAPS/Sources/Modules/Calibrations/CalibrationsProvider.swift @@ -0,0 +1,3 @@ +extension Calibrations { + final class Provider: BaseProvider, CalibrationsProvider {} +} diff --git a/FreeAPS/Sources/Modules/Calibrations/CalibrationsStateModel.swift b/FreeAPS/Sources/Modules/Calibrations/CalibrationsStateModel.swift new file mode 100644 index 0000000000..be98248f7b --- /dev/null +++ b/FreeAPS/Sources/Modules/Calibrations/CalibrationsStateModel.swift @@ -0,0 +1,73 @@ +import SwiftDate +import SwiftUI + +extension Calibrations { + final class StateModel: BaseStateModel { + @Injected() var glucoseStorage: GlucoseStorage! + @Injected() var calibrationService: CalibrationService! + + @Published var slope: Double = 1 + @Published var intercept: Double = 1 + @Published var newCalibration: Decimal = 0 + @Published var calibrations: [Calibration] = [] + @Published var calibrate: (Int) -> Double = { Double($0) } + @Published var items: [Item] = [] + + var units: GlucoseUnits = .mmolL + + override func subscribe() { + units = settingsManager.settings.units + calibrate = calibrationService.calibrate + setupCalibrations() + } + + private func setupCalibrations() { + slope = calibrationService.slope + intercept = calibrationService.intercept + calibrations = calibrationService.calibrations + items = calibrations.map { + Item(calibration: $0) + } + } + + func addCalibration() { + defer { + UIApplication.shared.endEditing() + setupCalibrations() + } + + var glucose = newCalibration + if units == .mmolL { + glucose = newCalibration.asMgdL + } + + guard let lastGlucose = glucoseStorage.recent().last, + lastGlucose.dateString.addingTimeInterval(60 * 4.5) > Date(), + let unfiltered = lastGlucose.unfiltered + else { + info(.service, "Glucose is stale for calibration") + return + } + + let calibration = Calibration(x: Double(unfiltered), y: Double(glucose)) + + calibrationService.addCalibration(calibration) + } + + func removeLast() { + calibrationService.removeLast() + setupCalibrations() + } + + func removeAll() { + calibrationService.removeAllCalibrations() + setupCalibrations() + } + + func removeAtIndex(_ index: Int) { + let calibration = calibrations[index] + calibrationService.removeCalibration(calibration) + setupCalibrations() + } + } +} diff --git a/FreeAPS/Sources/Modules/Calibrations/View/CalibrationsChart.swift b/FreeAPS/Sources/Modules/Calibrations/View/CalibrationsChart.swift new file mode 100644 index 0000000000..5057058624 --- /dev/null +++ b/FreeAPS/Sources/Modules/Calibrations/View/CalibrationsChart.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct CalibrationsChart: View { + @EnvironmentObject var state: Calibrations.StateModel + + private var dateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.timeStyle = .short + formatter.dateStyle = .short + return formatter + } + + private let maxValue = 400.0 + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .top) { + Rectangle().fill(Color.secondary) + .frame(height: geo.size.width) + Path { path in + let size = geo.size.width + path.move( + to: + CGPoint( + x: 0, + y: size - state.calibrate(0) / maxValue * geo.size.width + ) + ) + path.addLine( + to: CGPoint( + x: size, + y: size - state.calibrate(Int(maxValue)) / maxValue * geo.size.width + ) + ) + } + .stroke(.blue, lineWidth: 2) + + ForEach(state.calibrations, id: \.self) { value in + ZStack { + Circle().fill(.red) + .frame(width: 6, height: 6) + .position( + x: value.x / maxValue * geo.size.width, + y: geo.size.width - (value.y / maxValue * geo.size.width) + ) + Text(dateFormatter.string(from: value.date)) + .foregroundColor(.white) + .font(.system(size: 10)) + .position( + x: value.x / maxValue * geo.size.width, + y: geo.size.width - (value.y / maxValue * geo.size.width) + 10 + ) + } + } + } + .frame(height: geo.size.width) + .clipped() + } + } +} diff --git a/FreeAPS/Sources/Modules/Calibrations/View/CalibrationsRootView.swift b/FreeAPS/Sources/Modules/Calibrations/View/CalibrationsRootView.swift new file mode 100644 index 0000000000..8bfaead27c --- /dev/null +++ b/FreeAPS/Sources/Modules/Calibrations/View/CalibrationsRootView.swift @@ -0,0 +1,109 @@ +import SwiftUI +import Swinject + +extension Calibrations { + struct RootView: BaseView { + let resolver: Resolver + @StateObject var state = StateModel() + + private var formatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 2 + return formatter + } + + private var dateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.timeStyle = .short + formatter.dateStyle = .short + return formatter + } + + var body: some View { + GeometryReader { geo in + Form { + Section(header: Text("Add calibration")) { + HStack { + Text("Meter glucose") + Spacer() + DecimalTextField( + "0", + value: $state.newCalibration, + formatter: formatter, + autofocus: false, + cleanInput: true + ) + Text(state.units.rawValue).foregroundColor(.secondary) + } + Button { + state.addCalibration() + } + label: { Text("Add") } + .disabled(state.newCalibration <= 0) + } + + Section(header: Text("Info")) { + HStack { + Text("Slope") + Spacer() + Text(formatter.string(from: state.slope as NSNumber)!) + } + HStack { + Text("Intercept") + Spacer() + Text(formatter.string(from: state.intercept as NSNumber)!) + } + } + + Section(header: Text("Remove")) { + Button { + state.removeLast() + } + label: { Text("Remove Last") } + .disabled(state.calibrations.isEmpty) + + Button { + state.removeAll() + } + label: { Text("Remove All") } + .disabled(state.calibrations.isEmpty) + List { + ForEach(state.items) { item in + HStack { + Text(dateFormatter.string(from: item.calibration.date)) + Spacer() + VStack(alignment: .leading) { + Text("raw: \(item.calibration.x)") + .font(.caption2) + .foregroundColor(.secondary) + Text("value: \(item.calibration.y)") + .font(.caption2) + .foregroundColor(.secondary) + } + } + + }.onDelete(perform: delete) + } + } + + if state.calibrations.isNotEmpty { + Section(header: Text("Chart")) { + CalibrationsChart().environmentObject(state) + .frame(minHeight: geo.size.width) + } + } + } + } + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .onAppear(perform: configureView) + .navigationTitle("Calibrations") + .navigationBarItems(trailing: EditButton().disabled(state.calibrations.isEmpty)) + .navigationBarTitleDisplayMode(.automatic) + } + + private func delete(at offsets: IndexSet) { + state.removeAtIndex(offsets[offsets.startIndex]) + } + } +} diff --git a/FreeAPS/Sources/Modules/DataTable/DataTableProvider.swift b/FreeAPS/Sources/Modules/DataTable/DataTableProvider.swift index 8fc90b8f1f..fe8a2b9035 100644 --- a/FreeAPS/Sources/Modules/DataTable/DataTableProvider.swift +++ b/FreeAPS/Sources/Modules/DataTable/DataTableProvider.swift @@ -8,6 +8,7 @@ extension DataTable { @Injected() var carbsStorage: CarbsStorage! @Injected() var nightscoutManager: NightscoutManager! @Injected() var healthkitManager: HealthKitManager! + @Injected() var tidePoolManager: TidePoolManager! func pumpHistory() -> [PumpHistoryEvent] { pumpHistoryStorage.recent() @@ -32,10 +33,28 @@ extension DataTable { } func deleteCarbs(_ treatement: Treatment) { - nightscoutManager.deleteCarbs(treatement, complexMeal: false) + // nightscoutManager.deleteCarbs(treatement, complexMeal: false) + // need to start with tidePool because Nightscout delete data + // probably to revise the logic + // TODO: + tidePoolManager.deleteCarbs( + at: treatement.date, + isFPU: treatement.isFPU, + fpuID: treatement.fpuID, + syncID: treatement.id + ) + + nightscoutManager.deleteCarbs( + at: treatement.date, + isFPU: treatement.isFPU, + fpuID: treatement.fpuID, + syncID: treatement.id + ) } func deleteInsulin(_ treatement: Treatment) { + // delete tidePoolManager before NS - TODO + tidePoolManager.deleteInsulin(at: treatement.date) nightscoutManager.deleteInsulin(at: treatement.date) if let id = treatement.idPumpEvent { healthkitManager.deleteInsulin(syncID: id) diff --git a/FreeAPS/Sources/Modules/Home/HomeStateModel.swift b/FreeAPS/Sources/Modules/Home/HomeStateModel.swift index ff95902586..6a6b5dadc3 100644 --- a/FreeAPS/Sources/Modules/Home/HomeStateModel.swift +++ b/FreeAPS/Sources/Modules/Home/HomeStateModel.swift @@ -10,6 +10,7 @@ extension Home { @Injected() var apsManager: APSManager! @Injected() var nightscoutManager: NightscoutManager! @Injected() var storage: TempTargetsStorage! + @Injected() var fetchGlucoseManager: FetchGlucoseManager! private let timer = DispatchTimer(timeInterval: 5) private(set) var filteredHours = 24 @Published var glucose: [BloodGlucose] = [] @@ -75,6 +76,7 @@ extension Home { @Published var alwaysUseColors: Bool = true @Published var timeSettings: Bool = true @Published var calculatedTins: String = "" + @Published var cgmAvailable: Bool = false private var numberFormatter: NumberFormatter { let formatter = NumberFormatter() @@ -299,6 +301,7 @@ extension Home { self.glucoseDelta = nil } self.alarm = self.provider.glucoseStorage.alarm + cgmAvailable = (fetchGlucoseManager.cgmGlucoseSourceType != CGMType.none) } } @@ -516,18 +519,7 @@ extension Home { } func openCGM() { - guard var url = nightscoutManager.cgmURL else { return } - - switch url.absoluteString { - case "http://127.0.0.1:1979": - url = URL(string: "spikeapp://")! - case "http://127.0.0.1:17580": - url = URL(string: "diabox://")! -// case CGMType.libreTransmitter.appURL?.absoluteString: -// showModal(for: .libreConfig) - default: break - } - UIApplication.shared.open(url, options: [:], completionHandler: nil) + showModal(for: .cgmDirect) } func infoPanelTTPercentage(_ hbt_: Double, _ target: Decimal) -> Decimal { diff --git a/FreeAPS/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift b/FreeAPS/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift index bc49a6d982..f77e83b934 100644 --- a/FreeAPS/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift +++ b/FreeAPS/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift @@ -8,6 +8,7 @@ struct CurrentGlucoseView: View { @Binding var alarm: GlucoseAlarm? @Binding var lowGlucose: Decimal @Binding var highGlucose: Decimal + @Binding var cgmAvailable: Bool @State private var rotationDegrees: Double = 0.0 @State private var angularGradient = AngularGradient(colors: [ @@ -62,12 +63,7 @@ struct CurrentGlucoseView: View { } var body: some View { - let triangleColor = Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902) - - ZStack { - TrendShape(gradient: angularGradient, color: triangleColor) - .rotationEffect(.degrees(rotationDegrees)) - + if cgmAvailable { VStack(alignment: .center) { HStack { Text( @@ -77,8 +73,10 @@ struct CurrentGlucoseView: View { .string(from: Double(units == .mmolL ? $0.asMmolL : Decimal($0)) as NSNumber)! } ?? "--" ) - .font(.system(size: 40, weight: .bold)) - .foregroundColor(alarm == nil ? colourGlucoseText : .loopRed) + .font(.title).fontWeight(.bold) + .foregroundColor(alarm == nil ? colorOfGlucose : .loopRed) + + image } HStack { let minutesAgo = -1 * (recentGlucose?.dateString.timeIntervalSinceNow ?? 0) / 60 @@ -89,7 +87,7 @@ struct CurrentGlucoseView: View { NSLocalizedString("min", comment: "Short form for minutes") + " " ) ) - .font(.caption2).foregroundColor(colorScheme == .dark ? Color.white.opacity(0.9) : Color.secondary) + .font(.caption2).foregroundColor(.secondary) Text( delta @@ -97,9 +95,20 @@ struct CurrentGlucoseView: View { deltaFormatter.string(from: Double(units == .mmolL ? $0.asMmolL : Decimal($0)) as NSNumber)! } ?? "--" ) - .font(.caption2).foregroundColor(colorScheme == .dark ? Color.white.opacity(0.9) : Color.secondary) + .font(.caption2).foregroundColor(.secondary) }.frame(alignment: .top) } + } else { + VStack(alignment: .center, spacing: 12) { + HStack + { + // no cgm defined so display a generic CGM + Image(systemName: "sensor.tag.radiowaves.forward.fill").font(.body).imageScale(.large) + } + HStack { + Text("Add CGM").font(.caption).bold() + } + }.frame(alignment: .top) } .onChange(of: recentGlucose?.direction) { newDirection in withAnimation { diff --git a/FreeAPS/Sources/Modules/Home/View/HomeRootView.swift b/FreeAPS/Sources/Modules/Home/View/HomeRootView.swift index 462da7fd85..cf8387ea87 100644 --- a/FreeAPS/Sources/Modules/Home/View/HomeRootView.swift +++ b/FreeAPS/Sources/Modules/Home/View/HomeRootView.swift @@ -145,23 +145,16 @@ extension Home { units: $state.units, alarm: $state.alarm, lowGlucose: $state.lowGlucose, - highGlucose: $state.highGlucose + highGlucose: $state.highGlucose, + cgmAvailable: $state.cgmAvailable ) .onTapGesture { - if state.alarm == nil { - state.openCGM() - } else { - state.showModal(for: .snooze) - } + state.openCGM() } .onLongPressGesture { let impactHeavy = UIImpactFeedbackGenerator(style: .heavy) impactHeavy.impactOccurred() - if state.alarm == nil { - state.showModal(for: .snooze) - } else { - state.openCGM() - } + state.showModal(for: .snooze) } } diff --git a/FreeAPS/Sources/Modules/NightscoutConfig/View/NightscoutConfigRootView.swift b/FreeAPS/Sources/Modules/NightscoutConfig/View/NightscoutConfigRootView.swift index 4cf1d64633..1f98f63352 100644 --- a/FreeAPS/Sources/Modules/NightscoutConfig/View/NightscoutConfigRootView.swift +++ b/FreeAPS/Sources/Modules/NightscoutConfig/View/NightscoutConfigRootView.swift @@ -72,6 +72,13 @@ extension NightscoutConfig { Button("Delete") { state.delete() }.foregroundColor(.red).disabled(state.connecting) } + Section { + Button("Open Nighstcout") { + UIApplication.shared.open(URL(string: state.url)!, options: [:], completionHandler: nil) + } + .disabled(state.url.isEmpty || state.connecting) + } + Section { Toggle("Upload", isOn: $state.isUploadEnabled) if state.isUploadEnabled { diff --git a/FreeAPS/Sources/Modules/Settings/SettingsProvider.swift b/FreeAPS/Sources/Modules/Settings/SettingsProvider.swift index 571bc1bf8d..7918cbd634 100644 --- a/FreeAPS/Sources/Modules/Settings/SettingsProvider.swift +++ b/FreeAPS/Sources/Modules/Settings/SettingsProvider.swift @@ -1,3 +1,5 @@ extension Settings { - final class Provider: BaseProvider, SettingsProvider {} + final class Provider: BaseProvider, SettingsProvider { + @Injected() var tidePoolManager: TidePoolManager! + } } diff --git a/FreeAPS/Sources/Modules/Settings/SettingsStateModel.swift b/FreeAPS/Sources/Modules/Settings/SettingsStateModel.swift index ca67d402c5..417435ca58 100644 --- a/FreeAPS/Sources/Modules/Settings/SettingsStateModel.swift +++ b/FreeAPS/Sources/Modules/Settings/SettingsStateModel.swift @@ -1,3 +1,5 @@ +import LoopKit +import LoopKitUI import SwiftUI extension Settings { @@ -5,10 +7,14 @@ extension Settings { @Injected() private var broadcaster: Broadcaster! @Injected() private var fileManager: FileManager! @Injected() private var nightscoutManager: NightscoutManager! + @Injected() var pluginManager: PluginManager! + @Injected() var fetchCgmManager: FetchGlucoseManager! @Published var closedLoop = false @Published var debugOptions = false @Published var animatedBackground = false + @Published var serviceUIType: ServiceUI.Type? + @Published var setupTidePool = false private(set) var buildNumber = "" private(set) var versionNumber = "" @@ -49,6 +55,8 @@ extension Settings { copyrightNotice = Bundle.main.infoDictionary?["NSHumanReadableCopyright"] as? String ?? "" subscribeSetting(\.animatedBackground, on: $animatedBackground) { animatedBackground = $0 } + + serviceUIType = pluginManager.getServiceTypeByIdentifier("TidepoolService") } func logItems() -> [URL] { @@ -90,3 +98,22 @@ extension Settings.StateModel: SettingsObserver { debugOptions = settings.debugOptions } } + +extension Settings.StateModel: ServiceOnboardingDelegate { + func serviceOnboarding(didCreateService service: Service) { + debug(.nightscout, "Service with identifier \(service.pluginIdentifier) created") + provider.tidePoolManager.addTidePoolService(service: service) + } + + func serviceOnboarding(didOnboardService service: Service) { + precondition(service.isOnboarded) + debug(.nightscout, "Service with identifier \(service.pluginIdentifier) onboarded") + } +} + +extension Settings.StateModel: CompletionDelegate { + func completionNotifyingDidComplete(_: CompletionNotifying) { + setupTidePool = false + provider.tidePoolManager.forceUploadData(device: fetchCgmManager.cgmManager?.cgmManagerStatus.device) + } +} diff --git a/FreeAPS/Sources/Modules/Settings/View/SettingsRootView.swift b/FreeAPS/Sources/Modules/Settings/View/SettingsRootView.swift index 5444c15e86..d03badf1d8 100644 --- a/FreeAPS/Sources/Modules/Settings/View/SettingsRootView.swift +++ b/FreeAPS/Sources/Modules/Settings/View/SettingsRootView.swift @@ -1,4 +1,6 @@ import HealthKit +import LoopKit +import LoopKitUI import SwiftUI import Swinject @@ -64,6 +66,11 @@ extension Settings { Text("UI/UX Settings").navigationLink(to: .statisticsConfig, from: self) Text("Bolus Calculator").navigationLink(to: .bolusCalculatorConfig, from: self) Text("Nightscout").navigationLink(to: .nighscoutConfig, from: self) + + Text("TidePool") + .onTapGesture { + state.setupTidePool = true + } if HKHealthStore.isHealthDataAvailable() { Text("Apple Health").navigationLink(to: .healthkit, from: self) } @@ -178,6 +185,27 @@ extension Settings { ShareSheet(activityItems: state.logItems()) } .scrollContentBackground(.hidden).background(color) + .sheet(isPresented: $state.setupTidePool) { + if let serviceUIType = state.serviceUIType, + let pluginHost = state.provider.tidePoolManager.getTidePoolPluginHost() + { + if let serviceUI = state.provider.tidePoolManager.getTidePoolServiceUI() { + TidePoolSettingsView( + serviceUI: serviceUI, + serviceOnBoardDelegate: self.state, + serviceDelegate: self.state + ) + } else { + TidePoolSetupView( + serviceUIType: serviceUIType, + pluginHost: pluginHost, + serviceOnBoardDelegate: self.state, + serviceDelegate: self.state + ) + } + } + } + .scrollContentBackground(.hidden).background(color) .onAppear(perform: configureView) .navigationTitle("Settings") .toolbar { diff --git a/FreeAPS/Sources/Modules/Settings/View/TidePoolConfigView.swift b/FreeAPS/Sources/Modules/Settings/View/TidePoolConfigView.swift new file mode 100644 index 0000000000..fcef4b43a8 --- /dev/null +++ b/FreeAPS/Sources/Modules/Settings/View/TidePoolConfigView.swift @@ -0,0 +1,45 @@ +import Foundation +import LoopKit +import LoopKitUI +import SwiftUI + +struct TidePoolSetupView: UIViewControllerRepresentable { + let serviceUIType: ServiceUI.Type + let pluginHost: PluginHost + let serviceOnBoardDelegate: ServiceOnboardingDelegate + let serviceDelegate: CompletionDelegate + + func makeUIViewController(context _: UIViewControllerRepresentableContext) -> UIViewController { + let result = serviceUIType.setupViewController( + colorPalette: .default, + pluginHost: pluginHost + ) + switch result { + case let .createdAndOnboarded(serviceUI): + serviceOnBoardDelegate.serviceOnboarding(didCreateService: serviceUI) + serviceOnBoardDelegate.serviceOnboarding(didOnboardService: serviceUI) + return UIViewController() + case var .userInteractionRequired(setupViewControllerUI): + setupViewControllerUI.serviceOnboardingDelegate = serviceOnBoardDelegate + setupViewControllerUI.completionDelegate = serviceDelegate + return setupViewControllerUI + } + } + + func updateUIViewController(_: UIViewController, context _: UIViewControllerRepresentableContext) {} +} + +struct TidePoolSettingsView: UIViewControllerRepresentable { + let serviceUI: ServiceUI + let serviceOnBoardDelegate: ServiceOnboardingDelegate + let serviceDelegate: CompletionDelegate? + + func makeUIViewController(context _: UIViewControllerRepresentableContext) -> UIViewController { + var vc = serviceUI.settingsViewController(colorPalette: .default) + vc.completionDelegate = serviceDelegate + vc.serviceOnboardingDelegate = serviceOnBoardDelegate + return vc + } + + func updateUIViewController(_: UIViewController, context _: UIViewControllerRepresentableContext) {} +} diff --git a/FreeAPS/Sources/Router/Screen.swift b/FreeAPS/Sources/Router/Screen.swift index e18666d85b..4abe031fb6 100644 --- a/FreeAPS/Sources/Router/Screen.swift +++ b/FreeAPS/Sources/Router/Screen.swift @@ -21,6 +21,7 @@ enum Screen: Identifiable, Hashable { case autotuneConfig case dataTable case cgm + case cgmDirect case healthkit case notificationsConfig case fpuConfig @@ -36,6 +37,7 @@ enum Screen: Identifiable, Hashable { case autoISFConf case B30Conf case KetoConfig + case calibrations case contactTrick var id: Int { String(reflecting: self).hashValue } @@ -81,7 +83,9 @@ extension Screen { case .dataTable: DataTable.RootView(resolver: resolver) case .cgm: - CGM.RootView(resolver: resolver) + CGM.RootView(resolver: resolver, displayClose: false) + case .cgmDirect: + CGM.RootView(resolver: resolver, displayClose: true) case .healthkit: AppleHealthKit.RootView(resolver: resolver) case .notificationsConfig: @@ -114,6 +118,8 @@ extension Screen { KetoConf.RootView(resolver: resolver) case .contactTrick: ContactTrick.RootView(resolver: resolver) + case .calibrations: + Calibrations.RootView(resolver: resolver) } } diff --git a/FreeAPS/Sources/Services/Network/TidepoolManager.swift b/FreeAPS/Sources/Services/Network/TidepoolManager.swift new file mode 100644 index 0000000000..3cd85b3efb --- /dev/null +++ b/FreeAPS/Sources/Services/Network/TidepoolManager.swift @@ -0,0 +1,426 @@ +import Combine +import Foundation +import HealthKit +import LoopKit +import LoopKitUI +import Swinject + +protocol TidePoolManager { + func addTidePoolService(service: Service) + func getTidePoolServiceUI() -> ServiceUI? + func getTidePoolPluginHost() -> PluginHost? + func deleteCarbs(at date: Date, isFPU: Bool?, fpuID: String?, syncID: String) + func deleteInsulin(at date: Date) +// func uploadStatus() + func uploadGlucose(device: HKDevice?) + func forceUploadData(device: HKDevice?) +// func uploadStatistics(dailystat: Statistics) +// func uploadPreferences(_ preferences: Preferences) +// func uploadProfileAndSettings(_: Bool) +} + +final class BaseTidePoolManager: TidePoolManager, Injectable { + @Injected() private var broadcaster: Broadcaster! + @Injected() private var pluginManager: PluginManager! + @Injected() private var glucoseStorage: GlucoseStorage! + @Injected() private var carbsStorage: CarbsStorage! + @Injected() private var storage: FileStorage! + @Injected() private var pumpHistoryStorage: PumpHistoryStorage! + + private let processQueue = DispatchQueue(label: "BaseNetworkManager.processQueue") + private var tidePoolService: RemoteDataService? { + didSet { + if let tidePoolService = tidePoolService { + rawTidePoolManager = tidePoolService.rawValue + } else { + rawTidePoolManager = nil + } + } + } + + @PersistedProperty(key: "TidePoolState") var rawTidePoolManager: Service.RawValue? + + init(resolver: Resolver) { + injectServices(resolver) + loadTidePoolManager() + subscribe() + } + + /// load the TidePool Remote Data Service if available + fileprivate func loadTidePoolManager() { + if let rawTidePoolManager = rawTidePoolManager { + tidePoolService = tidePoolServiceFromRaw(rawTidePoolManager) + tidePoolService?.serviceDelegate = self + tidePoolService?.stateDelegate = self + } + } + + /// allows to acces to tidePoolService as a simple ServiceUI + func getTidePoolServiceUI() -> ServiceUI? { + if let tidePoolService = self.tidePoolService { + return tidePoolService as! any ServiceUI as ServiceUI + } else { + return nil + } + } + + /// get the pluginHost of TidePool + func getTidePoolPluginHost() -> PluginHost? { + self as PluginHost + } + + func addTidePoolService(service: Service) { + tidePoolService = service as! any RemoteDataService as RemoteDataService + } + + /// load the TidePool Remote Data Service from raw storage + private func tidePoolServiceFromRaw(_ rawValue: [String: Any]) -> RemoteDataService? { + guard let rawState = rawValue["state"] as? Service.RawStateValue, + let serviceType = pluginManager.getServiceTypeByIdentifier("TidepoolService") + else { + return nil + } + if let service = serviceType.init(rawState: rawState) { + return service as! any RemoteDataService as RemoteDataService + } else { return nil } + } + + private func subscribe() { + broadcaster.register(PumpHistoryObserver.self, observer: self) + broadcaster.register(CarbsObserver.self, observer: self) + broadcaster.register(TempTargetsObserver.self, observer: self) + } + + func sourceInfo() -> [String: Any]? { + nil + } + + func uploadCarbs() { + let carbs: [CarbsEntry] = carbsStorage.recent() + + guard !carbs.isEmpty, let tidePoolService = self.tidePoolService else { return } + + processQueue.async { + carbs.chunks(ofCount: tidePoolService.carbDataLimit ?? 100).forEach { chunk in + + let syncCarb: [SyncCarbObject] = Array(chunk).map { + $0.convertSyncCarb() + } + tidePoolService.uploadCarbData(created: syncCarb, updated: [], deleted: []) { result in + switch result { + case let .failure(error): + debug(.nightscout, "Error synchronizing carbs data: \(String(describing: error))") + case .success: + debug(.nightscout, "Success synchronizing carbs data:") + } + } + } + } + } + + func deleteCarbs(at date: Date, isFPU: Bool?, fpuID: String?, syncID _: String) { + guard let tidePoolService = self.tidePoolService else { return } + + processQueue.async { + var carbsToDelete: [CarbsEntry] = [] + let allValues = self.storage.retrieve(OpenAPS.Monitor.carbHistory, as: [CarbsEntry].self) ?? [] + + if let isFPU = isFPU, isFPU { + guard let fpuID = fpuID else { return } + carbsToDelete = allValues.filter { $0.fpuID == fpuID }.removeDublicates() + } else { + carbsToDelete = allValues.filter { $0.createdAt == date }.removeDublicates() + } + + let syncCarb = carbsToDelete.map { d in + d.convertSyncCarb(operation: .delete) + } + + tidePoolService.uploadCarbData(created: [], updated: [], deleted: syncCarb) { result in + switch result { + case let .failure(error): + debug(.nightscout, "Error synchronizing carbs data: \(String(describing: error))") + case .success: + debug(.nightscout, "Success synchronizing carbs data:") + } + } + } + } + + func deleteInsulin(at d: Date) { + let allValues = storage.retrieve(OpenAPS.Monitor.pumpHistory, as: [PumpHistoryEvent].self) ?? [] + + guard !allValues.isEmpty, let tidePoolService = self.tidePoolService else { return } + + var doseDataToDelete: [DoseEntry] = [] + + guard let entry = allValues.first(where: { $0.timestamp == d }) else { + return + } + doseDataToDelete + .append(DoseEntry( + type: .bolus, + startDate: entry.timestamp, + value: Double(entry.amount!), + unit: .units, + syncIdentifier: entry.id + )) + + processQueue.async { + tidePoolService.uploadDoseData(created: [], deleted: doseDataToDelete) { result in + switch result { + case let .failure(error): + debug(.nightscout, "Error synchronizing Dose delete data: \(String(describing: error))") + case .success: + debug(.nightscout, "Success synchronizing Dose delete data:") + } + } + } + } + + func uploadDose() { + let events = pumpHistoryStorage.recent() + guard !events.isEmpty, let tidePoolService = self.tidePoolService else { return } + + let eventsBasal = events.filter { $0.type == .tempBasal || $0.type == .tempBasalDuration } + .sorted { $0.timestamp < $1.timestamp } + + let doseDataBasal: [DoseEntry] = eventsBasal.reduce([]) { result, event in + var result = result + switch event.type { + case .tempBasal: + // update the previous tempBasal with endtime = starttime of the last event + if let last: DoseEntry = result.popLast() { + let value = max( + 0, + Double(event.timestamp.timeIntervalSince1970 - last.startDate.timeIntervalSince1970) / 3600 + ) * + (last.scheduledBasalRate?.doubleValue(for: .internationalUnitsPerHour) ?? 0.0) + result.append(DoseEntry( + type: .tempBasal, + startDate: last.startDate, + endDate: event.timestamp, + value: value, + unit: last.unit, + deliveredUnits: value, + syncIdentifier: last.syncIdentifier, + // scheduledBasalRate: last.scheduledBasalRate, + insulinType: last.insulinType, + automatic: last.automatic, + manuallyEntered: last.manuallyEntered + )) + } + result.append(DoseEntry( + type: .tempBasal, + startDate: event.timestamp, + value: 0.0, + unit: .unitsPerHour, + syncIdentifier: event.id, + scheduledBasalRate: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: Double(event.rate!)), + insulinType: nil, + automatic: true, + manuallyEntered: false, + isMutable: true + )) + case .tempBasalDuration: + if let last: DoseEntry = result.popLast(), + last.type == .tempBasal, + last.startDate == event.timestamp + { + let durationMin = event.durationMin ?? 0 + // result.append(last) + let value = (Double(durationMin) / 60.0) * + (last.scheduledBasalRate?.doubleValue(for: .internationalUnitsPerHour) ?? 0.0) + result.append(DoseEntry( + type: .tempBasal, + startDate: last.startDate, + endDate: Calendar.current.date(byAdding: .minute, value: durationMin, to: last.startDate) ?? last + .startDate, + value: value, + unit: last.unit, + deliveredUnits: value, + syncIdentifier: last.syncIdentifier, + scheduledBasalRate: last.scheduledBasalRate, + insulinType: last.insulinType, + automatic: last.automatic, + manuallyEntered: last.manuallyEntered + )) + } + default: break + } + return result + } + + let boluses: [DoseEntry] = events.compactMap { event -> DoseEntry? in + switch event.type { + case .bolus: + return DoseEntry( + type: .bolus, + startDate: event.timestamp, + endDate: event.timestamp, + value: Double(event.amount!), + unit: .units, + deliveredUnits: nil, + syncIdentifier: event.id, + scheduledBasalRate: nil, + insulinType: nil, + automatic: true, + manuallyEntered: false + ) + default: return nil + } + } + + let pumpEvents: [PersistedPumpEvent] = events.compactMap { event -> PersistedPumpEvent? in + if let pumpEventType = event.type.mapEventTypeToPumpEventType() { + let dose: DoseEntry? = switch pumpEventType { + case .suspend: + DoseEntry(suspendDate: event.timestamp, automatic: true) + case .resume: + DoseEntry(resumeDate: event.timestamp, automatic: true) + default: + nil + } + + return PersistedPumpEvent( + date: event.timestamp, + persistedDate: event.timestamp, + dose: dose, + isUploaded: true, + objectIDURL: URL(string: "x-coredata:///PumpEvent/\(event.id)")!, + raw: event.id.data(using: .utf8), + title: event.note, + type: pumpEventType + ) + } else { + return nil + } + } + + processQueue.async { + tidePoolService.uploadDoseData(created: doseDataBasal + boluses, deleted: []) { result in + switch result { + case let .failure(error): + debug(.nightscout, "Error synchronizing Dose data: \(String(describing: error))") + case .success: + debug(.nightscout, "Success synchronizing Dose data:") + } + } + + tidePoolService.uploadPumpEventData(pumpEvents) { result in + switch result { + case let .failure(error): + debug(.nightscout, "Error synchronizing Pump Event data: \(String(describing: error))") + case .success: + debug(.nightscout, "Success synchronizing Pump Event data:") + } + } + } + } + + func uploadGlucose(device: HKDevice?) { + let glucose: [BloodGlucose] = glucoseStorage.recent() + + guard !glucose.isEmpty, let tidePoolService = self.tidePoolService else { return } + + let glucoseWithoutCorrectID = glucose.filter { UUID(uuidString: $0._id) != nil } + + processQueue.async { + glucoseWithoutCorrectID.chunks(ofCount: tidePoolService.glucoseDataLimit ?? 100) + .forEach { chunk in + // all glucose attached with the current device ;-( + + let chunkStoreGlucose = Array(chunk).map { + $0.convertStoredGlucoseSample(device: device) + } + tidePoolService.uploadGlucoseData(chunkStoreGlucose) { result in + switch result { + case let .failure(error): + debug(.nightscout, "Error synchronizing glucose data: \(String(describing: error))") + // self.uploadFailed(key) + case .success: + debug(.nightscout, "Success synchronizing glucose data:") + } + } + } + } + } + + /// force to uploads all data in TidePool Service + func forceUploadData(device: HKDevice?) { + uploadDose() + uploadCarbs() + uploadGlucose(device: device) + } +} + +extension BaseTidePoolManager: PumpHistoryObserver { + func pumpHistoryDidUpdate(_: [PumpHistoryEvent]) { + uploadDose() + } +} + +extension BaseTidePoolManager: CarbsObserver { + func carbsDidUpdate(_: [CarbsEntry]) { + uploadCarbs() + } +} + +extension BaseTidePoolManager: TempTargetsObserver { + func tempTargetsDidUpdate(_: [TempTarget]) {} +} + +extension BaseTidePoolManager: ServiceDelegate { + var hostIdentifier: String { + "com.loopkit.Loop" // To check + } + + var hostVersion: String { + var semanticVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String + + while semanticVersion.split(separator: ".").count < 3 { + semanticVersion += ".0" + } + + semanticVersion += "+\(Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as! String)" + + return semanticVersion + } + + func issueAlert(_: LoopKit.Alert) {} + + func retractAlert(identifier _: LoopKit.Alert.Identifier) {} + + func enactRemoteOverride(name _: String, durationTime _: TimeInterval?, remoteAddress _: String) async throws {} + + func cancelRemoteOverride() async throws {} + + func deliverRemoteCarbs( + amountInGrams _: Double, + absorptionTime _: TimeInterval?, + foodType _: String?, + startDate _: Date? + ) async throws {} + + func deliverRemoteBolus(amountInUnits _: Double) async throws {} +} + +extension BaseTidePoolManager: StatefulPluggableDelegate { + func pluginDidUpdateState(_: LoopKit.StatefulPluggable) {} + + func pluginWantsDeletion(_: LoopKit.StatefulPluggable) { + tidePoolService = nil + } +} + +// Service extension for rawValue +extension Service { + typealias RawValue = [String: Any] + + var rawValue: RawValue { + [ + "serviceIdentifier": pluginIdentifier, + "state": rawState + ] + } +} diff --git a/FreeAPSTests/CalibrationsTests.swift b/FreeAPSTests/CalibrationsTests.swift new file mode 100644 index 0000000000..97fcc89388 --- /dev/null +++ b/FreeAPSTests/CalibrationsTests.swift @@ -0,0 +1,55 @@ +@testable import FreeAPS +import Swinject +import XCTest + +class CalibrationsTests: XCTestCase, Injectable { + let fileStorage = BaseFileStorage() + @Injected() var calibrationService: CalibrationService! + let resolver = FreeAPSApp().resolver + + override func setUp() { + injectServices(resolver) + } + + func testCreateSimpleCalibration() { + let calibration = Calibration(x: 100.0, y: 102.0) + calibrationService.addCalibration(calibration) + + XCTAssertTrue(calibrationService.calibrations.isNotEmpty) + + XCTAssertTrue(calibrationService.slope == 1) + + XCTAssertTrue(calibrationService.intercept == 2) + + XCTAssertTrue(calibrationService.calibrate(value: 104) == 106) + } + + func testCreateMultipleCalibration() { + let calibration = Calibration(x: 100.0, y: 120) + calibrationService.addCalibration(calibration) + + let calibration2 = Calibration(x: 120.0, y: 130.0) + calibrationService.addCalibration(calibration2) + + XCTAssertTrue(calibrationService.slope == 0.8) + + XCTAssertTrue(calibrationService.intercept == 37) + + XCTAssertTrue(calibrationService.calibrate(value: 80) == 101) + + calibrationService.removeLast() + + XCTAssertTrue(calibrationService.calibrations.count == 1) + + calibrationService.removeAllCalibrations() + XCTAssertTrue(calibrationService.calibrations.isEmpty) + } + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } +} diff --git a/FreeAPSTests/PluginManagerTests.swift b/FreeAPSTests/PluginManagerTests.swift new file mode 100644 index 0000000000..7d82fb99e2 --- /dev/null +++ b/FreeAPSTests/PluginManagerTests.swift @@ -0,0 +1,71 @@ +@testable import FreeAPS +import Swinject +import XCTest + +class PluginManagerTests: XCTestCase, Injectable { + let fileStorage = BaseFileStorage() + @Injected() var pluginManager: PluginManager! + let resolver = FreeAPSApp().resolver + + override func setUp() { + injectServices(resolver) + } + + func testCGMManagerLoad() { + let cgmLoopManagers = pluginManager.availableCGMManagers + XCTAssertNotNil(cgmLoopManagers) + XCTAssertTrue(!cgmLoopManagers.isEmpty) + if let cgmLoop = cgmLoopManagers.first { + let cgmLoopManager = pluginManager.getCGMManagerTypeByIdentifier(cgmLoop.identifier) + XCTAssertNotNil(cgmLoopManager) + } else { + XCTFail("Not found CGM loop manager") + } + /// try to load a Pump manager with a CGM identifier + if let cgmLoop = cgmLoopManagers.last { + let cgmLoopManager = pluginManager.getPumpManagerTypeByIdentifier(cgmLoop.identifier) + XCTAssertNil(cgmLoopManager) + } else { + XCTFail("Not found CGM loop manager") + } + } + + func testPumpManagerLoad() { + let pumpLoopManagers = pluginManager.availablePumpManagers + XCTAssertNotNil(pumpLoopManagers) + XCTAssertTrue(!pumpLoopManagers.isEmpty) + if let pumpLoop = pumpLoopManagers.first { + let pumpLoopManager = pluginManager.getPumpManagerTypeByIdentifier(pumpLoop.identifier) + XCTAssertNotNil(pumpLoopManager) + } else { + XCTFail("Not found pump loop manager") + } + /// try to load a CGM manager with a pump identifier + if let pumpLoop = pumpLoopManagers.last { + let pumpLoopManager = pluginManager.getCGMManagerTypeByIdentifier(pumpLoop.identifier) + XCTAssertNil(pumpLoopManager) + } else { + XCTFail("Not found pump loop manager") + } + } + + func testServiceManagerLoad() { + let serviceManagers = pluginManager.availableServices + XCTAssertNotNil(serviceManagers) + XCTAssertTrue(!serviceManagers.isEmpty) + if let serviceLoop = serviceManagers.first { + let serviceManager = pluginManager.getServiceTypeByIdentifier(serviceLoop.identifier) + XCTAssertNotNil(serviceManager) + } else { + XCTFail("Not found Service loop manager") + } + } + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } +} diff --git a/TidepoolService b/TidepoolService new file mode 160000 index 0000000000..f7d46701f2 --- /dev/null +++ b/TidepoolService @@ -0,0 +1 @@ +Subproject commit f7d46701f24356e8ff387087cb4f687268ae0f3d diff --git a/scripts/swiftformat.sh b/scripts/swiftformat.sh index dc625331a0..6780ebe96f 100755 --- a/scripts/swiftformat.sh +++ b/scripts/swiftformat.sh @@ -97,4 +97,4 @@ trailingClosures \ --typeattributes same-line \ --varattributes same-line \ --wrapcollections before-first \ ---exclude Pods,Generated,R.generated.swift,fastlane/swift,Dependencies, LoopKit, LibreTransmitter,G7SensorKit,OmniKit, dexcom-share-client-swift,CGMBLEKit,RileyLinkKit,OmniBLE,MinimedKit +--exclude Pods,Generated,R.generated.swift,fastlane/swift,Dependencies, LoopKit, LibreTransmitter,G7SensorKit,OmniKit, dexcom-share-client-swift,CGMBLEKit,RileyLinkKit,OmniBLE,MinimedKit,TidepoolService