TrioRemoteControl.swift 15 KB

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