Skip to content
Draft
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
65 changes: 54 additions & 11 deletions Moblin/Integrations/CyclingPowerDevice/CyclingPowerDevice.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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 {
Expand Down
111 changes: 99 additions & 12 deletions Moblin/Integrations/RealtimeIrl/RealtimeIrl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,36 @@ class RealtimeIrl {
private let pushUrl: URL
private let stopUrl: URL
private var updateCount = 0
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
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 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 {
Expand All @@ -29,27 +59,84 @@ 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
} else {
pedometerStepsDevice = steps
}
}
Copy link
Owner

@eerimoq eerimoq Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this class have to know about the source (watch or device) of the measurement? Not needed imo.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we should prefer using watch as it should be more precise I think

Copy link
Owner

@eerimoq eerimoq Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we are lucky the watch work at all =) I probably didn't officially added to to text widget because it didn't work, but maybe..

either way, logic like this should not be part of this class, but some other class that can be used by the text widget as well.


func updateHeartRate(_ heartRate: Int, fromWatch: Bool = false) {
if fromWatch {
heartRateWatch = heartRate
} else {
heartRateDevice = heartRate
}
}

func updateCyclingPower(_ power: Int, fromWatch: Bool = false) {
if fromWatch {
cyclingPowerWatch = power
} else {
cyclingPowerDevice = power
}
}

func updateCyclingCrank(_ cadence: Int, fromWatch: Bool = false) {
if fromWatch {
cyclingCrankWatch = cadence
} else {
cyclingCrankDevice = cadence
}
}

func updateCyclingWheel(_ rpm: Int?, fromWatch: Bool = false) {
if fromWatch {
cyclingWheelWatch = rpm
}
else {
cyclingWheelDevice = rpm
}
}

private func resetState() {
pedometerStepsWatch = nil
pedometerStepsDevice = nil
heartRateWatch = nil
heartRateDevice = nil
cyclingPowerWatch = nil
cyclingPowerDevice = nil
cyclingCrankWatch = nil
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()
}
}
2 changes: 2 additions & 0 deletions Moblin/Various/Model/Model.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions Moblin/Various/Model/ModelAppleWatch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}
}
Expand Down
7 changes: 5 additions & 2 deletions Moblin/Various/Model/ModelCyclingPowerDevice.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
1 change: 1 addition & 0 deletions Moblin/Various/Model/ModelHeartRateDevice.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ extension Model: HeartRateDeviceDelegate {
return
}
self.heartRates[device.name.lowercased()] = heartRate
self.realtimeIrl?.updateHeartRate(heartRate)
}
}
}
2 changes: 2 additions & 0 deletions Moblin/Various/Model/ModelLocation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,11 @@ extension Model {

func reloadRealtimeIrl() {
realtimeIrl?.stop()
stopRealtimeIrlPedometer()
realtimeIrl = nil
if isRealtimeIrlConfigured() {
realtimeIrl = RealtimeIrl(baseUrl: stream.realtimeIrlBaseUrl, pushKey: stream.realtimeIrlPushKey)
startRealtimeIrlPedometer()
}
}

Expand Down
36 changes: 36 additions & 0 deletions Moblin/Various/Model/ModelPedometer.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
2 changes: 2 additions & 0 deletions Moblin/Various/Model/ModelStream.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ extension Model {
streamTotalChatMessages = 0
updateScreenAutoOff()
startNetStream()
startRealtimeIrlPedometer()
startFetchingYouTubeChatVideoId()
if stream.recording.autoStartRecording {
startRecording()
Expand All @@ -114,6 +115,7 @@ extension Model {
setIsLive(value: false)
updateScreenAutoOff()
realtimeIrl?.stop()
stopRealtimeIrlPedometer()
stopFetchingYouTubeChatVideoId()
if !streaming {
return false
Expand Down