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
37 changes: 21 additions & 16 deletions Apps/G6SensorHarness/Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ struct ContentView: View {
.textInputAutocapitalization(.characters)
.disabled(model.isRunning)

Toggle("Passive mode (listen to Dexcom app)", isOn: $model.passiveModeEnabled)
.disabled(model.isRunning)

if model.isRunning {
Button("Stop", role: .destructive) { model.stop() }
} else {
Expand All @@ -31,26 +34,28 @@ struct ContentView: View {
row("Last connect", model.lastConnect.map { $0.formatted(date: .omitted, time: .standard) } ?? "—")
}

Section("Session commands (sent on next connection)") {
HStack {
TextField("Sensor code (blank = no code)", text: $sensorCode)
.keyboardType(.numberPad)
Button("Start sensor") {
model.enqueueStartSensor(code: sensorCode.isEmpty ? nil : sensorCode)
if !model.passiveModeEnabled {
Section("Session commands (sent on next connection)") {
HStack {
TextField("Sensor code (blank = no code)", text: $sensorCode)
.keyboardType(.numberPad)
Button("Start sensor") {
model.enqueueStartSensor(code: sensorCode.isEmpty ? nil : sensorCode)
}
}
}
Button("Stop sensor") { model.enqueueStopSensor() }
HStack {
TextField("Calibration mg/dL", text: $calibrationValue)
.keyboardType(.numberPad)
Button("Calibrate") {
if let value = Double(calibrationValue) {
model.enqueueCalibration(mgDL: value)
Button("Stop sensor") { model.enqueueStopSensor() }
HStack {
TextField("Calibration mg/dL", text: $calibrationValue)
.keyboardType(.numberPad)
Button("Calibrate") {
if let value = Double(calibrationValue) {
model.enqueueCalibration(mgDL: value)
}
}
}
Button("Read battery") { model.requestBattery() }
Button("Backfill last 3h") { model.requestBackfill(hours: 3) }
}
Button("Read battery") { model.requestBattery() }
Button("Backfill last 3h") { model.requestBackfill(hours: 3) }
}

Section("Log") {
Expand Down
71 changes: 49 additions & 22 deletions Apps/G6SensorHarness/Sources/HarnessViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
//
// Copyright © 2026 Nightscout Foundation. MIT License.
//
// Drives a TransmitterSession directly for on-device validation of the
// Drives a transmitter session directly for on-device validation of the
// core: pairing, per-cycle reads, session start/stop with sensor codes,
// calibration, battery, and backfill — with an in-app event log so
// background behavior can be inspected without a debugger.
// calibration, battery, and backfill in direct mode — or passive listening
// alongside the Dexcom app — with an in-app event log so background
// behavior can be inspected without a debugger.
//

import Foundation
Expand All @@ -23,13 +24,14 @@ struct LogLine: Identifiable {
final class HarnessViewModel: ObservableObject, TransmitterSessionDelegate, TransmitterCommandSource {

@Published var transmitterID: String = UserDefaults.standard.string(forKey: "harness.transmitterID") ?? ""
@Published var passiveModeEnabled: Bool = UserDefaults.standard.bool(forKey: "harness.passiveModeEnabled")
@Published var isRunning = false
@Published var latestReading: String = "—"
@Published var lastConnect: Date?
@Published var log: [LogLine] = []
@Published var pendingCommands: [Command] = []

private var session: TransmitterSession?
private var session: (any TransmitterSessioning)?

private var peripheralIdentifier: UUID? {
get { UserDefaults.standard.string(forKey: "harness.peripheralIdentifier").flatMap(UUID.init) }
Expand All @@ -49,14 +51,23 @@ final class HarnessViewModel: ObservableObject, TransmitterSessionDelegate, Tran
}

UserDefaults.standard.set(transmitterID, forKey: "harness.transmitterID")

let session = TransmitterSession(id: transmitterID, peripheralIdentifier: peripheralIdentifier)
UserDefaults.standard.set(passiveModeEnabled, forKey: "harness.passiveModeEnabled")

let session: any TransmitterSessioning
if passiveModeEnabled {
session = PassiveTransmitterSession(id: transmitterID, peripheralIdentifier: peripheralIdentifier)
} else {
let active = TransmitterSession(id: transmitterID, peripheralIdentifier: peripheralIdentifier)
active.commandSource = self
session = active
}
session.delegate = self
session.commandSource = self
self.session = session
session.start()
isRunning = true
append("Session armed for \(transmitterID); waiting for transmitter advertisement (~5 min cycle)")
append(passiveModeEnabled
? "Passive session armed for \(transmitterID); listening for the Dexcom app's traffic"
: "Session armed for \(transmitterID); waiting for transmitter advertisement (~5 min cycle)")
}

func stop() {
Expand Down Expand Up @@ -87,17 +98,29 @@ final class HarnessViewModel: ObservableObject, TransmitterSessionDelegate, Tran
}

func requestBattery() {
session?.shouldReadBattery = true
guard let session = session as? TransmitterSession else {
append("Battery reads require direct mode")
return
}
session.shouldReadBattery = true
append("Battery read queued for next connection")
}

func requestBackfill(hours: Double) {
guard let session = session as? TransmitterSession else {
append("Backfill requests require direct mode")
return
}
let end = Date()
session?.requestedBackfillWindow = DateInterval(start: end.addingTimeInterval(-hours * 3600), end: end)
session.requestedBackfillWindow = DateInterval(start: end.addingTimeInterval(-hours * 3600), end: end)
append("Backfill queued for last \(Int(hours))h")
}

private func enqueue(_ command: Command) {
guard !passiveModeEnabled else {
append("Commands require direct mode")
return
}
DispatchQueue.main.async {
self.pendingCommands.append(command)
self.append("Queued: \(String(describing: command))")
Expand All @@ -115,49 +138,53 @@ final class HarnessViewModel: ObservableObject, TransmitterSessionDelegate, Tran

// MARK: - TransmitterSessionDelegate (background queue)

func transmitterSessionDidConnect(_ session: TransmitterSession) {
func transmitterSessionDidConnect(_ session: any TransmitterSessioning) {
DispatchQueue.main.async { self.lastConnect = Date() }
append("Connected")
}

func transmitterSession(_ session: TransmitterSession, didError error: Error) {
func transmitterSession(_ session: any TransmitterSessioning, didError error: Error) {
append("Error: \(error)")
}

func transmitterSession(_ session: TransmitterSession, didRead glucose: Glucose) {
func transmitterSession(_ session: any TransmitterSessioning, didRead glucose: Glucose) {
let value = glucose.glucoseMgDL.map { "\(Int($0)) mg/dL" } ?? "unreliable (\(glucose.state))"
DispatchQueue.main.async { self.latestReading = value }
append("Glucose: \(value), state: \(glucose.state), trend: \(glucose.trend)")
}

func transmitterSession(_ session: TransmitterSession, didReadBackfill glucose: [Glucose]) {
func transmitterSession(_ session: any TransmitterSessioning, didReadBackfill glucose: [Glucose]) {
append("Backfill: \(glucose.count) readings")
}

func transmitterSession(_ session: TransmitterSession, didReadTransmitterVersion message: TransmitterVersionRxMessage) {
func transmitterSession(_ session: any TransmitterSessioning, didReadTransmitterVersion message: TransmitterVersionRxMessage) {
append("Version: fw \(message.firmwareVersion.map(String.init).joined(separator: ".")), expiry \(message.transmitterExpiryInDays)d\(message.isAnubis ? " (Anubis)" : "")")
}

func transmitterSession(_ session: TransmitterSession, didReadBattery message: BatteryStatusRxMessage) {
func transmitterSession(_ session: any TransmitterSessioning, didReadBattery message: BatteryStatusRxMessage) {
append("Battery: A \(Double(message.voltageA)/100)V, B \(Double(message.voltageB)/100)V")
}

func transmitterSession(_ session: TransmitterSession, didReadUnknownData data: Data) {
func transmitterSession(_ session: any TransmitterSessioning, didReadTransmitterTime activationDate: Date) {
append("Transmitter clock: activation \(activationDate.formatted(date: .abbreviated, time: .standard))")
}

func transmitterSession(_ session: any TransmitterSessioning, didReadUnknownData data: Data) {
append("Unknown data: \(data.map { String(format: "%02x", $0) }.joined())")
}

func transmitterSession(_ session: TransmitterSession, didUpdatePeripheralIdentifier identifier: UUID?) {
func transmitterSession(_ session: any TransmitterSessioning, didUpdatePeripheralIdentifier identifier: UUID?) {
DispatchQueue.main.async { self.peripheralIdentifier = identifier }
append("Peripheral identifier: \(identifier?.uuidString ?? "nil")")
}

func transmitterSessionDidRequestBond(_ session: TransmitterSession) {
func transmitterSessionDidRequestBond(_ session: any TransmitterSessioning) {
append("⚠️ Accept the iOS pairing prompt within 60 seconds")
}

// MARK: - TransmitterCommandSource (background queue)

func dequeuePendingCommand(for session: TransmitterSession) -> Command? {
func dequeuePendingCommand(for session: any TransmitterSessioning) -> Command? {
var command: Command?
DispatchQueue.main.sync {
if !pendingCommands.isEmpty {
Expand All @@ -167,11 +194,11 @@ final class HarnessViewModel: ObservableObject, TransmitterSessionDelegate, Tran
return command
}

func transmitterSession(_ session: TransmitterSession, didFail command: Command, with error: Error) {
func transmitterSession(_ session: any TransmitterSessioning, didFail command: Command, with error: Error) {
append("Command failed: \(String(describing: command)) — \(error)")
}

func transmitterSession(_ session: TransmitterSession, didComplete command: Command, response: TransmitterRxMessage?) {
func transmitterSession(_ session: any TransmitterSessioning, didComplete command: Command, response: TransmitterRxMessage?) {
append("Command completed: \(String(describing: command))")
}
}
30 changes: 30 additions & 0 deletions Apps/G6SensorHarness/Sources/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>G6SensorHarness connects to your Dexcom transmitter over Bluetooth to read glucose values.</string>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
</array>
<key>UILaunchScreen</key>
<dict/>
</dict>
</plist>
2 changes: 1 addition & 1 deletion Apps/G6SensorHarness/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ name: G6SensorHarness
options:
bundleIdPrefix: org.nightscout
deploymentTarget:
iOS: "15.0"
iOS: "17.0"
settings:
TARGETED_DEVICE_FAMILY: "1"
SWIFT_VERSION: "5.9"
Expand Down
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
50 changes: 41 additions & 9 deletions Docs/CGMBLEKitDeviations.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ Nothing in the BLE layer comes from xDrip4iOS.
| Ours | From | Changed |
|---|---|---|
| `Transport/PeripheralManager.swift` | `PeripheralManager.swift` | **0 lines of 471** — byte-identical |
| `Transport/PeripheralManager+G5.swift` | `PeripheralManager+G5.swift` | +61 lines, **0 deletions** — purely additive |
| `Transport/TransmitterConnection.swift` | `BluetoothManager.swift` | 350 → 352 lines; mostly renames, 2 substantive |
| `Session/TransmitterSession.swift` | `Transmitter.swift` | heavily restructured — see below |
| `Transport/PeripheralManager+G5.swift` | `PeripheralManager+G5.swift` | +75 lines, **0 deletions** — purely additive |
| `Transport/TransmitterConnection.swift` | `BluetoothManager.swift` | mostly renames, 3 substantive deviations |
| `Session/TransmitterSession.swift` | `Transmitter.swift` (active path) | heavily restructured — see below |
| `Session/PassiveTransmitterSession.swift` | `Transmitter.swift` (passive path) | clean-room reimplementation — see below |

`PeripheralManager.swift` is where the connection intricacies actually live —
`runCommand`, the condition list, `commandLock`, the delegate callbacks. It
Expand Down Expand Up @@ -50,7 +51,7 @@ refreshes displayed text and touches nothing BLE-related.
For reference, CGMBLEKit itself does use a `DispatchSourceTimer`, in
`TransmitterManager`'s simulated sample generator. That file is not vendored.

## The two deviations in `TransmitterConnection`
## The deviations in `TransmitterConnection`

Everything else in that file is a rename (`BluetoothManager` →
`TransmitterConnection` and its delegate methods) or added logging.
Expand All @@ -75,6 +76,14 @@ match can fall back to `CBAdvertisementDataLocalNameKey` when
persisted and passed back into `init`, so a known transmitter can be
reconnected directly across launches.

**3. Unsolicited control and authentication values reach the delegate.**
CGMBLEKit routes them to `Transmitter`'s passive listener. Here they were
initially dropped (only backfill was forwarded); passive mode re-adds two
delegate callbacks, `didReceiveControlResponse` and
`didReceiveAuthenticationResponse`. Values claimed by the condition
machinery — an active session's own request/response traffic — still never
reach these callbacks.

Also: the configuration constant changed from `.dexcomG5` to `.dexcomG6`, and
the authentication-response delegate callback was dropped because auth
responses are now awaited inline (below).
Expand Down Expand Up @@ -131,23 +140,46 @@ left on the characteristic could otherwise satisfy a bare type match.
## The session layer

`TransmitterSession` is where the real divergence is, and it is policy rather
than connection handling.
than connection handling. It covers the **active** path only; the passive
path is a separate class (below).

- **Removed:** the passive path entirely. G6SensorKit always owns the
transmitter; it never observes another app's session. Also gone:
- **Removed:** the passive path from this class (it lives in
`PassiveTransmitterSession` now). Also gone:
`resumeScanning`, `stopScanning`, `transmitterDidConnect`; `computeHash`
moved to `TransmitterID`.
- **Added:** `start`/`stop`/`retarget`, `requestBackfill`/`drainBackfill`,
`readBatteryStatus`.
- **Kept:** `authenticate`, `requestBond`, `enableNotify`,
`listenToCharacteristic`, `sendCommand`, `dequeuePendingCommand`,
`listenToCharacteristic` (moved to `PeripheralManager+G5.swift`, shared
with the passive session), `sendCommand`, `dequeuePendingCommand`,
`readGlucose`, `readTimeMessage`, `readCalibrationData`,
`readTransmitterVersion`, `disconnect`.

Backfill is a request in our version. In CGMBLEKit backfill arrives as
Backfill is a request in the active session. In CGMBLEKit backfill arrives as
notifications and there is no request function, because the Dexcom app drives
the session.

## The passive session

`PassiveTransmitterSession` + `PassiveMessageHandler` reimplement CGMBLEKit's
passive path without its accumulated workarounds:

- **Subscribing is done once, at connect, for all three characteristics**
(authentication, control, backfill), and subscriptions are never toggled
for the life of the connection. CGMBLEKit subscribes to control only after
observing an authenticated session (commit `dec4ff0`), because toggling
subscriptions mid-exchange confused CoreBluetooth — a workaround this
design sidesteps rather than replicates. If field testing shows missed
traffic, the fallback is to gate the control subscription on the observed
`AuthChallengeRxMessage`, which the handler already parses.
- **No writes exist in scope at all** — no auth, bond, commands, active reads
or disconnect request. CGMBLEKit's passive path shares a class with the
active path, so writes were reachable from it.
- Message handling is a pure type (`PassiveMessageHandler`, no CoreBluetooth)
that turns observed frames into events, so the whole decode path —
time-frame caching, glucose dating, backfill frame reassembly and CRC
validation against the observed acknowledgement — is unit-tested.

## What came from xDrip4iOS

Four things, none of them code, none in the BLE layer:
Expand Down
Loading