TrioRemoteControl.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. import Foundation
  2. import Swinject
  3. class TrioRemoteControl: Injectable {
  4. static let shared = TrioRemoteControl()
  5. @Injected() private var tempTargetsStorage: TempTargetsStorage!
  6. @Injected() private var carbsStorage: CarbsStorage!
  7. @Injected() private var nightscoutManager: NightscoutManager!
  8. @Injected() private var pumpHistoryStorage: PumpHistoryStorage!
  9. private let timeWindow: TimeInterval = 600 // Defines how old messages that are accepted, 10 minutes
  10. private init() {
  11. injectServices(FreeAPSApp.resolver)
  12. }
  13. private func logError(_ errorMessage: String, pushMessage: PushMessage? = nil) async {
  14. var note = errorMessage
  15. if let pushMessage = pushMessage {
  16. note += " Details: \(pushMessage.humanReadableDescription())"
  17. }
  18. debug(.remoteControl, note)
  19. await nightscoutManager.uploadNoteTreatment(note: note)
  20. }
  21. func handleRemoteNotification(userInfo: [AnyHashable: Any]) async {
  22. let enabled = UserDefaults.standard.bool(forKey: "TRCenabled")
  23. guard enabled else {
  24. await logError("Remote command received, but remote control is disabled in settings. Ignoring the command.")
  25. return
  26. }
  27. do {
  28. let jsonData = try JSONSerialization.data(withJSONObject: userInfo)
  29. let pushMessage = try JSONDecoder().decode(PushMessage.self, from: jsonData)
  30. let currentTime = Date().timeIntervalSince1970
  31. let timeDifference = currentTime - pushMessage.timestamp
  32. if timeDifference > timeWindow {
  33. await logError(
  34. "Command rejected: the message is too old (sent \(Int(timeDifference)) seconds ago, which exceeds the allowed limit).",
  35. pushMessage: pushMessage
  36. )
  37. return
  38. } else if timeDifference < -timeWindow {
  39. await logError(
  40. "Command rejected: the message has an invalid future timestamp (timestamp is \(Int(-timeDifference)) seconds ahead of the current time).",
  41. pushMessage: pushMessage
  42. )
  43. return
  44. }
  45. debug(.remoteControl, "Command received with acceptable time difference: \(Int(timeDifference)) seconds.")
  46. let storedSecret = UserDefaults.standard.string(forKey: "TRCsharedSecret") ?? ""
  47. guard !storedSecret.isEmpty else {
  48. await logError(
  49. "Command rejected: shared secret is missing in settings. Cannot authenticate the command.",
  50. pushMessage: pushMessage
  51. )
  52. return
  53. }
  54. guard pushMessage.sharedSecret == storedSecret else {
  55. await logError(
  56. "Command rejected: shared secret does not match. Cannot authenticate the command.",
  57. pushMessage: pushMessage
  58. )
  59. return
  60. }
  61. switch pushMessage.commandType {
  62. case "bolus":
  63. await handleBolusCommand(pushMessage)
  64. case "temp_target":
  65. await handleTempTargetCommand(pushMessage)
  66. case "cancel_temp_target":
  67. await cancelTempTarget()
  68. case "meal":
  69. await handleMealCommand(pushMessage)
  70. default:
  71. await logError(
  72. "Command rejected: unsupported command type '\(pushMessage.commandType)'.",
  73. pushMessage: pushMessage
  74. )
  75. }
  76. } catch {
  77. await logError("Error: unable to process the command due to decoding failure (\(error.localizedDescription)).")
  78. }
  79. }
  80. private func handleMealCommand(_ pushMessage: PushMessage) async {
  81. guard
  82. let carbs = pushMessage.carbs,
  83. let fat = pushMessage.fat,
  84. let protein = pushMessage.protein
  85. else {
  86. await logError("Command rejected: meal data is incomplete or invalid.", pushMessage: pushMessage)
  87. return
  88. }
  89. let settings = await FreeAPSApp.resolver.resolve(SettingsManager.self)?.settings
  90. let maxCarbs = settings?.maxCarbs ?? Decimal(0)
  91. let maxFat = settings?.maxFat ?? Decimal(0)
  92. let maxProtein = settings?.maxProtein ?? Decimal(0)
  93. if Decimal(carbs) > maxCarbs {
  94. await logError(
  95. "Command rejected: carbs amount (\(carbs)g) exceeds the maximum allowed (\(maxCarbs)g).",
  96. pushMessage: pushMessage
  97. )
  98. return
  99. }
  100. if Decimal(fat) > maxFat {
  101. await logError(
  102. "Command rejected: fat amount (\(fat)g) exceeds the maximum allowed (\(maxFat)g).",
  103. pushMessage: pushMessage
  104. )
  105. return
  106. }
  107. if Decimal(protein) > maxProtein {
  108. await logError(
  109. "Command rejected: protein amount (\(protein)g) exceeds the maximum allowed (\(maxProtein)g).",
  110. pushMessage: pushMessage
  111. )
  112. return
  113. }
  114. let pushMessageDate = Date(timeIntervalSince1970: pushMessage.timestamp)
  115. let recentCarbEntries = carbsStorage.recent()
  116. let carbsAfterPushMessage = recentCarbEntries.filter { $0.createdAt > pushMessageDate }
  117. if !carbsAfterPushMessage.isEmpty {
  118. await logError(
  119. "Command rejected: newer carb entries have been logged since the command was sent.",
  120. pushMessage: pushMessage
  121. )
  122. return
  123. }
  124. let mealEntry = CarbsEntry(
  125. id: UUID().uuidString,
  126. createdAt: Date(),
  127. actualDate: nil,
  128. carbs: Decimal(carbs),
  129. fat: Decimal(fat),
  130. protein: Decimal(protein),
  131. note: "Remote meal command",
  132. enteredBy: CarbsEntry.manual,
  133. isFPU: false,
  134. fpuID: nil
  135. )
  136. await carbsStorage.storeCarbs([mealEntry], areFetchedFromRemote: false)
  137. debug(.remoteControl, "Meal command processed successfully with carbs: \(carbs)g, fat: \(fat)g, protein: \(protein)g.")
  138. }
  139. private func handleBolusCommand(_ pushMessage: PushMessage) async {
  140. guard let bolusAmount = pushMessage.bolusAmount else {
  141. await logError("Command rejected: bolus amount is missing or invalid.", pushMessage: pushMessage)
  142. return
  143. }
  144. let maxBolus = await FreeAPSApp.resolver.resolve(SettingsManager.self)?.pumpSettings.maxBolus ?? Decimal(0)
  145. if bolusAmount > maxBolus {
  146. await logError(
  147. "Command rejected: bolus amount (\(bolusAmount) units) exceeds the maximum allowed (\(maxBolus) units).",
  148. pushMessage: pushMessage
  149. )
  150. return
  151. }
  152. let recentPumpEvents = pumpHistoryStorage.recent()
  153. let recentBoluses = recentPumpEvents.filter { event in
  154. event.type == .bolus && event.timestamp > Date(timeIntervalSince1970: pushMessage.timestamp)
  155. }
  156. let totalRecentBolusAmount = recentBoluses.reduce(Decimal(0)) { $0 + ($1.amount ?? 0) }
  157. if totalRecentBolusAmount >= bolusAmount * 0.2 {
  158. await logError(
  159. "Command rejected: boluses totaling more than 20% of the requested amount have been delivered since the command was sent.",
  160. pushMessage: pushMessage
  161. )
  162. return
  163. }
  164. debug(.remoteControl, "Enacting bolus command with amount: \(bolusAmount) units.")
  165. guard let apsManager = await FreeAPSApp.resolver.resolve(APSManager.self) else {
  166. await logError(
  167. "Error: unable to process bolus command because the APS Manager is not available.",
  168. pushMessage: pushMessage
  169. )
  170. return
  171. }
  172. await apsManager.enactBolus(amount: Double(truncating: bolusAmount as NSNumber), isSMB: false)
  173. }
  174. private func handleTempTargetCommand(_ pushMessage: PushMessage) async {
  175. guard let targetValue = pushMessage.target,
  176. let durationValue = pushMessage.duration
  177. else {
  178. await logError("Command rejected: temp target data is incomplete or invalid.", pushMessage: pushMessage)
  179. return
  180. }
  181. let durationInMinutes = Int(durationValue)
  182. let tempTarget = TempTarget(
  183. name: "Remote Control",
  184. createdAt: Date(),
  185. targetTop: Decimal(targetValue),
  186. targetBottom: Decimal(targetValue),
  187. duration: Decimal(durationInMinutes),
  188. enteredBy: pushMessage.user,
  189. reason: "Remote temp target command"
  190. )
  191. tempTargetsStorage.storeTempTargets([tempTarget])
  192. debug(.remoteControl, "Temp target set with target: \(targetValue), duration: \(durationInMinutes) minutes.")
  193. }
  194. func cancelTempTarget() async {
  195. debug(.remoteControl, "Cancelling temp target.")
  196. guard tempTargetsStorage.current() != nil else {
  197. await logError("Command rejected: no active temp target to cancel.")
  198. return
  199. }
  200. let cancelEntry = TempTarget.cancel(at: Date())
  201. tempTargetsStorage.storeTempTargets([cancelEntry])
  202. debug(.remoteControl, "Temp target cancelled successfully.")
  203. }
  204. func handleAPNSChanges(deviceToken: String?) async {
  205. let previousDeviceToken = UserDefaults.standard.string(forKey: "deviceToken")
  206. let previousIsAPNSProduction = UserDefaults.standard.bool(forKey: "isAPNSProduction")
  207. let isAPNSProduction = isRunningInAPNSProductionEnvironment()
  208. var shouldUploadProfiles = false
  209. if let token = deviceToken, token != previousDeviceToken {
  210. UserDefaults.standard.set(token, forKey: "deviceToken")
  211. debug(.remoteControl, "Device token updated: \(token)")
  212. shouldUploadProfiles = true
  213. }
  214. if previousIsAPNSProduction != isAPNSProduction {
  215. UserDefaults.standard.set(isAPNSProduction, forKey: "isAPNSProduction")
  216. debug(.remoteControl, "APNS environment changed to: \(isAPNSProduction ? "Production" : "Sandbox")")
  217. shouldUploadProfiles = true
  218. }
  219. if shouldUploadProfiles {
  220. await nightscoutManager.uploadProfiles()
  221. } else {
  222. debug(.remoteControl, "No changes detected in device token or APNS environment.")
  223. }
  224. }
  225. private func isRunningInAPNSProductionEnvironment() -> Bool {
  226. if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL {
  227. return appStoreReceiptURL.lastPathComponent != "sandboxReceipt"
  228. }
  229. return false
  230. }
  231. }