From 53a1004e58cf88406fc06d11732bd2c4525df925 Mon Sep 17 00:00:00 2001 From: DobbyWanKenoby Date: Sun, 18 Dec 2022 15:51:12 +0300 Subject: [PATCH] Added LibreView integration for BloodGlucose data Update to iOS 16 --- FreeAPS.xcodeproj/project.pbxproj | 44 +- .../xcschemes/FreeAPSWatch.xcscheme | 25 +- FreeAPS/Sources/APS/FetchGlucoseManager.swift | 6 +- .../Sources/Assemblies/NetworkAssembly.swift | 1 + .../Main/en.lproj/Localizable.strings | 67 ++- .../Main/ru.lproj/Localizable.strings | 67 ++- FreeAPS/Sources/Logger/Logger.swift | 4 + FreeAPS/Sources/Models/FreeAPSSettings.swift | 36 +- .../LibreViewConfigDataFlow.swift | 77 +++ .../LibreViewConfigProvider.swift | 12 + .../LibreViewConfig/LibreViewStateModel.swift | 124 +++++ .../View/LibreViewConfigRootView.swift | 136 +++++ .../Settings/View/SettingsRootView.swift | 1 + FreeAPS/Sources/Router/Screen.swift | 3 + .../Services/Network/LibreLinkManager.swift | 470 ++++++++++++++++++ 15 files changed, 1046 insertions(+), 27 deletions(-) create mode 100644 FreeAPS/Sources/Modules/LibreViewConfig/LibreViewConfigDataFlow.swift create mode 100644 FreeAPS/Sources/Modules/LibreViewConfig/LibreViewConfigProvider.swift create mode 100644 FreeAPS/Sources/Modules/LibreViewConfig/LibreViewStateModel.swift create mode 100644 FreeAPS/Sources/Modules/LibreViewConfig/View/LibreViewConfigRootView.swift create mode 100644 FreeAPS/Sources/Services/Network/LibreLinkManager.swift diff --git a/FreeAPS.xcodeproj/project.pbxproj b/FreeAPS.xcodeproj/project.pbxproj index 7f12b9aea..4c5251856 100644 --- a/FreeAPS.xcodeproj/project.pbxproj +++ b/FreeAPS.xcodeproj/project.pbxproj @@ -251,6 +251,11 @@ 61962FCAF8A2D222553AC5A3 /* LibreConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66A5B83E7967C38F7CBD883C /* LibreConfigDataFlow.swift */; }; 63E890B4D951EAA91C071D5C /* BasalProfileEditorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFF91130F2FCCC7EBBA11AD /* BasalProfileEditorStateModel.swift */; }; 642F76A05A4FF530463A9FD0 /* NightscoutConfigRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8782B44544F38F2B2D82C38E /* NightscoutConfigRootView.swift */; }; + 6559084D2933A5BB00960C87 /* LibreViewConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6559084C2933A5BB00960C87 /* LibreViewConfigDataFlow.swift */; }; + 6559084F2933A5C800960C87 /* LibreViewConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6559084E2933A5C800960C87 /* LibreViewConfigProvider.swift */; }; + 655908512933A5D700960C87 /* LibreViewStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 655908502933A5D700960C87 /* LibreViewStateModel.swift */; }; + 655908542933A63300960C87 /* LibreViewConfigRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 655908532933A63300960C87 /* LibreViewConfigRootView.swift */; }; + 65D16C5A29422ED100ABF87A /* LibreLinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65D16C5929422ED100ABF87A /* LibreLinkManager.swift */; }; 6632A0DC746872439A858B44 /* ISFEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79BDA519C9B890FD9A5DFCF3 /* ISFEditorDataFlow.swift */; }; 69A31254F2451C20361D172F /* BolusStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 223EC0494F55A91E3EA69EF4 /* BolusStateModel.swift */; }; 69B9A368029F7EB39F525422 /* CREditorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA5E04A2761F6EEA6568E1 /* CREditorStateModel.swift */; }; @@ -677,6 +682,11 @@ 60744C3E9BB3652895C908CC /* DataTableProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DataTableProvider.swift; sourceTree = ""; }; 618E62C9757B2F95431B5DC0 /* AddCarbsProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AddCarbsProvider.swift; sourceTree = ""; }; 64AA5E04A2761F6EEA6568E1 /* CREditorStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CREditorStateModel.swift; sourceTree = ""; }; + 6559084C2933A5BB00960C87 /* LibreViewConfigDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibreViewConfigDataFlow.swift; sourceTree = ""; }; + 6559084E2933A5C800960C87 /* LibreViewConfigProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibreViewConfigProvider.swift; sourceTree = ""; }; + 655908502933A5D700960C87 /* LibreViewStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibreViewStateModel.swift; sourceTree = ""; }; + 655908532933A63300960C87 /* LibreViewConfigRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibreViewConfigRootView.swift; sourceTree = ""; }; + 65D16C5929422ED100ABF87A /* LibreLinkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibreLinkManager.swift; sourceTree = ""; }; 66A5B83E7967C38F7CBD883C /* LibreConfigDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LibreConfigDataFlow.swift; sourceTree = ""; }; 67F94DD2853CF42BA4E30616 /* BasalProfileEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BasalProfileEditorDataFlow.swift; sourceTree = ""; }; 680C4420C9A345D46D90D06C /* ManualTempBasalProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ManualTempBasalProvider.swift; sourceTree = ""; }; @@ -902,6 +912,7 @@ C11D545CED3ECEB525EDEE23 /* LibreConfig */, 3811DE1A25C9D48300A708ED /* Main */, 5031FE61F63C2A8A8B7674DD /* ManualTempBasal */, + 6559084B2933A58E00960C87 /* LibreViewConfig */, D533BF261CDC1C3F871E7BFD /* NightscoutConfig */, F66B236E00924A05D6A9F9DF /* NotificationsConfig */, 3E1C41D9301B7058AA7BF5EA /* PreferencesEditor */, @@ -1047,6 +1058,7 @@ 3811DE9725C9D88300A708ED /* NightscoutManager.swift */, 38FE826925CC82DB001FF17A /* NetworkService.swift */, 38FE826C25CC8461001FF17A /* NightscoutAPI.swift */, + 65D16C5929422ED100ABF87A /* LibreLinkManager.swift */, ); path = Network; sourceTree = ""; @@ -1628,6 +1640,25 @@ path = TargetsEditor; sourceTree = ""; }; + 6559084B2933A58E00960C87 /* LibreViewConfig */ = { + isa = PBXGroup; + children = ( + 655908522933A61D00960C87 /* View */, + 6559084C2933A5BB00960C87 /* LibreViewConfigDataFlow.swift */, + 6559084E2933A5C800960C87 /* LibreViewConfigProvider.swift */, + 655908502933A5D700960C87 /* LibreViewStateModel.swift */, + ); + path = LibreViewConfig; + sourceTree = ""; + }; + 655908522933A61D00960C87 /* View */ = { + isa = PBXGroup; + children = ( + 655908532933A63300960C87 /* LibreViewConfigRootView.swift */, + ); + path = View; + sourceTree = ""; + }; 672F63EEAE27400625E14BAD /* AutotuneConfig */ = { isa = PBXGroup; children = ( @@ -2237,6 +2268,7 @@ 388358C825EEF6D200E024B2 /* BasalProfileEntry.swift in Sources */, 3811DE0B25C9D32F00A708ED /* BaseView.swift in Sources */, 3811DE3225C9D49500A708ED /* HomeDataFlow.swift in Sources */, + 6559084D2933A5BB00960C87 /* LibreViewConfigDataFlow.swift in Sources */, 38569347270B5DFB0002C50D /* CGMType.swift in Sources */, 3821ED4C25DD18BA00BC42AD /* Constants.swift in Sources */, 384E803425C385E60086DB71 /* JavaScriptWorker.swift in Sources */, @@ -2302,6 +2334,7 @@ 5D16287A969E64D18CE40E44 /* PumpConfigStateModel.swift in Sources */, E974172296125A5AE99E634C /* PumpConfigRootView.swift in Sources */, 448B6FCB252BD4796E2960C0 /* PumpSettingsEditorDataFlow.swift in Sources */, + 6559084F2933A5C800960C87 /* LibreViewConfigProvider.swift in Sources */, 38E44536274E411700EC9A94 /* Disk.swift in Sources */, 2BE9A6FA20875F6F4F9CD461 /* PumpSettingsEditorProvider.swift in Sources */, 6B9625766B697D1C98E455A2 /* PumpSettingsEditorStateModel.swift in Sources */, @@ -2337,6 +2370,7 @@ 17A9D0899046B45E87834820 /* CREditorProvider.swift in Sources */, 69B9A368029F7EB39F525422 /* CREditorStateModel.swift in Sources */, F97F722827A9741C007B6620 /* MigrationStateModel.swift in Sources */, + 655908512933A5D700960C87 /* LibreViewStateModel.swift in Sources */, 38E44538274E411700EC9A94 /* Disk+[Data].swift in Sources */, 98641AF4F92123DA668AB931 /* CREditorRootView.swift in Sources */, 38E4453D274E411700EC9A94 /* Disk+Errors.swift in Sources */, @@ -2382,6 +2416,7 @@ 38E4453B274E411700EC9A94 /* Disk+VolumeInformation.swift in Sources */, F97F722127A957AF007B6620 /* MigrationPublisher.swift in Sources */, 7BCFACB97C821041BA43A114 /* ManualTempBasalRootView.swift in Sources */, + 655908542933A63300960C87 /* LibreViewConfigRootView.swift in Sources */, 38E44534274E411700EC9A94 /* Disk+InternalHelpers.swift in Sources */, 38A00B2325FC2B55006BC0B0 /* LRUCache.swift in Sources */, 3083261C4B268E353F36CD0B /* AutotuneConfigDataFlow.swift in Sources */, @@ -2390,6 +2425,7 @@ A05235B9112E677ED03B6E8E /* AutotuneConfigRootView.swift in Sources */, 7F7B756BE8543965D9FDF1A2 /* DataTableDataFlow.swift in Sources */, 1D845DF2E3324130E1D95E67 /* DataTableProvider.swift in Sources */, + 65D16C5A29422ED100ABF87A /* LibreLinkManager.swift in Sources */, 0D9A5E34A899219C5C4CDFAF /* DataTableStateModel.swift in Sources */, D6D02515BBFBE64FEBE89856 /* DataTableRootView.swift in Sources */, 38569349270B5DFB0002C50D /* AppGroupSource.swift in Sources */, @@ -2583,7 +2619,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.3; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -2641,7 +2677,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.3; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -2666,7 +2702,7 @@ DEVELOPMENT_TEAM = "${DEVELOPER_TEAM}"; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = FreeAPS/Resources/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2703,7 +2739,7 @@ DEVELOPMENT_TEAM = "${DEVELOPER_TEAM}"; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = FreeAPS/Resources/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/FreeAPS.xcodeproj/xcshareddata/xcschemes/FreeAPSWatch.xcscheme b/FreeAPS.xcodeproj/xcshareddata/xcschemes/FreeAPSWatch.xcscheme index 3319031fc..30a8958cb 100644 --- a/FreeAPS.xcodeproj/xcshareddata/xcschemes/FreeAPSWatch.xcscheme +++ b/FreeAPS.xcodeproj/xcshareddata/xcschemes/FreeAPSWatch.xcscheme @@ -55,10 +55,8 @@ debugServiceExtension = "internal" allowLocationSimulation = "YES" notificationPayloadFile = "FreeAPSWatch WatchKit Extension/PushNotificationPayload.apns"> - + - + - + - - - - - + diff --git a/FreeAPS/Sources/APS/FetchGlucoseManager.swift b/FreeAPS/Sources/APS/FetchGlucoseManager.swift index 9bf0cd9dc..f0a742306 100644 --- a/FreeAPS/Sources/APS/FetchGlucoseManager.swift +++ b/FreeAPS/Sources/APS/FetchGlucoseManager.swift @@ -9,11 +9,13 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { private let processQueue = DispatchQueue(label: "BaseGlucoseManager.processQueue") @Injected() var glucoseStorage: GlucoseStorage! @Injected() var nightscoutManager: NightscoutManager! + @Injected() var libreLinkManager: LibreLinkManager! @Injected() var apsManager: APSManager! @Injected() var settingsManager: SettingsManager! @Injected() var libreTransmitter: LibreTransmitterSource! @Injected() var healthKitManager: HealthKitManager! @Injected() var deviceDataManager: DeviceDataManager! + @Injected() private var keychain: Keychain! private var lifetime = Lifetime() private let timer = DispatchTimer(timeInterval: 1.minutes.timeInterval) @@ -83,8 +85,10 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { self.glucoseStorage.storeGlucose(filtered) self.apsManager.heartbeat(date: date) self.nightscoutManager.uploadGlucose() - let glucoseForHealth = filteredByDate.filter { !glucoseFromHealth.contains($0) } + self.libreLinkManager.uploadIfNeeded() + + let glucoseForHealth = filteredByDate.filter { !glucoseFromHealth.contains($0) } guard glucoseForHealth.isNotEmpty else { return } self.healthKitManager.saveIfNeeded(bloodGlucose: glucoseForHealth) } diff --git a/FreeAPS/Sources/Assemblies/NetworkAssembly.swift b/FreeAPS/Sources/Assemblies/NetworkAssembly.swift index 619b81669..0e1d7a882 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(LibreLinkManager.self) { r in BaseLibreLinkManager(resolver: r) } } } diff --git a/FreeAPS/Sources/Localizations/Main/en.lproj/Localizable.strings b/FreeAPS/Sources/Localizations/Main/en.lproj/Localizable.strings index 48740e8ac..11e4bfa7e 100644 --- a/FreeAPS/Sources/Localizations/Main/en.lproj/Localizable.strings +++ b/FreeAPS/Sources/Localizations/Main/en.lproj/Localizable.strings @@ -224,7 +224,7 @@ Enact a temp Basal or a temp target */ /* */ "Manual Temp Basal" = "Manual Temp Basal"; -/* Allow uploads tp NS */ +/* Allow uploads to different services */ "Allow uploads" = "Allow uploads"; /* API secret in NS */ @@ -1066,3 +1066,68 @@ Enact a temp Basal or a temp target */ /* "Noisy CGM Target Multiplier" */ "Defaults to 1.3. Increase target by this amount when looping off raw/noisy CGM data" = "Defaults to 1.3. Increase target by this amount when looping off raw/noisy CGM data"; + +/* Libre View --------------------*/ +/* */ +"LibreView config" = "LibreView config"; + +/* Secription on LibreView settings screen */ +"To use LibreView, you need to enter credentials and create a connection" = "To use LibreView, you need to enter credentials and create a connection"; + +/* Button on LibreView settings screen */ +"Update a connection" = "Update a connection"; + +/* Button on LibreView settings screen */ +"Create a connection" = "Create a connection"; + +/* Setting on LibreView settings screen */ +"Custom server" = "Custom server"; + +/* Section on LibreView settings screen */ +"Connection settings" = "Connection settings"; + +/* Button on LibreView settings screen */ +"Remove a connection" = "Remove a connection"; + +/* Alert message on LibreView settings screen */ +"Please, set correct LibreView server" = "Please, set correct LibreView server"; + +/* Setting on LibreView settings screen */ +"LibreView Server" = "LibreView Server"; + +/* Button on LibreView settings screen */ +"Force upload glocose" = "Force upload glocose"; + +/* Message after success uploading of glusoce to LibreView server */ +"Glucose was upload success" = "Glucose was upload success"; + +"Last upload date to LibreLink is more, that current date. Try later or check preferences" = "Last upload date to LibreLink is more, that current date. Try later or check preferences"; + +"Have not new glucose to upload" = "Have not new glucose to upload"; + +"Wrong connection's token" = "Wrong connection's token"; + +"Actions" = "Actions"; + +"Frequency of uploads" = "Frequency of uploads"; +"Each Loop" = "Each Loop"; +"Every 15 minutes" = "Every 15 minutes"; +"Every 30 minutes" = "Every 30 minutes"; +"Every hour" = "Every hour"; +"Every 4 hours" = "Every 4 hours"; +"Random upload, but less than 4 hours after the last one" = "Random upload, but less than 4 hours after the last one"; +"It is recommended to use random uploads, they are more natural" = "It is recommended to use random uploads, they are more natural"; +"Last upload on %@. Next upload no earlier than %@" = "Last upload on %@. Next upload no earlier than %@"; + +/* Credentials and autentification --------------------*/ +/* */ +"Credentials" = "Credentials"; + +/* */ +"Login" = "Login"; + +/* */ +"Password" = "Password"; + +/* */ +"Wrong password or login" = "Wrong password or login"; diff --git a/FreeAPS/Sources/Localizations/Main/ru.lproj/Localizable.strings b/FreeAPS/Sources/Localizations/Main/ru.lproj/Localizable.strings index 2daa2f8da..d5313fac4 100644 --- a/FreeAPS/Sources/Localizations/Main/ru.lproj/Localizable.strings +++ b/FreeAPS/Sources/Localizations/Main/ru.lproj/Localizable.strings @@ -224,7 +224,7 @@ Enact a temp Basal or a temp target */ /* */ "Manual Temp Basal" = "Ручная ВБС"; -/* Allow uploads tp NS */ +/* Allow uploads data to different services */ "Allow uploads" = "Разрешить выгрузку"; /* API secret in NS */ @@ -1066,3 +1066,68 @@ Enact a temp Basal or a temp target */ /* "Noisy CGM Target Multiplier" */ "Defaults to 1.3. Increase target by this amount when looping off raw/noisy CGM data" = "По умолчанию 1,3. Увеличивает цель на эту величину, когда цикл отключается из за шума сенсора"; + +/* Libre View --------------------*/ +/* */ +"LibreView config" = "Настройки LibreView"; + +/* Secription on LibreView settings screen */ +"To use LibreView, you need to enter credentials and create a connection" = "Для использования LibreView вам необходимо ввести данные авторизации и создать подключение"; + +/* Button on LibreView settings screen */ +"Update a connection" = "Обновить подключение"; + +/* Button on LibreView settings screen */ +"Create a connection" = "Создать подключение"; + +/* Setting on LibreView settings screen */ +"Custom server" = "Собственный сервер"; + +/* Section on LibreView settings screen */ +"Connection settings" = "Настройки подключения"; + +/* Button on LibreView settings screen */ +"Remove a connection" = "Удалить подключение"; + +/* Alert message on LibreView settings screen */ +"Please, set correct LibreView server" = "Пожалуйста, установите корректный адрес сервера"; + +/* Setting on LibreView settings screen */ +"LibreView Server" = "Сервер LibreView"; + +/* Button on LibreView settings screen */ +"Force upload glocose" = "Принудительная выгрузка глюкозы"; + +/* Message after success uploading of glusoce to LibreView server */ +"Glucose was upload success" = "Глюкоза была успешно выгружена"; + +"Last upload date to LibreLink is more, that current date. Try later or check preferences" = "Дата последней выгрузки больше текущей даты. Попробуйте позже или проверьте настройки"; + +"Have not new glucose to upload" = "Новая глюкоза для выгрузки отсутсвует"; + +"Wrong connection's token" = "Неверный токен безопасности"; + +"Actions" = "Действия"; + +"Frequency of uploads" = "Частота выгрузки"; +"Each Loop" = "Каждый цикл петли"; +"Every 15 minutes" = "Каждые 15 минут"; +"Every 30 minutes" = "Каждые 30 минут"; +"Every hour" = "Каждый час"; +"Every 4 hours" = "Каждые 4 часа"; +"Random upload, but less than 4 hours after the last one" = "Случайные выгрузки, но не менее 1 раза в 4 часа"; +"It is recommended to use random uploads, they are more natural" = "Рекомендовано использовать случайную частоту выгрузки, так как она более естественна"; +"Last upload on %@. Next upload no earlier than %@" = "Последняя выгрузка %@. Следующая не ранее, чем %@"; + +/* Credentials and autentification --------------------*/ +/* */ +"Credentials" = "Данные для входа"; + +/* */ +"Login" = "Логин"; + +/* */ +"Password" = "Пароль"; + +/* */ +"Wrong password or login" = "Неверный логин или пароль"; diff --git a/FreeAPS/Sources/Logger/Logger.swift b/FreeAPS/Sources/Logger/Logger.swift index 7a2b5acf5..ede39b821 100644 --- a/FreeAPS/Sources/Logger/Logger.swift +++ b/FreeAPS/Sources/Logger/Logger.swift @@ -111,6 +111,7 @@ final class Logger { static let deviceManager = Logger(category: .deviceManager, reporter: baseReporter) static let apsManager = Logger(category: .apsManager, reporter: baseReporter) static let nightscout = Logger(category: .nightscout, reporter: baseReporter) + static let librelink = Logger(category: .librelink, reporter: baseReporter) enum Category: String { case `default` @@ -120,6 +121,7 @@ final class Logger { case deviceManager case apsManager case nightscout + case librelink var name: String { rawValue.capitalizingFirstLetter() @@ -134,6 +136,7 @@ final class Logger { case .deviceManager: return .deviceManager case .apsManager: return .apsManager case .nightscout: return .nightscout + case .librelink: return .librelink } } @@ -144,6 +147,7 @@ final class Logger { case .apsManager, .businessLogic, .deviceManager, + .librelink, .nightscout, .openAPS, .service: diff --git a/FreeAPS/Sources/Models/FreeAPSSettings.swift b/FreeAPS/Sources/Models/FreeAPSSettings.swift index 817200d54..46baaa1d5 100644 --- a/FreeAPS/Sources/Models/FreeAPSSettings.swift +++ b/FreeAPS/Sources/Models/FreeAPSSettings.swift @@ -14,7 +14,6 @@ struct FreeAPSSettings: JSON, Equatable { var cgm: CGMType = .nightscout var uploadGlucose: Bool = false var useCalendar: Bool = false - var useAppleHealth: Bool = false var glucoseBadge: Bool = false var glucoseNotificationsAlways: Bool = false var useAlarmSound: Bool = false @@ -23,6 +22,17 @@ struct FreeAPSSettings: JSON, Equatable { var highGlucose: Decimal = 270 var carbsRequiredThreshold: Decimal = 10 var animatedBackground: Bool = false + + // Apple Health Settings + var useAppleHealth: Bool = false + + // LibreView Settings + var libreViewServer = 0 + var libreViewCustomServer = "" + var libreViewLastUploadTimestamp = 0.0 + var libreViewLastAllowUploadGlucose = false + var libreViewFrequenceUploads = 0 + var libreViewNextUploadDelta = 0.0 } extension FreeAPSSettings: Decodable { @@ -122,6 +132,30 @@ extension FreeAPSSettings: Decodable { settings.animatedBackground = animatedBackground } + if let libreViewServer = try? container.decode(Int.self, forKey: .libreViewServer) { + settings.libreViewServer = libreViewServer + } + + if let libreViewCustomServer = try? container.decode(String.self, forKey: .libreViewCustomServer) { + settings.libreViewCustomServer = libreViewCustomServer + } + + if let libreViewLastUploadTimestamp = try? container.decode(Double.self, forKey: .libreViewLastUploadTimestamp) { + settings.libreViewLastUploadTimestamp = libreViewLastUploadTimestamp + } + + if let libreViewLastAllowUploadGlucose = try? container.decode(Bool.self, forKey: .libreViewLastAllowUploadGlucose) { + settings.libreViewLastAllowUploadGlucose = libreViewLastAllowUploadGlucose + } + + if let libreViewFrequenceUploads = try? container.decode(Int.self, forKey: .libreViewFrequenceUploads) { + settings.libreViewFrequenceUploads = libreViewFrequenceUploads + } + + if let libreViewNextUploadDelta = try? container.decode(Double.self, forKey: .libreViewNextUploadDelta) { + settings.libreViewNextUploadDelta = libreViewNextUploadDelta + } + self = settings } } diff --git a/FreeAPS/Sources/Modules/LibreViewConfig/LibreViewConfigDataFlow.swift b/FreeAPS/Sources/Modules/LibreViewConfig/LibreViewConfigDataFlow.swift new file mode 100644 index 000000000..42759aed8 --- /dev/null +++ b/FreeAPS/Sources/Modules/LibreViewConfig/LibreViewConfigDataFlow.swift @@ -0,0 +1,77 @@ +import Combine +import Foundation + +enum LibreViewConfig { + enum Config { + static let lvLoginKey = "LibreViewConfig.login" + static let lvPasswordKey = "LibreViewConfig.password" + static let lvTokenKey = "LibreViewConfig.token" + } + + enum Server: String, CaseIterable { + case custom + case ru = "api.libreview.ru" + case eu = "api-eu.libreview.io" + case us = "api-us.libreview.io" + case de = "api-de.libreview.io" + case fr = "api-fr.libreview.io" + case jp = "api-jp.libreview.io" + case ap = "api-ap.libreview.io" + case au = "api-au.libreview.io" + case ae = "api-ae.libreview.io" + + static func byViewTag(_ tag: Int) -> Self? { + switch tag { + case 0: return .custom + case 1: return .ru + case 2: return .eu + case 3: return .us + case 4: return .de + case 5: return .fr + case 6: return .jp + case 7: return .ap + case 8: return .au + case 9: return .ae + default: return nil + } + } + } + + enum UploadsFrequency: Int, CaseIterable { + case randomUpTo4Hours = 0 + case eachLoop + case minutes15 + case minutes30 + case hour1 + case hour4 + + var description: String { + switch self { + case .eachLoop: return NSLocalizedString("Each Loop", comment: "") + case .minutes15: return NSLocalizedString("Every 15 minutes", comment: "") + case .minutes30: return NSLocalizedString("Every 30 minutes", comment: "") + case .hour1: return NSLocalizedString("Every hour", comment: "") + case .hour4: return NSLocalizedString("Every 4 hours", comment: "") + case .randomUpTo4Hours: return NSLocalizedString( + "Random upload, but less than 4 hours after the last one", + comment: "" + ) + } + } + + var secondsToNextUpload: Double { + switch self { + case .eachLoop: return 0 + case .minutes15: return 900 + case .minutes30: return 1800 + case .hour1: return 3600 + case .hour4: return 14400 + case .randomUpTo4Hours: return Double(Int.random(in: 0 ... 14400)) + } + } + } +} + +protocol LibreViewConfigProvider: Provider { + func createConnection(url: URL, username: String, password: String) -> AnyPublisher +} diff --git a/FreeAPS/Sources/Modules/LibreViewConfig/LibreViewConfigProvider.swift b/FreeAPS/Sources/Modules/LibreViewConfig/LibreViewConfigProvider.swift new file mode 100644 index 000000000..f65cea94d --- /dev/null +++ b/FreeAPS/Sources/Modules/LibreViewConfig/LibreViewConfigProvider.swift @@ -0,0 +1,12 @@ +import Combine +import Foundation + +extension LibreViewConfig { + final class Provider: BaseProvider, LibreViewConfigProvider { + @Injected() private var libreViewManager: LibreLinkManager! + + func createConnection(url: URL, username: String, password: String) -> AnyPublisher { + libreViewManager.createConnection(url: url, username: username, password: password) + } + } +} diff --git a/FreeAPS/Sources/Modules/LibreViewConfig/LibreViewStateModel.swift b/FreeAPS/Sources/Modules/LibreViewConfig/LibreViewStateModel.swift new file mode 100644 index 000000000..a38308428 --- /dev/null +++ b/FreeAPS/Sources/Modules/LibreViewConfig/LibreViewStateModel.swift @@ -0,0 +1,124 @@ +import Combine +import SwiftDate +import SwiftUI + +extension LibreViewConfig { + final class StateModel: BaseStateModel { + @Injected() private var keychain: Keychain! + @Injected() private var libreLinkManager: LibreLinkManager! + + @Published var login = "" + @Published var password = "" + @Published var token = "" + @Published var server = 0 + @Published var customServer = "" + @Published var allowUploadGlucose = false + + @Published var alertMessage: String? + @Published var lastUpload = 0.0 + @Published var uploadsFrequency = 0 + @Published var nextUploadDelta = 0.0 + + @Published var onLoading = false + @Published var onUploading = false + + let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter + }() + + override func subscribe() { + login = keychain.getValue(String.self, forKey: Config.lvLoginKey) ?? "" + password = keychain.getValue(String.self, forKey: Config.lvPasswordKey) ?? "" + token = keychain.getValue(String.self, forKey: Config.lvTokenKey) ?? "" + + $login.sink { [weak self] login in + self?.keychain.setValue(login, forKey: Config.lvLoginKey) + }.store(in: &lifetime) + + $password.sink { [weak self] password in + self?.keychain.setValue(password, forKey: Config.lvPasswordKey) + }.store(in: &lifetime) + + $token.sink { [weak self] token in + self?.keychain.setValue(token, forKey: Config.lvTokenKey) + }.store(in: &lifetime) + + subscribeSetting(\.libreViewServer, on: $server) { server = $0 } + subscribeSetting(\.libreViewCustomServer, on: $customServer) { customServer = $0 } + subscribeSetting(\.libreViewLastUploadTimestamp, on: $lastUpload) { lastUpload = $0 } + subscribeSetting(\.libreViewLastAllowUploadGlucose, on: $allowUploadGlucose) { allowUploadGlucose = $0 } + subscribeSetting(\.libreViewFrequenceUploads, on: $uploadsFrequency) { uploadsFrequency = $0 } + subscribeSetting(\.libreViewNextUploadDelta, on: $nextUploadDelta) { nextUploadDelta = $0 } + } + + func connect() { + guard let server = Server.byViewTag(server), + let url = server == .custom ? URL(string: customServer) : URL(string: "https://\(server.rawValue)") + else { + alertMessage = "Please, set correct LibreView server" + return + } + onLoading = true + provider.createConnection(url: url, username: login, password: password) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + switch completion { + case .finished: break + case let .failure(error): + self?.alertMessage = error.localizedDescription + self?.token = "" + } + self?.onLoading = false + } receiveValue: { response in + self.token = response + } + .store(in: &lifetime) + } + + func forceUploadGlocose() { + guard let server = Server.byViewTag(server), + let url = server == .custom ? URL(string: customServer) : URL(string: "https://\(server.rawValue)") + else { + alertMessage = "Please, set correct LibreView server" + return + } + let currentTimestamp = Date().timeIntervalSince1970 + onUploading = true + libreLinkManager + .uploadGlucose( + url: url, + token: token, + from: lastUpload, + to: currentTimestamp + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + switch completion { + case .finished: break + case let .failure(error): + self?.alertMessage = error.localizedDescription + debug(.librelink, "Error during uploading data: \(error.localizedDescription)") + } + self?.onUploading = false + } receiveValue: { _ in + self.alertMessage = "Glucose was upload success" + self.onUploading = false + self.lastUpload = currentTimestamp + } + .store(in: &lifetime) + } + + func updateUploadTimestampDelta() { + guard let frequency = LibreViewConfig.UploadsFrequency(rawValue: uploadsFrequency) + else { + uploadsFrequency = 0 + nextUploadDelta = 0 + return + } + nextUploadDelta = frequency.secondsToNextUpload + } + } +} diff --git a/FreeAPS/Sources/Modules/LibreViewConfig/View/LibreViewConfigRootView.swift b/FreeAPS/Sources/Modules/LibreViewConfig/View/LibreViewConfigRootView.swift new file mode 100644 index 000000000..8cc7ab68e --- /dev/null +++ b/FreeAPS/Sources/Modules/LibreViewConfig/View/LibreViewConfigRootView.swift @@ -0,0 +1,136 @@ +import SwiftUI +import Swinject + +extension LibreViewConfig { + struct RootView: BaseView { + let resolver: Resolver + @StateObject var state = StateModel() + + var body: some View { + List { + credentialsSection + actionsSection + settingsSection + actionSection + } + .onAppear(perform: configureView) + .navigationBarTitle("LibreView") + .navigationBarTitleDisplayMode(.automatic) + .alert("Error", isPresented: Binding( + get: { state.alertMessage != nil }, + set: { _, _ in state.alertMessage = nil } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(state.alertMessage ?? "") + } + } + + private var actionSection: some View { + Section("Actions") { + Button { + state.forceUploadGlocose() + } label: { + if state.onUploading { + ProgressView() + } else { + Text("Force upload glocose") + } + } + .disabled(state.onUploading) + } + } + + private var settingsSection: some View { + Section { + Picker("LibreView Server", selection: $state.server) { + ForEach(0 ..< Server.allCases.count, id: \.self) { index in + let server = Server.allCases[index].rawValue + Text(server).tag(server) + } + } + TextField("Custom server", text: $state.customServer) + .disableAutocorrection(true) + .autocapitalization(.none) + .textContentType(.URL) + .keyboardType(.URL) + Toggle("Allow uploads", isOn: $state.allowUploadGlucose) + Picker("Frequency of uploads", selection: $state.uploadsFrequency) { + ForEach(LibreViewConfig.UploadsFrequency.allCases, id: \.self) { frequencyItem in + Text(frequencyItem.description).tag(frequencyItem.rawValue) + } + } + .pickerStyle(.navigationLink) + .onChange(of: state.uploadsFrequency) { _ in state.updateUploadTimestampDelta() } + } header: { + Text("Connection settings") + } footer: { + Text("It is recommended to use random uploads, they are more natural") + } + } + + private var credentialsSection: some View { + Section { + TextField("Login", text: $state.login) + .disableAutocorrection(true) + .textContentType(.emailAddress) + .autocapitalization(.none) + .keyboardType(.emailAddress) + SecureField("Password", text: $state.password) + .disableAutocorrection(true) + .autocapitalization(.none) + .textContentType(.password) + .keyboardType(.asciiCapable) + } header: { + Text("Credentials") + } footer: { + if state.lastUpload > 0, + let lastUploadDate = Date(timeIntervalSince1970: state.lastUpload), + let nextuploadDate = Date(timeIntervalSince1970: state.lastUpload + state.nextUploadDelta) + { + Text( + "Last upload on \(state.dateFormatter.string(from: lastUploadDate)). Next upload no earlier than \(state.dateFormatter.string(from: nextuploadDate))" + ) + } + } + } + + private var actionsSection: some View { + Section { + if state.token != "" { + Button { + state.connect() + } label: { + if state.onLoading { + ProgressView() + } else { + Text("Update a connection") + } + } + .disabled(state.onLoading) + Button { + state.token = "" + } label: { + Text("Remove a connection") + .foregroundColor(.red) + } + } else { + Button { + state.connect() + } label: { + if state.onLoading { + ProgressView() + } else { + Text("Create a connection") + } + } + .disabled(state.onLoading) + } + } footer: { + if state.token == "" { + Text("To use LibreView, you need to enter credentials and create a connection") + } + } + } + } +} diff --git a/FreeAPS/Sources/Modules/Settings/View/SettingsRootView.swift b/FreeAPS/Sources/Modules/Settings/View/SettingsRootView.swift index 04d7a3dc0..97df27dee 100644 --- a/FreeAPS/Sources/Modules/Settings/View/SettingsRootView.swift +++ b/FreeAPS/Sources/Modules/Settings/View/SettingsRootView.swift @@ -20,6 +20,7 @@ extension Settings { Section(header: Text("Services")) { Text("Nightscout").navigationLink(to: .nighscoutConfig, from: self) + Text("LibreView").navigationLink(to: .libreViewConfig, from: self) Text("CGM").navigationLink(to: .cgm, from: self) if HKHealthStore.isHealthDataAvailable() { Text("Apple Health").navigationLink(to: .healthkit, from: self) diff --git a/FreeAPS/Sources/Router/Screen.swift b/FreeAPS/Sources/Router/Screen.swift index 519df8aae..543b2461d 100644 --- a/FreeAPS/Sources/Router/Screen.swift +++ b/FreeAPS/Sources/Router/Screen.swift @@ -7,6 +7,7 @@ enum Screen: Identifiable, Hashable { case settings case configEditor(file: String) case nighscoutConfig + case libreViewConfig case pumpConfig case pumpSettingsEditor case basalProfileEditor @@ -45,6 +46,8 @@ extension Screen { ConfigEditor.RootView(resolver: resolver, file: file) case .nighscoutConfig: NightscoutConfig.RootView(resolver: resolver) + case .libreViewConfig: + LibreViewConfig.RootView(resolver: resolver) case .pumpConfig: PumpConfig.RootView(resolver: resolver) case .pumpSettingsEditor: diff --git a/FreeAPS/Sources/Services/Network/LibreLinkManager.swift b/FreeAPS/Sources/Services/Network/LibreLinkManager.swift new file mode 100644 index 000000000..2a31560d4 --- /dev/null +++ b/FreeAPS/Sources/Services/Network/LibreLinkManager.swift @@ -0,0 +1,470 @@ +import Combine +import Foundation +import Swinject +import UIKit + +protocol LibreLinkManager { + func createConnection(url: URL, username: String, password: String) -> AnyPublisher + func uploadIfNeeded() + func uploadGlucose(url: URL, token: String, from: TimeInterval, to: TimeInterval) + -> AnyPublisher +} + +enum LibreLinkManagerError: LocalizedError { + case wrongToken + case wrongSettings + case wrongPasswordOrLogin + case wrongLastUploadDate + case notGlucoseToUpload + case notAllowUploadData + case tooEarlyToUpload + case error(String) + + var errorDescription: String? { + switch self { + case .wrongToken: + return "Wrong connection's token" + case .wrongSettings: + return "Wrong connection's settings" + case .wrongPasswordOrLogin: + return "Wrong password or login" + case .wrongLastUploadDate: + return "Last upload date to LibreLink is more, that current date. Try later or check preferences" + case .notGlucoseToUpload: + return "Have not new glucose to upload" + case .notAllowUploadData: + return "Not allow upload data to LibreView" + case .tooEarlyToUpload: + return "Too early to upload data" + case let .error(description): + return description + } + } +} + +class BaseLibreLinkManager: Injectable { + @Injected() private var reachabilityManager: ReachabilityManager! + @Injected() private var settingsManager: SettingsManager! + @Injected() private var glucoseStorage: GlucoseStorage! + @Injected() private var keychain: Keychain! + + private let processQueue = DispatchQueue(label: "BaseLibreLinkManager.processQueue") + private let service = NetworkService() + + private var lifetime = Lifetime() + + enum Config { + static let authenticationPath = "/lsl/api/nisperson/getauthentication" + static let measurementPath = "lsl/api/measurements" + } + + init(resolver: Resolver) { + injectServices(resolver) + subscribe() + } + + private var isNetworkReachable: Bool { + reachabilityManager.isReachable + } + + private func subscribe() { + _ = reachabilityManager.startListening(onQueue: processQueue) { status in + debug(.librelink, "Network status: \(status)") + } + } +} + +extension BaseLibreLinkManager: LibreLinkManager { + func uploadIfNeeded() { + debug(.librelink, "Start uploading data to LibreLink") + do { + guard settingsManager.settings.libreViewLastAllowUploadGlucose else { + throw LibreLinkManagerError.notAllowUploadData + } + guard let server = LibreViewConfig.Server.byViewTag(settingsManager.settings.libreViewServer), + let url = server == .custom ? URL(string: settingsManager.settings.libreViewCustomServer) : + URL(string: "https://\(server.rawValue)") + else { + throw LibreLinkManagerError.wrongSettings + } + guard let token = keychain.getValue(String.self, forKey: LibreViewConfig.Config.lvTokenKey), token != "" else { + throw LibreLinkManagerError.wrongToken + } + let currentTimestamp = Date().timeIntervalSince1970 + let nextUploadTimeStamp = settingsManager.settings.libreViewLastUploadTimestamp + settingsManager.settings + .libreViewNextUploadDelta + guard currentTimestamp >= nextUploadTimeStamp else { + throw LibreLinkManagerError.tooEarlyToUpload + } + uploadGlucose( + url: url, + token: token, + from: settingsManager.settings.libreViewLastUploadTimestamp, + to: currentTimestamp + ) + .replaceError(with: false) + .sink { [weak self] uploadResult in + guard uploadResult else { return } + self?.settingsManager.settings.libreViewLastUploadTimestamp = currentTimestamp + } + .store(in: &lifetime) + debug(.librelink, "Upload to libreLink successfully ended") + } catch { + debug(.librelink, "Error during uploading data to LibreLink: \(error.localizedDescription)") + } + } + + func uploadGlucose( + url: URL, + token: String, + from lastUploadTimestamp: TimeInterval, + to currentTimestamp: TimeInterval + ) -> AnyPublisher { + debug(.librelink, "Start uploading glucose to LibreLink from \(lastUploadTimestamp) to \(currentTimestamp)") + guard token != "" else { + return Fail(error: LibreLinkManagerError.wrongToken).eraseToAnyPublisher() + } + + guard lastUploadTimestamp < currentTimestamp else { + return Fail(error: LibreLinkManagerError.wrongLastUploadDate).eraseToAnyPublisher() + } + let notUploadedGlucose = glucoseStorage.recent().filter { glucose in + glucose.dateString.timeIntervalSince1970 > lastUploadTimestamp + } + + guard notUploadedGlucose.isNotEmpty else { + return Fail(error: LibreLinkManagerError.notGlucoseToUpload).eraseToAnyPublisher() + } + + var request = URLRequest(url: url.appendingPathComponent(Config.measurementPath)) + let requestBody = MeasurementRequest( + token: token, + bg: notUploadedGlucose.compactMap { bgItem -> PreparedBloodGlucose? in + + guard let glucose = bgItem.glucose else { return nil } + + return PreparedBloodGlucose( + id: Int(bgItem.dateString.timeIntervalSince1970), + value: glucose, + date: bgItem.dateString, + trend: bgItem.direction ?? .none + ) + } + ) + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + request.httpBody = try! JSONCoding.encoder.encode(requestBody) + + return service.run(request) + .decode(type: MeasurementResponse.self, decoder: JSONDecoder()) + .tryMap { response -> MeasurementsUploadResult in + guard response.status == 0 else { + if response.status == 24 { throw LibreLinkManagerError.wrongPasswordOrLogin } + else { throw LibreLinkManagerError.error(response.reason ?? "Something was wrong") } + } + self.updateUploadTimestampDelta() + debug(.librelink, "Finish uploading data to LibreLink. Was upload \(notUploadedGlucose.count) bloodGlucose items") + return true + } + .eraseToAnyPublisher() + } + + func createConnection(url: URL, username: String, password: String) -> AnyPublisher { + var request = URLRequest(url: url.appendingPathComponent(Config.authenticationPath)) + let requestBody = NetworkConnectionRequest(username: username, password: password) + + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + request.httpBody = try! JSONCoding.encoder.encode(requestBody) + + return service.run(request) + .decode(type: NetworkConnectionResponse.self, decoder: JSONDecoder()) + .tryMap { response in + guard response.status == 0, let responseBody = response.result else { + if response.status == 10 { throw LibreLinkManagerError.wrongToken } + else { throw LibreLinkManagerError.error(response.reason ?? "Something was wrong") } + } + return responseBody.userToken + } + .eraseToAnyPublisher() + } + + private func updateUploadTimestampDelta() { + guard let frequency = LibreViewConfig.UploadsFrequency(rawValue: settingsManager.settings.libreViewFrequenceUploads) + else { + settingsManager.settings.libreViewFrequenceUploads = 0 + settingsManager.settings.libreViewNextUploadDelta = 0 + return + } + settingsManager.settings.libreViewNextUploadDelta = frequency.secondsToNextUpload + } +} + +// MARK: - Subtypes + +typealias LibreLinkToken = String +typealias MeasurementsUploadResult = Bool + +extension BaseLibreLinkManager { + // MARK: Local models + + struct PreparedBloodGlucose { + var id: Int + var value: Int + var date: Date + var trend: BloodGlucose.Direction + } + + // MARK: Connection + + struct NetworkConnectionRequest: Codable { + var username: String + var password: String + var domain = "Libreview" + var gatewayType = "FSLibreLink.iOS" + var deviceID = UIDevice.current.identifierForVendor!.uuidString + var setDevice = true + } + + struct NetworkConnectionResponse: Codable { + let status: Int + let reason: String? + let result: Result? + + struct Result: Codable { + let userToken, accountID, userName, firstName: String + let lastName, middleInitial, email, country: String + let culture: String + let timezone: String? + let dateOfBirth: String + let backupFileExists, isHCP, validated, needToAcceptPolicies: Bool + let communicationLanguage, uiLanguage: String + let supportedDevices: String? + let created, guardianLastName, guardianFirstName, domainData: String + + enum CodingKeys: String, CodingKey { + case userToken = "UserToken" + case accountID = "AccountId" + case userName = "UserName" + case firstName = "FirstName" + case lastName = "LastName" + case middleInitial = "MiddleInitial" + case email = "Email" + case country = "Country" + case culture = "Culture" + case timezone = "Timezone" + case dateOfBirth = "DateOfBirth" + case backupFileExists = "BackupFileExists" + case isHCP = "IsHCP" + case validated = "Validated" + case needToAcceptPolicies = "NeedToAcceptPolicies" + case communicationLanguage = "CommunicationLanguage" + case uiLanguage = "UiLanguage" + case supportedDevices = "SupportedDevices" + case created = "Created" + case guardianLastName = "GuardianLastName" + case guardianFirstName = "GuardianFirstName" + case domainData = "DomainData" + } + } + } + + // MARK: Measurement + + struct MeasurementRequest: Encodable { + let gatewayType = "FSLibreLink.iOS" + let domain = "Libreview" + let userToken: String + let deviceData: DeviceData + + init(token: String, bg: [PreparedBloodGlucose]) { + userToken = token + deviceData = DeviceData(bg: bg) + } + + struct DeviceData: Encodable { + let header = Header() + let measurementLog: MeasurementLog + + init(bg: [PreparedBloodGlucose]) { + measurementLog = MeasurementLog(bg: bg) + } + } + + struct Header: Encodable { + let device = Device() + } + + struct Device: Encodable { + let hardwareDescriptor = "iPhone14,3" + let osVersion = "16.0" + let modelName = "com.abbott.librelink.ru" + let osType = "iOS" + let uniqueIdentifier = UIDevice.current.identifierForVendor!.uuidString + let hardwareName = "iPhone" + } + + struct MeasurementLog: Encodable { + let bloodGlucoseEntries = [String]() + let capabilities = [ + "scheduledContinuousGlucose", + "unscheduledContinuousGlucose", + "bloodGlucose", + "insulin", + "food", + "generic-com.abbottdiabetescare.informatics.exercise", + "generic-com.abbottdiabetescare.informatics.customnote", + "generic-com.abbottdiabetescare.informatics.ondemandalarm.low", + "generic-com.abbottdiabetescare.informatics.ondemandalarm.high", + "generic-com.abbottdiabetescare.informatics.ondemandalarm.projectedlow", + "generic-com.abbottdiabetescare.informatics.ondemandalarm.projectedhigh", + "generic-com.abbottdiabetescare.informatics.sensorstart", + "generic-com.abbottdiabetescare.informatics.error", + "generic-com.abbottdiabetescare.informatics.isfGlucoseAlarm", + "generic-com.abbottdiabetescare.informatics.alarmSetting" + ] + let scheduledContinuousGlucoseEntries: [ScheduledContinuousGlucoseEntry] + let insulinEntries = [String]() + let foodEntries = [String]() + let unscheduledContinuousGlucoseEntries: [UnscheduledContinuousGlucoseEntry] + + init(bg: [PreparedBloodGlucose]) { + scheduledContinuousGlucoseEntries = bg.map { bgItem in ScheduledContinuousGlucoseEntry(bg: bgItem) } + if let lastBG = bg.last { + unscheduledContinuousGlucoseEntries = [UnscheduledContinuousGlucoseEntry(bg: lastBG)] + } else { + unscheduledContinuousGlucoseEntries = [] + } + } + } + + struct ScheduledContinuousGlucoseEntry: Encodable { + let extendedProperties: ExtendedScheduledProperties + let recordNumber: Int + let timestamp: String + let valueInMgPerDL: Int + + init(bg: PreparedBloodGlucose) { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + formatter.timeZone = TimeZone.current + + recordNumber = bg.id + timestamp = formatter.string(from: bg.date) + valueInMgPerDL = Int(bg.value) + + formatter.timeZone = TimeZone(secondsFromGMT: 0) + extendedProperties = ExtendedScheduledProperties( + bgValue: bg.value, + factoryTimestamp: formatter.string(from: bg.date) + ) + } + } + + struct ExtendedScheduledProperties: Encodable { + let highOutOfRange: String + let canMerge = "true" + let isFirstAfterTimeChange = false + let factoryTimestamp: String + let lowOutOfRange: String + + init(bgValue: Int, factoryTimestamp: String) { + if bgValue <= 70 { + highOutOfRange = "false" + lowOutOfRange = "true" + } else if bgValue >= 180 { + highOutOfRange = "true" + lowOutOfRange = "false" + } else { + highOutOfRange = "false" + lowOutOfRange = "false" + } + self.factoryTimestamp = factoryTimestamp + } + } + + struct UnscheduledContinuousGlucoseEntry: Encodable { + let extendedProperties: ExtendedUnscheduledProperties + let recordNumber: Int + let timestamp: String + let valueInMgPerDL: Int + + init(bg: PreparedBloodGlucose) { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withTimeZone] + formatter.timeZone = TimeZone.current + + recordNumber = bg.id + timestamp = formatter.string(from: bg.date) + valueInMgPerDL = Int(bg.value) + + formatter.timeZone = TimeZone(secondsFromGMT: 0) + extendedProperties = ExtendedUnscheduledProperties( + bgValue: bg.value, + factoryTimestamp: formatter.string(from: bg.date), + direction: bg.trend + ) + } + } + + struct ExtendedUnscheduledProperties: Encodable { + let highOutOfRange: String + let isActionable = true + let trendArrow: String + let isFirstAfterTimeChange = false + let factoryTimestamp: String + let lowOutOfRange: String + + init(bgValue: Int, factoryTimestamp: String, direction: BloodGlucose.Direction) { + if bgValue <= 70 { + highOutOfRange = "false" + lowOutOfRange = "true" + } else if bgValue >= 180 { + highOutOfRange = "true" + lowOutOfRange = "false" + } else { + highOutOfRange = "false" + lowOutOfRange = "false" + } + self.factoryTimestamp = factoryTimestamp + switch direction { + case .doubleUp, + .singleUp, + .tripleUp: + trendArrow = "Rising" + case .flat, + .fortyFiveDown, + .fortyFiveUp: + trendArrow = "Stable" + case .doubleDown, + .singleDown, + .tripleDown: + trendArrow = "Falling" + case .none, + .notComputable, + .rateOutOfRange: + trendArrow = "Stable" + } + } + } + } + + struct MeasurementResponse: Decodable { + let status: Int + let result: Result? + let reason: String? + + struct Result: Decodable { + let uploadID: String? + let status: Int? + let measurementCounts: MeasurementCounts? + let itemCount: Int? + let createdDateTime, serialNumber: String? + } + + struct MeasurementCounts: Decodable { + let scheduledGlucoseCount, unScheduledGlucoseCount, bloodGlucoseCount, insulinCount: Int? + let genericCount, foodCount, ketoneCount, totalCount: Int? + } + } +}