@@ -10,6 +10,7 @@ import CoreData
1010import InsulinKit
1111import MinimedKit
1212import NightscoutUploadKit
13+ import LoopKit
1314
1415
1516extension NightscoutUploader : CarbStoreSyncDelegate {
@@ -83,3 +84,276 @@ extension NightscoutUploader {
8384 }
8485 }
8586}
87+
88+ /// Code adopted from @trixing for uploading Loop settings to NS profile
89+
90+ private let defaultNightscoutProfilePath = " /api/v1/profile "
91+
92+ class NightscoutTimeFormat : NSObject {
93+ private static var formatterISO8601 : DateFormatter {
94+ let formatter = DateFormatter ( )
95+ formatter. calendar = Calendar ( identifier: Calendar . Identifier. iso8601)
96+ formatter. locale = Locale ( identifier: " en_US_POSIX " )
97+ formatter. timeZone = TimeZone ( secondsFromGMT: 0 )
98+ formatter. dateFormat = " yyyy-MM-dd'T'HH:mm:ssX "
99+
100+ return formatter
101+ }
102+
103+ static func timestampStrFromDate( _ date: Date ) -> String {
104+ return formatterISO8601. string ( from: date)
105+ }
106+ }
107+
108+ public class NightscoutProfile {
109+
110+ let timestamp : Date
111+ let name : String
112+ let rangeSchedule : GlucoseRangeSchedule
113+ let sensitivity : InsulinSensitivitySchedule
114+ let carbs : CarbRatioSchedule
115+ let basal : BasalRateSchedule
116+ let timezone : String
117+ let dia : Double
118+ let settings : [ String : Any ]
119+
120+ public init ( timestamp: Date , name: String , rangeSchedule: GlucoseRangeSchedule ,
121+ sensitivity: InsulinSensitivitySchedule ,
122+ carbs: CarbRatioSchedule ,
123+ basal : BasalRateSchedule ,
124+ timezone : TimeZone ,
125+ dia : Double ,
126+ settings : [ String : Any ] = [ : ]
127+ ) {
128+ self . timestamp = timestamp
129+ self . name = name
130+ self . rangeSchedule = rangeSchedule
131+ self . sensitivity = sensitivity
132+ self . carbs = carbs
133+ self . basal = basal
134+ self . timezone = timezone. identifier
135+ self . dia = dia
136+ self . settings = settings
137+ }
138+
139+ private func formatItem( _ time: TimeInterval , _ value: Any ) -> [ String : Any ] {
140+ let hours = Int ( time / 3600 )
141+ let minutes = ( time / 60 ) . truncatingRemainder ( dividingBy: 60 )
142+ var rep : [ String : Any ] = [ : ]
143+ rep [ " time " ] = String ( format: " %02i:%02i " , hours, minutes)
144+ rep [ " value " ] = value
145+ rep [ " timeAsSeconds " ] = Int ( time)
146+ return rep
147+ }
148+
149+ public var json : String ? {
150+ do {
151+ var dict = dictionaryRepresentation
152+ dict [ " created_at " ] = " <blanked> "
153+ dict [ " startDate " ] = " <blanked> "
154+ let data = try JSONSerialization . data ( withJSONObject: dict, options: [ . prettyPrinted, . sortedKeys] )
155+ if let encodedData = String ( data: data, encoding: . utf8) {
156+ print ( " NightscoutProfile string " , encodedData)
157+ return encodedData
158+ }
159+ } catch ( let error) {
160+ print ( " NightscoutProfile encoding to json error " , error)
161+ }
162+ return nil
163+ }
164+
165+ public var dictionaryRepresentation : [ String : Any ] {
166+ var profile : [ String : Any ] = [ : ]
167+ profile [ " dia " ] = self . dia
168+ profile [ " carbs_hr " ] = " 0 "
169+ profile [ " delay " ] = " 0 "
170+
171+ profile [ " timezone " ] = timezone
172+
173+ var target_low = [ [ String: Any] ] ( )
174+ var target_high = [ [ String: Any] ] ( )
175+ for item in self . rangeSchedule. items {
176+ target_low. append ( formatItem ( item. startTime, item. value. minValue) )
177+ target_high. append ( formatItem ( item. startTime, item. value. maxValue) )
178+ }
179+ profile [ " target_low " ] = target_low
180+ profile [ " target_high " ] = target_high
181+
182+ var sens = [ [ String: Any] ] ( )
183+ for item in self . sensitivity. items {
184+ sens. append ( formatItem ( item. startTime, item. value) )
185+ }
186+ profile [ " sens " ] = sens
187+
188+ var basal = [ [ String: Any] ] ( )
189+ for item in self . basal. items {
190+ basal. append ( formatItem ( item. startTime, item. value) )
191+ }
192+ profile [ " basal " ] = basal
193+
194+ var carbratio = [ [ String: Any] ] ( )
195+ for item in self . carbs. items {
196+ carbratio. append ( formatItem ( item. startTime, item. value) )
197+ }
198+ profile [ " carbratio " ] = carbratio
199+
200+ var store : [ String : Any ] = [ : ]
201+ let profileName = " Default "
202+ store [ profileName] = profile
203+
204+ var rval : [ String : Any ] = [ : ]
205+
206+ rval [ " defaultProfile " ] = profileName
207+ rval [ " mills " ] = " 0 " // ?
208+ rval [ " units " ] = self . rangeSchedule. unit. glucoseUnitDisplayString
209+ rval [ " startDate " ] = NightscoutTimeFormat . timestampStrFromDate ( timestamp)
210+ rval [ " created_at " ] = NightscoutTimeFormat . timestampStrFromDate ( timestamp)
211+ rval [ " enteredBy " ] = " Loop "
212+ rval [ " store " ] = store
213+ var settings = self . settings
214+ settings. removeValue ( forKey: " glucoseTargetRangeSchedule " )
215+ rval [ " loopSettings " ] = settings
216+ return rval
217+ }
218+ }
219+
220+ extension NightscoutUploader {
221+
222+ public func uploadProfile( _ profile: NightscoutProfile , completion: @escaping ( Either < [ String ] , Error > ) -> Void ) {
223+ let inFlight = [ profile]
224+
225+ profilePostToNS ( inFlight. map ( { $0. dictionaryRepresentation} ) , endpoint: defaultNightscoutProfilePath) { ( result) in
226+ switch result {
227+ case . failure( let error) :
228+ self . errorHandler ? ( error, " Uploading nightscout profile records " )
229+ // Requeue
230+ //self.treatmentsQueue.append(contentsOf: inFlight)
231+ case . success( _) :
232+ //if let last = inFlight.last {
233+ // self.lastStoredTreatmentTimestamp = last.timestamp
234+ //}
235+ break
236+ }
237+ completion ( result)
238+ }
239+ }
240+
241+ // Blunt copies but internal protection level makes them inaccessible
242+ func profilePostToNS( _ json: [ Any ] , endpoint: String , completion: @escaping ( Either < [ String ] , Error > ) -> Void ) {
243+ if json. count == 0 {
244+ completion ( . success( [ ] ) )
245+ return
246+ }
247+
248+ profileCallNS ( json, endpoint: endpoint, method: " POST " ) { ( result) in
249+ switch result {
250+ case . success( let json) :
251+ guard let insertedEntries = json as? [ [ String : Any ] ] else {
252+ completion ( . failure( UploadError . invalidResponse ( reason: " Expected array of objects in JSON response " ) ) )
253+ return
254+ }
255+
256+ let ids = insertedEntries. map ( { ( entry: [ String : Any ] ) -> String in
257+ if let id = entry [ " _id " ] as? String {
258+ return id
259+ } else {
260+ // Upload still succeeded; likely that this is an old version of NS
261+ // Instead of failing (which would cause retries later, we just mark
262+ // This entry has having an id of 'NA', which will let us consider it
263+ // uploaded.
264+ //throw UploadError.invalidResponse(reason: "Invalid/missing id in response.")
265+ return " NA "
266+ }
267+ } )
268+ completion ( . success( ids) )
269+ case . failure( let error) :
270+ completion ( . failure( error) )
271+ }
272+
273+ }
274+ }
275+
276+ func profileCallNS( _ json: Any ? , endpoint: String , method: String , completion: @escaping ( Either < Any , Error > ) -> Void ) {
277+ let uploadURL = siteURL. appendingPathComponent ( endpoint)
278+ var request = URLRequest ( url: uploadURL)
279+ request. httpMethod = method
280+ request. setValue ( " application/json " , forHTTPHeaderField: " Content-Type " )
281+ request. setValue ( " application/json " , forHTTPHeaderField: " Accept " )
282+ request. setValue ( apiSecret. sha1, forHTTPHeaderField: " api-secret " )
283+
284+ do {
285+
286+ if let json = json {
287+ let sendData = try JSONSerialization . data ( withJSONObject: json, options: [ ] )
288+ let task = URLSession . shared. uploadTask ( with: request, from: sendData, completionHandler: { ( data, response, error) in
289+ if let error = error {
290+ completion ( . failure( error) )
291+ return
292+ }
293+
294+ guard let httpResponse = response as? HTTPURLResponse else {
295+ completion ( . failure( UploadError . invalidResponse ( reason: " Response is not HTTPURLResponse " ) ) )
296+ return
297+ }
298+
299+ if httpResponse. statusCode != 200 {
300+ let error = UploadError . httpError ( status: httpResponse. statusCode, body: String ( data: data!, encoding: String . Encoding. utf8) !)
301+ completion ( . failure( error) )
302+ return
303+ }
304+
305+ guard let data = data else {
306+ completion ( . failure( UploadError . invalidResponse ( reason: " No data in response " ) ) )
307+ return
308+ }
309+
310+ do {
311+ let json = try JSONSerialization . jsonObject ( with: data, options: JSONSerialization . ReadingOptions ( ) )
312+ completion ( . success( json) )
313+ } catch {
314+ completion ( . failure( error) )
315+ return
316+ }
317+ } )
318+ task. resume ( )
319+ } else {
320+ let task = URLSession . shared. dataTask ( with: request, completionHandler: { ( data, response, error) in
321+ if let error = error {
322+ completion ( . failure( error) )
323+ return
324+ }
325+
326+ guard let httpResponse = response as? HTTPURLResponse else {
327+ completion ( . failure( UploadError . invalidResponse ( reason: " Response is not HTTPURLResponse " ) ) )
328+ return
329+ }
330+
331+ if httpResponse. statusCode != 200 {
332+ let error = UploadError . httpError ( status: httpResponse. statusCode, body: String ( data: data!, encoding: String . Encoding. utf8) !)
333+ completion ( . failure( error) )
334+ return
335+ }
336+
337+ guard let data = data else {
338+ completion ( . failure( UploadError . invalidResponse ( reason: " No data in response " ) ) )
339+ return
340+ }
341+
342+ do {
343+ let json = try JSONSerialization . jsonObject ( with: data, options: JSONSerialization . ReadingOptions ( ) )
344+ completion ( . success( json) )
345+ } catch {
346+ completion ( . failure( error) )
347+ return
348+ }
349+ } )
350+ task. resume ( )
351+ }
352+
353+ } catch let error {
354+ completion ( . failure( error) )
355+ }
356+ }
357+
358+ }
359+
0 commit comments