From 8a68232678f9d7068e5310c333bd9ebcc369b628 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 2 Dec 2025 07:05:56 +0000 Subject: [PATCH 1/2] Enhance RealtimeIrl by adding the missing metrics --- .../CyclingPowerDevice.swift | 65 +++++++-- .../RealtimeIrl/RealtimeIrl.swift | 123 ++++++++++++++++-- Moblin/Various/Model/Model.swift | 2 + Moblin/Various/Model/ModelAppleWatch.swift | 3 + .../Model/ModelCyclingPowerDevice.swift | 7 +- .../Various/Model/ModelHeartRateDevice.swift | 1 + Moblin/Various/Model/ModelLocation.swift | 2 + Moblin/Various/Model/ModelPedometer.swift | 36 +++++ Moblin/Various/Model/ModelStream.swift | 2 + 9 files changed, 219 insertions(+), 22 deletions(-) create mode 100644 Moblin/Various/Model/ModelPedometer.swift diff --git a/Moblin/Integrations/CyclingPowerDevice/CyclingPowerDevice.swift b/Moblin/Integrations/CyclingPowerDevice/CyclingPowerDevice.swift index eff6d1a44..edb0ccfc9 100644 --- a/Moblin/Integrations/CyclingPowerDevice/CyclingPowerDevice.swift +++ b/Moblin/Integrations/CyclingPowerDevice/CyclingPowerDevice.swift @@ -6,7 +6,7 @@ private let cyclingPowerDeviceDispatchQueue = DispatchQueue(label: "com.eerimoq. protocol CyclingPowerDeviceDelegate: AnyObject { func cyclingPowerDeviceState(_ device: CyclingPowerDevice, state: CyclingPowerDeviceState) - func cyclingPowerStatus(_ device: CyclingPowerDevice, power: Int, cadence: Int) + func cyclingPowerStatus(_ device: CyclingPowerDevice, power: Int, crankCadence: Int, wheelRpm: Int?) } enum CyclingPowerDeviceState { @@ -217,11 +217,15 @@ class CyclingPowerDevice: NSObject { // private var featureCharacteristic: CBCharacteristic? private var deviceId: UUID? weak var delegate: (any CyclingPowerDeviceDelegate)? - private var previousRevolutions: UInt16? - private var previousRevolutionsTime: UInt16? + private var previousCrankRevolutions: UInt16? + private var previousCrankRevolutionsTime: UInt16? + private var previousWheelRevolutions: UInt32? + private var previousWheelRevolutionsTime: UInt16? private var averagePower = AverageMeasurementCalculator() private var averageCadence = AverageMeasurementCalculator() + private var averageWheelRpm = AverageMeasurementCalculator() private var latestAverageCadenceUpdateTime = ContinuousClock.now + private var latestAverageWheelUpdateTime = ContinuousClock.now func start(deviceId: UUID?) { cyclingPowerDeviceDispatchQueue.async { @@ -255,8 +259,15 @@ class CyclingPowerDevice: NSObject { measurementCharacteristic = nil // vectorCharacteristic = nil // featureCharacteristic = nil - previousRevolutions = nil - previousRevolutionsTime = nil + previousCrankRevolutions = nil + previousCrankRevolutionsTime = nil + previousWheelRevolutions = nil + previousWheelRevolutionsTime = nil + averagePower = AverageMeasurementCalculator() + averageCadence = AverageMeasurementCalculator() + averageWheelRpm = AverageMeasurementCalculator() + latestAverageCadenceUpdateTime = ContinuousClock.now + latestAverageWheelUpdateTime = ContinuousClock.now setState(state: .disconnected) } @@ -366,15 +377,16 @@ extension CyclingPowerDevice: CBPeripheralDelegate { private func handlePowerMeasurement(value: Data) throws { let measurement = try CyclingPowerMeasurement(value: value) var cadence = -1.0 + var wheelRpm: Double? if let revolutions = measurement.cumulativeCrankRevolutions, let time = measurement.lastCrankEventTime { - if let previousRevolutions, let previousRevolutionsTime { - var deltaRevolutions = Int(revolutions) - Int(previousRevolutions) + if let previousCrankRevolutions, let previousCrankRevolutionsTime { + var deltaRevolutions = Int(revolutions) - Int(previousCrankRevolutions) if deltaRevolutions < 0 { deltaRevolutions += 65536 } - var deltaTime = Int(time) - Int(previousRevolutionsTime) + var deltaTime = Int(time) - Int(previousCrankRevolutionsTime) if deltaTime < 0 { deltaTime += 65536 } @@ -384,8 +396,28 @@ extension CyclingPowerDevice: CBPeripheralDelegate { cadence = min(cadence, 10000) } } - previousRevolutions = revolutions - previousRevolutionsTime = time + previousCrankRevolutions = revolutions + previousCrankRevolutionsTime = time + } + if let wheelRevolutions = measurement.cumulativeWheelRevolutions, + let wheelTime = measurement.lastWheelEventTime + { + if let previousWheelRevolutions, let previousWheelRevolutionsTime { + var deltaRevolutions = Int(wheelRevolutions) - Int(previousWheelRevolutions) + if deltaRevolutions < 0 { + deltaRevolutions += Int(UInt32.max) + 1 + } + var deltaTime = Int(wheelTime) - Int(previousWheelRevolutionsTime) + if deltaTime < 0 { + deltaTime += 65536 + } + let deltaTimeSeconds = Double(deltaTime) / 1024 + if deltaTimeSeconds > 0 { + wheelRpm = min(60 * Double(deltaRevolutions) / deltaTimeSeconds, 10000) + } + } + previousWheelRevolutions = wheelRevolutions + previousWheelRevolutionsTime = wheelTime } averagePower.update(value: Int(measurement.instantaneousPower)) let now = ContinuousClock.now @@ -395,7 +427,18 @@ extension CyclingPowerDevice: CBPeripheralDelegate { } else if latestAverageCadenceUpdateTime.duration(to: now) > .seconds(3) { averageCadence.update(value: 0) } - delegate?.cyclingPowerStatus(self, power: averagePower.average(), cadence: averageCadence.averageIngoreZeros()) + if let wheelRpm { + averageWheelRpm.update(value: Int(wheelRpm)) + latestAverageWheelUpdateTime = now + } else if latestAverageWheelUpdateTime.duration(to: now) > .seconds(3) { + averageWheelRpm.update(value: 0) + } + delegate?.cyclingPowerStatus( + self, + power: averagePower.average(), + crankCadence: averageCadence.averageIngoreZeros(), + wheelRpm: averageWheelRpm.averageIngoreZeros() + ) } private func handlePowerVector(value: Data) throws { diff --git a/Moblin/Integrations/RealtimeIrl/RealtimeIrl.swift b/Moblin/Integrations/RealtimeIrl/RealtimeIrl.swift index 3b2e8d898..f2ddf6e3d 100644 --- a/Moblin/Integrations/RealtimeIrl/RealtimeIrl.swift +++ b/Moblin/Integrations/RealtimeIrl/RealtimeIrl.swift @@ -5,6 +5,49 @@ class RealtimeIrl { private let pushUrl: URL private let stopUrl: URL private var updateCount = 0 + private var pedometerStepsWatch: (value: Int, date: Date)? + private var pedometerStepsDevice: (value: Int, date: Date)? + private var heartRateWatch: (value: Int, date: Date)? + private var heartRateDevice: (value: Int, date: Date)? + private var cyclingPowerWatch: (value: Int, date: Date)? + private var cyclingPowerDevice: (value: Int, date: Date)? + private var cyclingCrankWatch: (value: Int, date: Date)? + private var cyclingCrankDevice: (value: Int, date: Date)? + private var cyclingWheelWatch: (value: Int, date: Date)? + private var cyclingWheelDevice: (value: Int, date: Date)? + + private struct Payload: Encodable { + let latitude: Double + let longitude: Double + let speed: Double + let altitude: Double + let heading: CLLocationDirection? + let timestamp: TimeInterval + let pedometerSteps: Int? + let heartRate: Int? + let cyclingPower: Int? + let cyclingCrank: Int? + let cyclingWheel: Int? + } + + private func watchFirst( + watch: (value: Int, date: Date)?, + device: (value: Int, date: Date)? + ) -> Int? { + if let watch { + return watch.value + } + if let device { + return device.value + } + return nil + } + + private var pedometerSteps: Int? { watchFirst(watch: pedometerStepsWatch, device: pedometerStepsDevice) } + private var heartRate: Int? { watchFirst(watch: heartRateWatch, device: heartRateDevice) } + private var cyclingPower: Int? { watchFirst(watch: cyclingPowerWatch, device: cyclingPowerDevice) } + private var cyclingCrank: Int? { watchFirst(watch: cyclingCrankWatch, device: cyclingCrankDevice) } + private var cyclingWheel: Int? { watchFirst(watch: cyclingWheelWatch, device: cyclingWheelDevice) } init?(baseUrl: String, pushKey: String) { guard let url = URL(string: "\(baseUrl)/push?key=\(pushKey)") else { @@ -29,23 +72,85 @@ class RealtimeIrl { updateCount += 1 var request = URLRequest(url: pushUrl) request.httpMethod = "POST" - request.httpBody = """ - { - \"latitude\":\(location.coordinate.latitude), - \"longitude\":\(location.coordinate.longitude), - \"speed\":\(location.speed), - \"altitude\":\(location.altitude), - \"timestamp\":\(location.timestamp.timeIntervalSince1970) - } - """.utf8Data + request.httpBody = try? JSONEncoder().encode(Payload( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude, + speed: location.speed, + altitude: location.altitude, + heading: location.course, + pedometerSteps: pedometerSteps, + heartRate: heartRate, + cyclingPower: cyclingPower, + cyclingCrank: cyclingCrank, + cyclingWheel: cyclingWheel, + timestamp: location.timestamp.timeIntervalSince1970 + )) request.setContentType("application/json") URLSession.shared.dataTask(with: request) { _, _, _ in } .resume() } + func updatePedometerSteps(_ steps: Int, fromWatch: Bool = false) { + if fromWatch { + pedometerStepsWatch = (steps, Date()) + } else { + pedometerStepsDevice = (steps, Date()) + } + } + + func updateHeartRate(_ heartRate: Int, fromWatch: Bool = false) { + if fromWatch { + heartRateWatch = (heartRate, Date()) + } else { + heartRateDevice = (heartRate, Date()) + } + } + + func updateCyclingPower(_ power: Int, fromWatch: Bool = false) { + if fromWatch { + cyclingPowerWatch = (power, Date()) + } else { + cyclingPowerDevice = (power, Date()) + } + } + + func updateCyclingCrank(_ cadence: Int, fromWatch: Bool = false) { + if fromWatch { + cyclingCrankWatch = (cadence, Date()) + } else { + cyclingCrankDevice = (cadence, Date()) + } + } + + func updateCyclingWheel(_ rpm: Int?, fromWatch: Bool = false) { + if fromWatch { + if let rpm { + cyclingWheelWatch = (rpm, Date()) + } else { + cyclingWheelWatch = nil + } + } else { + if let rpm { + cyclingWheelDevice = (rpm, Date()) + } else { + cyclingWheelDevice = nil + } + } + } + func stop() { updateCount = 0 + pedometerStepsWatch = nil + pedometerStepsDevice = nil + heartRateWatch = nil + heartRateDevice = nil + cyclingPowerWatch = nil + cyclingPowerDevice = nil + cyclingCrankWatch = nil + cyclingCrankDevice = nil + cyclingWheelWatch = nil + cyclingWheelDevice = nil var request = URLRequest(url: stopUrl) request.httpMethod = "POST" URLSession.shared.dataTask(with: request) { _, _, _ in diff --git a/Moblin/Various/Model/Model.swift b/Moblin/Various/Model/Model.swift index b487213c8..102a95d21 100644 --- a/Moblin/Various/Model/Model.swift +++ b/Moblin/Various/Model/Model.swift @@ -599,6 +599,8 @@ final class Model: NSObject, ObservableObject, @unchecked Sendable { var replayEffect: ReplayEffect? var locationManager = Location() var realtimeIrl: RealtimeIrl? + private let pedometer = CMPedometer() + private var pedometerUpdatesActive = false private var failedVideoEffect: String? var supportsAppleLog: Bool = false let weatherManager = WeatherManager() diff --git a/Moblin/Various/Model/ModelAppleWatch.swift b/Moblin/Various/Model/ModelAppleWatch.swift index 94f99c41c..32d863aa2 100644 --- a/Moblin/Various/Model/ModelAppleWatch.swift +++ b/Moblin/Various/Model/ModelAppleWatch.swift @@ -619,6 +619,7 @@ extension Model: WCSessionDelegate { if self.isWatchLocal() { if let heartRate = stats.heartRate { self.heartRates[""] = heartRate + self.realtimeIrl?.updateHeartRate(heartRate, fromWatch: true) } if let activeEnergyBurned = stats.activeEnergyBurned { self.workoutActiveEnergyBurned = activeEnergyBurned @@ -628,9 +629,11 @@ extension Model: WCSessionDelegate { } if let stepCount = stats.stepCount { self.workoutStepCount = stepCount + self.realtimeIrl?.updatePedometerSteps(stepCount, fromWatch: true) } if let power = stats.power { self.workoutPower = power + self.realtimeIrl?.updateCyclingPower(power, fromWatch: true) } } } diff --git a/Moblin/Various/Model/ModelCyclingPowerDevice.swift b/Moblin/Various/Model/ModelCyclingPowerDevice.swift index 58bc3aec0..d7cecb769 100644 --- a/Moblin/Various/Model/ModelCyclingPowerDevice.swift +++ b/Moblin/Various/Model/ModelCyclingPowerDevice.swift @@ -66,10 +66,13 @@ extension Model: CyclingPowerDeviceDelegate { } } - func cyclingPowerStatus(_: CyclingPowerDevice, power: Int, cadence: Int) { + func cyclingPowerStatus(_: CyclingPowerDevice, power: Int, crankCadence: Int, wheelRpm: Int?) { DispatchQueue.main.async { self.cyclingPower = power - self.cyclingCadence = cadence + self.cyclingCadence = crankCadence + self.realtimeIrl?.updateCyclingPower(power) + self.realtimeIrl?.updateCyclingCrank(crankCadence) + self.realtimeIrl?.updateCyclingWheel(wheelRpm) } } } diff --git a/Moblin/Various/Model/ModelHeartRateDevice.swift b/Moblin/Various/Model/ModelHeartRateDevice.swift index 838b706ce..dbca47d59 100644 --- a/Moblin/Various/Model/ModelHeartRateDevice.swift +++ b/Moblin/Various/Model/ModelHeartRateDevice.swift @@ -73,6 +73,7 @@ extension Model: HeartRateDeviceDelegate { return } self.heartRates[device.name.lowercased()] = heartRate + self.realtimeIrl?.updateHeartRate(heartRate) } } } diff --git a/Moblin/Various/Model/ModelLocation.swift b/Moblin/Various/Model/ModelLocation.swift index 6f0fd0193..28221e8d0 100644 --- a/Moblin/Various/Model/ModelLocation.swift +++ b/Moblin/Various/Model/ModelLocation.swift @@ -69,9 +69,11 @@ extension Model { func reloadRealtimeIrl() { realtimeIrl?.stop() + stopRealtimeIrlPedometer() realtimeIrl = nil if isRealtimeIrlConfigured() { realtimeIrl = RealtimeIrl(baseUrl: stream.realtimeIrlBaseUrl, pushKey: stream.realtimeIrlPushKey) + startRealtimeIrlPedometer() } } diff --git a/Moblin/Various/Model/ModelPedometer.swift b/Moblin/Various/Model/ModelPedometer.swift new file mode 100644 index 000000000..7ce7de681 --- /dev/null +++ b/Moblin/Various/Model/ModelPedometer.swift @@ -0,0 +1,36 @@ +import CoreMotion + +extension Model { + func startRealtimeIrlPedometer() { + guard isLive else { + return + } + guard isRealtimeIrlConfigured(), CMPedometer.isStepCountingAvailable() else { + return + } + guard !pedometerUpdatesActive else { + return + } + + pedometerUpdatesActive = true + pedometer.startUpdates(from: Date()) { [weak self] data, error in + guard let self else { + return + } + guard error == nil, let steps = data?.numberOfSteps.intValue else { + return + } + DispatchQueue.main.async { + self.realtimeIrl?.updatePedometerSteps(steps) + } + } + } + + func stopRealtimeIrlPedometer() { + guard pedometerUpdatesActive else { + return + } + pedometer.stopUpdates() + pedometerUpdatesActive = false + } +} diff --git a/Moblin/Various/Model/ModelStream.swift b/Moblin/Various/Model/ModelStream.swift index 9a9c1a88f..ef2fffede 100644 --- a/Moblin/Various/Model/ModelStream.swift +++ b/Moblin/Various/Model/ModelStream.swift @@ -95,6 +95,7 @@ extension Model { streamTotalChatMessages = 0 updateScreenAutoOff() startNetStream() + startRealtimeIrlPedometer() startFetchingYouTubeChatVideoId() if stream.recording.autoStartRecording { startRecording() @@ -114,6 +115,7 @@ extension Model { setIsLive(value: false) updateScreenAutoOff() realtimeIrl?.stop() + stopRealtimeIrlPedometer() stopFetchingYouTubeChatVideoId() if !streaming { return false From 27d1f38c27c32b12eeb4e8b7ab2b98071c751282 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 2 Dec 2025 13:16:47 +0000 Subject: [PATCH 2/2] Refactor RealtimeIrl.swift --- .../RealtimeIrl/RealtimeIrl.swift | 86 ++++++++----------- 1 file changed, 34 insertions(+), 52 deletions(-) diff --git a/Moblin/Integrations/RealtimeIrl/RealtimeIrl.swift b/Moblin/Integrations/RealtimeIrl/RealtimeIrl.swift index f2ddf6e3d..effdacc93 100644 --- a/Moblin/Integrations/RealtimeIrl/RealtimeIrl.swift +++ b/Moblin/Integrations/RealtimeIrl/RealtimeIrl.swift @@ -5,16 +5,16 @@ class RealtimeIrl { private let pushUrl: URL private let stopUrl: URL private var updateCount = 0 - private var pedometerStepsWatch: (value: Int, date: Date)? - private var pedometerStepsDevice: (value: Int, date: Date)? - private var heartRateWatch: (value: Int, date: Date)? - private var heartRateDevice: (value: Int, date: Date)? - private var cyclingPowerWatch: (value: Int, date: Date)? - private var cyclingPowerDevice: (value: Int, date: Date)? - private var cyclingCrankWatch: (value: Int, date: Date)? - private var cyclingCrankDevice: (value: Int, date: Date)? - private var cyclingWheelWatch: (value: Int, date: Date)? - private var cyclingWheelDevice: (value: Int, date: Date)? + private var pedometerStepsWatch: Int? + private var pedometerStepsDevice: Int? + private var heartRateWatch: Int? + private var heartRateDevice: Int? + private var cyclingPowerWatch: Int? + private var cyclingPowerDevice: Int? + private var cyclingCrankWatch: Int? + private var cyclingCrankDevice: Int? + private var cyclingWheelWatch: Int? + private var cyclingWheelDevice: Int? private struct Payload: Encodable { let latitude: Double @@ -30,24 +30,11 @@ class RealtimeIrl { let cyclingWheel: Int? } - private func watchFirst( - watch: (value: Int, date: Date)?, - device: (value: Int, date: Date)? - ) -> Int? { - if let watch { - return watch.value - } - if let device { - return device.value - } - return nil - } - - private var pedometerSteps: Int? { watchFirst(watch: pedometerStepsWatch, device: pedometerStepsDevice) } - private var heartRate: Int? { watchFirst(watch: heartRateWatch, device: heartRateDevice) } - private var cyclingPower: Int? { watchFirst(watch: cyclingPowerWatch, device: cyclingPowerDevice) } - private var cyclingCrank: Int? { watchFirst(watch: cyclingCrankWatch, device: cyclingCrankDevice) } - private var cyclingWheel: Int? { watchFirst(watch: cyclingWheelWatch, device: cyclingWheelDevice) } + private var pedometerSteps: Int? { pedometerStepsWatch ?? pedometerStepsDevice } + private var heartRate: Int? { heartRateWatch ?? heartRateDevice } + private var cyclingPower: Int? { cyclingPowerWatch ?? cyclingPowerDevice } + private var cyclingCrank: Int? { cyclingCrankWatch ?? cyclingCrankDevice } + private var cyclingWheel: Int? { cyclingWheelWatch ?? cyclingWheelDevice } init?(baseUrl: String, pushKey: String) { guard let url = URL(string: "\(baseUrl)/push?key=\(pushKey)") else { @@ -93,54 +80,46 @@ class RealtimeIrl { func updatePedometerSteps(_ steps: Int, fromWatch: Bool = false) { if fromWatch { - pedometerStepsWatch = (steps, Date()) + pedometerStepsWatch = steps } else { - pedometerStepsDevice = (steps, Date()) + pedometerStepsDevice = steps } } func updateHeartRate(_ heartRate: Int, fromWatch: Bool = false) { if fromWatch { - heartRateWatch = (heartRate, Date()) + heartRateWatch = heartRate } else { - heartRateDevice = (heartRate, Date()) + heartRateDevice = heartRate } } func updateCyclingPower(_ power: Int, fromWatch: Bool = false) { if fromWatch { - cyclingPowerWatch = (power, Date()) + cyclingPowerWatch = power } else { - cyclingPowerDevice = (power, Date()) + cyclingPowerDevice = power } } func updateCyclingCrank(_ cadence: Int, fromWatch: Bool = false) { if fromWatch { - cyclingCrankWatch = (cadence, Date()) + cyclingCrankWatch = cadence } else { - cyclingCrankDevice = (cadence, Date()) + cyclingCrankDevice = cadence } } func updateCyclingWheel(_ rpm: Int?, fromWatch: Bool = false) { if fromWatch { - if let rpm { - cyclingWheelWatch = (rpm, Date()) - } else { - cyclingWheelWatch = nil - } - } else { - if let rpm { - cyclingWheelDevice = (rpm, Date()) - } else { - cyclingWheelDevice = nil - } + cyclingWheelWatch = rpm + } + else { + cyclingWheelDevice = rpm } } - func stop() { - updateCount = 0 + private func resetState() { pedometerStepsWatch = nil pedometerStepsDevice = nil heartRateWatch = nil @@ -151,10 +130,13 @@ class RealtimeIrl { cyclingCrankDevice = nil cyclingWheelWatch = nil cyclingWheelDevice = nil + } + + func stop() { + updateCount = 0 + resetState() var request = URLRequest(url: stopUrl) request.httpMethod = "POST" - URLSession.shared.dataTask(with: request) { _, _, _ in - } - .resume() + URLSession.shared.dataTask(with: request).resume() } }