TrioRemoteControl.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. import CoreData
  2. import Foundation
  3. import Swinject
  4. class TrioRemoteControl: Injectable {
  5. static let shared = TrioRemoteControl()
  6. @Injected() private var tempTargetsStorage: TempTargetsStorage!
  7. @Injected() private var carbsStorage: CarbsStorage!
  8. @Injected() private var nightscoutManager: NightscoutManager!
  9. @Injected() private var overrideStorage: OverrideStorage!
  10. @Injected() private var settings: SettingsManager!
  11. private let timeWindow: TimeInterval = 600 // Defines how old messages that are accepted, 10 minutes
  12. private let pumpHistoryFetchContext: NSManagedObjectContext
  13. private let viewContext: NSManagedObjectContext
  14. private init() {
  15. pumpHistoryFetchContext = CoreDataStack.shared.newTaskContext()
  16. viewContext = CoreDataStack.shared.persistentContainer.viewContext
  17. injectServices(FreeAPSApp.resolver)
  18. }
  19. private func logError(_ errorMessage: String, pushMessage: PushMessage? = nil) async {
  20. var note = errorMessage
  21. if let pushMessage = pushMessage {
  22. note += " Details: \(pushMessage.humanReadableDescription())"
  23. }
  24. debug(.remoteControl, note)
  25. await nightscoutManager.uploadNoteTreatment(note: note)
  26. }
  27. func handleRemoteNotification(pushMessage: PushMessage) async {
  28. let isTrioRemoteControlEnabled = UserDefaults.standard.bool(forKey: "isTrioRemoteControlEnabled")
  29. guard isTrioRemoteControlEnabled else {
  30. await logError("Remote command received, but remote control is disabled in settings. Ignoring the command.")
  31. return
  32. }
  33. let currentTime = Date().timeIntervalSince1970
  34. let timeDifference = currentTime - pushMessage.timestamp
  35. if timeDifference > timeWindow {
  36. await logError(
  37. "Command rejected: the message is too old (sent \(Int(timeDifference)) seconds ago, which exceeds the allowed limit).",
  38. pushMessage: pushMessage
  39. )
  40. return
  41. } else if timeDifference < -timeWindow {
  42. await logError(
  43. "Command rejected: the message has an invalid future timestamp (timestamp is \(Int(-timeDifference)) seconds ahead of the current time).",
  44. pushMessage: pushMessage
  45. )
  46. return
  47. }
  48. debug(.remoteControl, "Command received with acceptable time difference: \(Int(timeDifference)) seconds.")
  49. let storedSecret = UserDefaults.standard.string(forKey: "trioRemoteControlSharedSecret") ?? ""
  50. guard !storedSecret.isEmpty else {
  51. await logError(
  52. "Command rejected: shared secret is missing in settings. Cannot authenticate the command.",
  53. pushMessage: pushMessage
  54. )
  55. return
  56. }
  57. guard pushMessage.sharedSecret == storedSecret else {
  58. await logError(
  59. "Command rejected: shared secret does not match. Cannot authenticate the command.",
  60. pushMessage: pushMessage
  61. )
  62. return
  63. }
  64. switch pushMessage.commandType {
  65. case .bolus:
  66. await handleBolusCommand(pushMessage)
  67. case .tempTarget:
  68. await handleTempTargetCommand(pushMessage)
  69. case .cancelTempTarget:
  70. await cancelTempTarget(pushMessage)
  71. case .meal:
  72. await handleMealCommand(pushMessage)
  73. case .startOverride:
  74. await handleStartOverrideCommand(pushMessage)
  75. case .cancelOverride:
  76. await handleCancelOverrideCommand(pushMessage)
  77. }
  78. }
  79. private func handleMealCommand(_ pushMessage: PushMessage) async {
  80. guard pushMessage.carbs != nil || pushMessage.fat != nil || pushMessage.protein != nil else {
  81. await logError("Command rejected: meal data is incomplete or invalid.", pushMessage: pushMessage)
  82. return
  83. }
  84. let carbsDecimal = pushMessage.carbs != nil ? Decimal(pushMessage.carbs!) : nil
  85. let fatDecimal = pushMessage.fat != nil ? Decimal(pushMessage.fat!) : nil
  86. let proteinDecimal = pushMessage.protein != nil ? Decimal(pushMessage.protein!) : nil
  87. let settings = await FreeAPSApp.resolver.resolve(SettingsManager.self)?.settings
  88. let maxCarbs = settings?.maxCarbs ?? Decimal(0)
  89. let maxFat = settings?.maxFat ?? Decimal(0)
  90. let maxProtein = settings?.maxProtein ?? Decimal(0)
  91. if let carbs = carbsDecimal, carbs > maxCarbs {
  92. await logError(
  93. "Command rejected: carbs amount (\(carbs)g) exceeds the maximum allowed (\(maxCarbs)g).",
  94. pushMessage: pushMessage
  95. )
  96. return
  97. }
  98. if let fat = fatDecimal, fat > maxFat {
  99. await logError(
  100. "Command rejected: fat amount (\(fat)g) exceeds the maximum allowed (\(maxFat)g).",
  101. pushMessage: pushMessage
  102. )
  103. return
  104. }
  105. if let protein = proteinDecimal, protein > maxProtein {
  106. await logError(
  107. "Command rejected: protein amount (\(protein)g) exceeds the maximum allowed (\(maxProtein)g).",
  108. pushMessage: pushMessage
  109. )
  110. return
  111. }
  112. let pushMessageDate = Date(timeIntervalSince1970: pushMessage.timestamp)
  113. let recentCarbEntries = carbsStorage.recent()
  114. let carbsAfterPushMessage = recentCarbEntries.filter { $0.createdAt > pushMessageDate }
  115. if !carbsAfterPushMessage.isEmpty {
  116. await logError(
  117. "Command rejected: newer carb entries have been logged since the command was sent.",
  118. pushMessage: pushMessage
  119. )
  120. return
  121. }
  122. let actualDate: Date?
  123. if let scheduledTime = pushMessage.scheduledTime {
  124. actualDate = Date(timeIntervalSince1970: scheduledTime)
  125. } else {
  126. actualDate = nil
  127. }
  128. let mealEntry = CarbsEntry(
  129. id: UUID().uuidString,
  130. createdAt: Date(),
  131. actualDate: actualDate,
  132. carbs: carbsDecimal ?? 0,
  133. fat: fatDecimal,
  134. protein: proteinDecimal,
  135. note: "Remote meal command",
  136. enteredBy: CarbsEntry.manual,
  137. isFPU: false,
  138. fpuID: nil
  139. )
  140. await carbsStorage.storeCarbs([mealEntry], areFetchedFromRemote: false)
  141. debug(
  142. .remoteControl,
  143. "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
  144. )
  145. }
  146. private func handleBolusCommand(_ pushMessage: PushMessage) async {
  147. guard let bolusAmount = pushMessage.bolusAmount else {
  148. await logError("Command rejected: bolus amount is missing or invalid.", pushMessage: pushMessage)
  149. return
  150. }
  151. let maxBolus = await FreeAPSApp.resolver.resolve(SettingsManager.self)?.pumpSettings.maxBolus ?? Decimal(0)
  152. if bolusAmount > maxBolus {
  153. await logError(
  154. "Command rejected: bolus amount (\(bolusAmount) units) exceeds the maximum allowed (\(maxBolus) units).",
  155. pushMessage: pushMessage
  156. )
  157. return
  158. }
  159. let maxIOB = settings.preferences.maxIOB
  160. let currentIOB = await fetchCurrentIOB()
  161. if (currentIOB + bolusAmount) > maxIOB {
  162. await logError(
  163. "Command rejected: bolus amount (\(bolusAmount) units) would exceed max IOB (\(maxIOB) units). Current IOB: \(currentIOB) units.",
  164. pushMessage: pushMessage
  165. )
  166. return
  167. }
  168. let totalRecentBolusAmount = await fetchTotalRecentBolusAmount(since: Date(timeIntervalSince1970: pushMessage.timestamp))
  169. if totalRecentBolusAmount >= bolusAmount * 0.2 {
  170. await logError(
  171. "Command rejected: boluses totaling more than 20% of the requested amount have been delivered since the command was sent.",
  172. pushMessage: pushMessage
  173. )
  174. return
  175. }
  176. debug(.remoteControl, "Enacting bolus command with amount: \(bolusAmount) units.")
  177. guard let apsManager = await FreeAPSApp.resolver.resolve(APSManager.self) else {
  178. await logError(
  179. "Error: unable to process bolus command because the APS Manager is not available.",
  180. pushMessage: pushMessage
  181. )
  182. return
  183. }
  184. await apsManager.enactBolus(amount: Double(truncating: bolusAmount as NSNumber), isSMB: false)
  185. debug(
  186. .remoteControl,
  187. "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
  188. )
  189. }
  190. private func fetchCurrentIOB() async -> Decimal {
  191. let predicate = NSPredicate.predicateFor30MinAgoForDetermination
  192. let determinations = await CoreDataStack.shared.fetchEntitiesAsync(
  193. ofType: OrefDetermination.self,
  194. onContext: pumpHistoryFetchContext,
  195. predicate: predicate,
  196. key: "timestamp",
  197. ascending: false,
  198. fetchLimit: 1,
  199. propertiesToFetch: ["iob"]
  200. )
  201. guard let fetchedResults = determinations as? [[String: Any]],
  202. let firstResult = fetchedResults.first,
  203. let iob = firstResult["iob"] as? Decimal
  204. else {
  205. await logError("Failed to fetch current IOB.")
  206. return Decimal(0)
  207. }
  208. return iob
  209. }
  210. private func fetchTotalRecentBolusAmount(since date: Date) async -> Decimal {
  211. let predicate = NSPredicate(
  212. format: "type == %@ AND timestamp > %@",
  213. PumpEventStored.EventType.bolus.rawValue,
  214. date as NSDate
  215. )
  216. let results: Any = await CoreDataStack.shared.fetchEntitiesAsync(
  217. ofType: PumpEventStored.self,
  218. onContext: pumpHistoryFetchContext,
  219. predicate: predicate,
  220. key: "timestamp",
  221. ascending: true,
  222. fetchLimit: nil,
  223. propertiesToFetch: ["bolus.amount"]
  224. )
  225. guard let bolusDictionaries = results as? [[String: Any]] else {
  226. await logError("Failed to cast fetched bolus events. Fetched entities type: \(type(of: results))")
  227. return 0
  228. }
  229. let totalAmount = bolusDictionaries.compactMap { ($0["bolus.amount"] as? NSNumber)?.decimalValue }.reduce(0, +)
  230. return totalAmount
  231. }
  232. private func handleTempTargetCommand(_ pushMessage: PushMessage) async {
  233. guard let targetValue = pushMessage.target,
  234. let durationValue = pushMessage.duration
  235. else {
  236. await logError("Command rejected: temp target data is incomplete or invalid.", pushMessage: pushMessage)
  237. return
  238. }
  239. let durationInMinutes = Int(durationValue)
  240. let pushMessageDate = Date(timeIntervalSince1970: pushMessage.timestamp)
  241. let tempTarget = TempTarget(
  242. name: TempTarget.custom,
  243. createdAt: pushMessageDate,
  244. targetTop: Decimal(targetValue),
  245. targetBottom: Decimal(targetValue),
  246. duration: Decimal(durationInMinutes),
  247. enteredBy: TempTarget.manual,
  248. reason: TempTarget.custom
  249. )
  250. tempTargetsStorage.storeTempTargets([tempTarget])
  251. debug(
  252. .remoteControl,
  253. "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
  254. )
  255. }
  256. func cancelTempTarget(_ pushMessage: PushMessage) async {
  257. debug(.remoteControl, "Cancelling temp target.")
  258. guard tempTargetsStorage.current() != nil else {
  259. await logError("Command rejected: no active temp target to cancel.")
  260. return
  261. }
  262. let cancelEntry = TempTarget.cancel(at: Date())
  263. tempTargetsStorage.storeTempTargets([cancelEntry])
  264. debug(
  265. .remoteControl,
  266. "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
  267. )
  268. }
  269. @MainActor private func handleCancelOverrideCommand(_ pushMessage: PushMessage) async {
  270. await disableAllActiveOverrides()
  271. debug(
  272. .remoteControl,
  273. "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
  274. )
  275. }
  276. @MainActor private func handleStartOverrideCommand(_ pushMessage: PushMessage) async {
  277. guard let overrideName = pushMessage.overrideName, !overrideName.isEmpty else {
  278. await logError("Command rejected: override name is missing.", pushMessage: pushMessage)
  279. return
  280. }
  281. let presetIDs = await overrideStorage.fetchForOverridePresets()
  282. let presets = presetIDs.compactMap { id in
  283. try? viewContext.existingObject(with: id) as? OverrideStored
  284. }
  285. if let preset = presets.first(where: { $0.name == overrideName }) {
  286. await enactOverridePreset(preset: preset, pushMessage: pushMessage)
  287. } else {
  288. await logError("Command rejected: override preset '\(overrideName)' not found.", pushMessage: pushMessage)
  289. }
  290. }
  291. @MainActor private func enactOverridePreset(preset: OverrideStored, pushMessage: PushMessage) async {
  292. await disableAllActiveOverrides()
  293. preset.enabled = true
  294. preset.date = Date()
  295. preset.isUploadedToNS = false
  296. do {
  297. if viewContext.hasChanges {
  298. try viewContext.save()
  299. Foundation.NotificationCenter.default.post(name: .willUpdateOverrideConfiguration, object: nil)
  300. await awaitNotification(.didUpdateOverrideConfiguration)
  301. debug(
  302. .remoteControl,
  303. "Remote command processed successfully. \(pushMessage.humanReadableDescription())"
  304. )
  305. }
  306. } catch {
  307. debug(.remoteControl, "Failed to enact override preset: \(error.localizedDescription)")
  308. }
  309. }
  310. @MainActor func disableAllActiveOverrides() async {
  311. let ids = await overrideStorage.loadLatestOverrideConfigurations(fetchLimit: 0) // 0 = no fetch limit
  312. let didPostNotification = await viewContext.perform { () -> Bool in
  313. do {
  314. let results = try ids.compactMap { id in
  315. try self.viewContext.existingObject(with: id) as? OverrideStored
  316. }
  317. guard !results.isEmpty else { return false }
  318. for canceledOverride in results where canceledOverride.enabled {
  319. let newOverrideRunStored = OverrideRunStored(context: self.viewContext)
  320. newOverrideRunStored.id = UUID()
  321. newOverrideRunStored.name = canceledOverride.name
  322. newOverrideRunStored.startDate = canceledOverride.date ?? .distantPast
  323. newOverrideRunStored.endDate = Date()
  324. newOverrideRunStored
  325. .target = NSDecimalNumber(decimal: self.overrideStorage.calculateTarget(override: canceledOverride))
  326. newOverrideRunStored.override = canceledOverride
  327. newOverrideRunStored.isUploadedToNS = false
  328. canceledOverride.enabled = false
  329. }
  330. if self.viewContext.hasChanges {
  331. try self.viewContext.save()
  332. Foundation.NotificationCenter.default.post(name: .willUpdateOverrideConfiguration, object: nil)
  333. return true
  334. } else {
  335. return false
  336. }
  337. } catch {
  338. debugPrint(
  339. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to disable active Overrides with error: \(error.localizedDescription)"
  340. )
  341. return false
  342. }
  343. }
  344. if didPostNotification {
  345. await awaitNotification(.didUpdateOverrideConfiguration)
  346. }
  347. }
  348. func handleAPNSChanges(deviceToken: String?) async {
  349. let previousDeviceToken = UserDefaults.standard.string(forKey: "deviceToken")
  350. let previousIsAPNSProduction = UserDefaults.standard.bool(forKey: "isAPNSProduction")
  351. let isAPNSProduction = isRunningInAPNSProductionEnvironment()
  352. var shouldUploadProfiles = false
  353. if let token = deviceToken, token != previousDeviceToken {
  354. UserDefaults.standard.set(token, forKey: "deviceToken")
  355. debug(.remoteControl, "Device token updated: \(token)")
  356. shouldUploadProfiles = true
  357. }
  358. if previousIsAPNSProduction != isAPNSProduction {
  359. UserDefaults.standard.set(isAPNSProduction, forKey: "isAPNSProduction")
  360. debug(.remoteControl, "APNS environment changed to: \(isAPNSProduction ? "Production" : "Sandbox")")
  361. shouldUploadProfiles = true
  362. }
  363. if shouldUploadProfiles {
  364. await nightscoutManager.uploadProfiles()
  365. } else {
  366. debug(.remoteControl, "No changes detected in device token or APNS environment.")
  367. }
  368. }
  369. private func isRunningInAPNSProductionEnvironment() -> Bool {
  370. if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL {
  371. return appStoreReceiptURL.lastPathComponent != "sandboxReceipt"
  372. }
  373. return false
  374. }
  375. }
  376. // MARK: - CommandType Enum
  377. extension TrioRemoteControl {
  378. enum CommandType: String, Codable {
  379. case bolus
  380. case tempTarget = "temp_target"
  381. case cancelTempTarget = "cancel_temp_target"
  382. case meal
  383. case startOverride = "start_override"
  384. case cancelOverride = "cancel_override"
  385. var description: String {
  386. switch self {
  387. case .bolus:
  388. return "Bolus"
  389. case .tempTarget:
  390. return "Temporary Target"
  391. case .cancelTempTarget:
  392. return "Cancel Temporary Target"
  393. case .meal:
  394. return "Meal"
  395. case .startOverride:
  396. return "Start Override"
  397. case .cancelOverride:
  398. return "Cancel Override"
  399. }
  400. }
  401. }
  402. }