NightscoutAPI.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. import Combine
  2. import CommonCrypto
  3. import Foundation
  4. class NightscoutAPI {
  5. init(url: URL, secret: String? = nil) {
  6. self.url = url
  7. self.secret = secret?.nonEmpty
  8. }
  9. private enum Config {
  10. static let entriesPath = "/api/v1/entries/sgv.json"
  11. static let uploadEntriesPath = "/api/v1/entries.json"
  12. static let treatmentsPath = "/api/v1/treatments.json"
  13. static let statusPath = "/api/v1/devicestatus.json"
  14. static let profilePath = "/api/v1/profile.json"
  15. static let retryCount = 1
  16. static let timeout: TimeInterval = 60
  17. }
  18. enum Error: LocalizedError {
  19. case badStatusCode
  20. case missingURL
  21. }
  22. let url: URL
  23. let secret: String?
  24. private let service = NetworkService()
  25. }
  26. extension NightscoutAPI {
  27. func checkConnection() -> AnyPublisher<Void, Swift.Error> {
  28. struct Check: Codable, Equatable {
  29. var eventType = "Note"
  30. var enteredBy = "freeaps-x"
  31. var notes = "FreeAPS X connected"
  32. }
  33. let check = Check()
  34. var request = URLRequest(url: url.appendingPathComponent(Config.treatmentsPath))
  35. if let secret = secret {
  36. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  37. request.httpMethod = "POST"
  38. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  39. request.httpBody = try! JSONCoding.encoder.encode(check)
  40. } else {
  41. request.httpMethod = "GET"
  42. }
  43. return service.run(request)
  44. .map { _ in () }
  45. .eraseToAnyPublisher()
  46. }
  47. func fetchLastGlucose(sinceDate: Date? = nil) -> AnyPublisher<[BloodGlucose], Swift.Error> {
  48. var components = URLComponents()
  49. components.scheme = url.scheme
  50. components.host = url.host
  51. components.port = url.port
  52. components.path = Config.entriesPath
  53. components.queryItems = [URLQueryItem(name: "count", value: "\(1600)")]
  54. if let date = sinceDate {
  55. let dateItem = URLQueryItem(
  56. name: "find[dateString][$gte]",
  57. value: Formatter.iso8601withFractionalSeconds.string(from: date)
  58. )
  59. components.queryItems?.append(dateItem)
  60. }
  61. var request = URLRequest(url: components.url!)
  62. request.allowsConstrainedNetworkAccess = false
  63. request.timeoutInterval = Config.timeout
  64. if let secret = secret {
  65. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  66. }
  67. return service.run(request)
  68. .retry(Config.retryCount)
  69. .decode(type: [BloodGlucose].self, decoder: JSONCoding.decoder)
  70. .catch { error -> AnyPublisher<[BloodGlucose], Swift.Error> in
  71. warning(.nightscout, "Glucose fetching error: \(error.localizedDescription)")
  72. return Just([]).setFailureType(to: Swift.Error.self).eraseToAnyPublisher()
  73. }
  74. .map { glucose in
  75. glucose
  76. .map {
  77. var reading = $0
  78. reading.glucose = $0.sgv
  79. return reading
  80. }
  81. }
  82. .eraseToAnyPublisher()
  83. }
  84. func fetchCarbs(sinceDate: Date? = nil) -> AnyPublisher<[CarbsEntry], Swift.Error> {
  85. var components = URLComponents()
  86. components.scheme = url.scheme
  87. components.host = url.host
  88. components.port = url.port
  89. components.path = Config.treatmentsPath
  90. components.queryItems = [
  91. URLQueryItem(name: "find[carbs][$exists]", value: "true"),
  92. URLQueryItem(
  93. name: "find[enteredBy][$ne]",
  94. value: CarbsEntry.manual.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
  95. ),
  96. URLQueryItem(
  97. name: "find[enteredBy][$ne]",
  98. value: NigtscoutTreatment.local.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
  99. )
  100. ]
  101. if let date = sinceDate {
  102. let dateItem = URLQueryItem(
  103. name: "find[created_at][$gt]",
  104. value: Formatter.iso8601withFractionalSeconds.string(from: date)
  105. )
  106. components.queryItems?.append(dateItem)
  107. }
  108. var request = URLRequest(url: components.url!)
  109. request.allowsConstrainedNetworkAccess = false
  110. request.timeoutInterval = Config.timeout
  111. if let secret = secret {
  112. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  113. }
  114. return service.run(request)
  115. .retry(Config.retryCount)
  116. .decode(type: [CarbsEntry].self, decoder: JSONCoding.decoder)
  117. .catch { error -> AnyPublisher<[CarbsEntry], Swift.Error> in
  118. warning(.nightscout, "Carbs fetching error: \(error.localizedDescription)")
  119. return Just([]).setFailureType(to: Swift.Error.self).eraseToAnyPublisher()
  120. }
  121. .eraseToAnyPublisher()
  122. }
  123. func deleteCarbs(at date: Date) -> AnyPublisher<Void, Swift.Error> {
  124. var components = URLComponents()
  125. components.scheme = url.scheme
  126. components.host = url.host
  127. components.port = url.port
  128. components.path = Config.treatmentsPath
  129. components.queryItems = [
  130. URLQueryItem(name: "find[carbs][$exists]", value: "true"),
  131. URLQueryItem(
  132. name: "find[created_at][$eq]",
  133. value: Formatter.iso8601withFractionalSeconds.string(from: date)
  134. )
  135. ]
  136. var request = URLRequest(url: components.url!)
  137. request.allowsConstrainedNetworkAccess = false
  138. request.timeoutInterval = Config.timeout
  139. request.httpMethod = "DELETE"
  140. if let secret = secret {
  141. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  142. }
  143. return service.run(request)
  144. .retry(Config.retryCount)
  145. .map { _ in () }
  146. .eraseToAnyPublisher()
  147. }
  148. func deleteInsulin(at date: Date) -> AnyPublisher<Void, Swift.Error> {
  149. var components = URLComponents()
  150. components.scheme = url.scheme
  151. components.host = url.host
  152. components.port = url.port
  153. components.path = Config.treatmentsPath
  154. components.queryItems = [
  155. URLQueryItem(name: "find[bolus][$exists]", value: "true"),
  156. URLQueryItem(
  157. name: "find[created_at][$eq]",
  158. value: Formatter.iso8601withFractionalSeconds.string(from: date)
  159. )
  160. ]
  161. var request = URLRequest(url: components.url!)
  162. request.allowsConstrainedNetworkAccess = false
  163. request.timeoutInterval = Config.timeout
  164. request.httpMethod = "DELETE"
  165. if let secret = secret {
  166. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  167. }
  168. return service.run(request)
  169. .retry(Config.retryCount)
  170. .map { _ in () }
  171. .eraseToAnyPublisher()
  172. }
  173. func fetchTempTargets(sinceDate: Date? = nil) -> AnyPublisher<[TempTarget], Swift.Error> {
  174. var components = URLComponents()
  175. components.scheme = url.scheme
  176. components.host = url.host
  177. components.port = url.port
  178. components.path = Config.treatmentsPath
  179. components.queryItems = [
  180. URLQueryItem(name: "find[eventType]", value: "Temporary+Target"),
  181. URLQueryItem(
  182. name: "find[enteredBy][$ne]",
  183. value: TempTarget.manual.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
  184. ),
  185. URLQueryItem(
  186. name: "find[enteredBy][$ne]",
  187. value: NigtscoutTreatment.local.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
  188. ),
  189. URLQueryItem(name: "find[duration][$exists]", value: "true")
  190. ]
  191. if let date = sinceDate {
  192. let dateItem = URLQueryItem(
  193. name: "find[created_at][$gt]",
  194. value: Formatter.iso8601withFractionalSeconds.string(from: date)
  195. )
  196. components.queryItems?.append(dateItem)
  197. }
  198. var request = URLRequest(url: components.url!)
  199. request.allowsConstrainedNetworkAccess = false
  200. request.timeoutInterval = Config.timeout
  201. if let secret = secret {
  202. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  203. }
  204. return service.run(request)
  205. .retry(Config.retryCount)
  206. .decode(type: [TempTarget].self, decoder: JSONCoding.decoder)
  207. .catch { error -> AnyPublisher<[TempTarget], Swift.Error> in
  208. warning(.nightscout, "TempTarget fetching error: \(error.localizedDescription)")
  209. return Just([]).setFailureType(to: Swift.Error.self).eraseToAnyPublisher()
  210. }
  211. .eraseToAnyPublisher()
  212. }
  213. func fetchAnnouncement(sinceDate: Date? = nil) -> AnyPublisher<[Announcement], Swift.Error> {
  214. var components = URLComponents()
  215. components.scheme = url.scheme
  216. components.host = url.host
  217. components.port = url.port
  218. components.path = Config.treatmentsPath
  219. components.queryItems = [
  220. URLQueryItem(name: "find[eventType]", value: "Announcement"),
  221. URLQueryItem(
  222. name: "find[enteredBy]",
  223. value: Announcement.remote.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
  224. )
  225. ]
  226. if let date = sinceDate {
  227. let dateItem = URLQueryItem(
  228. name: "find[created_at][$gte]",
  229. value: Formatter.iso8601withFractionalSeconds.string(from: date)
  230. )
  231. components.queryItems?.append(dateItem)
  232. }
  233. var request = URLRequest(url: components.url!)
  234. request.allowsConstrainedNetworkAccess = false
  235. request.timeoutInterval = Config.timeout
  236. if let secret = secret {
  237. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  238. }
  239. return service.run(request)
  240. .retry(Config.retryCount)
  241. .decode(type: [Announcement].self, decoder: JSONCoding.decoder)
  242. .eraseToAnyPublisher()
  243. }
  244. func uploadTreatments(_ treatments: [NigtscoutTreatment]) -> AnyPublisher<Void, Swift.Error> {
  245. var components = URLComponents()
  246. components.scheme = url.scheme
  247. components.host = url.host
  248. components.port = url.port
  249. components.path = Config.treatmentsPath
  250. var request = URLRequest(url: components.url!)
  251. request.allowsConstrainedNetworkAccess = false
  252. request.timeoutInterval = Config.timeout
  253. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  254. if let secret = secret {
  255. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  256. }
  257. request.httpBody = try! JSONCoding.encoder.encode(treatments)
  258. request.httpMethod = "POST"
  259. return service.run(request)
  260. .retry(Config.retryCount)
  261. .map { _ in () }
  262. .eraseToAnyPublisher()
  263. }
  264. func uploadGlucose(_ glucose: [BloodGlucose]) -> AnyPublisher<Void, Swift.Error> {
  265. var components = URLComponents()
  266. components.scheme = url.scheme
  267. components.host = url.host
  268. components.port = url.port
  269. components.path = Config.uploadEntriesPath
  270. var request = URLRequest(url: components.url!)
  271. request.allowsConstrainedNetworkAccess = false
  272. request.timeoutInterval = Config.timeout
  273. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  274. if let secret = secret {
  275. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  276. }
  277. request.httpBody = try! JSONCoding.encoder.encode(glucose)
  278. request.httpMethod = "POST"
  279. return service.run(request)
  280. .retry(Config.retryCount)
  281. .map { _ in () }
  282. .eraseToAnyPublisher()
  283. }
  284. func uploadStats(_ stats: NightscoutStatistics) -> AnyPublisher<Void, Swift.Error> {
  285. var components = URLComponents()
  286. components.scheme = url.scheme
  287. components.host = url.host
  288. components.port = url.port
  289. components.path = Config.statusPath
  290. var request = URLRequest(url: components.url!)
  291. request.allowsConstrainedNetworkAccess = false
  292. request.timeoutInterval = Config.timeout
  293. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  294. if let secret = secret {
  295. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  296. }
  297. request.httpBody = try! JSONCoding.encoder.encode(stats)
  298. request.httpMethod = "POST"
  299. return service.run(request)
  300. .retry(Config.retryCount)
  301. .map { _ in () }
  302. .eraseToAnyPublisher()
  303. }
  304. func uploadStatus(_ status: NightscoutStatus) -> AnyPublisher<Void, Swift.Error> {
  305. var components = URLComponents()
  306. components.scheme = url.scheme
  307. components.host = url.host
  308. components.port = url.port
  309. components.path = Config.statusPath
  310. var request = URLRequest(url: components.url!)
  311. request.allowsConstrainedNetworkAccess = false
  312. request.timeoutInterval = Config.timeout
  313. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  314. if let secret = secret {
  315. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  316. }
  317. request.httpBody = try! JSONCoding.encoder.encode(status)
  318. request.httpMethod = "POST"
  319. return service.run(request)
  320. .retry(Config.retryCount)
  321. .map { _ in () }
  322. .eraseToAnyPublisher()
  323. }
  324. func uploadPrefs(_ prefs: NightscoutPreferences) -> AnyPublisher<Void, Swift.Error> {
  325. var components = URLComponents()
  326. components.scheme = url.scheme
  327. components.host = url.host
  328. components.port = url.port
  329. components.path = Config.statusPath
  330. var request = URLRequest(url: components.url!)
  331. request.allowsConstrainedNetworkAccess = false
  332. request.timeoutInterval = Config.timeout
  333. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  334. if let secret = secret {
  335. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  336. }
  337. request.httpBody = try! JSONCoding.encoder.encode(prefs)
  338. request.httpMethod = "POST"
  339. return service.run(request)
  340. .retry(Config.retryCount)
  341. .map { _ in () }
  342. .eraseToAnyPublisher()
  343. }
  344. func uploadProfile(_ profile: NightscoutProfileStore) -> AnyPublisher<Void, Swift.Error> {
  345. var components = URLComponents()
  346. components.scheme = url.scheme
  347. components.host = url.host
  348. components.port = url.port
  349. components.path = Config.profilePath
  350. var request = URLRequest(url: components.url!)
  351. request.allowsConstrainedNetworkAccess = false
  352. request.timeoutInterval = Config.timeout
  353. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  354. if let secret = secret {
  355. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  356. }
  357. request.httpBody = try! JSONCoding.encoder.encode(profile)
  358. request.httpMethod = "POST"
  359. return service.run(request)
  360. .retry(Config.retryCount)
  361. .map { _ in () }
  362. .eraseToAnyPublisher()
  363. }
  364. }
  365. private extension String {
  366. func sha1() -> String {
  367. let data = Data(utf8)
  368. var digest = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
  369. data.withUnsafeBytes {
  370. _ = CC_SHA1($0.baseAddress, CC_LONG(data.count), &digest)
  371. }
  372. let hexBytes = digest.map { String(format: "%02hhx", $0) }
  373. return hexBytes.joined()
  374. }
  375. }