Skip to content

Commit 3585e0f

Browse files
authored
Merge pull request LoopKit#8 from Kdisimone/ns-profile-update
Ns profile update
2 parents af2d85d + 2912ac8 commit 3585e0f

File tree

3 files changed

+339
-0
lines changed

3 files changed

+339
-0
lines changed

Loop/Extensions/NSUserDefaults.swift

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import LoopKit
1111
import InsulinKit
1212
import MinimedKit
1313
import HealthKit
14+
import NightscoutUploadKit
1415

1516
extension UserDefaults {
1617

@@ -33,6 +34,7 @@ extension UserDefaults {
3334
case pumpModelNumber = "com.loudnate.Naterade.PumpModelNumber"
3435
case pumpRegion = "com.loopkit.Loop.PumpRegion"
3536
case pumpTimeZone = "com.loudnate.Naterade.PumpTimeZone"
37+
case lastUploadedNightscoutProfile = "com.loopkit.Loop.lastUploadedNightscoutProfile"
3638
}
3739

3840
var basalRateSchedule: BasalRateSchedule? {
@@ -343,3 +345,62 @@ extension UserDefaults {
343345
}
344346

345347
}
348+
349+
/// Code adopted from @trixing for automatic uploading of NS profile using Loop settings
350+
351+
extension UserDefaults {
352+
353+
var lastUploadedNightscoutProfile: String {
354+
get {
355+
return string(forKey: Key.lastUploadedNightscoutProfile.rawValue) ?? "{}"
356+
}
357+
set {
358+
set(newValue, forKey: Key.lastUploadedNightscoutProfile.rawValue)
359+
}
360+
}
361+
362+
func uploadProfile(uploader: NightscoutUploader, retry: Int = 0) {
363+
// TODO: Check last upload date, and only upload on demand.
364+
guard let glucoseTargetRangeSchedule = loopSettings?.glucoseTargetRangeSchedule,
365+
let insulinSensitivitySchedule = insulinSensitivitySchedule,
366+
let carbRatioSchedule = carbRatioSchedule,
367+
let basalRateSchedule = basalRateSchedule
368+
369+
else {
370+
return
371+
}
372+
if retry > 5 {
373+
return
374+
}
375+
let profile = NightscoutProfile(
376+
timestamp: Date(),
377+
name: "Loop",
378+
rangeSchedule: glucoseTargetRangeSchedule,
379+
sensitivity: insulinSensitivitySchedule,
380+
carbs: carbRatioSchedule,
381+
basal : basalRateSchedule,
382+
timezone : TimeZone.current,
383+
dia : (insulinModelSettings?.model.effectDuration ?? 0) / 3600,
384+
settings : loopSettings?.rawValue ?? [:]
385+
)
386+
if profile.json != lastUploadedNightscoutProfile {
387+
uploader.uploadProfile(profile) { (result) in
388+
switch result {
389+
case .failure(let error):
390+
print("uploadProfile failed, try \(retry)", error as Any)
391+
// Try again with linear backoff
392+
let retries = retry + 1
393+
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Double(300 * retries) ) {
394+
self.uploadProfile(uploader: uploader, retry: retries)
395+
}
396+
case .success(_):
397+
if let json = profile.json {
398+
self.lastUploadedNightscoutProfile = json
399+
}
400+
}
401+
}
402+
}
403+
}
404+
405+
}
406+

Loop/Extensions/NightscoutUploader.swift

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import CoreData
1010
import InsulinKit
1111
import MinimedKit
1212
import NightscoutUploadKit
13+
import LoopKit
1314

1415

1516
extension 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+

Loop/View Controllers/SettingsTableViewController.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu
7070
super.viewDidDisappear(animated)
7171

7272
dataManager.rileyLinkManager.setDeviceScanningEnabled(false)
73+
// TODO: Do not upload every time we exit settings. Perhaps have a check for change?
74+
if let uploader = dataManager.remoteDataManager.nightscoutService.uploader {
75+
UserDefaults.standard.uploadProfile(uploader: uploader)
76+
}
7377
}
7478

7579
deinit {

0 commit comments

Comments
 (0)