NightscoutAPI.swift 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  1. import Combine
  2. import CommonCrypto
  3. import Foundation
  4. import JavaScriptCore
  5. import Swinject
  6. class NightscoutAPI {
  7. init(url: URL, secret: String? = nil) {
  8. self.url = url
  9. self.secret = secret?.nonEmpty
  10. }
  11. private enum Config {
  12. static let entriesPath = "/api/v1/entries/sgv.json"
  13. static let uploadEntriesPath = "/api/v1/entries.json"
  14. static let treatmentsPath = "/api/v1/treatments.json"
  15. static let statusPath = "/api/v1/devicestatus.json"
  16. static let profilePath = "/api/v1/profile.json"
  17. static let retryCount = 1
  18. static let timeout: TimeInterval = 60
  19. }
  20. private let excludedEnteredBy: [String] = [
  21. NightscoutTreatment.local,
  22. "AndroidAPS",
  23. "openaps://AndroidAPS",
  24. "iAPS",
  25. "loop://iPhone"
  26. ]
  27. enum Error: LocalizedError {
  28. case badStatusCode
  29. case missingURL
  30. }
  31. let url: URL
  32. let secret: String?
  33. private let service = NetworkService()
  34. @Injected() private var settingsManager: SettingsManager!
  35. }
  36. extension NightscoutAPI {
  37. func checkConnection() -> AnyPublisher<Void, Swift.Error> {
  38. struct Check: Codable, Equatable {
  39. var eventType = "Note"
  40. var enteredBy = "Trio"
  41. var notes = "Trio connected"
  42. }
  43. let check = Check()
  44. var request = URLRequest(url: url.appendingPathComponent(Config.treatmentsPath))
  45. if let secret = secret {
  46. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  47. request.httpMethod = "POST"
  48. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  49. request.httpBody = try! JSONCoding.encoder.encode(check)
  50. } else {
  51. request.httpMethod = "GET"
  52. }
  53. return service.run(request)
  54. .map { _ in () }
  55. .eraseToAnyPublisher()
  56. }
  57. func fetchLastGlucose(sinceDate: Date? = nil) async throws -> [BloodGlucose] {
  58. var components = URLComponents()
  59. components.scheme = url.scheme
  60. components.host = url.host
  61. components.port = url.port
  62. components.path = Config.entriesPath
  63. components.queryItems = [URLQueryItem(name: "count", value: "\(1600)")]
  64. if let date = sinceDate {
  65. let dateItem = URLQueryItem(
  66. name: "find[dateString][$gte]",
  67. value: Formatter.iso8601withFractionalSeconds.string(from: date)
  68. )
  69. components.queryItems?.append(dateItem)
  70. }
  71. guard let url = components.url else {
  72. throw URLError(.badURL)
  73. }
  74. var request = URLRequest(url: url)
  75. request.allowsConstrainedNetworkAccess = false
  76. request.timeoutInterval = Config.timeout
  77. if let secret = secret {
  78. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  79. }
  80. do {
  81. let (data, _) = try await URLSession.shared.data(for: request)
  82. let glucose = try JSONCoding.decoder.decode([BloodGlucose].self, from: data)
  83. return glucose.map {
  84. var reading = $0
  85. reading.glucose = $0.sgv
  86. return reading
  87. }
  88. } catch {
  89. warning(.nightscout, "Glucose fetching error: \(error)")
  90. return []
  91. }
  92. }
  93. private func makeNeQueryItems() -> [URLQueryItem] {
  94. excludedEnteredBy.enumerated().map { idx, value in
  95. URLQueryItem(
  96. name: "find[$and][\(idx)][enteredBy][$ne]",
  97. value: value
  98. )
  99. }
  100. }
  101. func fetchCarbs(sinceDate: Date? = nil) async throws -> [CarbsEntry] {
  102. var components = URLComponents()
  103. components.scheme = url.scheme
  104. components.host = url.host
  105. components.port = url.port
  106. components.path = Config.treatmentsPath
  107. var items: [URLQueryItem] = [
  108. URLQueryItem(name: "find[carbs][$exists]", value: "true")
  109. ]
  110. items.append(contentsOf: makeNeQueryItems())
  111. components.queryItems = items
  112. if let date = sinceDate {
  113. let dateItem = URLQueryItem(
  114. name: "find[created_at][$gt]",
  115. value: Formatter.iso8601withFractionalSeconds.string(from: date)
  116. )
  117. components.queryItems?.append(dateItem)
  118. }
  119. var request = URLRequest(url: components.url!)
  120. request.allowsConstrainedNetworkAccess = false
  121. request.timeoutInterval = Config.timeout
  122. if let secret = secret {
  123. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  124. }
  125. do {
  126. let (data, response) = try await URLSession.shared.data(for: request)
  127. guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else {
  128. throw URLError(.badServerResponse)
  129. }
  130. let carbs = try JSONCoding.decoder.decode([CarbsEntry].self, from: data)
  131. return carbs
  132. } catch {
  133. warning(.nightscout, "Carbs fetching error: \(error)")
  134. throw error
  135. }
  136. }
  137. func deleteCarbs(withId id: String) async throws {
  138. var components = URLComponents()
  139. components.scheme = url.scheme
  140. components.host = url.host
  141. components.port = url.port
  142. components.path = Config.treatmentsPath
  143. components.queryItems = [
  144. URLQueryItem(name: "find[id][$eq]", value: id)
  145. ]
  146. var request = URLRequest(url: components.url!)
  147. request.allowsConstrainedNetworkAccess = false
  148. request.timeoutInterval = Config.timeout
  149. request.httpMethod = "DELETE"
  150. if let secret = secret {
  151. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  152. }
  153. let (_, response) = try await URLSession.shared.data(for: request)
  154. guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else {
  155. throw URLError(.badServerResponse)
  156. }
  157. return
  158. }
  159. func deleteGlucose(withId id: String, withDate date: Date) async throws {
  160. var components = URLComponents()
  161. components.scheme = url.scheme
  162. components.host = url.host
  163. components.port = url.port
  164. components.path = Config.uploadEntriesPath
  165. components.queryItems = [
  166. URLQueryItem(
  167. name: "find[$or][0][id][$eq]",
  168. value: id
  169. ),
  170. URLQueryItem(
  171. name: "find[$or][1][dateString][$eq]",
  172. value: Formatter.iso8601withFractionalSeconds.string(from: date)
  173. )
  174. ]
  175. guard let url = components.url else {
  176. throw URLError(.badURL)
  177. }
  178. var request = URLRequest(url: url)
  179. request.allowsConstrainedNetworkAccess = false
  180. request.timeoutInterval = Config.timeout
  181. request.httpMethod = "DELETE"
  182. if let secret = secret {
  183. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  184. }
  185. let (_, response) = try await URLSession.shared.data(for: request)
  186. guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else {
  187. throw URLError(.badServerResponse)
  188. }
  189. }
  190. func deleteInsulin(withId id: String) async throws {
  191. var components = URLComponents()
  192. components.scheme = url.scheme
  193. components.host = url.host
  194. components.port = url.port
  195. components.path = Config.treatmentsPath
  196. components.queryItems = [
  197. URLQueryItem(name: "find[id][$eq]", value: id)
  198. ]
  199. guard let url = components.url else {
  200. throw URLError(.badURL)
  201. }
  202. var request = URLRequest(url: url)
  203. request.allowsConstrainedNetworkAccess = false
  204. request.timeoutInterval = Config.timeout
  205. request.httpMethod = "DELETE"
  206. if let secret = secret {
  207. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  208. }
  209. let (_, response) = try await URLSession.shared.data(for: request)
  210. guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else {
  211. throw URLError(.badServerResponse)
  212. }
  213. }
  214. func fetchTempTargets(sinceDate: Date? = nil) async throws -> [TempTarget] {
  215. var components = URLComponents()
  216. components.scheme = url.scheme
  217. components.host = url.host
  218. components.port = url.port
  219. components.path = Config.treatmentsPath
  220. var items: [URLQueryItem] = [
  221. URLQueryItem(name: "find[eventType]", value: "Temporary+Target"),
  222. URLQueryItem(name: "find[duration][$exists]", value: "true")
  223. ]
  224. items.append(contentsOf: makeNeQueryItems())
  225. components.queryItems = items
  226. if let date = sinceDate {
  227. let dateItem = URLQueryItem(
  228. name: "find[created_at][$gt]",
  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. do {
  240. let (data, response) = try await URLSession.shared.data(for: request)
  241. guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else {
  242. throw URLError(.badServerResponse)
  243. }
  244. let tempTargets = try JSONCoding.decoder.decode([TempTarget].self, from: data)
  245. return tempTargets
  246. } catch {
  247. warning(.nightscout, "TempTarget fetching error: \(error)")
  248. throw error
  249. }
  250. }
  251. func uploadTreatments(_ treatments: [NightscoutTreatment]) async throws {
  252. var components = URLComponents()
  253. components.scheme = url.scheme
  254. components.host = url.host
  255. components.port = url.port
  256. components.path = Config.treatmentsPath
  257. guard let requestURL = components.url else {
  258. throw URLError(.badURL)
  259. }
  260. var request = URLRequest(url: requestURL)
  261. request.allowsConstrainedNetworkAccess = false
  262. request.timeoutInterval = Config.timeout
  263. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  264. if let secret = secret {
  265. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  266. }
  267. do {
  268. let encodedBody = try JSONCoding.encoder.encode(treatments)
  269. request.httpBody = encodedBody
  270. // debugPrint("Payload treatments size: \(encodedBody.count) bytes")
  271. // debugPrint(String(data: encodedBody, encoding: .utf8) ?? "Invalid payload")
  272. } catch {
  273. debugPrint("Error encoding payload: \(error)")
  274. throw error
  275. }
  276. request.httpMethod = "POST"
  277. let (_, response) = try await URLSession.shared.data(for: request)
  278. // Check the response status code
  279. guard let httpResponse = response as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
  280. throw URLError(.badServerResponse)
  281. }
  282. // debugPrint("Upload successful, response data: \(String(data: data, encoding: .utf8) ?? "No data")")
  283. }
  284. func uploadGlucose(_ glucose: [BloodGlucose]) async throws {
  285. var components = URLComponents()
  286. components.scheme = url.scheme
  287. components.host = url.host
  288. components.port = url.port
  289. components.path = Config.uploadEntriesPath
  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. do {
  298. let encodedBody = try JSONCoding.encoder.encode(glucose)
  299. request.httpBody = encodedBody
  300. // debugPrint("Payload glucose size: \(encodedBody.count) bytes")
  301. // debugPrint(String(data: encodedBody, encoding: .utf8) ?? "Invalid payload")
  302. } catch {
  303. debugPrint("Error encoding payload: \(error)")
  304. throw error
  305. }
  306. request.httpMethod = "POST"
  307. let (_, response) = try await URLSession.shared.data(for: request)
  308. // Check the response status code
  309. guard let httpResponse = response as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
  310. throw URLError(.badServerResponse)
  311. }
  312. // debugPrint("Upload successful, response data: \(String(data: data, encoding: .utf8) ?? "No data")")
  313. }
  314. func uploadDeviceStatus(_ status: NightscoutStatus) async throws {
  315. var components = URLComponents()
  316. components.scheme = url.scheme
  317. components.host = url.host
  318. components.port = url.port
  319. components.path = Config.statusPath
  320. var request = URLRequest(url: components.url!)
  321. request.allowsConstrainedNetworkAccess = false
  322. request.timeoutInterval = Config.timeout
  323. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  324. if let secret = secret {
  325. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  326. }
  327. do {
  328. let encodedBody = try JSONCoding.encoder.encode(status)
  329. request.httpBody = encodedBody
  330. // debugPrint("Payload status size: \(encodedBody.count) bytes")
  331. // debugPrint(String(data: encodedBody, encoding: .utf8) ?? "Invalid payload")
  332. } catch {
  333. debugPrint("Error encoding payload: \(error)")
  334. throw error
  335. }
  336. request.httpMethod = "POST"
  337. let (_, response) = try await URLSession.shared.data(for: request)
  338. guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else {
  339. throw URLError(.badServerResponse)
  340. }
  341. }
  342. func uploadProfile(_ profile: NightscoutProfileStore) async throws {
  343. var components = URLComponents()
  344. components.scheme = url.scheme
  345. components.host = url.host
  346. components.port = url.port
  347. components.path = Config.profilePath
  348. guard let url = components.url else {
  349. throw URLError(.badURL)
  350. }
  351. var request = URLRequest(url: url)
  352. request.allowsConstrainedNetworkAccess = false
  353. request.timeoutInterval = Config.timeout
  354. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  355. if let secret = secret {
  356. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  357. }
  358. do {
  359. let encodedBody = try JSONCoding.encoder.encode(profile)
  360. request.httpBody = encodedBody
  361. // debugPrint("Payload profile upload size: \(encodedBody.count) bytes")
  362. // debugPrint(String(data: encodedBody, encoding: .utf8) ?? "Invalid payload")
  363. } catch {
  364. debugPrint("Error encoding payload: \(error)")
  365. throw error
  366. }
  367. request.httpMethod = "POST"
  368. let (_, response) = try await URLSession.shared.data(for: request)
  369. if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
  370. throw URLError(.badServerResponse)
  371. }
  372. }
  373. /// The delete func is needed to force re-rendering of overrides with changed durations in Nightscout main chart
  374. /// since just updating durations in existing entries doesn't trigger re-rendering.
  375. func deleteNightscoutOverride(withCreatedAt createdAt: String) async throws {
  376. var components = URLComponents()
  377. components.scheme = url.scheme
  378. components.host = url.host
  379. components.port = url.port
  380. components.path = Config.treatmentsPath
  381. components.queryItems = [
  382. URLQueryItem(name: "find[created_at][$eq]", value: createdAt)
  383. ]
  384. guard let url = components.url else {
  385. throw URLError(.badURL)
  386. }
  387. var request = URLRequest(url: url)
  388. request.timeoutInterval = Config.timeout
  389. request.httpMethod = "DELETE"
  390. if let secret = secret {
  391. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  392. }
  393. let (_, response) = try await URLSession.shared.data(for: request)
  394. if let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) {
  395. } else {
  396. let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
  397. debug(.nightscout, "Failed to delete override with created_at: \(createdAt). HTTP status code: \(statusCode)")
  398. throw URLError(.badServerResponse)
  399. }
  400. }
  401. func uploadOverrides(_ overrides: [NightscoutExercise]) async throws {
  402. var components = URLComponents()
  403. components.scheme = url.scheme
  404. components.host = url.host
  405. components.port = url.port
  406. components.path = Config.treatmentsPath
  407. var request = URLRequest(url: components.url!)
  408. request.allowsConstrainedNetworkAccess = false
  409. request.timeoutInterval = Config.timeout
  410. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  411. if let secret = secret {
  412. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  413. }
  414. do {
  415. let encodedBody = try JSONCoding.encoder.encode(overrides)
  416. request.httpBody = encodedBody
  417. // debugPrint("Payload glucose size: \(encodedBody.count) bytes")
  418. // debugPrint(String(data: encodedBody, encoding: .utf8) ?? "Invalid payload")
  419. } catch {
  420. debugPrint("Error encoding payload: \(error)")
  421. throw error
  422. }
  423. request.httpMethod = "POST"
  424. let (_, response) = try await URLSession.shared.data(for: request)
  425. // Check the response status code
  426. guard let httpResponse = response as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
  427. throw URLError(.badServerResponse)
  428. }
  429. // debugPrint("Upload successful, response data: \(String(data: data, encoding: .utf8) ?? "No data")")
  430. }
  431. func importSettings() async throws -> ScheduledNightscoutProfile {
  432. var components = URLComponents()
  433. components.scheme = url.scheme
  434. components.host = url.host
  435. components.port = url.port
  436. components.path = Config.profilePath
  437. components.queryItems = [URLQueryItem(name: "count", value: "1")]
  438. guard let url = components.url else {
  439. throw URLError(.badURL)
  440. }
  441. var request = URLRequest(url: url)
  442. request.allowsConstrainedNetworkAccess = false
  443. request.timeoutInterval = Config.timeout
  444. if let secret = secret {
  445. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  446. }
  447. do {
  448. let (data, response) = try await URLSession.shared.data(for: request)
  449. guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else {
  450. throw URLError(.badServerResponse)
  451. }
  452. guard let mimeType = httpResponse.mimeType, mimeType == "application/json" else {
  453. throw URLError(.unsupportedURL)
  454. }
  455. let jsonDecoder = JSONCoding.decoder
  456. let fetchedProfileStore = try jsonDecoder.decode([FetchedNightscoutProfileStore].self, from: data)
  457. guard let fetchedProfile = fetchedProfileStore.first?.store["default"] else {
  458. throw NSError(
  459. domain: "ImportError",
  460. code: 1,
  461. userInfo: [NSLocalizedDescriptionKey: "Can't find the default Nightscout Profile."]
  462. )
  463. }
  464. return fetchedProfile
  465. } catch {
  466. warning(.nightscout, "Could not fetch Nightscout Profile! Error: \(error)")
  467. throw error
  468. }
  469. }
  470. }
  471. private extension String {
  472. func sha1() -> String {
  473. let data = Data(utf8)
  474. var digest = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
  475. data.withUnsafeBytes {
  476. _ = CC_SHA1($0.baseAddress, CC_LONG(data.count), &digest)
  477. }
  478. let hexBytes = digest.map { String(format: "%02hhx", $0) }
  479. return hexBytes.joined()
  480. }
  481. }