diff --git a/TidepoolService.xcodeproj/project.pbxproj b/TidepoolService.xcodeproj/project.pbxproj index 82cf48e..a9a0191 100644 --- a/TidepoolService.xcodeproj/project.pbxproj +++ b/TidepoolService.xcodeproj/project.pbxproj @@ -69,6 +69,7 @@ C12E4BBB288F2215009C98A2 /* TidepoolServiceKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = A9DAACFF22E7987800E76C9F /* TidepoolServiceKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; C12E4BBE288F2215009C98A2 /* TidepoolServiceKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A9DAAD1B22E7988900E76C9F /* TidepoolServiceKitUI.framework */; platformFilter = ios; }; C12E4BBF288F2215009C98A2 /* TidepoolServiceKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = A9DAAD1B22E7988900E76C9F /* TidepoolServiceKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C1A685432C067E410071C171 /* DeviceLogUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A685422C067E410071C171 /* DeviceLogUploader.swift */; }; C1C9414629F0CB21008D3E05 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C9414529F0CB21008D3E05 /* UIImage.swift */; }; C1D0B62929848A460098D215 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D0B62829848A460098D215 /* SettingsView.swift */; }; C1D0B62C29848BEB0098D215 /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D0B62B29848BEB0098D215 /* Image.swift */; }; @@ -231,6 +232,7 @@ C199E4DA29C64072003D32F7 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; C1A3529629C640A5002322A5 /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; C1A3529729C640A5002322A5 /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; + C1A685422C067E410071C171 /* DeviceLogUploader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLogUploader.swift; sourceTree = ""; }; C1B0CFE129C786BF0045B04D /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; C1B267AA2995824000BCB7C1 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; C1C9414529F0CB21008D3E05 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; @@ -402,6 +404,7 @@ A9DAAD3522E7CAC100E76C9F /* TidepoolService.swift */, A913B37B24200C86000805C4 /* Extensions */, A9DAAD4122E7DF9B00E76C9F /* Localizable.strings */, + C1A685422C067E410071C171 /* DeviceLogUploader.swift */, ); path = TidepoolServiceKit; sourceTree = ""; @@ -752,6 +755,7 @@ A9F9F317271A046E00D19374 /* StoredCarbEntry.swift in Sources */, A9D1AC9D27B1E3C6008C5A12 /* DoseEntry.swift in Sources */, A9752A9B270B941C00E50750 /* SingleQuantitySchedule.swift in Sources */, + C1A685432C067E410071C171 /* DeviceLogUploader.swift in Sources */, A9752A93270B766A00E50750 /* StoredDosingDecision.swift in Sources */, A9752A97270B91E000E50750 /* Double.swift in Sources */, C110888F2A39149100BA4898 /* BuildDetails.swift in Sources */, diff --git a/TidepoolServiceKit/DeviceLogUploader.swift b/TidepoolServiceKit/DeviceLogUploader.swift new file mode 100644 index 0000000..61b5a18 --- /dev/null +++ b/TidepoolServiceKit/DeviceLogUploader.swift @@ -0,0 +1,130 @@ +// +// DeviceLogUploader.swift +// TidepoolServiceKit +// +// Created by Pete Schwamb on 5/28/24. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import os.log +import LoopKit +import TidepoolKit + +/// Periodically uploads device logs in hourly chunks to backend +actor DeviceLogUploader { + private let log = OSLog(category: "DeviceLogUploader") + + private let api: TAPI + + private var delegate: RemoteDataServiceDelegate? + + private var logChunkDuration = TimeInterval(hours: 1) + + func setDelegate(_ delegate: RemoteDataServiceDelegate?) { + self.delegate = delegate + } + + init(api: TAPI) { + self.api = api + + Task { + await main() + } + } + + func main() async { + let backfillLimitInterval = TimeInterval(days: 2) + // Default start uploading logs from 2 days ago + var nextUploadStart = Date().addingTimeInterval(-backfillLimitInterval).dateFlooredToTimeInterval(logChunkDuration) + + // Fetch device log metadata records + while true { + do { + // TODO: fetching logs is not implemented on the backend yet: awaiting https://tidepool.atlassian.net/browse/BACK-3011 + // For now, we expect this to error, so the catch has been modified to break out of the loop. Once this is implemented, + // We will want to retry on error, so the break should eventually be removed. + + var uploadMetadata = try await api.listDeviceLogs(start: Date().addingTimeInterval(-backfillLimitInterval), end: Date()) + uploadMetadata.sort { a, b in + return a.endAtTime > b.endAtTime + } + if let lastEnd = uploadMetadata.last?.endAtTime { + nextUploadStart = lastEnd.dateFlooredToTimeInterval(logChunkDuration) + } + break + } catch { + log.error("Unable to fetch device log metadata: %@", String(describing: error)) + try? await Task.sleep(nanoseconds: TimeInterval(minutes: 1).nanoseconds) + break // TODO: Remove when backend has implemented device log metadata fetching (see above) + } + } + // Start upload loop + while true { + let nextUploadEnd = nextUploadStart.addingTimeInterval(logChunkDuration) + let timeUntilNextUpload = nextUploadEnd.timeIntervalSinceNow + if timeUntilNextUpload > 0 { + log.debug("Waiting %@s until next upload", String(timeUntilNextUpload)) + try? await Task.sleep(nanoseconds: timeUntilNextUpload.nanoseconds) + } + await upload(from: nextUploadStart, to: nextUploadEnd) + nextUploadStart = nextUploadEnd + } + } + + func upload(from start: Date, to end: Date) async { + log.default("Uploading from %@ to %@", String(describing: start), String(describing: end)) + do { + if let logs = try await delegate?.fetchDeviceLogs(startDate: start, endDate: end) { + log.default("Fetched %d logs", logs.count) + if logs.count > 0 { + let data = logs.map({ + entry in + TDeviceLogEntry( + type: entry.type.tidepoolType, + managerIdentifier: entry.managerIdentifier, + deviceIdentifier: entry.deviceIdentifier ?? "unknown", + timestamp: entry.timestamp, + message: entry.message + ) + }) + do { + let metatdata = try await api.uploadDeviceLogs(logs: data, start: start, end: end) + log.default("metadata: %@", String(describing: metatdata)) + print("hi") + } catch { + log.error("error uploading device logs:: %@", String(describing: error)) + print("hi") + } + } + } + } catch { + log.error("Upload failed: %@", String(describing: error)) + } + } +} + +extension TimeInterval { + var nanoseconds: UInt64 { + return UInt64(self * 1e+9) + } +} + +extension DeviceLogEntryType { + var tidepoolType: TDeviceLogEntry.TDeviceLogEntryType { + switch self { + case .send: + return .send + case .receive: + return .receive + case .error: + return .error + case .delegate: + return .delegate + case .delegateResponse: + return .delegateResponse + case .connection: + return .connection + } + } +} diff --git a/TidepoolServiceKit/TidepoolService.swift b/TidepoolServiceKit/TidepoolService.swift index ba7ea50..58d42e5 100644 --- a/TidepoolServiceKit/TidepoolService.swift +++ b/TidepoolServiceKit/TidepoolService.swift @@ -48,6 +48,8 @@ public final class TidepoolService: Service, TAPIObserver, ObservableObject { public weak var stateDelegate: StatefulPluggableDelegate? + public weak var remoteDataServiceDelegate: RemoteDataServiceDelegate? + public lazy var sessionStorage: SessionStorage = KeychainManager() public let tapi: TAPI = TAPI(clientId: BuildDetails.default.tidepoolServiceClientId, redirectURL: BuildDetails.default.tidepoolServiceRedirectURL) @@ -69,17 +71,25 @@ public final class TidepoolService: Service, TAPIObserver, ObservableObject { private let log = OSLog(category: "TidepoolService") private let tidepoolKitLog = OSLog(category: "TidepoolKit") + private var deviceLogUploader: DeviceLogUploader? + public init(hostIdentifier: String, hostVersion: String) { self.id = UUID().uuidString self.hostIdentifier = hostIdentifier self.hostVersion = hostVersion Task { - await tapi.setLogging(self) - await tapi.addObserver(self) + await finishSetup() } } + public func finishSetup() async { + await tapi.setLogging(self) + await tapi.addObserver(self) + deviceLogUploader = DeviceLogUploader(api: tapi) + await deviceLogUploader?.setDelegate(remoteDataServiceDelegate) + } + public init?(rawState: RawStateValue) { self.isOnboarded = true // Assume when restoring from state, that we're onboarded guard let id = rawState["id"] as? String else { @@ -96,8 +106,7 @@ public final class TidepoolService: Service, TAPIObserver, ObservableObject { self.session = try sessionStorage.getSession(for: sessionService) Task { await tapi.setSession(session) - await tapi.setLogging(self) - await tapi.addObserver(self) + await finishSetup() } } catch let error { tidepoolKitLog.error("Error initializing TidepoolService %{public}@", error.localizedDescription) @@ -275,7 +284,7 @@ extension TidepoolService: TLogging { extension TidepoolService: RemoteDataService { - public func uploadTemporaryOverrideData(updated: [TemporaryScheduleOverride], deleted: [TemporaryScheduleOverride], completion: @escaping (Result) -> Void) { + public func uploadTemporaryOverrideData(updated: [TemporaryScheduleOverride], deleted: [TemporaryScheduleOverride]) async throws { // TODO: https://tidepool.atlassian.net/browse/LOOP-4769 // The following code is taken from previous upload code when override events where stored in settings @@ -301,82 +310,49 @@ extension TidepoolService: RemoteDataService { // timeZoneOffset: datumTimeZoneOffset, // payload: datumPayload, // origin: origin) - - completion(.success(true)) } public var alertDataLimit: Int? { return 1000 } - public func uploadAlertData(_ stored: [SyncAlertObject], completion: @escaping (_ result: Result) -> Void) { + public func uploadAlertData(_ stored: [SyncAlertObject]) async throws { guard let userId = userId, let hostIdentifier = hostIdentifier, let hostVersion = hostVersion else { - completion(.failure(TidepoolServiceError.configuration)) - return - } - Task { - do { - let result = try await createData(stored.compactMap { $0.datum(for: userId, hostIdentifier: hostIdentifier, hostVersion: hostVersion) }) - completion(.success(result)) - } catch { - completion(.failure(error)) - } + throw TidepoolServiceError.configuration } + + let _ = try await createData(stored.compactMap { $0.datum(for: userId, hostIdentifier: hostIdentifier, hostVersion: hostVersion) }) } public var carbDataLimit: Int? { return 1000 } - public func uploadCarbData(created: [SyncCarbObject], updated: [SyncCarbObject], deleted: [SyncCarbObject], completion: @escaping (Result) -> Void) { + public func uploadCarbData(created: [SyncCarbObject], updated: [SyncCarbObject], deleted: [SyncCarbObject]) async throws { guard let userId = userId, let hostIdentifier = hostIdentifier, let hostVersion = hostVersion else { - completion(.failure(TidepoolServiceError.configuration)) - return + throw TidepoolServiceError.configuration } - Task { - do { - let createdUploaded = try await createData(created.compactMap { $0.datum(for: userId, hostIdentifier: hostIdentifier, hostVersion: hostVersion) }) - let updatedUploaded = try await updateData(updated.compactMap { $0.datum(for: userId, hostIdentifier: hostIdentifier, hostVersion: hostVersion) }) - let deletedUploaded = try await deleteData(withSelectors: deleted.compactMap { $0.selector }) - completion(.success(createdUploaded || updatedUploaded || deletedUploaded)) - } catch { - completion(.failure(error)) - } - } + let _ = try await createData(created.compactMap { $0.datum(for: userId, hostIdentifier: hostIdentifier, hostVersion: hostVersion) }) + let _ = try await updateData(updated.compactMap { $0.datum(for: userId, hostIdentifier: hostIdentifier, hostVersion: hostVersion) }) + let _ = try await deleteData(withSelectors: deleted.compactMap { $0.selector }) } public var doseDataLimit: Int? { return 1000 } - public func uploadDoseData(created: [DoseEntry], deleted: [DoseEntry], completion: @escaping (_ result: Result) -> Void) { + public func uploadDoseData(created: [DoseEntry], deleted: [DoseEntry]) async throws { guard let userId = userId, let hostIdentifier = hostIdentifier, let hostVersion = hostVersion else { - completion(.failure(TidepoolServiceError.configuration)) - return + throw TidepoolServiceError.configuration } - Task { - do { - let createdUploaded = try await createData(created.flatMap { $0.data(for: userId, hostIdentifier: hostIdentifier, hostVersion: hostVersion) }) - let deletedUploaded = try await deleteData(withSelectors: deleted.flatMap { $0.selectors }) - completion(.success(createdUploaded || deletedUploaded)) - } catch { - completion(.failure(error)) - } - } + let _ = try await createData(created.flatMap { $0.data(for: userId, hostIdentifier: hostIdentifier, hostVersion: hostVersion) }) + let _ = try await deleteData(withSelectors: deleted.flatMap { $0.selectors }) } public var dosingDecisionDataLimit: Int? { return 50 } // Each can be up to 20K bytes of serialized JSON, target ~1M or less - public func uploadDosingDecisionData(_ stored: [StoredDosingDecision], completion: @escaping (_ result: Result) -> Void) { + public func uploadDosingDecisionData(_ stored: [StoredDosingDecision]) async throws { guard let userId = userId, let hostIdentifier = hostIdentifier, let hostVersion = hostVersion else { - completion(.failure(TidepoolServiceError.configuration)) - return + throw TidepoolServiceError.configuration } - Task { - do { - let result = try await createData(calculateDosingDecisionData(stored, for: userId, hostIdentifier: hostIdentifier, hostVersion: hostVersion)) - completion(.success(result)) - } catch { - completion(.failure(error)) - } - } + let _ = try await createData(calculateDosingDecisionData(stored, for: userId, hostIdentifier: hostIdentifier, hostVersion: hostVersion)) } func calculateDosingDecisionData(_ stored: [StoredDosingDecision], for userId: String, hostIdentifier: String, hostVersion: String) -> [TDatum] { @@ -427,63 +403,39 @@ extension TidepoolService: RemoteDataService { public var glucoseDataLimit: Int? { return 1000 } - public func uploadGlucoseData(_ stored: [StoredGlucoseSample], completion: @escaping (Result) -> Void) { + public func uploadGlucoseData(_ stored: [StoredGlucoseSample]) async throws { guard let userId = userId, let hostIdentifier = hostIdentifier, let hostVersion = hostVersion else { - completion(.failure(TidepoolServiceError.configuration)) - return + throw TidepoolServiceError.configuration } - Task { - do { - let result = try await createData(stored.compactMap { $0.datum(for: userId, hostIdentifier: hostIdentifier, hostVersion: hostVersion) }) - completion(.success(result)) - } catch { - completion(.failure(error)) - } - } + let _ = try await createData(stored.compactMap { $0.datum(for: userId, hostIdentifier: hostIdentifier, hostVersion: hostVersion) }) } public var pumpDataEventLimit: Int? { return 1000 } - public func uploadPumpEventData(_ stored: [PersistedPumpEvent], completion: @escaping (_ result: Result) -> Void) { + public func uploadPumpEventData(_ stored: [PersistedPumpEvent]) async throws { guard let userId = userId, let hostIdentifier = hostIdentifier, let hostVersion = hostVersion else { - completion(.failure(TidepoolServiceError.configuration)) - return + throw TidepoolServiceError.configuration } - Task { - do { - let result = try await createData(stored.flatMap { $0.data(for: userId, hostIdentifier: hostIdentifier, hostVersion: hostVersion) }) - completion(.success(result)) - } catch { - completion(.failure(error)) - } - } + let _ = try await createData(stored.flatMap { $0.data(for: userId, hostIdentifier: hostIdentifier, hostVersion: hostVersion) }) } public var settingsDataLimit: Int? { return 400 } // Each can be up to 2.5K bytes of serialized JSON, target ~1M or less - public func uploadSettingsData(_ stored: [StoredSettings], completion: @escaping (_ result: Result) -> Void) { + public func uploadSettingsData(_ stored: [StoredSettings]) async throws { guard let userId = userId, let hostIdentifier = hostIdentifier, let hostVersion = hostVersion else { - completion(.failure(TidepoolServiceError.configuration)) - return + throw TidepoolServiceError.configuration } let (created, updated, lastControllerSettingsDatum, lastCGMSettingsDatum, lastPumpSettingsDatum) = calculateSettingsData(stored, for: userId, hostIdentifier: hostIdentifier, hostVersion: hostVersion) - Task { - do { - let createdUploaded = try await createData(created) - let updatedUploaded = try await updateData(updated) - self.lastControllerSettingsDatum = lastControllerSettingsDatum - self.lastCGMSettingsDatum = lastCGMSettingsDatum - self.lastPumpSettingsDatum = lastPumpSettingsDatum - self.completeUpdate() - completion(.success(createdUploaded || updatedUploaded)) - } catch { - completion(.failure(error)) - } - } + let _ = try await createData(created) + let _ = try await updateData(updated) + self.lastControllerSettingsDatum = lastControllerSettingsDatum + self.lastCGMSettingsDatum = lastCGMSettingsDatum + self.lastPumpSettingsDatum = lastPumpSettingsDatum + self.completeUpdate() } func calculateSettingsData(_ stored: [StoredSettings], for userId: String, hostIdentifier: String, hostVersion: String) -> ([TDatum], [TDatum], TControllerSettingsDatum?, TCGMSettingsDatum?, TPumpSettingsDatum?) { @@ -606,9 +558,8 @@ extension TidepoolService: RemoteDataService { } } - public func uploadCgmEventData(_ stored: [LoopKit.PersistedCgmEvent], completion: @escaping (Result) -> Void) { + public func uploadCgmEventData(_ stored: [PersistedCgmEvent]) async throws { // TODO: Upload sensor/transmitter changes - completion(.success(false)) } public func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async throws {