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
1 change: 1 addition & 0 deletions Common/Locked.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public class Locked<T> {
}
}

@discardableResult
public func mutate(_ changes: (_ value: inout T) -> Void) -> T {
os_unfair_lock_lock(&lock)
defer { os_unfair_lock_unlock(&lock) }
Expand Down
712 changes: 358 additions & 354 deletions G6SensorKit.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1B26058BD31E40741B0058A5"
BlueprintIdentifier = "215CD1147465A2E980A94D63"
BuildableName = "G6SensorKit.framework"
BlueprintName = "G6SensorKit"
ReferencedContainer = "container:G6SensorKit.xcodeproj">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EDCCBEADE99E54A732DD551F"
BlueprintIdentifier = "D09D95523AA824744122C1CB"
BuildableName = "G6SensorKitPlugin.loopplugin"
BlueprintName = "G6SensorKitPlugin"
ReferencedContainer = "container:G6SensorKit.xcodeproj">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3ADDB0CD055A33C01C7B54FE"
BlueprintIdentifier = "8F3461A960A54D2320B21FE4"
BuildableName = "G6SensorKitUI.framework"
BlueprintName = "G6SensorKitUI"
ReferencedContainer = "container:G6SensorKit.xcodeproj">
Expand Down
134 changes: 62 additions & 72 deletions G6SensorKit/G6CGMManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,7 @@ public final class G6CGMManager: CGMManager {
public private(set) var session: TransmitterSession?

/// Commands queued for the next connection cycle.
private let lockedPendingCommands: Locked<[Command]> = Locked([])

private var lastBatteryReadDate: Date?
private let lockedCommandQueue: Locked<CommandQueue> = Locked(CommandQueue())

/// Response code from a session start awaiting confirmation. The reply
/// alone does not say whether a session began, so it is held until the
Expand Down Expand Up @@ -134,7 +132,7 @@ public final class G6CGMManager: CGMManager {

public init(state: G6CGMManagerState) {
self.lockedState = Locked(state)
lockedPendingCommands.mutate { $0 = state.pendingCommands.compactMap(Command.init(rawValue:)) }
lockedCommandQueue.mutate { $0 = CommandQueue(rawValues: state.pendingCommands) }
startSession()
}

Expand All @@ -151,7 +149,7 @@ public final class G6CGMManager: CGMManager {
}

self.lockedState = Locked(state)
lockedPendingCommands.mutate { $0 = state.pendingCommands.compactMap(Command.init(rawValue:)) }
lockedCommandQueue.mutate { $0 = CommandQueue(rawValues: state.pendingCommands) }
startSession()
}

Expand Down Expand Up @@ -304,22 +302,16 @@ public final class G6CGMManager: CGMManager {
// is persisted, a stale code-less start could reach the transmitter
// ahead of the one carrying the sensor code the user actually typed.
var superseded = 0
lockedPendingCommands.mutate { commands in
let before = commands.count
commands.removeAll { $0.supersededBy(command) }
superseded = before - commands.count
commands.append(command)
lockedCommandQueue.mutate { queue in
superseded = queue.enqueue(command)
}

if superseded > 0 {
log.default("Replaced %d queued command(s) with %{public}@", superseded, String(describing: command))
}

mutateState { state in
state.pendingCommands.removeAll { raw in
Command(rawValue: raw).map { $0.supersededBy(command) } ?? true
}
state.pendingCommands.append(command.rawValue)
state.pendingCommands = self.lockedCommandQueue.value.rawValues

// Reflect the entered code straight away. Waiting until the
// transmitter answered meant the screen said "Not used" for as
Expand All @@ -332,6 +324,38 @@ public final class G6CGMManager: CGMManager {

// Nudge the connection in case it is idle.
session?.start()

#if targetEnvironment(simulator)
// No link will ever drain the queue in the simulator; apply the
// command to state directly, mirroring didComplete on hardware,
// so End Sensor / Start New Sensor work end-to-end there.
lockedCommandQueue.mutate { _ = $0.dequeue() }
if case .stopSensor = command, state.sensorStartDate != nil {
let sensorEnded = PersistedCgmEvent(
date: Date(),
type: .sensorEnd,
deviceIdentifier: state.transmitterID,
failureMessage: LocalizedString("Stopped by user", comment: "Reason recorded when a session ends because the user stopped it")
)
delegate.notify { delegate in
delegate?.cgmManager(self, hasNew: [sensorEnded])
}
}
mutateState { state in
state.pendingCommands = self.lockedCommandQueue.value.rawValues
switch command {
case .stopSensor:
state.sensorStartDate = nil
state.sensorCode = nil
case .startSensor(let date, let sensorCode):
state.sensorCode = sensorCode.carriesParameters ? sensorCode.code : nil
state.sensorStartDate = date
.addingTimeInterval(-(state.warmupPeriod - G6CGMManager.simulatedWarmupRemaining))
case .calibrateSensor, .resetTransmitter:
break
}
}
#endif
}

public func setSensorLifeDays(_ days: Int) {
Expand Down Expand Up @@ -383,7 +407,7 @@ public final class G6CGMManager: CGMManager {
if connectionPhase == .connected {
log.default("Old transmitter still connected; sending session stop before switching")
session?.commandSource = self
lockedPendingCommands.mutate { $0 = [.stopSensor(at: Date())] }
lockedCommandQueue.mutate { $0 = CommandQueue([.stopSensor(at: Date())]) }
} else {
log.default("Old transmitter not connected; its session is left as-is")
}
Expand All @@ -407,7 +431,7 @@ public final class G6CGMManager: CGMManager {
// middle of the state reset. The session itself is kept and pointed at
// the new transmitter — the radio does not need rebuilding for a swap.
session?.stop()
lockedPendingCommands.mutate { $0.removeAll() }
lockedCommandQueue.mutate { $0.removeAll() }
raisedAlerts = []

mutateState { state in
Expand Down Expand Up @@ -530,9 +554,9 @@ public final class G6CGMManager: CGMManager {
}
}

if let last = lastBatteryReadDate, Date().timeIntervalSince(last) < Self.batteryReadInterval {
// Battery was read recently.
} else {
let batteryReadRecently = state.lastBatteryReadDate
.map { Date().timeIntervalSince($0) < Self.batteryReadInterval } ?? false
if !batteryReadRecently {
session?.shouldReadBattery = true
}

Expand Down Expand Up @@ -632,7 +656,6 @@ extension G6CGMManager: TransmitterSessionDelegate {
}

public func transmitterSession(_ session: TransmitterSession, didReadBattery message: BatteryStatusRxMessage) {
lastBatteryReadDate = Date()
log.default("Battery: A %d, B %d, resist %d", Int(message.voltageA), Int(message.voltageB), Int(message.resist))

mutateState { state in
Expand Down Expand Up @@ -814,27 +837,10 @@ extension G6CGMManager: TransmitterSessionDelegate {
}

private func glucoseTrend(for glucose: Glucose) -> GlucoseTrend? {
guard let rate = glucose.trendRateMgDLPerMinute else {
guard let arrow = G6TrendArrow(dexcomRateMgDLPerMinute: glucose.trendRateMgDLPerMinute) else {
return nil
}

// Standard Dexcom trend arrow thresholds in (mg/dL)/min.
switch rate {
case ..<(-3):
return .downDownDown
case ..<(-2):
return .downDown
case ..<(-1):
return .down
case ..<1:
return .flat
case ..<2:
return .up
case ..<3:
return .upUp
default:
return .upUpUp
}
return GlucoseTrend(rawValue: arrow.rawValue)
}
}

Expand All @@ -846,36 +852,17 @@ extension G6CGMManager: TransmitterCommandSource {
public func dequeuePendingCommand(for session: TransmitterSession) -> Command? {
var next: Command?
var dropped: [Command] = []
lockedPendingCommands.mutate { commands in
// A calibration is only meaningful next to the fingerstick it came
// from; xDrip uses the same five-minute window. Sending a stale one
// would teach the sensor from a value that no longer holds.
while let candidate = commands.first {
commands.removeFirst()
if case .calibrateSensor(_, let date) = candidate,
Date().timeIntervalSince(date) > .minutes(5) {
dropped.append(candidate)
continue
}
next = candidate
break
}
lockedCommandQueue.mutate { queue in
(next, dropped) = queue.dequeue()
}

for command in dropped {
log.default("Dropping stale command: %{public}@", String(describing: command))
mutateState { state in
if !state.pendingCommands.isEmpty {
state.pendingCommands.removeFirst()
}
}
}

if next != nil {
if next != nil || !dropped.isEmpty {
mutateState { state in
if !state.pendingCommands.isEmpty {
state.pendingCommands.removeFirst()
}
state.pendingCommands = self.lockedCommandQueue.value.rawValues
}
}

Expand Down Expand Up @@ -911,6 +898,17 @@ extension G6CGMManager: TransmitterCommandSource {
state.lastSessionStartFailure = nil
}
case .stopSensor:
if state.sensorStartDate != nil {
let sensorEnded = PersistedCgmEvent(
date: Date(),
type: .sensorEnd,
deviceIdentifier: state.transmitterID,
failureMessage: LocalizedString("Stopped by user", comment: "Reason recorded when a session ends because the user stopped it")
)
delegate.notify { delegate in
delegate?.cgmManager(self, hasNew: [sensorEnded])
}
}
mutateState { state in
state.sensorStartDate = nil
state.sensorCode = nil
Expand All @@ -933,18 +931,10 @@ struct G6GlucoseDisplay: GlucoseDisplayable {
}

var trendType: GlucoseTrend? {
guard let rate = reading.trendRateMgDLPerMinute else {
guard let arrow = G6TrendArrow(dexcomRateMgDLPerMinute: reading.trendRateMgDLPerMinute) else {
return nil
}
switch rate {
case ..<(-3): return .downDownDown
case ..<(-2): return .downDown
case ..<(-1): return .down
case ..<1: return .flat
case ..<2: return .up
case ..<3: return .upUp
default: return .upUpUp
}
return GlucoseTrend(rawValue: arrow.rawValue)
}

var trendRate: HKQuantity? {
Expand Down
15 changes: 10 additions & 5 deletions G6SensorKit/G6CGMManagerState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,14 @@ public struct G6CGMManagerState: RawRepresentable {
return sensorStartDate?.addingTimeInterval(sensorLife)
}

/// Unknown until the first version read: the fallback constants in
/// `transmitterLifetime` are guesses (a 180-day Anubis would alert as
/// "expired" against the stock 90 days), so no expiry is reported before
/// the transmitter's own value is in.
public var transmitterExpirationDate: Date? {
guard transmitterExpiryInDays != nil else {
return nil
}
return transmitterStartDate?.addingTimeInterval(transmitterLifetime)
}

Expand Down Expand Up @@ -338,11 +345,9 @@ public struct G6CGMManagerState: RawRepresentable {
/// The device model implied by the transmitter ID prefix. G6 transmitters
/// use `8…`; Dexcom ONE uses `5…` or `C…`.
public var deviceModel: String {
switch transmitterID.first {
case "5", "C", "c":
return "Dexcom ONE"
default:
return "Dexcom G6"
switch G6TransmitterModel(transmitterID: transmitterID) {
case .g6: return "Dexcom G6"
case .one: return "Dexcom ONE"
}
}
}
Expand Down
7 changes: 6 additions & 1 deletion G6SensorKit/G6SimulatedSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ extension G6CGMManager {
// warming up.
state.transmitterStartDate = now.addingTimeInterval(-.hours(24 * 12))
state.sensorStartDate = now.addingTimeInterval(-(state.warmupPeriod - Self.simulatedWarmupRemaining))
state.transmitterExpiryInDays = 90
// 180 days so the simulator presents as an Anubis, making the
// longer session length and expiry visible in the UI.
state.transmitterExpiryInDays = 180
state.firmwareVersion = "1.0.0.0 (simulated)"
state.peripheralIdentifier = UUID()
}
Expand All @@ -64,6 +66,9 @@ extension G6CGMManager {
}

private func emitSimulatedReading() {
// No active session (ended, or not yet started): stay quiet.
guard state.sensorStartDate != nil else { return }

guard !state.isInWarmup else {
// Still warming up: the state change alone refreshes the UI.
notifySimulationObservers()
Expand Down
4 changes: 4 additions & 0 deletions G6SensorKit/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@
"mg/dL": {
"extractionState": "manual",
"comment": "The short unit display string for milligrams of glucose per deciliter"
},
"Stopped by user": {
"extractionState": "manual",
"comment": "Reason recorded when a session ends because the user stopped it"
}
},
"version": "1.0"
Expand Down
Loading