diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index c809b4458d..af8264db4f 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -388,6 +388,7 @@ 11C9E43C2505B04E00492A88 /* HACoreAudioObjectSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11C9E43A2505B04E00492A88 /* HACoreAudioObjectSystem.swift */; }; 11CB98C6249DE15B00B05222 /* LastUpdateSensor.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11CB98C5249DE15B00B05222 /* LastUpdateSensor.test.swift */; }; 11CB98C8249DE24100B05222 /* PedometerSensor.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11CB98C7249DE24000B05222 /* PedometerSensor.test.swift */; }; + 8DFA3DEE4881E59961C3B5E2 /* BarometerSensor.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07A4CD40BADC119045547D77 /* BarometerSensor.test.swift */; }; 11CB98CA249E62E700B05222 /* Version+HA.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11CB98C9249E62E700B05222 /* Version+HA.swift */; }; 11CB98CB249E62E700B05222 /* Version+HA.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11CB98C9249E62E700B05222 /* Version+HA.swift */; }; 11CB98CD249E637300B05222 /* Version+HA.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11CB98CC249E637300B05222 /* Version+HA.test.swift */; }; @@ -1137,6 +1138,8 @@ 42E95C592CA46AD50010ECE3 /* ShareActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E95C582CA46AD50010ECE3 /* ShareActivityView.swift */; }; 42E9AFFF2CE63944009DDA46 /* AudioOutputSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E9AFFE2CE63944009DDA46 /* AudioOutputSensor.swift */; }; 42E9B0002CE63944009DDA46 /* AudioOutputSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E9AFFE2CE63944009DDA46 /* AudioOutputSensor.swift */; }; + C3EB3740FA097F36D51F525E /* BarometerSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1396822195C7FF562AB891F2 /* BarometerSensor.swift */; }; + D87EC7A89E0515C4CAB93220 /* BarometerSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1396822195C7FF562AB891F2 /* BarometerSensor.swift */; }; 42EB030A2C6E4D0E00A184A6 /* WatchMagicViewRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42EB03092C6E4D0E00A184A6 /* WatchMagicViewRow.swift */; }; 42EEEFE22E2791430080E973 /* Service.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42EEEFE12E2791430080E973 /* Service.swift */; }; 42EEEFE32E2791430080E973 /* Service.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42EEEFE12E2791430080E973 /* Service.swift */; }; @@ -2175,6 +2178,7 @@ 11C9E43A2505B04E00492A88 /* HACoreAudioObjectSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HACoreAudioObjectSystem.swift; sourceTree = ""; }; 11CB98C5249DE15B00B05222 /* LastUpdateSensor.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastUpdateSensor.test.swift; sourceTree = ""; }; 11CB98C7249DE24000B05222 /* PedometerSensor.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PedometerSensor.test.swift; sourceTree = ""; }; + 07A4CD40BADC119045547D77 /* BarometerSensor.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarometerSensor.test.swift; sourceTree = ""; }; 11CB98C9249E62E700B05222 /* Version+HA.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Version+HA.swift"; sourceTree = ""; }; 11CB98CC249E637300B05222 /* Version+HA.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Version+HA.test.swift"; sourceTree = ""; }; 11CD94B424B2C06700BA801D /* WebhookResponseUpdateSensors.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebhookResponseUpdateSensors.test.swift; sourceTree = ""; }; @@ -2971,6 +2975,7 @@ 42E95C562CA45EFA0010ECE3 /* OnboardingErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingErrorView.swift; sourceTree = ""; }; 42E95C582CA46AD50010ECE3 /* ShareActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareActivityView.swift; sourceTree = ""; }; 42E9AFFE2CE63944009DDA46 /* AudioOutputSensor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioOutputSensor.swift; sourceTree = ""; }; + 1396822195C7FF562AB891F2 /* BarometerSensor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarometerSensor.swift; sourceTree = ""; }; 42EB03092C6E4D0E00A184A6 /* WatchMagicViewRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchMagicViewRow.swift; sourceTree = ""; }; 42EEEFE12E2791430080E973 /* Service.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Service.swift; sourceTree = ""; }; 42EEEFE42E2792B20080E973 /* Service.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Service.test.swift; sourceTree = ""; }; @@ -4212,6 +4217,7 @@ B6D3B4EB225B26300082BB4F /* SensorContainer.swift */, 42E9AFFE2CE63944009DDA46 /* AudioOutputSensor.swift */, 11AF4D10249C7DFD006C74C0 /* ActivitySensor.swift */, + 1396822195C7FF562AB891F2 /* BarometerSensor.swift */, 11AF4D1B249C8AA0006C74C0 /* BatterySensor.swift */, 11AF4D1E249C8AF0006C74C0 /* ConnectivitySensor.swift */, 11AF4D21249C924B006C74C0 /* GeocoderSensor.swift */, @@ -4237,6 +4243,7 @@ isa = PBXGroup; children = ( 11AF4D29249D88C5006C74C0 /* ActivitySensor.test.swift */, + 07A4CD40BADC119045547D77 /* BarometerSensor.test.swift */, 11AF4D2B249D965C006C74C0 /* BatterySensor.test.swift */, 11AF4D2F249DCA87006C74C0 /* ConnectivitySensor.test.swift */, 11AF4D2D249DA5AF006C74C0 /* GeocoderSensor.test.swift */, @@ -9866,6 +9873,7 @@ 1120C580274638330046C38B /* PerServerContainer.swift in Sources */, 427647262C8F38590027B21F /* HAAreasRegistryResponse.swift in Sources */, 42E9AFFF2CE63944009DDA46 /* AudioOutputSensor.swift in Sources */, + C3EB3740FA097F36D51F525E /* BarometerSensor.swift in Sources */, 110ED56425A563D600489AF7 /* DisplaySensor.swift in Sources */, 42383F702D9576F700C745F2 /* AppTriggerSource.swift in Sources */, 4251AAC22C6CE9CB004CCC9D /* WatchConfig.swift in Sources */, @@ -10316,6 +10324,7 @@ 42FCCFDA2B9B19F70057783F /* ThreadClientService.swift in Sources */, 1101568724D7712F009424C9 /* TagManagerProtocol.swift in Sources */, 42E9B0002CE63944009DDA46 /* AudioOutputSensor.swift in Sources */, + D87EC7A89E0515C4CAB93220 /* BarometerSensor.swift in Sources */, 1141182624AF9A0500E6525C /* WebhookManager.swift in Sources */, 119A7E0F2529769A00D7000D /* UIImageView+UIActivityIndicator.swift in Sources */, 111858D624CB620500B8CDDC /* Intents.intentdefinition in Sources */, @@ -10377,6 +10386,7 @@ 1165707C2702BAF5003906A7 /* DiskCache.test.swift in Sources */, 11AF4D30249DCA88006C74C0 /* ConnectivitySensor.test.swift in Sources */, 11CB98C8249DE24100B05222 /* PedometerSensor.test.swift in Sources */, + 8DFA3DEE4881E59961C3B5E2 /* BarometerSensor.test.swift in Sources */, 42EEEFE52E2792B20080E973 /* Service.test.swift in Sources */, 118511C224B25BEB00D18F60 /* WebhookManager.test.swift in Sources */, 114CBAEB2839FC2500A9BAFF /* SecurityExceptions.test.swift in Sources */, diff --git a/Sources/Shared/API/Webhook/Sensors/BarometerSensor.swift b/Sources/Shared/API/Webhook/Sensors/BarometerSensor.swift new file mode 100644 index 0000000000..a5b78d3d8e --- /dev/null +++ b/Sources/Shared/API/Webhook/Sensors/BarometerSensor.swift @@ -0,0 +1,132 @@ +import CoreMotion +import Foundation +import PromiseKit + +final class BarometerSensorUpdateSignaler: BaseSensorUpdateSignaler, SensorProviderUpdateSignaler { + private let signal: () -> Void + private var lastPressureKpa: Double? + private var observationQueue: OperationQueue? + + /// The most recent altitude data received from CMAltimeter, used by BarometerSensor + /// to avoid starting a separate one-shot read that would conflict with the signaler's stream. + private(set) var latestData: CMAltitudeData? + + required init(signal: @escaping () -> Void) { + self.signal = signal + super.init(relatedSensorsIds: [.pressure]) + } + + override func observe() { + super.observe() + guard !isObserving else { return } + guard Current.barometer.isAvailable(), Current.barometer.isAuthorized() else { return } + + let queue = OperationQueue() + queue.name = "barometer-signaler" + observationQueue = queue + + Current.barometer.startUpdatesOnQueueHandler(queue) { [weak self] data, _ in + guard let self, let data else { return } + latestData = data + let newPressure = data.pressure.doubleValue + if let last = lastPressureKpa, abs(newPressure - last) < 0.01 { + // Less than 0.1 hPa change, skip update + return + } + lastPressureKpa = newPressure + signal() + } + isObserving = true + + #if DEBUG + notifyObservation?() + #endif + } + + override func stopObserving() { + super.stopObserving() + guard isObserving else { return } + Current.barometer.stopUpdates() + observationQueue = nil + lastPressureKpa = nil + latestData = nil + isObserving = false + } +} + +public class BarometerSensor: SensorProvider { + public enum BarometerError: Error, Equatable { + case unauthorized + case unavailable + case noData + } + + public let request: SensorProviderRequest + public required init(request: SensorProviderRequest) { + self.request = request + } + + public func sensors() -> Promise<[WebhookSensor]> { + let signaler: BarometerSensorUpdateSignaler = request.dependencies.updateSignaler(for: self) + + return firstly { + // If the signaler is actively observing, use its cached data to avoid + // starting a separate one-shot read that would stop the signaler's stream. + // If observing but no data yet, fall back to noData rather than racing. + if let cached = signaler.latestData { + return Promise.value(cached) + } else if signaler.isObserving { + return .init(error: BarometerError.noData) + } + return latestBarometerData() + }.map { data in + // CMAltitudeData.pressure is in kilopascals; HA pressure device class expects hPa (= mbar) + let pressureHpa = data.pressure.doubleValue * 10.0 + + let pressureSensor = WebhookSensor( + name: "Pressure", + uniqueID: WebhookSensorId.pressure.rawValue, + icon: "mdi:gauge", + deviceClass: .pressure, + state: round(pressureHpa * 100) / 100, + unit: "hPa" + ) + + return [pressureSensor] + } + } + + private func latestBarometerData() -> Promise { + guard Current.barometer.isAuthorized() else { + return .init(error: BarometerError.unauthorized) + } + + guard Current.barometer.isAvailable() else { + Current.Log.warning("Barometer is not available") + return .init(error: BarometerError.unavailable) + } + + let (promise, seal) = Promise.pending() + let queue = OperationQueue() + queue.name = "barometer-sensor" + + // startRelativeAltitudeUpdates is a streaming API, so an in-flight callback + // could arrive after stopUpdates(). Guard against double-resolving the promise. + var resolved = false + Current.barometer.startUpdatesOnQueueHandler(queue) { data, error in + Current.barometer.stopUpdates() + guard !resolved else { return } + resolved = true + + if let data { + seal.fulfill(data) + } else if let error { + seal.reject(error) + } else { + seal.reject(BarometerError.noData) + } + } + + return promise + } +} diff --git a/Sources/Shared/API/Webhook/WebhookSensorId.swift b/Sources/Shared/API/Webhook/WebhookSensorId.swift index 88471f25ad..0206908fba 100644 --- a/Sources/Shared/API/Webhook/WebhookSensorId.swift +++ b/Sources/Shared/API/Webhook/WebhookSensorId.swift @@ -22,4 +22,5 @@ public enum WebhookSensorId: String, CaseIterable { case appVersion = "app-version" case locationPermission = "location-permission" case focus + case pressure } diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index 19fbeba192..03413d559e 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -225,6 +225,7 @@ public class AppEnvironment { $0.register(provider: AppVersionSensor.self) $0.register(provider: LocationPermissionSensor.self) $0.register(provider: AudioOutputSensor.self) + $0.register(provider: BarometerSensor.self) } public var localized = LocalizedManager() @@ -452,6 +453,30 @@ public class AppEnvironment { public var pedometer = Pedometer() + /// Wrapper around CMAltimeter for barometric pressure readings + public struct Barometer { + private let underlyingAltimeter = CMAltimeter() + // isRelativeAltitudeAvailable checks for a barometer chip; the same hardware delivers + // both relative altitude and barometric pressure via startRelativeAltitudeUpdates. + public var isAvailable: () -> Bool = CMAltimeter.isRelativeAltitudeAvailable + public var isAuthorized: () -> Bool = { + guard !Current.isCatalyst else { return false } + return CMAltimeter.authorizationStatus() == .authorized + } + + public lazy var startUpdatesOnQueueHandler: ( + OperationQueue, @escaping CMAltitudeHandler + ) -> Void = { [underlyingAltimeter] queue, handler in + underlyingAltimeter.startRelativeAltitudeUpdates(to: queue, withHandler: handler) + } + + public lazy var stopUpdates: () -> Void = { [underlyingAltimeter] in + underlyingAltimeter.stopRelativeAltitudeUpdates() + } + } + + public var barometer = Barometer() + public var device = DeviceWrapper() public var matter = MatterWrapper() diff --git a/Tests/App/Webhook/WebhookSensorIdTests.swift b/Tests/App/Webhook/WebhookSensorIdTests.swift index cd234e9a49..3ba22aff38 100644 --- a/Tests/App/Webhook/WebhookSensorIdTests.swift +++ b/Tests/App/Webhook/WebhookSensorIdTests.swift @@ -24,8 +24,9 @@ struct WebhookSensorIdTests { assert(WebhookSensorId.appVersion.rawValue == "app-version") assert(WebhookSensorId.locationPermission.rawValue == "location-permission") assert(WebhookSensorId.focus.rawValue == "focus") + assert(WebhookSensorId.pressure.rawValue == "pressure") assert( - WebhookSensorId.allCases.count == 21, + WebhookSensorId.allCases.count == 22, "WebhookSensorId has different number of cases than defined in test, \(WebhookSensorId.allCases.count)" ) } diff --git a/Tests/Shared/Sensors/BarometerSensor.test.swift b/Tests/Shared/Sensors/BarometerSensor.test.swift new file mode 100644 index 0000000000..1a38dc42c7 --- /dev/null +++ b/Tests/Shared/Sensors/BarometerSensor.test.swift @@ -0,0 +1,288 @@ +import CoreMotion +import Foundation +import PromiseKit +@testable import Shared +import Version +import XCTest + +class BarometerSensorTests: XCTestCase { + private enum TestError: Error { + case someError + } + + private var request: SensorProviderRequest! + + override func setUp() { + super.setUp() + + request = .init( + reason: .trigger("unit-test"), + dependencies: .init(), + location: nil, + serverVersion: Version() + ) + + // start by assuming nothing is enabled/available + Current.barometer.isAuthorized = { false } + Current.barometer.isAvailable = { false } + Current.barometer.startUpdatesOnQueueHandler = { _, handler in handler(nil, nil) } + Current.barometer.stopUpdates = {} + } + + func testUnauthorizedReturnsError() { + let promise = BarometerSensor(request: request).sensors() + XCTAssertThrowsError(try hang(promise)) { error in + XCTAssertEqual(error as? BarometerSensor.BarometerError, .unauthorized) + } + } + + func testUnavailableReturnsError() { + Current.barometer.isAuthorized = { true } + let promise = BarometerSensor(request: request).sensors() + XCTAssertThrowsError(try hang(promise)) { error in + XCTAssertEqual(error as? BarometerSensor.BarometerError, .unavailable) + } + } + + func testNoDataReturnsError() { + Current.barometer.isAuthorized = { true } + Current.barometer.isAvailable = { true } + let promise = BarometerSensor(request: request).sensors() + XCTAssertThrowsError(try hang(promise)) { error in + XCTAssertEqual(error as? BarometerSensor.BarometerError, .noData) + } + } + + func testQueryErrorReturnsError() { + Current.barometer.isAuthorized = { true } + Current.barometer.isAvailable = { true } + Current.barometer.startUpdatesOnQueueHandler = { _, handler in handler(nil, TestError.someError) } + + let promise = BarometerSensor(request: request).sensors() + XCTAssertThrowsError(try hang(promise)) { error in + XCTAssertEqual(error as? TestError, .someError) + } + } + + func testStopUpdatesCalledAfterReading() { + Current.barometer.isAuthorized = { true } + Current.barometer.isAvailable = { true } + + var stopUpdatesCalled = false + Current.barometer.stopUpdates = { stopUpdatesCalled = true } + Current.barometer.startUpdatesOnQueueHandler = { _, handler in + handler(FakeAltitudeData(pressureValue: 101.325), nil) + } + + let promise = BarometerSensor(request: request).sensors() + _ = try? hang(promise) + XCTAssertTrue(stopUpdatesCalled) + } + + func testPressureConvertedToHpa() throws { + Current.barometer.isAuthorized = { true } + Current.barometer.isAvailable = { true } + // CMAltitudeData.pressure is in kilopascals; 101.325 kPa = 1013.25 hPa + Current.barometer.startUpdatesOnQueueHandler = { _, handler in + handler(FakeAltitudeData(pressureValue: 101.325), nil) + } + + let promise = BarometerSensor(request: request).sensors() + let sensors = try hang(promise) + + XCTAssertEqual(sensors.count, 1) + XCTAssertEqual(sensors[0].UniqueID, WebhookSensorId.pressure.rawValue) + XCTAssertEqual(sensors[0].Name, "Pressure") + XCTAssertEqual(sensors[0].Icon, "mdi:gauge") + XCTAssertEqual(sensors[0].DeviceClass, .pressure) + XCTAssertEqual(sensors[0].UnitOfMeasurement, "hPa") + XCTAssertEqual(sensors[0].State as? Double, 1013.25) + } + + func testPressureRoundedToTwoDecimalPlaces() throws { + Current.barometer.isAuthorized = { true } + Current.barometer.isAvailable = { true } + // 98.7654 kPa * 10 = 987.654, rounded to 987.65 + Current.barometer.startUpdatesOnQueueHandler = { _, handler in + handler(FakeAltitudeData(pressureValue: 98.7654), nil) + } + + let promise = BarometerSensor(request: request).sensors() + let sensors = try hang(promise) + + XCTAssertEqual(sensors[0].State as? Double, 987.65) + } + + func testSensorsUsesCachedDataWhenSignalerIsActive() throws { + Current.barometer.isAuthorized = { true } + Current.barometer.isAvailable = { true } + + var handler: CMAltitudeHandler? + var startCount = 0 + Current.barometer.startUpdatesOnQueueHandler = { _, h in + startCount += 1 + handler = h + } + Current.barometer.stopUpdates = {} + + // First call to sensors() starts the signaler and does a one-shot read + let sensor = BarometerSensor(request: request) + // Trigger the signaler's observe by getting the signaler registered + let signaler: BarometerSensorUpdateSignaler = request.dependencies.updateSignaler(for: sensor) + signaler.observe() + + // Simulate the signaler receiving data + handler?(FakeAltitudeData(pressureValue: 101.0), nil) + XCTAssertNotNil(signaler.latestData) + + // Reset count to track whether sensors() starts another read + startCount = 0 + + // Now calling sensors() should use cached data, not start a new one-shot + let promise = sensor.sensors() + let sensors = try hang(promise) + + XCTAssertEqual(sensors[0].State as? Double, 1010.0) // 101.0 kPa * 10 + // Should NOT have started another altimeter session + XCTAssertEqual(startCount, 0) + } + + func testSignalerStartsAndStopsUpdates() { + Current.barometer.isAuthorized = { true } + Current.barometer.isAvailable = { true } + + var startCalled = false + var stopCalled = false + Current.barometer.startUpdatesOnQueueHandler = { _, _ in startCalled = true } + Current.barometer.stopUpdates = { stopCalled = true } + + var signalCount = 0 + let signaler = BarometerSensorUpdateSignaler(signal: { signalCount += 1 }) + + signaler.observe() + XCTAssertTrue(startCalled) + + signaler.stopObserving() + XCTAssertTrue(stopCalled) + } + + func testSignalerClearsDataOnStop() { + Current.barometer.isAuthorized = { true } + Current.barometer.isAvailable = { true } + + var handler: CMAltitudeHandler? + Current.barometer.startUpdatesOnQueueHandler = { _, h in handler = h } + Current.barometer.stopUpdates = {} + + let signaler = BarometerSensorUpdateSignaler(signal: {}) + signaler.observe() + handler?(FakeAltitudeData(pressureValue: 101.0), nil) + XCTAssertNotNil(signaler.latestData) + + signaler.stopObserving() + XCTAssertNil(signaler.latestData) + } + + func testSignalerFiltersSmallPressureChanges() { + Current.barometer.isAuthorized = { true } + Current.barometer.isAvailable = { true } + + var handler: CMAltitudeHandler? + Current.barometer.startUpdatesOnQueueHandler = { _, h in handler = h } + Current.barometer.stopUpdates = {} + + var signalCount = 0 + let signaler = BarometerSensorUpdateSignaler(signal: { signalCount += 1 }) + signaler.observe() + + // First reading always signals + handler?(FakeAltitudeData(pressureValue: 101.325), nil) + XCTAssertEqual(signalCount, 1) + + // Tiny change (< 0.01 kPa = < 0.1 hPa) should not signal + handler?(FakeAltitudeData(pressureValue: 101.330), nil) + XCTAssertEqual(signalCount, 1) + + // Significant change (>= 0.01 kPa = >= 0.1 hPa) should signal + handler?(FakeAltitudeData(pressureValue: 101.340), nil) + XCTAssertEqual(signalCount, 2) + } + + func testSignalerIgnoresNilData() { + Current.barometer.isAuthorized = { true } + Current.barometer.isAvailable = { true } + + var handler: CMAltitudeHandler? + Current.barometer.startUpdatesOnQueueHandler = { _, h in handler = h } + Current.barometer.stopUpdates = {} + + var signalCount = 0 + let signaler = BarometerSensorUpdateSignaler(signal: { signalCount += 1 }) + signaler.observe() + + // nil data should not signal or cache + handler?(nil, nil) + XCTAssertEqual(signalCount, 0) + XCTAssertNil(signaler.latestData) + } + + func testSignalerDoesNotStartWhenUnavailable() { + Current.barometer.isAuthorized = { true } + Current.barometer.isAvailable = { false } + + var startCalled = false + Current.barometer.startUpdatesOnQueueHandler = { _, _ in startCalled = true } + + let signaler = BarometerSensorUpdateSignaler(signal: {}) + signaler.observe() + XCTAssertFalse(startCalled) + } + + func testSignalerDoesNotStartWhenUnauthorized() { + Current.barometer.isAuthorized = { false } + Current.barometer.isAvailable = { true } + + var startCalled = false + Current.barometer.startUpdatesOnQueueHandler = { _, _ in startCalled = true } + + let signaler = BarometerSensorUpdateSignaler(signal: {}) + signaler.observe() + XCTAssertFalse(startCalled) + } + + func testSignalerDoesNotDoubleObserve() { + Current.barometer.isAuthorized = { true } + Current.barometer.isAvailable = { true } + + var startCount = 0 + Current.barometer.startUpdatesOnQueueHandler = { _, _ in startCount += 1 } + Current.barometer.stopUpdates = {} + + let signaler = BarometerSensorUpdateSignaler(signal: {}) + signaler.observe() + signaler.observe() + XCTAssertEqual(startCount, 1) + } +} + +private class FakeAltitudeData: CMAltitudeData { + private let pressureKpa: NSNumber + + init(pressureValue: Double) { + self.pressureKpa = NSNumber(value: pressureValue) + super.init() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override var pressure: NSNumber { + pressureKpa + } + + override var relativeAltitude: NSNumber { + NSNumber(value: 0) + } +}