ShareClient.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  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 dexcomAuthenticatePath = "/ShareWebServices/Services/General/AuthenticatePublisherAccount"
  34. private let dexcomLoginByIdPath = "/ShareWebServices/Services/General/LoginPublisherAccountById"
  35. private let dexcomLatestGlucosePath = "/ShareWebServices/Services/Publisher/ReadPublisherLatestGlucoseValues"
  36. private let maxReauthAttempts = 2
  37. // TODO use an HTTP library which supports JSON and futures instead of callbacks.
  38. // using cocoapods in a playground appears complicated
  39. // ¯\_(ツ)_/¯
  40. private func dexcomPOST(_ url: URL, JSONData: [String: AnyObject]? = nil, callback: @escaping (Error?, String?) -> Void) {
  41. var data: Data?
  42. if let JSONData = JSONData {
  43. guard let encoded = try? JSONSerialization.data(withJSONObject: JSONData, options:[]) else {
  44. return callback(ShareError.dataError(reason: "Failed to encode JSON for POST to " + url.absoluteString), nil)
  45. }
  46. data = encoded
  47. }
  48. var request = URLRequest(url: url)
  49. request.httpMethod = "POST"
  50. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  51. request.addValue("application/json", forHTTPHeaderField: "Accept")
  52. request.addValue(dexcomUserAgent, forHTTPHeaderField: "User-Agent")
  53. request.httpBody = data
  54. URLSession.shared.dataTask(with: request, completionHandler: { (data, response, error) in
  55. if error != nil {
  56. callback(error, nil)
  57. } else {
  58. callback(nil, String(data: data!, encoding: .utf8))
  59. }
  60. }).resume()
  61. }
  62. public class ShareClient {
  63. public let username: String
  64. public let password: String
  65. private let shareServer:String
  66. private var token: String?
  67. public init(username: String, password: String, shareServer:String=KnownShareServers.US.rawValue) {
  68. self.username = username
  69. self.password = password
  70. self.shareServer = shareServer
  71. }
  72. public convenience init(username: String, password: String, shareServer:KnownShareServers=KnownShareServers.US) {
  73. self.init(username: username, password: password, shareServer:shareServer.rawValue)
  74. }
  75. public func fetchLast(_ n: Int, callback: @escaping (ShareError?, [ShareGlucose]?) -> Void) {
  76. fetchLastWithRetries(n, remaining: maxReauthAttempts, callback: callback)
  77. }
  78. private func ensureToken(_ callback: @escaping (ShareError?) -> Void) {
  79. if token != nil {
  80. callback(nil)
  81. } else {
  82. fetchAccountID { result in
  83. switch result {
  84. case .failure(let error):
  85. callback(error)
  86. case .success(let accountId):
  87. self.fetchTokenByAccountId(accountId) { (error, token) in
  88. if error != nil {
  89. callback(error)
  90. } else {
  91. self.token = token
  92. callback(nil)
  93. }
  94. }
  95. }
  96. }
  97. }
  98. }
  99. private func fetchAccountID(_ callback: @escaping (Result<String, ShareError>) -> Void) {
  100. let data = [
  101. "accountName": username,
  102. "password": password,
  103. "applicationId": dexcomApplicationId
  104. ]
  105. guard let url = URL(string: shareServer + dexcomAuthenticatePath) else {
  106. return callback(.failure(.fetchError))
  107. }
  108. dexcomPOST(url, JSONData: data as [String : AnyObject]?) { (error, response) in
  109. if let error = error {
  110. return callback(.failure(.httpError(error)))
  111. }
  112. guard let response = response,
  113. let data = response.data(using: .utf8),
  114. let decoded = try? JSONSerialization.jsonObject(with: data, options: .allowFragments)
  115. else {
  116. return callback(.failure(.loginError(errorCode: "unknown")))
  117. }
  118. if let token = decoded as? String {
  119. // success is a JSON-encoded string containing the token
  120. callback(.success(token))
  121. } else {
  122. // failure is a JSON object containing the error reason
  123. let errorCode = (decoded as? [String: String])?["Code"] ?? "unknown"
  124. callback(.failure(.loginError(errorCode: errorCode)))
  125. }
  126. }
  127. }
  128. private func fetchTokenByAccountId(_ accountId: String, callback: @escaping (ShareError?, String?) -> Void) {
  129. let data = [
  130. "accountId": accountId,
  131. "password": password,
  132. "applicationId": dexcomApplicationId
  133. ]
  134. guard let url = URL(string: shareServer + dexcomLoginByIdPath) else {
  135. return callback(ShareError.fetchError, nil)
  136. }
  137. dexcomPOST(url, JSONData: data as [String : AnyObject]?) { (error, response) in
  138. if let error = error {
  139. return callback(.httpError(error), nil)
  140. }
  141. guard let response = response,
  142. let data = response.data(using: .utf8),
  143. let decoded = try? JSONSerialization.jsonObject(with: data, options: .allowFragments)
  144. else {
  145. return callback(.loginError(errorCode: "unknown"), nil)
  146. }
  147. if let token = decoded as? String {
  148. // success is a JSON-encoded string containing the token
  149. callback(nil, token)
  150. } else {
  151. // failure is a JSON object containing the error reason
  152. let errorCode = (decoded as? [String: String])?["Code"] ?? "unknown"
  153. callback(.loginError(errorCode: errorCode), nil)
  154. }
  155. }
  156. }
  157. private func fetchLastWithRetries(_ n: Int, remaining: Int, callback: @escaping (ShareError?, [ShareGlucose]?) -> Void) {
  158. ensureToken() { (error) in
  159. guard error == nil else {
  160. return callback(error, nil)
  161. }
  162. guard var components = URLComponents(string: self.shareServer + dexcomLatestGlucosePath) else {
  163. return callback(.fetchError, nil)
  164. }
  165. components.queryItems = [
  166. URLQueryItem(name: "sessionId", value: self.token),
  167. URLQueryItem(name: "minutes", value: String(1440)),
  168. URLQueryItem(name: "maxCount", value: String(n))
  169. ]
  170. guard let url = components.url else {
  171. return callback(.fetchError, nil)
  172. }
  173. dexcomPOST(url) { (error, response) in
  174. if let error = error {
  175. return callback(.httpError(error), nil)
  176. }
  177. do {
  178. guard let response = response else {
  179. throw ShareError.fetchError
  180. }
  181. let decoded = try? JSONSerialization.jsonObject(with: response.data(using: .utf8)!, options: [])
  182. guard let sgvs = decoded as? Array<AnyObject> else {
  183. if remaining > 0 {
  184. self.token = nil
  185. return self.fetchLastWithRetries(n, remaining: remaining - 1, callback: callback)
  186. } else {
  187. throw ShareError.dataError(reason: "Failed to decode SGVs as array after trying to reauth: " + response)
  188. }
  189. }
  190. var transformed: Array<ShareGlucose> = []
  191. for sgv in sgvs {
  192. if let glucose = sgv["Value"] as? Int, let wt = sgv["WT"] as? String {
  193. let trend: Int?
  194. if let trendString = sgv["Trend"] as? String {
  195. // Dec 2021, Dexcom Share modified json encoding of Trend from int to string
  196. let trendmap = ["": 0, "DoubleUp":1, "SingleUp":2, "FortyFiveUp":3, "Flat":4, "FortyFiveDown":5, "SingleDown":6, "DoubleDown": 7, "NotComputable":8, "RateOutOfRange":9]
  197. trend = trendmap[trendString, default: 0]
  198. } else {
  199. trend = sgv["Trend"] as? Int
  200. }
  201. if let trend = trend {
  202. transformed.append(ShareGlucose(
  203. glucose: UInt16(glucose),
  204. trend: UInt8(trend),
  205. timestamp: try self.parseDate(wt)
  206. ))
  207. } else {
  208. throw ShareError.dataError(reason: "Failed to decode. SGV record had bad trend: " + response)
  209. }
  210. } else {
  211. throw ShareError.dataError(reason: "Failed to decode an SGV record: " + response)
  212. }
  213. }
  214. callback(nil, transformed)
  215. } catch let error as ShareError {
  216. callback(error, nil)
  217. } catch {
  218. callback(.fetchError, nil)
  219. }
  220. }
  221. }
  222. }
  223. private func parseDate(_ wt: String) throws -> Date {
  224. // wt looks like "/Date(1462404576000)/"
  225. let re = try NSRegularExpression(pattern: "\\((.*)\\)")
  226. if let match = re.firstMatch(in: wt, range: NSMakeRange(0, wt.count)) {
  227. #if swift(>=4)
  228. let matchRange = match.range(at: 1)
  229. #else
  230. let matchRange = match.rangeAt(1)
  231. #endif
  232. let epoch = Double((wt as NSString).substring(with: matchRange))! / 1000
  233. return Date(timeIntervalSince1970: epoch)
  234. } else {
  235. throw ShareError.dateError
  236. }
  237. }
  238. }