|
|
@@ -0,0 +1,274 @@
|
|
|
+import Foundation
|
|
|
+import Swinject
|
|
|
+
|
|
|
+class TrioRemoteControl: Injectable {
|
|
|
+ static let shared = TrioRemoteControl()
|
|
|
+
|
|
|
+ @Injected() private var tempTargetsStorage: TempTargetsStorage!
|
|
|
+ @Injected() private var carbsStorage: CarbsStorage!
|
|
|
+ @Injected() private var nightscoutManager: NightscoutManager!
|
|
|
+ @Injected() private var pumpHistoryStorage: PumpHistoryStorage!
|
|
|
+
|
|
|
+ private let timeWindow: TimeInterval = 600 // Defines how old messages that are accepted, 10 minutes
|
|
|
+
|
|
|
+ private init() {
|
|
|
+ injectServices(FreeAPSApp.resolver)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func logError(_ errorMessage: String, pushMessage: PushMessage? = nil) async {
|
|
|
+ var note = errorMessage
|
|
|
+ if let pushMessage = pushMessage {
|
|
|
+ note += " Details: \(pushMessage.humanReadableDescription())"
|
|
|
+ }
|
|
|
+ debug(.remoteControl, note)
|
|
|
+ await nightscoutManager.uploadNoteTreatment(note: note)
|
|
|
+ }
|
|
|
+
|
|
|
+ func handleRemoteNotification(userInfo: [AnyHashable: Any]) async {
|
|
|
+ let enabled = UserDefaults.standard.bool(forKey: "TRCenabled")
|
|
|
+ guard enabled else {
|
|
|
+ await logError("Remote command received, but remote control is disabled in settings. Ignoring the command.")
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ do {
|
|
|
+ let jsonData = try JSONSerialization.data(withJSONObject: userInfo)
|
|
|
+ let pushMessage = try JSONDecoder().decode(PushMessage.self, from: jsonData)
|
|
|
+ let currentTime = Date().timeIntervalSince1970
|
|
|
+ let timeDifference = currentTime - pushMessage.timestamp
|
|
|
+
|
|
|
+ if timeDifference > timeWindow {
|
|
|
+ await logError(
|
|
|
+ "Command rejected: the message is too old (sent \(Int(timeDifference)) seconds ago, which exceeds the allowed limit).",
|
|
|
+ pushMessage: pushMessage
|
|
|
+ )
|
|
|
+ return
|
|
|
+ } else if timeDifference < -timeWindow {
|
|
|
+ await logError(
|
|
|
+ "Command rejected: the message has an invalid future timestamp (timestamp is \(Int(-timeDifference)) seconds ahead of the current time).",
|
|
|
+ pushMessage: pushMessage
|
|
|
+ )
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ debug(.remoteControl, "Command received with acceptable time difference: \(Int(timeDifference)) seconds.")
|
|
|
+
|
|
|
+ let storedSecret = UserDefaults.standard.string(forKey: "TRCsharedSecret") ?? ""
|
|
|
+ guard !storedSecret.isEmpty else {
|
|
|
+ await logError(
|
|
|
+ "Command rejected: shared secret is missing in settings. Cannot authenticate the command.",
|
|
|
+ pushMessage: pushMessage
|
|
|
+ )
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ guard pushMessage.sharedSecret == storedSecret else {
|
|
|
+ await logError(
|
|
|
+ "Command rejected: shared secret does not match. Cannot authenticate the command.",
|
|
|
+ pushMessage: pushMessage
|
|
|
+ )
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ switch pushMessage.commandType {
|
|
|
+ case "bolus":
|
|
|
+ await handleBolusCommand(pushMessage)
|
|
|
+ case "temp_target":
|
|
|
+ await handleTempTargetCommand(pushMessage)
|
|
|
+ case "cancel_temp_target":
|
|
|
+ await cancelTempTarget()
|
|
|
+ case "meal":
|
|
|
+ await handleMealCommand(pushMessage)
|
|
|
+ default:
|
|
|
+ await logError(
|
|
|
+ "Command rejected: unsupported command type '\(pushMessage.commandType)'.",
|
|
|
+ pushMessage: pushMessage
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch {
|
|
|
+ await logError("Error: unable to process the command due to decoding failure (\(error.localizedDescription)).")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func handleMealCommand(_ pushMessage: PushMessage) async {
|
|
|
+ guard
|
|
|
+ let carbs = pushMessage.carbs,
|
|
|
+ let fat = pushMessage.fat,
|
|
|
+ let protein = pushMessage.protein
|
|
|
+ else {
|
|
|
+ await logError("Command rejected: meal data is incomplete or invalid.", pushMessage: pushMessage)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ let settings = await FreeAPSApp.resolver.resolve(SettingsManager.self)?.settings
|
|
|
+ let maxCarbs = settings?.maxCarbs ?? Decimal(0)
|
|
|
+ let maxFat = settings?.maxFat ?? Decimal(0)
|
|
|
+ let maxProtein = settings?.maxProtein ?? Decimal(0)
|
|
|
+
|
|
|
+ if Decimal(carbs) > maxCarbs {
|
|
|
+ await logError(
|
|
|
+ "Command rejected: carbs amount (\(carbs)g) exceeds the maximum allowed (\(maxCarbs)g).",
|
|
|
+ pushMessage: pushMessage
|
|
|
+ )
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if Decimal(fat) > maxFat {
|
|
|
+ await logError(
|
|
|
+ "Command rejected: fat amount (\(fat)g) exceeds the maximum allowed (\(maxFat)g).",
|
|
|
+ pushMessage: pushMessage
|
|
|
+ )
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if Decimal(protein) > maxProtein {
|
|
|
+ await logError(
|
|
|
+ "Command rejected: protein amount (\(protein)g) exceeds the maximum allowed (\(maxProtein)g).",
|
|
|
+ pushMessage: pushMessage
|
|
|
+ )
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ let pushMessageDate = Date(timeIntervalSince1970: pushMessage.timestamp)
|
|
|
+ let recentCarbEntries = carbsStorage.recent()
|
|
|
+ let carbsAfterPushMessage = recentCarbEntries.filter { $0.createdAt > pushMessageDate }
|
|
|
+
|
|
|
+ if !carbsAfterPushMessage.isEmpty {
|
|
|
+ await logError(
|
|
|
+ "Command rejected: newer carb entries have been logged since the command was sent.",
|
|
|
+ pushMessage: pushMessage
|
|
|
+ )
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ let mealEntry = CarbsEntry(
|
|
|
+ id: UUID().uuidString,
|
|
|
+ createdAt: Date(),
|
|
|
+ actualDate: nil,
|
|
|
+ carbs: Decimal(carbs),
|
|
|
+ fat: Decimal(fat),
|
|
|
+ protein: Decimal(protein),
|
|
|
+ note: "Remote meal command",
|
|
|
+ enteredBy: CarbsEntry.manual,
|
|
|
+ isFPU: false,
|
|
|
+ fpuID: nil
|
|
|
+ )
|
|
|
+
|
|
|
+ await carbsStorage.storeCarbs([mealEntry], areFetchedFromRemote: false)
|
|
|
+ debug(.remoteControl, "Meal command processed successfully with carbs: \(carbs)g, fat: \(fat)g, protein: \(protein)g.")
|
|
|
+ }
|
|
|
+
|
|
|
+ private func handleBolusCommand(_ pushMessage: PushMessage) async {
|
|
|
+ guard let bolusAmount = pushMessage.bolusAmount else {
|
|
|
+ await logError("Command rejected: bolus amount is missing or invalid.", pushMessage: pushMessage)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ let maxBolus = await FreeAPSApp.resolver.resolve(SettingsManager.self)?.pumpSettings.maxBolus ?? Decimal(0)
|
|
|
+
|
|
|
+ if bolusAmount > maxBolus {
|
|
|
+ await logError(
|
|
|
+ "Command rejected: bolus amount (\(bolusAmount) units) exceeds the maximum allowed (\(maxBolus) units).",
|
|
|
+ pushMessage: pushMessage
|
|
|
+ )
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ let recentPumpEvents = pumpHistoryStorage.recent()
|
|
|
+ let recentBoluses = recentPumpEvents.filter { event in
|
|
|
+ event.type == .bolus && event.timestamp > Date(timeIntervalSince1970: pushMessage.timestamp)
|
|
|
+ }
|
|
|
+
|
|
|
+ let totalRecentBolusAmount = recentBoluses.reduce(Decimal(0)) { $0 + ($1.amount ?? 0) }
|
|
|
+
|
|
|
+ if totalRecentBolusAmount >= bolusAmount * 0.2 {
|
|
|
+ await logError(
|
|
|
+ "Command rejected: boluses totaling more than 20% of the requested amount have been delivered since the command was sent.",
|
|
|
+ pushMessage: pushMessage
|
|
|
+ )
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ debug(.remoteControl, "Enacting bolus command with amount: \(bolusAmount) units.")
|
|
|
+
|
|
|
+ guard let apsManager = await FreeAPSApp.resolver.resolve(APSManager.self) else {
|
|
|
+ await logError(
|
|
|
+ "Error: unable to process bolus command because the APS Manager is not available.",
|
|
|
+ pushMessage: pushMessage
|
|
|
+ )
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ await apsManager.enactBolus(amount: Double(truncating: bolusAmount as NSNumber), isSMB: false)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func handleTempTargetCommand(_ pushMessage: PushMessage) async {
|
|
|
+ guard let targetValue = pushMessage.target,
|
|
|
+ let durationValue = pushMessage.duration
|
|
|
+ else {
|
|
|
+ await logError("Command rejected: temp target data is incomplete or invalid.", pushMessage: pushMessage)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ let durationInMinutes = Int(durationValue)
|
|
|
+ let tempTarget = TempTarget(
|
|
|
+ name: "Remote Control",
|
|
|
+ createdAt: Date(),
|
|
|
+ targetTop: Decimal(targetValue),
|
|
|
+ targetBottom: Decimal(targetValue),
|
|
|
+ duration: Decimal(durationInMinutes),
|
|
|
+ enteredBy: pushMessage.user,
|
|
|
+ reason: "Remote temp target command"
|
|
|
+ )
|
|
|
+
|
|
|
+ tempTargetsStorage.storeTempTargets([tempTarget])
|
|
|
+ debug(.remoteControl, "Temp target set with target: \(targetValue), duration: \(durationInMinutes) minutes.")
|
|
|
+ }
|
|
|
+
|
|
|
+ func cancelTempTarget() async {
|
|
|
+ debug(.remoteControl, "Cancelling temp target.")
|
|
|
+
|
|
|
+ guard tempTargetsStorage.current() != nil else {
|
|
|
+ await logError("Command rejected: no active temp target to cancel.")
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ let cancelEntry = TempTarget.cancel(at: Date())
|
|
|
+ tempTargetsStorage.storeTempTargets([cancelEntry])
|
|
|
+ debug(.remoteControl, "Temp target cancelled successfully.")
|
|
|
+ }
|
|
|
+
|
|
|
+ func handleAPNSChanges(deviceToken: String?) async {
|
|
|
+ let previousDeviceToken = UserDefaults.standard.string(forKey: "deviceToken")
|
|
|
+ let previousIsAPNSProduction = UserDefaults.standard.bool(forKey: "isAPNSProduction")
|
|
|
+
|
|
|
+ let isAPNSProduction = isRunningInAPNSProductionEnvironment()
|
|
|
+ var shouldUploadProfiles = false
|
|
|
+
|
|
|
+ if let token = deviceToken, token != previousDeviceToken {
|
|
|
+ UserDefaults.standard.set(token, forKey: "deviceToken")
|
|
|
+ debug(.remoteControl, "Device token updated: \(token)")
|
|
|
+ shouldUploadProfiles = true
|
|
|
+ }
|
|
|
+
|
|
|
+ if previousIsAPNSProduction != isAPNSProduction {
|
|
|
+ UserDefaults.standard.set(isAPNSProduction, forKey: "isAPNSProduction")
|
|
|
+ debug(.remoteControl, "APNS environment changed to: \(isAPNSProduction ? "Production" : "Sandbox")")
|
|
|
+ shouldUploadProfiles = true
|
|
|
+ }
|
|
|
+
|
|
|
+ if shouldUploadProfiles {
|
|
|
+ await nightscoutManager.uploadProfiles()
|
|
|
+ } else {
|
|
|
+ debug(.remoteControl, "No changes detected in device token or APNS environment.")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func isRunningInAPNSProductionEnvironment() -> Bool {
|
|
|
+ if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL {
|
|
|
+ return appStoreReceiptURL.lastPathComponent != "sandboxReceipt"
|
|
|
+ }
|
|
|
+ return false
|
|
|
+ }
|
|
|
+}
|