TrioRemoteControl.swift 19 KB

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