Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion LoopFollow/Controllers/Nightscout/NSProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ struct NSProfile: Decodable {
let target_low: [TargetEntry]?
let timezone: String

let units: String
/// Missing when the Nightscout profile editor saved the document; the document's `units` apply then.
let units: String?
}

let store: [String: Store]
Expand Down Expand Up @@ -88,6 +89,16 @@ struct NSProfile: Decodable {

let trioOverrides: [TrioOverrideEntry]?
let loopSettings: LoopSettings?
/// Bounds the uploader accepts for remotely edited profiles; nil when missing or malformed.
let profileEditLimits: LenientDecoded<ProfileEditLimits>?
/// The weekly profile starts; an entry that can not be read is nil, and so is a value that is not a list.
let scheduledSwitches: LenientDecoded<[LenientDecoded<ScheduledProfileSwitch>]>?
let scheduledSwitchesFingerprint: String?

/// The glucose units of a store entry: its own, or the document's.
func units(of store: Store) -> String {
store.units ?? units
}

enum CodingKeys: String, CodingKey {
case store
Expand All @@ -103,5 +114,32 @@ struct NSProfile: Decodable {
case startDate
case createdAt = "created_at"
case remoteCommands
case profileEditLimits
case scheduledSwitches
case scheduledSwitchesFingerprint
}
}

extension NSProfile {
/// A store entry that can not be read is left out, so one malformed profile does not hide the others.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let entries = try container.decode([String: LenientDecoded<Store>].self, forKey: .store)
store = entries.compactMapValues(\.value)
defaultProfile = try container.decode(String.self, forKey: .defaultProfile)
units = try container.decodeIfPresent(String.self, forKey: .units) ?? TherapyProfileValues.mgdl
bundleIdentifier = try? container.decodeIfPresent(String.self, forKey: .bundleIdentifier)
isAPNSProduction = try? container.decodeIfPresent(Bool.self, forKey: .isAPNSProduction)
deviceToken = try? container.decodeIfPresent(String.self, forKey: .deviceToken)
teamID = try? container.decodeIfPresent(String.self, forKey: .teamID)
expirationDate = try? container.decodeIfPresent(String.self, forKey: .expirationDate)
startDate = try? container.decodeIfPresent(String.self, forKey: .startDate)
createdAt = try? container.decodeIfPresent(String.self, forKey: .createdAt)
remoteCommands = try? container.decodeIfPresent([String].self, forKey: .remoteCommands)
trioOverrides = try? container.decodeIfPresent([TrioOverrideEntry].self, forKey: .trioOverrides)
loopSettings = try? container.decodeIfPresent(LoopSettings.self, forKey: .loopSettings)
profileEditLimits = try container.decodeIfPresent(LenientDecoded<ProfileEditLimits>.self, forKey: .profileEditLimits)
scheduledSwitches = try container.decodeIfPresent(LenientDecoded<[LenientDecoded<ScheduledProfileSwitch>]>.self, forKey: .scheduledSwitches)
scheduledSwitchesFingerprint = try? container.decodeIfPresent(String.self, forKey: .scheduledSwitchesFingerprint)
}
}
1 change: 1 addition & 0 deletions LoopFollow/Controllers/Nightscout/Profile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ extension MainViewController {
// NS Profile Response Processor
func updateProfile(profileData: NSProfile) {
guard let store = profileData.store["default"] ?? profileData.store["Default"] else {
LogManager.shared.log(category: .nightscout, message: "Profile document not loaded, its Default entry could not be read")
return
}
profileManager.loadProfile(from: profileData)
Expand Down
7 changes: 4 additions & 3 deletions LoopFollow/Controllers/Nightscout/ProfileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,11 @@ final class ProfileManager {

func loadProfile(from profileData: NSProfile) {
guard let store = profileData.store[profileData.defaultProfile] else {
LogManager.shared.log(category: .nightscout, message: "Profile document not loaded, its active entry could not be read")
return
}

units = store.units.lowercased() == "mg/dl" ? .milligramsPerDeciliter : .millimolesPerLiter
units = profileData.units(of: store).lowercased() == "mg/dl" ? .milligramsPerDeciliter : .millimolesPerLiter
defaultProfile = profileData.defaultProfile

timezone = getTimeZone(from: store.timezone)
Expand Down Expand Up @@ -126,7 +127,7 @@ final class ProfileManager {

Storage.shared.teamId.value = profileData.teamID ?? Storage.shared.teamId.value ?? ""
Storage.shared.remoteCommands.value = profileData.remoteCommands ?? []
Observable.shared.loopProfiles.set(LoopProfileSet(profileData))
Observable.shared.therapyProfiles.set(TherapyProfileSet(profileData))
}

func currentISF() -> HKQuantity? {
Expand Down Expand Up @@ -268,6 +269,6 @@ final class ProfileManager {
units = .millimolesPerLiter
timezone = TimeZone.current
defaultProfile = ""
Observable.shared.loopProfiles.set(nil)
Observable.shared.therapyProfiles.set(nil)
}
}
40 changes: 37 additions & 3 deletions LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ struct LoopAPNSRemoteView: View {
@Environment(\.presentationMode) var presentationMode
@StateObject private var viewModel = RemoteSettingsViewModel()
@ObservedObject private var activeOverrideNote = Observable.shared.override
@ObservedObject private var remoteCommands = Storage.shared.remoteCommands
@ObservedObject private var loopRemoteCommands = Storage.shared.loopRemoteCommands

var body: some View {
Expand All @@ -23,14 +24,14 @@ struct LoopAPNSRemoteView: View {
CommandButtonView(command: "Meal", iconName: "fork.knife", destination: LoopAPNSCarbsView())
CommandButtonView(command: "Bolus", iconName: "syringe", destination: LoopAPNSBolusView())
CommandButtonView(command: "Overrides", iconName: "slider.horizontal.3", destination: OverridePresetsView(), isActive: activeOverrideNote.value != nil)
if loopRemoteCommands.value.contains(LoopProfile.switchCommand) {
CommandButtonView(command: "Profiles", iconName: "arrow.triangle.2.circlepath", destination: LoopProfilesView())
if ProfileRemote.loop.supportsAny(in: ProfileRemote.loop.acceptedCommands(profileDocument: remoteCommands.value, loopDeviceStatus: loopRemoteCommands.value)) {
CommandButtonView(command: "Profiles", iconName: "arrow.triangle.2.circlepath", destination: TherapyProfilesView(remote: .loop))
} else {
UnavailableCommandButtonView(
command: "Profiles",
iconName: "arrow.triangle.2.circlepath",
title: "Profiles Not Available",
message: "Switching Loop profiles from LoopFollow needs Loop built with the Profile Save & Load customization, added with the Loop and Learn Customization Select script."
message: "Loop has not said it accepts profile commands. It does once it has a pump and a maximum basal rate set up and can receive remote commands. An older Loop also needs the Profile Save & Load customization, added with the Loop and Learn Customization Select script."
)
}
}
Expand Down Expand Up @@ -80,3 +81,36 @@ struct LoopAPNSRemoteView: View {
}
}
}

/// A remote command button for a command the connected Loop build does not offer.
struct UnavailableCommandButtonView: View {
let command: String
let iconName: String
let title: String
let message: String

@State private var showInfo = false

var body: some View {
Button {
showInfo = true
} label: {
VStack {
Image(systemName: iconName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 50, height: 50)
Text(command)
}
.frame(maxWidth: .infinity, minHeight: 100)
.padding()
.background(Color.gray.opacity(0.35))
.foregroundColor(.white)
.cornerRadius(8)
}
.buttonStyle(PlainButtonStyle())
.alert(isPresented: $showInfo) {
Alert(title: Text(title), message: Text(message), dismissButton: .default(Text("OK")))
}
}
}
75 changes: 71 additions & 4 deletions LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,30 @@ class LoopAPNSService {
}

/// Sends a command whose only confirmation is Loop's return push, so the encrypted return block is mandatory.
private func sendConfirmedCommand(_ payload: [String: Any], otp: String, completion: @escaping (Bool, String?) -> Void) {
guard let encryptedReturnInfo = encryptedReturnNotification(otp: otp) else {
/// `tooLargeMessage` explains a push over the APNs size limit; such a push is never sent.
private func sendConfirmedCommand(
_ payload: [String: Any],
otp: String,
tooLargeMessage: ((Int) -> String)? = nil,
completion: @escaping (Bool, String?) -> Void
) {
guard let payload = confirmedPayload(payload, otp: otp) else {
completion(false, Self.returnNotificationRequiredMessage)
return
}
if let tooLargeMessage, let size = try? LoopAPNSCommands.apnsBody(payload).count, size > APNSDelivery.maxPayloadBytes {
LogManager.shared.log(category: .apns, message: "Command not sent: \(size) bytes")
completion(false, tooLargeMessage(size))
return
}
sendRemoteCommand(payload, completion: completion)
}

private func confirmedPayload(_ payload: [String: Any], otp: String) -> [String: Any]? {
guard let encryptedReturnInfo = encryptedReturnNotification(otp: otp) else { return nil }
var payload = payload
payload["encrypted_return_notification"] = encryptedReturnInfo
sendRemoteCommand(payload, completion: completion)
return payload
}

/// Deletes the Loop carb entry with this `syncIdentifier`. Requires the Loop remote carb edit patch.
Expand Down Expand Up @@ -163,10 +179,61 @@ class LoopAPNSService {

/// Switches Loop to the saved profile `name`. Requires the Loop remote profile patch.
func sendProfileSwitch(name: String, fingerprint: String, otp: String, completion: @escaping (Bool, String?) -> Void) {
LogManager.shared.log(category: .apns, message: "Sending profile switch to \(name) fingerprint=\(fingerprint)")
LogManager.shared.log(category: .apns, message: "Sending profile switch fingerprint=\(fingerprint)")
sendConfirmedCommand(LoopAPNSCommands.profileSwitchCommandPayload(name: name, fingerprint: fingerprint, otp: otp), otp: otp, completion: completion)
}

/// Creates, updates or renames a Loop profile. Requires a Loop that lists `profile-save` in its remote commands.
func sendProfileSave(
name: String,
originalName: String?,
baseFingerprint: String?,
values: TherapyProfileValues,
otp: String,
completion: @escaping (Bool, String?) -> Void
) {
LogManager.shared.log(category: .apns, message: "Sending profile save base=\(baseFingerprint ?? "-") new=\(values.fingerprint)")
sendConfirmedCommand(
LoopAPNSCommands.profileSaveCommandPayload(name: name, originalName: originalName, baseFingerprint: baseFingerprint, values: values, otp: otp),
otp: otp,
tooLargeMessage: ProfilePushSize.tooLargeMessage,
completion: completion
)
}

func sendProfileDelete(name: String, fingerprint: String, otp: String, completion: @escaping (Bool, String?) -> Void) {
LogManager.shared.log(category: .apns, message: "Sending profile delete fingerprint=\(fingerprint)")
sendConfirmedCommand(LoopAPNSCommands.profileDeleteCommandPayload(name: name, fingerprint: fingerprint, otp: otp), otp: otp, completion: completion)
}

/// Replaces Loop's scheduled switches. Requires a Loop that lists `profile-scheduled-switches-save` in its remote commands.
func sendScheduledSwitchesSave(entries: [ScheduledProfileSwitch], baseFingerprint: String, otp: String, completion: @escaping (Bool, String?) -> Void) {
LogManager.shared.log(category: .apns, message: "Sending scheduled switches save base=\(baseFingerprint) new=\(ScheduledProfileSwitch.fingerprint(entries))")
sendConfirmedCommand(
LoopAPNSCommands.scheduledSwitchesSaveCommandPayload(entries: entries, baseFingerprint: baseFingerprint, otp: otp),
otp: otp,
tooLargeMessage: ScheduledSwitchesPushSize.tooLargeMessage,
completion: completion
)
}

/// The size of the push a scheduled switches save would send. Nil when the push can not be built.
func scheduledSwitchesSavePushSize(entries: [ScheduledProfileSwitch], baseFingerprint: String) -> Int? {
let otp = "000000"
let payload = LoopAPNSCommands.scheduledSwitchesSaveCommandPayload(entries: entries, baseFingerprint: baseFingerprint, otp: otp)
guard let confirmed = confirmedPayload(payload, otp: otp) else { return nil }
return try? LoopAPNSCommands.apnsBody(confirmed).count
}

/// The size of the push a profile save would send, with a stand-in one-time code of the same length.
/// Nil when the push can not be built (LoopFollow's own APNs credentials are missing).
func profileSavePushSize(name: String, originalName: String?, baseFingerprint: String?, values: TherapyProfileValues) -> Int? {
let otp = "000000"
let payload = LoopAPNSCommands.profileSaveCommandPayload(name: name, originalName: originalName, baseFingerprint: baseFingerprint, values: values, otp: otp)
guard let confirmed = confirmedPayload(payload, otp: otp) else { return nil }
return try? LoopAPNSCommands.apnsBody(confirmed).count
}

/// Sends carbs via APNS push notification
/// - Parameters:
/// - payload: The carbs payload to send
Expand Down
108 changes: 0 additions & 108 deletions LoopFollow/Remote/LoopAPNS/LoopProfile.swift

This file was deleted.

Loading