NightscoutAPI.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  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 retryCount = 1
  15. static let timeout: TimeInterval = 60
  16. }
  17. enum Error: LocalizedError {
  18. case badStatusCode
  19. case missingURL
  20. }
  21. let url: URL
  22. let secret: String?
  23. private let service = NetworkService()
  24. }
  25. extension NightscoutAPI {
  26. func checkConnection() -> AnyPublisher<Void, Swift.Error> {
  27. struct Check: Codable, Equatable {
  28. var eventType = "Note"
  29. var enteredBy = "freeaps-x"
  30. var notes = "FreeAPS X connected"
  31. }
  32. let check = Check()
  33. var request = URLRequest(url: url.appendingPathComponent(Config.treatmentsPath))
  34. if let secret = secret {
  35. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  36. request.httpMethod = "POST"
  37. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  38. request.httpBody = try! JSONCoding.encoder.encode(check)
  39. } else {
  40. request.httpMethod = "GET"
  41. }
  42. return service.run(request)
  43. .map { _ in () }
  44. .eraseToAnyPublisher()
  45. }
  46. func fetchLastGlucose(sinceDate: Date? = nil) -> AnyPublisher<[BloodGlucose], Swift.Error> {
  47. var components = URLComponents()
  48. components.scheme = url.scheme
  49. components.host = url.host
  50. components.port = url.port
  51. components.path = Config.entriesPath
  52. components.queryItems = [URLQueryItem(name: "count", value: "\(1600)")]
  53. if let date = sinceDate {
  54. let dateItem = URLQueryItem(
  55. name: "find[dateString][$gte]",
  56. value: Formatter.iso8601withFractionalSeconds.string(from: date)
  57. )
  58. components.queryItems?.append(dateItem)
  59. }
  60. var request = URLRequest(url: components.url!)
  61. request.allowsConstrainedNetworkAccess = false
  62. request.timeoutInterval = Config.timeout
  63. if let secret = secret {
  64. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  65. }
  66. return service.run(request)
  67. .retry(Config.retryCount)
  68. .decode(type: [BloodGlucose].self, decoder: JSONCoding.decoder)
  69. .catch { error -> AnyPublisher<[BloodGlucose], Swift.Error> in
  70. warning(.nightscout, "Glucose fetching error: \(error.localizedDescription)")
  71. return Just([]).setFailureType(to: Swift.Error.self).eraseToAnyPublisher()
  72. }
  73. .map { glucose in
  74. glucose
  75. .map {
  76. var reading = $0
  77. reading.glucose = $0.sgv
  78. return reading
  79. }
  80. }
  81. .eraseToAnyPublisher()
  82. }
  83. func fetchCarbs(sinceDate: Date? = nil) -> AnyPublisher<[CarbsEntry], Swift.Error> {
  84. var components = URLComponents()
  85. components.scheme = url.scheme
  86. components.host = url.host
  87. components.port = url.port
  88. components.path = Config.treatmentsPath
  89. components.queryItems = [
  90. URLQueryItem(name: "find[carbs][$exists]", value: "true"),
  91. URLQueryItem(
  92. name: "find[enteredBy][$ne]",
  93. value: CarbsEntry.manual.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
  94. ),
  95. URLQueryItem(
  96. name: "find[enteredBy][$ne]",
  97. value: NigtscoutTreatment.local.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
  98. )
  99. ]
  100. if let date = sinceDate {
  101. let dateItem = URLQueryItem(
  102. name: "find[created_at][$gt]",
  103. value: Formatter.iso8601withFractionalSeconds.string(from: date)
  104. )
  105. components.queryItems?.append(dateItem)
  106. }
  107. var request = URLRequest(url: components.url!)
  108. request.allowsConstrainedNetworkAccess = false
  109. request.timeoutInterval = Config.timeout
  110. if let secret = secret {
  111. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  112. }
  113. return service.run(request)
  114. .retry(Config.retryCount)
  115. .decode(type: [CarbsEntry].self, decoder: JSONCoding.decoder)
  116. .catch { error -> AnyPublisher<[CarbsEntry], Swift.Error> in
  117. warning(.nightscout, "Carbs fetching error: \(error.localizedDescription)")
  118. return Just([]).setFailureType(to: Swift.Error.self).eraseToAnyPublisher()
  119. }
  120. .eraseToAnyPublisher()
  121. }
  122. func deleteCarbs(at date: Date) -> AnyPublisher<Void, Swift.Error> {
  123. var components = URLComponents()
  124. components.scheme = url.scheme
  125. components.host = url.host
  126. components.port = url.port
  127. components.path = Config.treatmentsPath
  128. components.queryItems = [
  129. URLQueryItem(name: "find[carbs][$exists]", value: "true"),
  130. URLQueryItem(
  131. name: "find[created_at][$eq]",
  132. value: Formatter.iso8601withFractionalSeconds.string(from: date)
  133. )
  134. ]
  135. var request = URLRequest(url: components.url!)
  136. request.allowsConstrainedNetworkAccess = false
  137. request.timeoutInterval = Config.timeout
  138. request.httpMethod = "DELETE"
  139. if let secret = secret {
  140. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  141. }
  142. return service.run(request)
  143. .retry(Config.retryCount)
  144. .map { _ in () }
  145. .eraseToAnyPublisher()
  146. }
  147. func fetchTempTargets(sinceDate: Date? = nil) -> AnyPublisher<[TempTarget], Swift.Error> {
  148. var components = URLComponents()
  149. components.scheme = url.scheme
  150. components.host = url.host
  151. components.port = url.port
  152. components.path = Config.treatmentsPath
  153. components.queryItems = [
  154. URLQueryItem(name: "find[eventType]", value: "Temporary+Target"),
  155. URLQueryItem(
  156. name: "find[enteredBy][$ne]",
  157. value: TempTarget.manual.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
  158. ),
  159. URLQueryItem(
  160. name: "find[enteredBy][$ne]",
  161. value: NigtscoutTreatment.local.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
  162. ),
  163. URLQueryItem(name: "find[duration][$exists]", value: "true")
  164. ]
  165. if let date = sinceDate {
  166. let dateItem = URLQueryItem(
  167. name: "find[created_at][$gt]",
  168. value: Formatter.iso8601withFractionalSeconds.string(from: date)
  169. )
  170. components.queryItems?.append(dateItem)
  171. }
  172. var request = URLRequest(url: components.url!)
  173. request.allowsConstrainedNetworkAccess = false
  174. request.timeoutInterval = Config.timeout
  175. if let secret = secret {
  176. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  177. }
  178. return service.run(request)
  179. .retry(Config.retryCount)
  180. .decode(type: [TempTarget].self, decoder: JSONCoding.decoder)
  181. .catch { error -> AnyPublisher<[TempTarget], Swift.Error> in
  182. warning(.nightscout, "TempTarget fetching error: \(error.localizedDescription)")
  183. return Just([]).setFailureType(to: Swift.Error.self).eraseToAnyPublisher()
  184. }
  185. .eraseToAnyPublisher()
  186. }
  187. func fetchAnnouncement(sinceDate: Date? = nil) -> AnyPublisher<[Announcement], Swift.Error> {
  188. var components = URLComponents()
  189. components.scheme = url.scheme
  190. components.host = url.host
  191. components.port = url.port
  192. components.path = Config.treatmentsPath
  193. components.queryItems = [
  194. URLQueryItem(name: "find[eventType]", value: "Announcement"),
  195. URLQueryItem(
  196. name: "find[enteredBy]",
  197. value: Announcement.remote.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
  198. )
  199. ]
  200. if let date = sinceDate {
  201. let dateItem = URLQueryItem(
  202. name: "find[created_at][$gte]",
  203. value: Formatter.iso8601withFractionalSeconds.string(from: date)
  204. )
  205. components.queryItems?.append(dateItem)
  206. }
  207. var request = URLRequest(url: components.url!)
  208. request.allowsConstrainedNetworkAccess = false
  209. request.timeoutInterval = Config.timeout
  210. if let secret = secret {
  211. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  212. }
  213. return service.run(request)
  214. .retry(Config.retryCount)
  215. .decode(type: [Announcement].self, decoder: JSONCoding.decoder)
  216. .eraseToAnyPublisher()
  217. }
  218. func uploadTreatments(_ treatments: [NigtscoutTreatment]) -> AnyPublisher<Void, Swift.Error> {
  219. var components = URLComponents()
  220. components.scheme = url.scheme
  221. components.host = url.host
  222. components.port = url.port
  223. components.path = Config.treatmentsPath
  224. var request = URLRequest(url: components.url!)
  225. request.allowsConstrainedNetworkAccess = false
  226. request.timeoutInterval = Config.timeout
  227. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  228. if let secret = secret {
  229. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  230. }
  231. request.httpBody = try! JSONCoding.encoder.encode(treatments)
  232. request.httpMethod = "POST"
  233. return service.run(request)
  234. .retry(Config.retryCount)
  235. .map { _ in () }
  236. .eraseToAnyPublisher()
  237. }
  238. func uploadGlucose(_ glucose: [BloodGlucose]) -> AnyPublisher<Void, Swift.Error> {
  239. var components = URLComponents()
  240. components.scheme = url.scheme
  241. components.host = url.host
  242. components.port = url.port
  243. components.path = Config.uploadEntriesPath
  244. var request = URLRequest(url: components.url!)
  245. request.allowsConstrainedNetworkAccess = false
  246. request.timeoutInterval = Config.timeout
  247. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  248. if let secret = secret {
  249. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  250. }
  251. request.httpBody = try! JSONCoding.encoder.encode(glucose)
  252. request.httpMethod = "POST"
  253. return service.run(request)
  254. .retry(Config.retryCount)
  255. .map { _ in () }
  256. .eraseToAnyPublisher()
  257. }
  258. func uploadStatus(_ status: NightscoutStatus) -> AnyPublisher<Void, Swift.Error> {
  259. var components = URLComponents()
  260. components.scheme = url.scheme
  261. components.host = url.host
  262. components.port = url.port
  263. components.path = Config.statusPath
  264. var request = URLRequest(url: components.url!)
  265. request.allowsConstrainedNetworkAccess = false
  266. request.timeoutInterval = Config.timeout
  267. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  268. if let secret = secret {
  269. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  270. }
  271. request.httpBody = try! JSONCoding.encoder.encode(status)
  272. request.httpMethod = "POST"
  273. return service.run(request)
  274. .retry(Config.retryCount)
  275. .map { _ in () }
  276. .eraseToAnyPublisher()
  277. }
  278. }
  279. private extension String {
  280. func sha1() -> String {
  281. let data = Data(utf8)
  282. var digest = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
  283. data.withUnsafeBytes {
  284. _ = CC_SHA1($0.baseAddress, CC_LONG(data.count), &digest)
  285. }
  286. let hexBytes = digest.map { String(format: "%02hhx", $0) }
  287. return hexBytes.joined()
  288. }
  289. }