NightscoutAPI.swift 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
  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. enum Error: LocalizedError {
  21. case badStatusCode
  22. case missingURL
  23. }
  24. let url: URL
  25. let secret: String?
  26. private let service = NetworkService()
  27. @Injected() private var settingsManager: SettingsManager!
  28. }
  29. extension NightscoutAPI {
  30. func checkConnection() -> AnyPublisher<Void, Swift.Error> {
  31. struct Check: Codable, Equatable {
  32. var eventType = "Note"
  33. var enteredBy = "Trio"
  34. var notes = "Trio connected"
  35. }
  36. let check = Check()
  37. var request = URLRequest(url: url.appendingPathComponent(Config.treatmentsPath))
  38. if let secret = secret {
  39. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  40. request.httpMethod = "POST"
  41. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  42. request.httpBody = try! JSONCoding.encoder.encode(check)
  43. } else {
  44. request.httpMethod = "GET"
  45. }
  46. return service.run(request)
  47. .map { _ in () }
  48. .eraseToAnyPublisher()
  49. }
  50. func fetchLastGlucose(sinceDate: Date? = nil) async throws -> [BloodGlucose] {
  51. var components = URLComponents()
  52. components.scheme = url.scheme
  53. components.host = url.host
  54. components.port = url.port
  55. components.path = Config.entriesPath
  56. components.queryItems = [URLQueryItem(name: "count", value: "\(1600)")]
  57. if let date = sinceDate {
  58. let dateItem = URLQueryItem(
  59. name: "find[dateString][$gte]",
  60. value: Formatter.iso8601withFractionalSeconds.string(from: date)
  61. )
  62. components.queryItems?.append(dateItem)
  63. }
  64. guard let url = components.url else {
  65. throw URLError(.badURL)
  66. }
  67. var request = URLRequest(url: url)
  68. request.allowsConstrainedNetworkAccess = false
  69. request.timeoutInterval = Config.timeout
  70. if let secret = secret {
  71. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  72. }
  73. do {
  74. let (data, _) = try await URLSession.shared.data(for: request)
  75. let glucose = try JSONCoding.decoder.decode([BloodGlucose].self, from: data)
  76. return glucose.map {
  77. var reading = $0
  78. reading.glucose = $0.sgv
  79. return reading
  80. }
  81. } catch {
  82. warning(.nightscout, "Glucose fetching error: \(error.localizedDescription)")
  83. return []
  84. }
  85. }
  86. func fetchCarbs(sinceDate: Date? = nil) -> AnyPublisher<[CarbsEntry], Swift.Error> {
  87. var components = URLComponents()
  88. components.scheme = url.scheme
  89. components.host = url.host
  90. components.port = url.port
  91. components.path = Config.treatmentsPath
  92. components.queryItems = [
  93. URLQueryItem(name: "find[carbs][$exists]", value: "true"),
  94. URLQueryItem(
  95. name: "find[enteredBy][$ne]",
  96. value: CarbsEntry.manual.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
  97. ),
  98. URLQueryItem(
  99. name: "find[enteredBy][$ne]",
  100. value: NightscoutTreatment.local.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
  101. )
  102. ]
  103. if let date = sinceDate {
  104. let dateItem = URLQueryItem(
  105. name: "find[created_at][$gt]",
  106. value: Formatter.iso8601withFractionalSeconds.string(from: date)
  107. )
  108. components.queryItems?.append(dateItem)
  109. }
  110. var request = URLRequest(url: components.url!)
  111. request.allowsConstrainedNetworkAccess = false
  112. request.timeoutInterval = Config.timeout
  113. if let secret = secret {
  114. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  115. }
  116. return service.run(request)
  117. .retry(Config.retryCount)
  118. .decode(type: [CarbsEntry].self, decoder: JSONCoding.decoder)
  119. .catch { error -> AnyPublisher<[CarbsEntry], Swift.Error> in
  120. warning(.nightscout, "Carbs fetching error: \(error.localizedDescription)")
  121. return Just([]).setFailureType(to: Swift.Error.self).eraseToAnyPublisher()
  122. }
  123. .eraseToAnyPublisher()
  124. }
  125. func deleteCarbs(withId id: String) async throws {
  126. var components = URLComponents()
  127. components.scheme = url.scheme
  128. components.host = url.host
  129. components.port = url.port
  130. components.path = Config.treatmentsPath
  131. components.queryItems = [
  132. URLQueryItem(name: "find[id][$eq]", value: id)
  133. ]
  134. var request = URLRequest(url: components.url!)
  135. request.allowsConstrainedNetworkAccess = false
  136. request.timeoutInterval = Config.timeout
  137. request.httpMethod = "DELETE"
  138. if let secret = secret {
  139. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  140. }
  141. let (_, response) = try await URLSession.shared.data(for: request)
  142. guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else {
  143. throw URLError(.badServerResponse)
  144. }
  145. return
  146. }
  147. func deleteManualGlucose(withId id: String) async throws {
  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[id][$eq]", value: id)
  155. ]
  156. guard let url = components.url else {
  157. throw URLError(.badURL)
  158. }
  159. var request = URLRequest(url: url)
  160. request.allowsConstrainedNetworkAccess = false
  161. request.timeoutInterval = Config.timeout
  162. request.httpMethod = "DELETE"
  163. if let secret = secret {
  164. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  165. }
  166. let (_, response) = try await URLSession.shared.data(for: request)
  167. guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else {
  168. throw URLError(.badServerResponse)
  169. }
  170. debugPrint("Delete successful for ID \(id)")
  171. }
  172. func deleteInsulin(withId id: String) async throws {
  173. var components = URLComponents()
  174. components.scheme = url.scheme
  175. components.host = url.host
  176. components.port = url.port
  177. components.path = Config.treatmentsPath
  178. components.queryItems = [
  179. URLQueryItem(name: "find[id][$eq]", value: id)
  180. ]
  181. guard let url = components.url else {
  182. throw URLError(.badURL)
  183. }
  184. var request = URLRequest(url: url)
  185. request.allowsConstrainedNetworkAccess = false
  186. request.timeoutInterval = Config.timeout
  187. request.httpMethod = "DELETE"
  188. if let secret = secret {
  189. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  190. }
  191. let (_, response) = try await URLSession.shared.data(for: request)
  192. guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else {
  193. throw URLError(.badServerResponse)
  194. }
  195. }
  196. // func deleteInsulin(at date: Date) -> AnyPublisher<Void, Swift.Error> {
  197. // var components = URLComponents()
  198. // components.scheme = url.scheme
  199. // components.host = url.host
  200. // components.port = url.port
  201. // components.path = Config.treatmentsPath
  202. // components.queryItems = [
  203. // URLQueryItem(name: "find[bolus][$exists]", value: "true"),
  204. // URLQueryItem(
  205. // name: "find[created_at][$eq]",
  206. // value: Formatter.iso8601withFractionalSeconds.string(from: date)
  207. // )
  208. // ]
  209. //
  210. // var request = URLRequest(url: components.url!)
  211. // request.allowsConstrainedNetworkAccess = false
  212. // request.timeoutInterval = Config.timeout
  213. // request.httpMethod = "DELETE"
  214. //
  215. // if let secret = secret {
  216. // request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  217. // }
  218. //
  219. // return service.run(request)
  220. // .retry(Config.retryCount)
  221. // .map { _ in () }
  222. // .eraseToAnyPublisher()
  223. // }
  224. func fetchTempTargets(sinceDate: Date? = nil) -> AnyPublisher<[TempTarget], Swift.Error> {
  225. var components = URLComponents()
  226. components.scheme = url.scheme
  227. components.host = url.host
  228. components.port = url.port
  229. components.path = Config.treatmentsPath
  230. components.queryItems = [
  231. URLQueryItem(name: "find[eventType]", value: "Temporary+Target"),
  232. URLQueryItem(
  233. name: "find[enteredBy][$ne]",
  234. value: TempTarget.manual.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
  235. ),
  236. URLQueryItem(
  237. name: "find[enteredBy][$ne]",
  238. value: NightscoutTreatment.local.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
  239. ),
  240. URLQueryItem(name: "find[duration][$exists]", value: "true")
  241. ]
  242. if let date = sinceDate {
  243. let dateItem = URLQueryItem(
  244. name: "find[created_at][$gt]",
  245. value: Formatter.iso8601withFractionalSeconds.string(from: date)
  246. )
  247. components.queryItems?.append(dateItem)
  248. }
  249. var request = URLRequest(url: components.url!)
  250. request.allowsConstrainedNetworkAccess = false
  251. request.timeoutInterval = Config.timeout
  252. if let secret = secret {
  253. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  254. }
  255. return service.run(request)
  256. .retry(Config.retryCount)
  257. .decode(type: [TempTarget].self, decoder: JSONCoding.decoder)
  258. .catch { error -> AnyPublisher<[TempTarget], Swift.Error> in
  259. warning(.nightscout, "TempTarget fetching error: \(error.localizedDescription)")
  260. return Just([]).setFailureType(to: Swift.Error.self).eraseToAnyPublisher()
  261. }
  262. .eraseToAnyPublisher()
  263. }
  264. func fetchAnnouncement(sinceDate: Date? = nil) -> AnyPublisher<[Announcement], 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.treatmentsPath
  270. components.queryItems = [
  271. URLQueryItem(name: "find[eventType]", value: "Announcement"),
  272. URLQueryItem(
  273. name: "find[enteredBy]",
  274. value: Announcement.remote.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
  275. )
  276. ]
  277. if let date = sinceDate {
  278. let dateItem = URLQueryItem(
  279. name: "find[created_at][$gte]",
  280. value: Formatter.iso8601withFractionalSeconds.string(from: date)
  281. )
  282. components.queryItems?.append(dateItem)
  283. }
  284. var request = URLRequest(url: components.url!)
  285. request.allowsConstrainedNetworkAccess = false
  286. request.timeoutInterval = Config.timeout
  287. if let secret = secret {
  288. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  289. }
  290. return service.run(request)
  291. .retry(Config.retryCount)
  292. .decode(type: [Announcement].self, decoder: JSONCoding.decoder)
  293. .eraseToAnyPublisher()
  294. }
  295. func uploadTreatments(_ treatments: [NightscoutTreatment]) async throws {
  296. var components = URLComponents()
  297. components.scheme = url.scheme
  298. components.host = url.host
  299. components.port = url.port
  300. components.path = Config.treatmentsPath
  301. guard let requestURL = components.url else {
  302. throw URLError(.badURL)
  303. }
  304. var request = URLRequest(url: requestURL)
  305. request.allowsConstrainedNetworkAccess = false
  306. request.timeoutInterval = Config.timeout
  307. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  308. if let secret = secret {
  309. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  310. }
  311. do {
  312. let encodedBody = try JSONCoding.encoder.encode(treatments)
  313. request.httpBody = encodedBody
  314. debugPrint("Payload treatments size: \(encodedBody.count) bytes")
  315. debugPrint(String(data: encodedBody, encoding: .utf8) ?? "Invalid payload")
  316. } catch {
  317. debugPrint("Error encoding payload: \(error.localizedDescription)")
  318. throw error
  319. }
  320. request.httpMethod = "POST"
  321. let (data, response) = try await URLSession.shared.data(for: request)
  322. // Check the response status code
  323. guard let httpResponse = response as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
  324. throw URLError(.badServerResponse)
  325. }
  326. debugPrint("Upload successful, response data: \(String(data: data, encoding: .utf8) ?? "No data")")
  327. }
  328. func uploadGlucose(_ glucose: [BloodGlucose]) async throws {
  329. var components = URLComponents()
  330. components.scheme = url.scheme
  331. components.host = url.host
  332. components.port = url.port
  333. components.path = Config.uploadEntriesPath
  334. var request = URLRequest(url: components.url!)
  335. request.allowsConstrainedNetworkAccess = false
  336. request.timeoutInterval = Config.timeout
  337. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  338. if let secret = secret {
  339. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  340. }
  341. do {
  342. let encodedBody = try JSONCoding.encoder.encode(glucose)
  343. request.httpBody = encodedBody
  344. debugPrint("Payload glucose size: \(encodedBody.count) bytes")
  345. debugPrint(String(data: encodedBody, encoding: .utf8) ?? "Invalid payload")
  346. } catch {
  347. debugPrint("Error encoding payload: \(error.localizedDescription)")
  348. throw error
  349. }
  350. request.httpMethod = "POST"
  351. let (data, response) = try await URLSession.shared.data(for: request)
  352. // Check the response status code
  353. guard let httpResponse = response as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
  354. throw URLError(.badServerResponse)
  355. }
  356. debugPrint("Upload successful, response data: \(String(data: data, encoding: .utf8) ?? "No data")")
  357. }
  358. func uploadStats(_ stats: NightscoutStatistics) -> AnyPublisher<Void, Swift.Error> {
  359. var components = URLComponents()
  360. components.scheme = url.scheme
  361. components.host = url.host
  362. components.port = url.port
  363. components.path = Config.statusPath
  364. var request = URLRequest(url: components.url!)
  365. request.allowsConstrainedNetworkAccess = false
  366. request.timeoutInterval = Config.timeout
  367. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  368. if let secret = secret {
  369. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  370. }
  371. request.httpBody = try! JSONCoding.encoder.encode(stats)
  372. request.httpMethod = "POST"
  373. return service.run(request)
  374. .retry(Config.retryCount)
  375. .map { _ in () }
  376. .eraseToAnyPublisher()
  377. }
  378. func uploadStatus(_ status: NightscoutStatus) -> AnyPublisher<Void, Swift.Error> {
  379. var components = URLComponents()
  380. components.scheme = url.scheme
  381. components.host = url.host
  382. components.port = url.port
  383. components.path = Config.statusPath
  384. var request = URLRequest(url: components.url!)
  385. request.allowsConstrainedNetworkAccess = false
  386. request.timeoutInterval = Config.timeout
  387. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  388. if let secret = secret {
  389. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  390. }
  391. request.httpBody = try! JSONCoding.encoder.encode(status)
  392. request.httpMethod = "POST"
  393. return service.run(request)
  394. .retry(Config.retryCount)
  395. .map { _ in () }
  396. .eraseToAnyPublisher()
  397. }
  398. func uploadPrefs(_ prefs: NightscoutPreferences) -> AnyPublisher<Void, Swift.Error> {
  399. var components = URLComponents()
  400. components.scheme = url.scheme
  401. components.host = url.host
  402. components.port = url.port
  403. components.path = Config.statusPath
  404. var request = URLRequest(url: components.url!)
  405. request.allowsConstrainedNetworkAccess = false
  406. request.timeoutInterval = Config.timeout
  407. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  408. if let secret = secret {
  409. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  410. }
  411. request.httpBody = try! JSONCoding.encoder.encode(prefs)
  412. request.httpMethod = "POST"
  413. return service.run(request)
  414. .retry(Config.retryCount)
  415. .map { _ in () }
  416. .eraseToAnyPublisher()
  417. }
  418. func uploadSettings(_ settings: NightscoutSettings) -> AnyPublisher<Void, Swift.Error> {
  419. var components = URLComponents()
  420. components.scheme = url.scheme
  421. components.host = url.host
  422. components.port = url.port
  423. components.path = Config.statusPath
  424. var request = URLRequest(url: components.url!)
  425. request.allowsConstrainedNetworkAccess = false
  426. request.timeoutInterval = Config.timeout
  427. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  428. if let secret = secret {
  429. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  430. }
  431. request.httpBody = try! JSONCoding.encoder.encode(settings)
  432. request.httpMethod = "POST"
  433. return service.run(request)
  434. .retry(Config.retryCount)
  435. .map { _ in () }
  436. .eraseToAnyPublisher()
  437. }
  438. func uploadProfile(_ profile: NightscoutProfileStore) -> AnyPublisher<Void, Swift.Error> {
  439. var components = URLComponents()
  440. components.scheme = url.scheme
  441. components.host = url.host
  442. components.port = url.port
  443. components.path = Config.profilePath
  444. var request = URLRequest(url: components.url!)
  445. request.allowsConstrainedNetworkAccess = false
  446. request.timeoutInterval = Config.timeout
  447. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  448. if let secret = secret {
  449. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  450. }
  451. request.httpBody = try! JSONCoding.encoder.encode(profile)
  452. request.httpMethod = "POST"
  453. return service.run(request)
  454. .retry(Config.retryCount)
  455. .map { _ in () }
  456. .eraseToAnyPublisher()
  457. }
  458. func uploadPreferences(_ preferences: Preferences) -> AnyPublisher<Void, Swift.Error> {
  459. var components = URLComponents()
  460. components.scheme = url.scheme
  461. components.host = url.host
  462. components.port = url.port
  463. components.path = Config.profilePath
  464. var request = URLRequest(url: components.url!)
  465. request.allowsConstrainedNetworkAccess = false
  466. request.timeoutInterval = Config.timeout
  467. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  468. if let secret = secret {
  469. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  470. }
  471. request.httpBody = try! JSONCoding.encoder.encode(preferences)
  472. request.httpMethod = "POST"
  473. return service.run(request)
  474. .retry(Config.retryCount)
  475. .map { _ in () }
  476. .eraseToAnyPublisher()
  477. }
  478. func uploadOverrides(_ overrides: [NightscoutExercise]) async throws {
  479. var components = URLComponents()
  480. components.scheme = url.scheme
  481. components.host = url.host
  482. components.port = url.port
  483. components.path = Config.treatmentsPath
  484. var request = URLRequest(url: components.url!)
  485. request.allowsConstrainedNetworkAccess = false
  486. request.timeoutInterval = Config.timeout
  487. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  488. if let secret = secret {
  489. request.addValue(secret.sha1(), forHTTPHeaderField: "api-secret")
  490. }
  491. do {
  492. let encodedBody = try JSONCoding.encoder.encode(overrides)
  493. request.httpBody = encodedBody
  494. debugPrint("Payload glucose size: \(encodedBody.count) bytes")
  495. debugPrint(String(data: encodedBody, encoding: .utf8) ?? "Invalid payload")
  496. } catch {
  497. debugPrint("Error encoding payload: \(error.localizedDescription)")
  498. throw error
  499. }
  500. request.httpMethod = "POST"
  501. let (data, response) = try await URLSession.shared.data(for: request)
  502. // Check the response status code
  503. guard let httpResponse = response as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
  504. throw URLError(.badServerResponse)
  505. }
  506. debugPrint("Upload successful, response data: \(String(data: data, encoding: .utf8) ?? "No data")")
  507. }
  508. }
  509. private extension String {
  510. func sha1() -> String {
  511. let data = Data(utf8)
  512. var digest = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
  513. data.withUnsafeBytes {
  514. _ = CC_SHA1($0.baseAddress, CC_LONG(data.count), &digest)
  515. }
  516. let hexBytes = digest.map { String(format: "%02hhx", $0) }
  517. return hexBytes.joined()
  518. }
  519. }