TrioRemoteControl.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  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 init() {
  13. pumpHistoryFetchContext = CoreDataStack.shared.newTaskContext()
  14. injectServices(FreeAPSApp.resolver)
  15. }
  16. private func logError(_ errorMessage: String, pushMessage: PushMessage? = nil) async {
  17. var note = errorMessage
  18. if let pushMessage = pushMessage {
  19. note += " Details: \(pushMessage.humanReadableDescription())"
  20. }
  21. debug(.remoteControl, note)
  22. await nightscoutManager.uploadNoteTreatment(note: note)
  23. }
  24. func handleRemoteNotification(userInfo: [AnyHashable: Any]) async {
  25. let enabled = UserDefaults.standard.bool(forKey: "TRCenabled")
  26. guard enabled else {
  27. await logError("Remote command received, but remote control is disabled in settings. Ignoring the command.")
  28. return
  29. }
  30. do {
  31. let jsonData = try JSONSerialization.data(withJSONObject: userInfo)
  32. let pushMessage = try JSONDecoder().decode(PushMessage.self, from: jsonData)
  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: "TRCsharedSecret") ?? ""
  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()
  71. case .meal:
  72. await handleMealCommand(pushMessage)
  73. case .startOverride:
  74. await handleStartOverrideCommand(pushMessage)
  75. case .cancelOverride:
  76. await handleCancelOverrideCommand(pushMessage)
  77. }
  78. } catch {
  79. await logError("Error: unable to process the command due to decoding failure (\(error.localizedDescription)).")
  80. }
  81. }
  82. private func handleMealCommand(_ pushMessage: PushMessage) async {
  83. guard
  84. let carbs = pushMessage.carbs,
  85. let fat = pushMessage.fat,
  86. let protein = pushMessage.protein
  87. else {
  88. await logError("Command rejected: meal data is incomplete or invalid.", pushMessage: pushMessage)
  89. return
  90. }
  91. let settings = await FreeAPSApp.resolver.resolve(SettingsManager.self)?.settings
  92. let maxCarbs = settings?.maxCarbs ?? Decimal(0)
  93. let maxFat = settings?.maxFat ?? Decimal(0)
  94. let maxProtein = settings?.maxProtein ?? Decimal(0)
  95. if Decimal(carbs) > maxCarbs {
  96. await logError(
  97. "Command rejected: carbs amount (\(carbs)g) exceeds the maximum allowed (\(maxCarbs)g).",
  98. pushMessage: pushMessage
  99. )
  100. return
  101. }
  102. if Decimal(fat) > maxFat {
  103. await logError(
  104. "Command rejected: fat amount (\(fat)g) exceeds the maximum allowed (\(maxFat)g).",
  105. pushMessage: pushMessage
  106. )
  107. return
  108. }
  109. if Decimal(protein) > maxProtein {
  110. await logError(
  111. "Command rejected: protein amount (\(protein)g) exceeds the maximum allowed (\(maxProtein)g).",
  112. pushMessage: pushMessage
  113. )
  114. return
  115. }
  116. let pushMessageDate = Date(timeIntervalSince1970: pushMessage.timestamp)
  117. let recentCarbEntries = carbsStorage.recent()
  118. let carbsAfterPushMessage = recentCarbEntries.filter { $0.createdAt > pushMessageDate }
  119. if !carbsAfterPushMessage.isEmpty {
  120. await logError(
  121. "Command rejected: newer carb entries have been logged since the command was sent.",
  122. pushMessage: pushMessage
  123. )
  124. return
  125. }
  126. let mealEntry = CarbsEntry(
  127. id: UUID().uuidString,
  128. createdAt: Date(),
  129. actualDate: nil,
  130. carbs: Decimal(carbs),
  131. fat: Decimal(fat),
  132. protein: Decimal(protein),
  133. note: "Remote meal command",
  134. enteredBy: CarbsEntry.manual,
  135. isFPU: false,
  136. fpuID: nil
  137. )
  138. await carbsStorage.storeCarbs([mealEntry], areFetchedFromRemote: false)
  139. debug(.remoteControl, "Meal command processed successfully with carbs: \(carbs)g, fat: \(fat)g, protein: \(protein)g.")
  140. }
  141. private func handleBolusCommand(_ pushMessage: PushMessage) async {
  142. guard let bolusAmount = pushMessage.bolusAmount else {
  143. await logError("Command rejected: bolus amount is missing or invalid.", pushMessage: pushMessage)
  144. return
  145. }
  146. let maxBolus = await FreeAPSApp.resolver.resolve(SettingsManager.self)?.pumpSettings.maxBolus ?? Decimal(0)
  147. if bolusAmount > maxBolus {
  148. await logError(
  149. "Command rejected: bolus amount (\(bolusAmount) units) exceeds the maximum allowed (\(maxBolus) units).",
  150. pushMessage: pushMessage
  151. )
  152. return
  153. }
  154. let totalRecentBolusAmount = await fetchTotalRecentBolusAmount(since: Date(timeIntervalSince1970: pushMessage.timestamp))
  155. if totalRecentBolusAmount >= bolusAmount * 0.2 {
  156. await logError(
  157. "Command rejected: boluses totaling more than 20% of the requested amount have been delivered since the command was sent.",
  158. pushMessage: pushMessage
  159. )
  160. return
  161. }
  162. debug(.remoteControl, "Enacting bolus command with amount: \(bolusAmount) units.")
  163. guard let apsManager = await FreeAPSApp.resolver.resolve(APSManager.self) else {
  164. await logError(
  165. "Error: unable to process bolus command because the APS Manager is not available.",
  166. pushMessage: pushMessage
  167. )
  168. return
  169. }
  170. await apsManager.enactBolus(amount: Double(truncating: bolusAmount as NSNumber), isSMB: false)
  171. }
  172. private func fetchTotalRecentBolusAmount(since date: Date) async -> Decimal {
  173. let fetchRequest: NSFetchRequest<PumpEventStored> = PumpEventStored.fetchRequest()
  174. fetchRequest.predicate = NSPredicate(
  175. format: "type == %@ AND timestamp > %@",
  176. PumpEventStored.EventType.bolus.rawValue,
  177. date as NSDate
  178. )
  179. fetchRequest.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
  180. do {
  181. let totalAmount = try await pumpHistoryFetchContext.perform {
  182. let results = try self.pumpHistoryFetchContext.fetch(fetchRequest)
  183. var total = Decimal(0)
  184. for pumpEvent in results {
  185. if let bolus = pumpEvent.bolus, let amount = bolus.amount?.decimalValue {
  186. total += amount
  187. }
  188. }
  189. return total
  190. }
  191. return totalAmount
  192. } catch {
  193. await logError("Failed to fetch recent bolus pump events: \(error.localizedDescription)")
  194. return Decimal(0)
  195. }
  196. }
  197. private func handleTempTargetCommand(_ pushMessage: PushMessage) async {
  198. guard let targetValue = pushMessage.target,
  199. let durationValue = pushMessage.duration
  200. else {
  201. await logError("Command rejected: temp target data is incomplete or invalid.", pushMessage: pushMessage)
  202. return
  203. }
  204. let durationInMinutes = Int(durationValue)
  205. let pushMessageDate = Date(timeIntervalSince1970: pushMessage.timestamp)
  206. let tempTarget = TempTarget(
  207. name: TempTarget.custom,
  208. createdAt: pushMessageDate,
  209. targetTop: Decimal(targetValue),
  210. targetBottom: Decimal(targetValue),
  211. duration: Decimal(durationInMinutes),
  212. enteredBy: TempTarget.manual,
  213. reason: TempTarget.custom
  214. )
  215. tempTargetsStorage.storeTempTargets([tempTarget])
  216. debug(.remoteControl, "Temp target set with target: \(targetValue), duration: \(durationInMinutes) minutes.")
  217. }
  218. func cancelTempTarget() async {
  219. debug(.remoteControl, "Cancelling temp target.")
  220. guard tempTargetsStorage.current() != nil else {
  221. await logError("Command rejected: no active temp target to cancel.")
  222. return
  223. }
  224. let cancelEntry = TempTarget.cancel(at: Date())
  225. tempTargetsStorage.storeTempTargets([cancelEntry])
  226. debug(.remoteControl, "Temp target cancelled successfully.")
  227. }
  228. @MainActor private func handleCancelOverrideCommand(_: PushMessage) async {
  229. await disableAllActiveOverrides()
  230. debug(.remoteControl, "Active override cancelled successfully.")
  231. }
  232. @MainActor private func handleStartOverrideCommand(_ pushMessage: PushMessage) async {
  233. guard let overrideName = pushMessage.overrideName, !overrideName.isEmpty else {
  234. await logError("Command rejected: override name is missing.", pushMessage: pushMessage)
  235. return
  236. }
  237. let viewContext = CoreDataStack.shared.persistentContainer.viewContext
  238. let presetIDs = await overrideStorage.fetchForOverridePresets()
  239. let presets = presetIDs.compactMap { id in
  240. try? viewContext.existingObject(with: id) as? OverrideStored
  241. }
  242. if let preset = presets.first(where: { $0.name == overrideName }) {
  243. await enactOverridePreset(preset: preset)
  244. debug(.remoteControl, "Override '\(overrideName)' started successfully.")
  245. } else {
  246. await logError("Command rejected: override preset '\(overrideName)' not found.", pushMessage: pushMessage)
  247. }
  248. }
  249. @MainActor private func enactOverridePreset(preset: OverrideStored) async {
  250. await disableAllActiveOverrides()
  251. let viewContext = CoreDataStack.shared.persistentContainer.viewContext
  252. preset.enabled = true
  253. preset.date = Date()
  254. preset.isUploadedToNS = false
  255. do {
  256. if viewContext.hasChanges {
  257. try viewContext.save()
  258. Foundation.NotificationCenter.default.post(name: .willUpdateOverrideConfiguration, object: nil)
  259. await awaitNotification(.didUpdateOverrideConfiguration)
  260. }
  261. } catch {
  262. debug(.remoteControl, "Failed to enact override preset: \(error.localizedDescription)")
  263. }
  264. }
  265. @MainActor func disableAllActiveOverrides() async {
  266. let viewContext = CoreDataStack.shared.persistentContainer.viewContext
  267. let ids = await overrideStorage.loadLatestOverrideConfigurations(fetchLimit: 0) // 0 = no fetch limit
  268. let didPostNotification = await viewContext.perform { () -> Bool in
  269. do {
  270. let results = try ids.compactMap { id in
  271. try viewContext.existingObject(with: id) as? OverrideStored
  272. }
  273. guard !results.isEmpty else { return false }
  274. for canceledOverride in results where canceledOverride.enabled {
  275. let newOverrideRunStored = OverrideRunStored(context: viewContext)
  276. newOverrideRunStored.id = UUID()
  277. newOverrideRunStored.name = canceledOverride.name
  278. newOverrideRunStored.startDate = canceledOverride.date ?? .distantPast
  279. newOverrideRunStored.endDate = Date()
  280. newOverrideRunStored
  281. .target = NSDecimalNumber(decimal: self.overrideStorage.calculateTarget(override: canceledOverride))
  282. newOverrideRunStored.override = canceledOverride
  283. newOverrideRunStored.isUploadedToNS = false
  284. canceledOverride.enabled = false
  285. }
  286. if viewContext.hasChanges {
  287. try viewContext.save()
  288. Foundation.NotificationCenter.default.post(name: .willUpdateOverrideConfiguration, object: nil)
  289. return true
  290. } else {
  291. return false
  292. }
  293. } catch {
  294. debugPrint(
  295. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to disable active Overrides with error: \(error.localizedDescription)"
  296. )
  297. return false
  298. }
  299. }
  300. if didPostNotification {
  301. await awaitNotification(.didUpdateOverrideConfiguration)
  302. }
  303. }
  304. func handleAPNSChanges(deviceToken: String?) async {
  305. let previousDeviceToken = UserDefaults.standard.string(forKey: "deviceToken")
  306. let previousIsAPNSProduction = UserDefaults.standard.bool(forKey: "isAPNSProduction")
  307. let isAPNSProduction = isRunningInAPNSProductionEnvironment()
  308. var shouldUploadProfiles = false
  309. if let token = deviceToken, token != previousDeviceToken {
  310. UserDefaults.standard.set(token, forKey: "deviceToken")
  311. debug(.remoteControl, "Device token updated: \(token)")
  312. shouldUploadProfiles = true
  313. }
  314. if previousIsAPNSProduction != isAPNSProduction {
  315. UserDefaults.standard.set(isAPNSProduction, forKey: "isAPNSProduction")
  316. debug(.remoteControl, "APNS environment changed to: \(isAPNSProduction ? "Production" : "Sandbox")")
  317. shouldUploadProfiles = true
  318. }
  319. if shouldUploadProfiles {
  320. await nightscoutManager.uploadProfiles()
  321. } else {
  322. debug(.remoteControl, "No changes detected in device token or APNS environment.")
  323. }
  324. }
  325. private func isRunningInAPNSProductionEnvironment() -> Bool {
  326. if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL {
  327. return appStoreReceiptURL.lastPathComponent != "sandboxReceipt"
  328. }
  329. return false
  330. }
  331. }