Transfer view, change data flow, actors

This commit is contained in:
Christoph Hagen
2023-07-02 17:29:39 +02:00
parent 8b4c4800c9
commit 396571fd30
24 changed files with 1285 additions and 302 deletions

View File

@ -1,6 +1,6 @@
import Foundation
import SwiftUI
/*
final class BluetoothClient: ObservableObject {
private let updateInterval = 3.0
@ -9,7 +9,7 @@ final class BluetoothClient: ObservableObject {
private let connection = DeviceManager()
private let storage: TemperatureStorage
private let storage: PersistentStorage
var hasInfo: Bool {
deviceInfo != nil
@ -21,10 +21,24 @@ final class BluetoothClient: ObservableObject {
}
return false
}
private var isUpdatingFlag = false
@Published
var shouldConnect: Bool {
didSet {
isUpdatingFlag = true
connection.shouldConnectIfPossible = shouldConnect
log.info("Should connect: \(shouldConnect)")
isUpdatingFlag = false
}
}
init(storage: TemperatureStorage, deviceInfo: DeviceInfo? = nil) {
init(storage: PersistentStorage, shouldConnect: Bool = false, deviceInfo: DeviceInfo? = nil) {
self.storage = storage
self.deviceInfo = deviceInfo
self.shouldConnect = shouldConnect
connection.shouldConnectIfPossible = shouldConnect
connection.delegate = self
}
@ -47,7 +61,6 @@ final class BluetoothClient: ObservableObject {
@Published
private(set) var deviceInfo: DeviceInfo? {
didSet {
updateDeviceTimeIfNeeded()
// collectRecordedData()
if let deviceInfo, let runningTransfer {
runningTransfer.update(info: deviceInfo)
@ -102,6 +115,14 @@ final class BluetoothClient: ObservableObject {
}
// MARK: Requests
func clearDeviceStorage() {
guard let count = deviceInfo?.numberOfRecordedBytes else {
log.info("Can't clear device data without device info")
return
}
addRequest(.clearRecordingBuffer(byteCount: count))
}
private func performNextRequest() {
guard runningRequest == nil else {
@ -136,24 +157,6 @@ final class BluetoothClient: ObservableObject {
openRequests.append(request)
}
// MARK: Device time
private func updateDeviceTimeIfNeeded() {
guard let deviceInfo else {
return
}
guard !deviceInfo.hasDeviceStartTimeSet || abs(deviceInfo.clockOffset) > minimumOffsetToUpdateDeviceClock else {
return
}
guard !openRequests.contains(where: { if case .setDeviceStartTime = $0 { return true }; return false }) else {
return
}
let time = deviceInfo.calculatedDeviceStartTime.seconds
addRequest(.setDeviceStartTime(deviceStartTimeSeconds: time))
log.info("Setting device start time to \(time) s (correcting offset of \(Int(deviceInfo.clockOffset)) s)")
}
// MARK: Data transfer
@discardableResult
@ -173,8 +176,7 @@ final class BluetoothClient: ObservableObject {
guard info.numberOfStoredMeasurements > 0 else {
return false
}
let transfer = TemperatureDataTransfer(info: info)
let transfer = TemperatureDataTransfer(info: info, previous: storage.lastDeviceTime)
runningTransfer = transfer
let next = transfer.nextRequest()
log.info("Starting transfer")
@ -185,9 +187,13 @@ final class BluetoothClient: ObservableObject {
private func didReceive(data: Data, offset: Int, count: Int) {
guard let runningTransfer else {
log.warning("No running transfer to process device data")
self.runningRequest = nil
return // TODO: Start new transfer?
}
runningTransfer.add(data: data, offset: offset, count: count)
guard runningTransfer.add(data: data, offset: offset, count: count) else {
self.runningRequest = nil
return // TODO: Start new transfer
}
let next = runningTransfer.nextRequest()
addRequest(next)
}
@ -202,6 +208,13 @@ final class BluetoothClient: ObservableObject {
}
extension BluetoothClient: DeviceManagerDelegate {
func deviceManager(shouldConnectToDevice: Bool) {
guard !isUpdatingFlag else {
return
}
self.shouldConnect = shouldConnectToDevice
}
func deviceManager(didReceive data: Data) {
defer {
@ -232,20 +245,21 @@ extension BluetoothClient: DeviceManagerDelegate {
return
case .invalidNumberOfBytesToDelete:
guard case .clearRecordingBuffer = runningRequest else {
// If clearing the recording buffer fails due to byte mismatch,
// then requesting new info will resolve the mismatch, and the transfer will be resumed
addRequest(.getInfo)
log.error("Request \(runningRequest) received non-matching response about number of bytes to delete")
return
}
log.error("Request \(runningRequest) received non-matching responde about number of bytes to delete")
// If clearing the recording buffer fails due to byte mismatch,
// then requesting new info will resolve the mismatch, and the transfer will be resumed
addRequest(.getInfo)
case .responseTooLarge:
guard case .getRecordingData = runningRequest else {
// If requesting bytes fails due to the response size,
// then requesting new info will update the response size, and the transfer will be resumed
addRequest(.getInfo)
log.error("Unexpectedly exceeded payload size for request \(runningRequest)")
return
}
log.error("Unexpectedly exceeded payload size for request \(runningRequest)")
// If requesting bytes fails due to the response size,
// then requesting new info will update the response size, and the transfer will be resumed
addRequest(.getInfo)
default:
log.error("Unknown response \(data[0]) for request \(runningRequest)")
// If clearing the recording buffer fails due to byte mismatch,
@ -264,10 +278,6 @@ extension BluetoothClient: DeviceManagerDelegate {
didReceive(data: payload, offset: offset, count: count)
case .clearRecordingBuffer:
didClearDeviceStorage()
case .setDeviceStartTime:
log.info("Device time set")
break
}
}
@ -276,11 +286,12 @@ extension BluetoothClient: DeviceManagerDelegate {
log.warning("No running transfer after clearing device storage")
return
}
runningTransfer.completeTransfer()
defer { self.runningTransfer = nil }
guard runningTransfer.completeTransfer() else {
return
}
storage.add(runningTransfer.measurements)
self.runningTransfer = nil
updateDeviceTimeIfNeeded()
storage.lastDeviceTime = runningTransfer.time
}
func deviceManager(didChangeState state: DeviceState) {
@ -290,3 +301,4 @@ extension BluetoothClient: DeviceManagerDelegate {
}
}
*/

View File

@ -1,5 +1,5 @@
import Foundation
/*
enum BluetoothRequest {
/**
* Request the number of bytes already recorded
@ -44,11 +44,6 @@ enum BluetoothRequest {
*/
case clearRecordingBuffer(byteCount: Int)
/**
*/
case setDeviceStartTime(deviceStartTimeSeconds: Int)
var serialized: Data {
let firstByte = Data([byte])
switch self {
@ -58,8 +53,6 @@ enum BluetoothRequest {
return firstByte + count.twoByteData + offset.twoByteData
case .clearRecordingBuffer(let byteCount):
return firstByte + byteCount.twoByteData
case .setDeviceStartTime(let deviceStartTimeSeconds):
return firstByte + deviceStartTimeSeconds.fourByteData
}
}
@ -68,7 +61,7 @@ enum BluetoothRequest {
case .getInfo: return 0
case .getRecordingData: return 1
case .clearRecordingBuffer: return 2
case .setDeviceStartTime: return 3
}
}
}
*/

View File

@ -1,39 +1,31 @@
import Foundation
struct DeviceInfo {
let receivedDate: Date
/**
The maximum factor by which the device clock can run
*/
private let maximumTimeDilationFactor: Double = 0.01
/// The number of bytes recorded by the tracker
let numberOfRecordedBytes: Int
/// The number of measurements already performed
let numberOfStoredMeasurements: Int
/// The measurements since device start
let totalNumberOfMeasurements: Int
/// The interval between measurements (in seconds)
let measurementInterval: Int
let nextMeasurement: Date
let sensor0: TemperatureSensor?
let sensor1: TemperatureSensor?
// MARK: Device time
/**
The number of seconds the device has been powered on
*/
let numberOfSecondsRunning: Int
let deviceStartTime: Date
let hasDeviceStartTimeSet: Bool
let wakeupReason: DeviceWakeCause
let time: DeviceTime
// MARK: Storage
@ -49,48 +41,77 @@ struct DeviceInfo {
var storageFillPercentage: Int {
Int((storageFillRatio * 100).rounded())
}
var clockOffset: TimeInterval {
// Measurements are performed on device start (-1) and also count next measurement (+1)
let nextMeasurementTime = deviceStartTime.adding(seconds: totalNumberOfMeasurements * measurementInterval)
return nextMeasurement.timeIntervalSince(nextMeasurementTime)
var currentMeasurementStartTime: Date {
time.nextMeasurement.addingTimeInterval(-Double(numberOfStoredMeasurements * measurementInterval))
}
var calculatedDeviceStartTime: Date {
let runtime = totalNumberOfMeasurements * measurementInterval
return nextMeasurement.adding(seconds: -runtime)
func estimatedTimeDilation(to previous: DeviceTime?) -> (start: Date, dilation: Double) {
let trivialResult = (start: currentMeasurementStartTime, dilation: 1.0)
guard let previous else {
log.info("No previous device time to compare")
return trivialResult
}
// Check if device was restarted in between
guard time.secondsSincePowerOn >= previous.secondsSincePowerOn else {
log.info("Device restarted (runtime decreased from \(previous.secondsSincePowerOn) to \(time.secondsSincePowerOn))")
return trivialResult
}
let newMeasurementCount = time.totalNumberOfMeasurements - previous.totalNumberOfMeasurements
guard newMeasurementCount >= 0 else {
log.info("Device restarted (measurements decreased from \(previous.totalNumberOfMeasurements) to \(time.totalNumberOfMeasurements))")
return trivialResult
}
guard newMeasurementCount > 0 else {
log.warning("No new measurements to calculate time difference")
return trivialResult
}
// Check that no measurements are missing
// Calculate the difference between the expected time for the next measurement and the device time
let deviceTimeDifference = Double(newMeasurementCount * measurementInterval)
let expectedNextMeasurement = previous.nextMeasurement.addingTimeInterval(deviceTimeDifference)
let timeDifference = time.nextMeasurement.timeIntervalSince(expectedNextMeasurement)
let realTimeDifference = time.nextMeasurement.timeIntervalSince(previous.nextMeasurement)
let timeDilation = realTimeDifference / deviceTimeDifference
log.info("Device time dilation \(timeDilation) (difference \(timeDifference))")
guard abs(timeDilation - 1.0) < maximumTimeDilationFactor else {
log.warning("Device time too different from expected value (difference \(timeDifference) s)")
return (currentMeasurementStartTime, 1.0)
}
return (previous.nextMeasurement, timeDilation)
}
}
extension DeviceInfo {
init(info: Data) throws {
let date = Date()
var data = info
let date = Date().nearestSecond
self.receivedDate = date
self.numberOfRecordedBytes = try data.decodeTwoByteInteger()
self.nextMeasurement = date.adding(seconds: try data.decodeTwoByteInteger())
let secondsUntilNextMeasurement = try data.decodeTwoByteInteger()
self.measurementInterval = try data.decodeTwoByteInteger()
self.numberOfStoredMeasurements = try data.decodeTwoByteInteger()
self.totalNumberOfMeasurements = try data.decodeFourByteInteger()
let totalNumberOfMeasurements = try data.decodeFourByteInteger()
self.transferBlockSize = try data.decodeTwoByteInteger()
self.storageSize = try data.decodeTwoByteInteger()
let secondsSincePowerOn = try data.decodeFourByteInteger()
self.numberOfSecondsRunning = secondsSincePowerOn
let deviceStartTimeSeconds = try data.decodeFourByteInteger()
self.time = .init(
date: date,
secondsSincePowerOn: secondsSincePowerOn,
totalNumberOfMeasurements: totalNumberOfMeasurements,
secondsUntilNextMeasurement: secondsUntilNextMeasurement)
let _ = try data.decodeFourByteInteger()
self.sensor0 = try data.decodeSensor()
self.sensor1 = try data.decodeSensor()
self.wakeupReason = .init(rawValue: try data.getByte()) ?? .WAKEUP_UNDEFINED
if deviceStartTimeSeconds != 0 {
self.hasDeviceStartTimeSet = true
self.deviceStartTime = Date(seconds: deviceStartTimeSeconds)
} else {
self.hasDeviceStartTimeSet = false
self.deviceStartTime = Date(seconds: date.seconds - secondsSincePowerOn) // Round to nearest second
}
}
}
@ -98,18 +119,13 @@ extension DeviceInfo {
static var mock: DeviceInfo {
.init(
receivedDate: Date(),
numberOfRecordedBytes: 123,
numberOfStoredMeasurements: 234,
totalNumberOfMeasurements: 345,
measurementInterval: 60,
nextMeasurement: .now.addingTimeInterval(5),
sensor0: .init(address: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08], value: .value(21.0), date: .now.addingTimeInterval(-2)),
sensor1: .init(address: [0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09], value: .value(19.0), date: .now.addingTimeInterval(-4)),
numberOfSecondsRunning: 20,
deviceStartTime: .now.addingTimeInterval(-20755),
hasDeviceStartTimeSet: true,
wakeupReason: .WAKEUP_EXT0,
time: .mock,
storageSize: 10000,
transferBlockSize: 180)
}

View File

@ -24,16 +24,13 @@ final class DeviceManager: NSObject, CBCentralManagerDelegate {
self.manager = CBCentralManager(delegate: self, queue: nil)
}
private var dataUpdateTimer: Timer?
@discardableResult
func connect() -> Bool {
switch state {
case .bluetoothDisabled:
log.info("Can't connect, bluetooth disabled")
return false
case .disconnected, .bluetoothEnabled:
case .disconnected:
break
default:
return true
@ -42,18 +39,53 @@ final class DeviceManager: NSObject, CBCentralManagerDelegate {
state = .scanning
return true
}
shouldConnectIfPossible = true
if !shouldConnectIfPossible {
shouldConnectIfPossible = true
}
state = .scanning
manager.scanForPeripherals(withServices: [DeviceManager.serviceUUID])
return true
}
private var shouldConnectIfPossible = true
var shouldConnectIfPossible = true {
didSet {
updateConnectionOnChange()
delegate?.deviceManager(shouldConnectToDevice: shouldConnectIfPossible)
}
}
private func updateConnectionOnChange() {
if shouldConnectIfPossible {
ensureConnection()
} else {
disconnectIfNeeded()
}
}
private func ensureConnection() {
switch state {
case .disconnected:
connect()
default:
return
}
}
private func disconnectIfNeeded() {
switch state {
case .bluetoothDisabled, .disconnected:
return
default:
disconnect()
}
}
func disconnect() {
shouldConnectIfPossible = false
if shouldConnectIfPossible {
shouldConnectIfPossible = false
}
switch state {
case .bluetoothDisabled, .bluetoothEnabled:
case .bluetoothDisabled, .disconnected:
return
case .scanning:
manager.stopScan()
@ -67,8 +99,6 @@ final class DeviceManager: NSObject, CBCentralManagerDelegate {
manager.stopScan()
state = .disconnected
return
case .disconnected:
return
}
}
@ -91,6 +121,9 @@ final class DeviceManager: NSObject, CBCentralManagerDelegate {
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
guard shouldConnectIfPossible else {
return
}
peripheral.delegate = self
manager.connect(peripheral)
manager.stopScan()
@ -102,7 +135,7 @@ final class DeviceManager: NSObject, CBCentralManagerDelegate {
case .poweredOff:
state = .bluetoothDisabled
case .poweredOn:
state = .bluetoothEnabled
state = .disconnected
connect()
case .unsupported:
state = .bluetoothDisabled

View File

@ -1,6 +1,8 @@
import Foundation
protocol DeviceManagerDelegate: AnyObject {
func deviceManager(shouldConnectToDevice: Bool)
func deviceManager(didReceive data: Data)

View File

@ -4,8 +4,6 @@ import CoreBluetooth
enum DeviceState {
case bluetoothDisabled
case bluetoothEnabled
case scanning
@ -23,8 +21,6 @@ enum DeviceState {
switch self {
case .bluetoothDisabled:
return "Bluetooth is disabled"
case .bluetoothEnabled:
return "Bluetooth enabled"
case .scanning:
return "Scanning..."
case .connecting(let device):
@ -45,6 +41,18 @@ enum DeviceState {
return "Not connected"
}
}
var device: CBPeripheral? {
switch self {
case .bluetoothDisabled, .disconnected, .scanning:
return nil
case .connecting(let device),
.discoveringCharacteristic(let device),
.discoveringServices(device: let device),
.configured(let device, _):
return device
}
}
}
extension DeviceState: CustomStringConvertible {
@ -53,8 +61,6 @@ extension DeviceState: CustomStringConvertible {
switch self {
case .bluetoothDisabled:
return "Bluetooth disabled"
case .bluetoothEnabled:
return "Bluetooth enabled"
case .scanning:
return "Searching for device"
case .connecting:

View File

@ -0,0 +1,70 @@
import Foundation
struct DeviceTime {
let date: Date
let secondsSincePowerOn: Int
let totalNumberOfMeasurements: Int
let secondsUntilNextMeasurement: Int
var nextMeasurement: Date {
date.adding(seconds: secondsUntilNextMeasurement)
}
var deviceStartTime: Date {
date.adding(seconds: -secondsSincePowerOn)
}
var estimatedMeasurementInterval: TimeInterval {
guard totalNumberOfMeasurements > 0 else {
return 60
}
return Double(secondsSincePowerOn + secondsUntilNextMeasurement) / Double(totalNumberOfMeasurements)
}
func measurementStartTime(measurementInterval interval: TimeInterval) -> Date {
nextMeasurement.addingTimeInterval(-Double(totalNumberOfMeasurements) * interval)
}
func measurementOffset(measurementInterval interval: TimeInterval) -> TimeInterval {
measurementStartTime(measurementInterval: interval).timeIntervalSince(deviceStartTime)
}
}
extension DeviceTime: Equatable {
}
extension DeviceTime: Codable {
init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
let time = try container.decode(Double.self)
self.date = .init(timeIntervalSince1970: time)
self.secondsSincePowerOn = try container.decode(Int.self)
self.totalNumberOfMeasurements = try container.decode(Int.self)
self.secondsUntilNextMeasurement = try container.decode(Int.self)
}
func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(date.timeIntervalSince1970)
try container.encode(secondsSincePowerOn)
try container.encode(totalNumberOfMeasurements)
try container.encode(secondsUntilNextMeasurement)
}
}
extension DeviceTime {
static var mock: DeviceTime {
.init(
date: .now,
secondsSincePowerOn: 125,
totalNumberOfMeasurements: 3,
secondsUntilNextMeasurement: 55)
}
}