ShareClient.swift 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. //
  2. // ShareClient.h
  3. // ShareClient
  4. //
  5. // Created by Mark Wilson on 5/7/16.
  6. // Copyright © 2016 Mark Wilson. All rights reserved.
  7. //
  8. import Foundation
  9. public struct ShareGlucose {
  10. public let glucose: UInt16
  11. public let trend: UInt8
  12. public let timestamp: Date
  13. }
  14. public enum ShareError: Error {
  15. case httpError(Error)
  16. // some possible values of errorCode:
  17. // SSO_AuthenticateAccountNotFound
  18. // SSO_AuthenticatePasswordInvalid
  19. // SSO_AuthenticateMaxAttemptsExceeed
  20. case loginError(errorCode: String)
  21. case fetchError
  22. case dataError(reason: String)
  23. case dateError
  24. }
  25. public enum KnownShareServers: String {
  26. case US="https://share2.dexcom.com"
  27. case NON_US="https://shareous1.dexcom.com"
  28. }
  29. // From the Dexcom Share iOS app, via @bewest and @shanselman:
  30. // https://github.com/bewest/share2nightscout-bridge
  31. private let dexcomUserAgent = "Dexcom Share/3.0.2.11 CFNetwork/711.2.23 Darwin/14.0.0"
  32. private let dexcomApplicationId = "d89443d2-327c-4a6f-89e5-496bbb0317db"
  33. private let dexcomLoginPath = "/ShareWebServices/Services/General/LoginPublisherAccountByName"
  34. private let dexcomLatestGlucosePath = "/ShareWebServices/Services/Publisher/ReadPublisherLatestGlucoseValues"
  35. private let maxReauthAttempts = 2
  36. // TODO use an HTTP library which supports JSON and futures instead of callbacks.
  37. // using cocoapods in a playground appears complicated
  38. // ¯\_(ツ)_/¯
  39. private func dexcomPOST(_ url: URL, JSONData: [String: AnyObject]? = nil, callback: @escaping (Error?, String?) -> Void) {
  40. var data: Data?
  41. if let JSONData = JSONData {
  42. guard let encoded = try? JSONSerialization.data(withJSONObject: JSONData, options:[]) else {
  43. return callback(ShareError.dataError(reason: "Failed to encode JSON for POST to " + url.absoluteString), nil)
  44. }
  45. data = encoded
  46. }
  47. var request = URLRequest(url: url)
  48. request.httpMethod = "POST"
  49. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  50. request.addValue("application/json", forHTTPHeaderField: "Accept")
  51. request.addValue(dexcomUserAgent, forHTTPHeaderField: "User-Agent")
  52. request.httpBody = data
  53. URLSession.shared.dataTask(with: request, completionHandler: { (data, response, error) in
  54. if error != nil {
  55. callback(error, nil)
  56. } else {
  57. callback(nil, String(data: data!, encoding: .utf8))
  58. }
  59. }).resume()
  60. }
  61. public class ShareClient {
  62. public let username: String
  63. public let password: String
  64. private let shareServer:String
  65. private var token: String?
  66. public init(username: String, password: String, shareServer:String=KnownShareServers.US.rawValue) {
  67. self.username = username
  68. self.password = password
  69. self.shareServer = shareServer
  70. }
  71. public convenience init(username: String, password: String, shareServer:KnownShareServers=KnownShareServers.US) {
  72. self.init(username: username, password: password, shareServer:shareServer.rawValue)
  73. }
  74. public func fetchLast(_ n: Int, callback: @escaping (ShareError?, [ShareGlucose]?) -> Void) {
  75. fetchLastWithRetries(n, remaining: maxReauthAttempts, callback: callback)
  76. }
  77. private func ensureToken(_ callback: @escaping (ShareError?) -> Void) {
  78. if token != nil {
  79. callback(nil)
  80. } else {
  81. fetchToken() { (error, token) in
  82. if error != nil {
  83. callback(error)
  84. } else {
  85. self.token = token
  86. callback(nil)
  87. }
  88. }
  89. }
  90. }
  91. private func fetchToken(_ callback: @escaping (ShareError?, String?) -> Void) {
  92. let data = [
  93. "accountName": username,
  94. "password": password,
  95. "applicationId": dexcomApplicationId
  96. ]
  97. guard let url = URL(string: shareServer + dexcomLoginPath) else {
  98. return callback(ShareError.fetchError, nil)
  99. }
  100. dexcomPOST(url, JSONData: data as [String : AnyObject]?) { (error, response) in
  101. if let error = error {
  102. return callback(.httpError(error), nil)
  103. }
  104. guard let response = response,
  105. let data = response.data(using: .utf8),
  106. let decoded = try? JSONSerialization.jsonObject(with: data, options: .allowFragments)
  107. else {
  108. return callback(.loginError(errorCode: "unknown"), nil)
  109. }
  110. if let token = decoded as? String {
  111. // success is a JSON-encoded string containing the token
  112. callback(nil, token)
  113. } else {
  114. // failure is a JSON object containing the error reason
  115. let errorCode = (decoded as? [String: String])?["Code"] ?? "unknown"
  116. callback(.loginError(errorCode: errorCode), nil)
  117. }
  118. }
  119. }
  120. private func fetchLastWithRetries(_ n: Int, remaining: Int, callback: @escaping (ShareError?, [ShareGlucose]?) -> Void) {
  121. ensureToken() { (error) in
  122. guard error == nil else {
  123. return callback(error, nil)
  124. }
  125. guard var components = URLComponents(string: self.shareServer + dexcomLatestGlucosePath) else {
  126. return callback(.fetchError, nil)
  127. }
  128. components.queryItems = [
  129. URLQueryItem(name: "sessionId", value: self.token),
  130. URLQueryItem(name: "minutes", value: String(1440)),
  131. URLQueryItem(name: "maxCount", value: String(n))
  132. ]
  133. guard let url = components.url else {
  134. return callback(.fetchError, nil)
  135. }
  136. dexcomPOST(url) { (error, response) in
  137. if let error = error {
  138. return callback(.httpError(error), nil)
  139. }
  140. do {
  141. guard let response = response else {
  142. throw ShareError.fetchError
  143. }
  144. let decoded = try? JSONSerialization.jsonObject(with: response.data(using: .utf8)!, options: [])
  145. guard let sgvs = decoded as? Array<AnyObject> else {
  146. if remaining > 0 {
  147. self.token = nil
  148. return self.fetchLastWithRetries(n, remaining: remaining - 1, callback: callback)
  149. } else {
  150. throw ShareError.dataError(reason: "Failed to decode SGVs as array after trying to reauth: " + response)
  151. }
  152. }
  153. var transformed: Array<ShareGlucose> = []
  154. for sgv in sgvs {
  155. if let glucose = sgv["Value"] as? Int, let wt = sgv["WT"] as? String {
  156. let trend: Int?
  157. if let trendString = sgv["Trend"] as? String {
  158. // Dec 2021, Dexcom Share modified json encoding of Trend from int to string
  159. let trendmap = ["": 0, "DoubleUp":1, "SingleUp":2, "FortyFiveUp":3, "Flat":4, "FortyFiveDown":5, "SingleDown":6, "DoubleDown": 7, "NotComputable":8, "RateOutOfRange":9]
  160. trend = trendmap[trendString, default: 0]
  161. } else {
  162. trend = sgv["Trend"] as? Int
  163. }
  164. if let trend = trend {
  165. transformed.append(ShareGlucose(
  166. glucose: UInt16(glucose),
  167. trend: UInt8(trend),
  168. timestamp: try self.parseDate(wt)
  169. ))
  170. } else {
  171. throw ShareError.dataError(reason: "Failed to decode. SGV record had bad trend: " + response)
  172. }
  173. } else {
  174. throw ShareError.dataError(reason: "Failed to decode an SGV record: " + response)
  175. }
  176. }
  177. callback(nil, transformed)
  178. } catch let error as ShareError {
  179. callback(error, nil)
  180. } catch {
  181. callback(.fetchError, nil)
  182. }
  183. }
  184. }
  185. }
  186. private func parseDate(_ wt: String) throws -> Date {
  187. // wt looks like "/Date(1462404576000)/"
  188. let re = try NSRegularExpression(pattern: "\\((.*)\\)")
  189. if let match = re.firstMatch(in: wt, range: NSMakeRange(0, wt.count)) {
  190. #if swift(>=4)
  191. let matchRange = match.range(at: 1)
  192. #else
  193. let matchRange = match.rangeAt(1)
  194. #endif
  195. let epoch = Double((wt as NSString).substring(with: matchRange))! / 1000
  196. return Date(timeIntervalSince1970: epoch)
  197. } else {
  198. throw ShareError.dateError
  199. }
  200. }
  201. }