TrioRemoteControl.swift 17 KB

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