-
Notifications
You must be signed in to change notification settings - Fork 149
support sensor start, stop, calibrate #76
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the changes, Paul. I have another PR that I'm going to land that may create some conflicts, but I think it provides a better base for some of these changes to build on.
|
||
transmitterIDField.text = AppDelegate.sharedDelegate.transmitter?.ID | ||
|
||
titleLabel.isUserInteractionEnabled = true |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is just an example app... but this is what UIButton is for.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fair call. I was trying to keep it simple, but will change.
xDripG5/Transmitter.swift
Outdated
|
||
private var delegateQueue = DispatchQueue(label: "com.loudnate.xDripG5.delegateQueue", qos: .utility) | ||
|
||
private var messageQueue = MessageQueue() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The biggest concern I have here is that this doesn't support persistence, and that's important if the app were to be closed before being awoken when the transmitter appears next.
I don't think this framework should manage persistence, so I think the better approach is to ask the TransmitterDelegate for its pending commands, and to publicly expose a Command enum.
It's also important to allow re-queing of failed commands, and this should be up to the delegate to determine.
enum Command: RawRepresentable {
typealias RawValue = Data
case startSensor(at: Date())
case stopSensor(at: Date())
case calibrateSensor(to: HKQuantity())
// Provide serialization for clients to easily persist and restore
}
public protocol TransmitterDelegate: class {
func dequeuePendingCommand(for transmitter: Transmitter) -> Command?
func transmitter(_ transmitter: Transmitter, didFail command: Command, with error: Error)
func transmitter(_ transmitter: Transmitter, didComplete command: Command)
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed. I did wonder how to manage persistence - this is a better solution.
xDripG5/Transmitter.swift
Outdated
|
||
let activationDate = Date(timeIntervalSinceNow: -TimeInterval(timeMessage.currentTime)) | ||
|
||
while let message = getMessage() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These queued messages should run after the GlucoseRxMessage is received (and the delegate method called).
The client should consider the most up-to-date status of the sensor when choosing which additional messages to run, for example, a session start wouldn't be necessary if the sensor was already started, or the index of a new glucose value would inform whether backfill should be read.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The official iOS app calls the queued messages before the GlucoseRxMessage is asked for and received (confirmed by watching the messages on the control characteristic while running xDripG5 in passive mode). One advantage of this is that when the glucose message is read it is immediately updated based on the queued messages. For example, a calibration will be immediately reflected in the glucose that is read; and after a sensor start message the state will immediately switch to .warmup
. Otherwise one would have to wait 5 minutes for the next read.
I agree that the client should restrict which messages can be sent based on the previous state, but IMO we should just test for freshness of the last read glucose (perhaps < 15 mins old) and use the state contained in that message. In the event that a message is not compatible with the current state (e.g. a start message while in warmup) the transmitter just ignores it.
Backfill is an exception. The official app asks for backfill after all the other messages. The official app sends the Tx messages in this order (if there is a calibration pending and backfill is required):
TransmitterTimeTxMessage (0x24)
CalibrationTxMessage (0x34)
GlucoseTxMessage (0x30)
CalibrationDataTxMessage (0x32)
BackfillTxMessage (0x50)
Thanks for the feedback. These changes, along with #77, make for a much better solution. Will submit a new PR against the current dev. |
I have followed most of these suggestions on https://github.com/thebookins/xDripG5/tree/start-stop-calibrate2, but not sure how to call the transmitter delegate functions from within the fileprivate func control(shouldWaitForBond: Bool = false, getCommand: () -> Command?, didFail: (_ command: Command, _ error: Error ) -> Void, didComplete: (_ command: Command) -> Void) throws -> Glucose { and then call let glucose = try peripheral.control(shouldWaitForBond: status.bonded != 0x1, getCommand: {
self.delegate?.dequeuePendingCommand(for: self)
}, didFail: {(command: Command, error: Error) -> Void in
self.delegateQueue.async {
self.delegate?.transmitter(self, didFail: command, with: error)
}
}, didComplete: {(command: Command) -> Void in
self.delegateQueue.async {
self.delegate?.transmitter(self, didComplete: command)
}
}) or is there a better way? UPDATE: IMO flow is cleaner if we split up the |
xDripG5/Transmitter.swift
Outdated
import os.log | ||
|
||
|
||
public enum Command: RawRepresentable { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This belongs in its own file.
xDripG5/Transmitter.swift
Outdated
} | ||
|
||
switch command { | ||
case 0: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
0
, 1
, and 2
shouldn't be used as free-floating values. Define a private, Int-backed enum. If Command
continues to expand, it should probably be expanded into a protocol instead of a single enum.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks. I've added an Action enum. Please advise if the implementation isn't ideal.
typealias Response = SessionStartRxMessage | ||
|
||
let startTime: UInt32 | ||
let time: UInt32 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
time and timeEpoch need more disambiguation, either via naming or documentation comments
|
||
var glucoseUnit: HKUnit { | ||
return HKUnit.milligramsPerDeciliter() | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This doesn't make sense here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Plan was to support mmol/L, and set the units in one place. Probably better not to complicate things for example app. Reverted.
return nil | ||
} | ||
|
||
glucose = data[11..<13].to(UInt16.self) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can use toInt()
on this line like the one below. The type is inferred due to being declared above.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed. Thanks.
let time: UInt32 | ||
|
||
/// Time in seconds since Unix Epoch | ||
let timeEpoch: UInt32 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The platform epoch (known as the reference date) is actually January 1, 2001. A better name for this would be secondsSince1970
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
|
||
let startTime: UInt32 | ||
/// Time since activation in Dex seconds | ||
let time: UInt32 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why rename this from startTime
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just for consistency between CalibrateTx, StartSessionTx and StopSessionTx, and startTime
and stopTime
and calibrateTime
seem to me a bit verbose. Happy to revert to startTime
and stopTime
. Reverted.
typealias Response = SessionStopRxMessage | ||
|
||
let stopTime: UInt32 | ||
let time: UInt32 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why rename this from stopTime
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reverted.
xDripG5/Transmitter.swift
Outdated
let activationDate = Date(timeIntervalSinceNow: -TimeInterval(timeMessage.currentTime)) | ||
} | ||
|
||
fileprivate func readGlucose(timeMessage: TransmitterTimeRxMessage, activationDate: Date) throws -> Glucose { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method is an extension of PeripheralManager
, which is focused on providing easy, safe command-response API on top of Core Bluetooth. It doesn't make sense for this method to take timeMessage
and activationDate
arguments for the sole purpose of constructing a Glucose
object.
readGlucose() throws -> GlucoseRxMessage
seems more appropriate.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks. Changed, here and also for readCalibrationData
. Are you happy for sendCommand
to take an activationDate
?
xDripG5/Transmitter.swift
Outdated
return Glucose(glucoseMessage: glucoseMessage, timeMessage: timeMessage, activationDate: activationDate) | ||
} | ||
|
||
fileprivate func readCalibrationData(activationDate: Date) throws -> Calibration { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same comment applies here
xDripG5/Transmitter.swift
Outdated
|
||
func transmitter(_ transmitter: Transmitter, didRead glucose: Glucose) | ||
|
||
func transmitter(_ transmitter: Transmitter, didRead calibration: Calibration) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of returning this value separately and expanding the surface area for clients to manage, it would be more natural for a Calibration?
property to be added to Glucose
.
return | ||
} | ||
let glucose = HKQuantity(unit: unit, doubleValue: Double(entry)) | ||
AppDelegate.sharedDelegate.commandQueue.enqueue(.calibrateSensor(to: glucose, at: Date())) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not safe to mutate commandQueue
here on the main thread, and also mutate it on a background thread during TransmitterDelegate.dequeuePendingCommand(for:)
CGMBLEKit Example/AppDelegate.swift
Outdated
} | ||
} | ||
|
||
struct CommandQueue { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This doesn't provide anything on top of Array<Command>
, and it isn't thread-safe to use. You might consider making this type a class, and internally thread-safe using os_unfair_lock
@ps2 I think I have addressed the issues in the latest review, and I've also merged with latest dev. Would be great to get your feedback. |
CGMBLEKit Example/AppDelegate.swift
Outdated
|
||
var transmitter: Transmitter? | ||
|
||
var commandQueue = CommandQueue() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be let
.
} | ||
|
||
func dequeue() -> Command? { | ||
if !list.isEmpty { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This has to be accessed inside the same lock.
CGMBLEKit Example/CommandQueue.swift
Outdated
private var lock = os_unfair_lock() | ||
|
||
var isEmpty: Bool { | ||
return list.isEmpty |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This doesn't have any callers, so it should either be removed or moved inside the lock.
xDripG5/CalibrationState.swift
Outdated
public var description: String { | ||
switch self { | ||
case .known(let state): | ||
return "\(state)" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return String(describing: state)
xDripG5/CalibrationState.swift
Outdated
switch self { | ||
case .known(let state): | ||
return "\(state)" | ||
default: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be the .unknown
case; default cases should be avoided whenever possible.
return nil | ||
} | ||
|
||
glucose = data[11..<13].toInt() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you sure the first bit isn't used for something else like in GlucoseRxMessage
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You mean the bytes 1 to 10? Yes I am sure they are used for something, I just can't work out what that is. Here's an example: 3300352d0090012d00da00 cb00 3bce5100 9e19
. There's nothing in the bytes 1 to 10 that looks like timestamp, status, or anything else that's familiar. I was hoping to be able to extract calibration parameters from these bytes (e.g. slope and offset), but they change every read despite having the same last calibration.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What I mean is that the highest-order bit of byte[12] is used to indicate a "display-only" glucose value.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, good question. It's the 4 bits with highest order, isn't it, going by the mask glucoseIsDisplayOnly = (glucoseBytes & 0xf000) > 0
? And it's the lowest order bit of those 4 bits that is used to indicate display glucose (i.e. 0001
in binary).
They could certainly be used for something else, but I don't know what that would be. This is a meter BG, so "display-only" doesn't make sense here. To be safe, we could use a mask (glucoseBytes & 0xfff
). These transmitter messages aren't particularly optimized for size, so it would surprise me if they tried to fit something else in those 4 bits.
I also can't think of a reliable way to test. All I can say is I have never seen those 4 bits set to anything other than zero.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep – we should go ahead and mask the value. Glucose values will never use those bits anyway.
xDripG5/Transmitter.swift
Outdated
|
||
while let command: Command = { | ||
var c: Command? | ||
self.delegateQueue.sync { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't dispatch to another queue here... there's no reason for it.
xDripG5/Transmitter.swift
Outdated
}() { | ||
do { | ||
_ = try peripheral.sendCommand(command, activationDate: activationDate) | ||
self.delegateQueue.async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function of the didComplete
and didFail
methods is to allow the commander to make an immediate decision about how to handle subsequent commands, e.g. it might decide to not continue if an earlier command fails.
This needs to be synchronous. No need to dispatch.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks. I hope I've understood correctly. In the latest commit I'm not calling any of the TransmitterCommandSource
from the delegate queue. I assume that's what you meant; rather than calling self.delegateQueue.sync {
.
xDripG5/Transmitter.swift
Outdated
self.commandSource?.transmitter(self, didComplete: command) | ||
} | ||
} catch let error { | ||
self.delegateQueue.async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here.
return nil | ||
} | ||
|
||
glucose = data[11..<13].toInt() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep – we should go ahead and mask the value. Glucose values will never use those bits anyway.
This PR adds support for starting, stopping and calibrating sensors for in-date transmitters.
TimedTransmitterTxMessage
, with adate
and that requires anactivationDate
from the transmitter for synchronisation.SessionStartTxMessage
,SessionStopTxMessage
andCalibrateGlucoseTxMessage
now conform to this protocol.Transmitter
, which holds any user-initiated messages for processing at the next sensor read. This queue is processed in the control step.CalibrateGlucoseRxMessage
.Data
(for longer messages).startSensor
,stopSensor
andcalibrateSensor
toTransmitter