diff --git a/.swiftformat b/.swiftformat index ebad891..d812125 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1 +1,9 @@ --self init-only +--indent 2 +--ifdef indent +--trimwhitespace always +--wraparguments preserve +--wrapcollections preserve +--wrapconditions after-first +--typeblanklines preserve +--commas inline diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..38ad7e9 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,6 @@ +FROM swift:5.10 +WORKDIR /app +COPY ./Package.* ./ +RUN swift package resolve +COPY . . +CMD ["/bin/bash", "-xc", "swift", "test"] diff --git a/Makefile b/Makefile index 38b891f..e791357 100755 --- a/Makefile +++ b/Makefile @@ -23,6 +23,8 @@ stop-mosquitto: @docker-compose rm -f mosquitto || true test-docker: - @docker-compose run -i test + @docker-compose run --remove-orphans -i --rm test @docker-compose kill mosquitto-test @docker-compose rm -f + +test: test-docker diff --git a/Sources/Models/TemperatureAndHumiditySensor.swift b/Sources/Models/TemperatureAndHumiditySensor.swift index d27343c..fdd067a 100644 --- a/Sources/Models/TemperatureAndHumiditySensor.swift +++ b/Sources/Models/TemperatureAndHumiditySensor.swift @@ -1,65 +1,117 @@ import Psychrometrics +/// Represents a temperature and humidity sensor that can be used to derive +/// the dew-point temperature and enthalpy values. +/// public struct TemperatureAndHumiditySensor: Equatable, Identifiable { - /// The identifier of the sensor, same as the location. - public var id: Location { location } - public let altitude: Length + /// The identifier of the sensor, same as the location. + public var id: Location { location } - /// The location identifier of the sensor - public let location: Location + /// The altitude of the sensor. + public let altitude: Length - /// The current temperature value of the sensor. - @TrackedChanges - public var temperature: Temperature? + /// The current humidity value of the sensor. + @TrackedChanges + public var humidity: RelativeHumidity? - /// The current humidity value of the sensor. - @TrackedChanges - public var humidity: RelativeHumidity? + /// The location identifier of the sensor + public let location: Location - /// The psychrometric units of the sensor. - public let units: PsychrometricEnvironment.Units + /// The current temperature value of the sensor. + @TrackedChanges + public var temperature: Temperature? + + /// The topics to listen for updated sensor values. + public let topics: Topics + + /// The psychrometric units of the sensor. + public let units: PsychrometricEnvironment.Units + + /// Create a new temperature and humidity sensor. + /// + /// - Parameters: + /// - location: The location of the sensor. + /// - altitude: The altitude of the sensor. + /// - temperature: The current temperature value of the sensor. + /// - humidity: The current relative humidity value of the sensor. + /// - needsProcessed: If the sensor needs to be processed. + /// - units: The unit of measure for the sensor. + public init( + location: Location, + altitude: Length = .feet(800.0), + temperature: Temperature? = nil, + humidity: RelativeHumidity? = nil, + needsProcessed: Bool = false, + units: PsychrometricEnvironment.Units = .imperial, + topics: Topics? = nil + ) { + self.altitude = altitude + self.location = location + self._temperature = TrackedChanges(wrappedValue: temperature, needsProcessed: needsProcessed) + self._humidity = TrackedChanges(wrappedValue: humidity, needsProcessed: needsProcessed) + self.units = units + self.topics = topics ?? .init(location: location) + } + + /// The calculated dew-point temperature of the sensor. + public var dewPoint: DewPoint? { + guard let temperature = temperature, + let humidity = humidity, + !temperature.rawValue.isNaN, + !humidity.rawValue.isNaN + else { return nil } + return .init(dryBulb: temperature, humidity: humidity, units: units) + } + + /// The calculated enthalpy of the sensor. + public var enthalpy: EnthalpyOf? { + guard let temperature = temperature, + let humidity = humidity, + !temperature.rawValue.isNaN, + !humidity.rawValue.isNaN + else { return nil } + return .init(dryBulb: temperature, humidity: humidity, altitude: altitude, units: units) + } + + /// Check whether any of the sensor values have changed and need processed. + public var needsProcessed: Bool { + get { $temperature.needsProcessed || $humidity.needsProcessed } + set { + $temperature.needsProcessed = newValue + $humidity.needsProcessed = newValue + } + } + + /// Represents the different locations of a temperature and humidity sensor, which can + /// be used to derive the topic to both listen and publish new values to. + public enum Location: String, Equatable, Hashable { + case mixedAir = "mixed-air" + case postCoil = "post-coil" + case `return` + case supply + } + + /// Represents the MQTT topics to listen for updated sensor values on. + public struct Topics: Equatable { + + /// The temperature topic of the sensor. + public let temperature: String + + /// The humidity topic of the sensor. + public let humidity: String public init( - location: Location, - altitude: Length = .feet(800.0), - temperature: Temperature? = nil, - humidity: RelativeHumidity? = nil, - needsProcessed: Bool = false, - units: PsychrometricEnvironment.Units = .imperial + temperature: String, + humidity: String ) { - self.altitude = altitude - self.location = location - self._temperature = .init(wrappedValue: temperature, needsProcessed: needsProcessed) - self._humidity = .init(wrappedValue: humidity, needsProcessed: needsProcessed) - self.units = units + self.temperature = temperature + self.humidity = humidity } - /// The calculated dew-point temperature of the sensor. - public var dewPoint: DewPoint? { - guard let temperature = temperature, - let humidity = humidity, - !temperature.rawValue.isNaN, - !humidity.rawValue.isNaN - else { return nil } - return .init(dryBulb: temperature, humidity: humidity, units: units) - } - - /// Check whether any of the sensor values have changed and need processed. - public var needsProcessed: Bool { - get { $temperature.needsProcessed || $humidity.needsProcessed } - set { - $temperature.needsProcessed = newValue - $humidity.needsProcessed = newValue - } - } - - /// Represents the different locations of a temperature and humidity sensor, which can - /// be used to derive the topic to both listen and publish new values to. - public enum Location: String, Equatable, Hashable { - case mixedAir = "mixed-air" - case postCoil = "post-coil" - case `return` - case supply + init(location: TemperatureAndHumiditySensor.Location) { + self.temperature = "sensors/\(location.rawValue)/temperature" + self.humidity = "sensors/\(location.rawValue)/temperature" } + } } diff --git a/Sources/Models/Topics.swift b/Sources/Models/Topics.swift index e17fd54..96aa85e 100755 --- a/Sources/Models/Topics.swift +++ b/Sources/Models/Topics.swift @@ -1,7 +1,5 @@ - /// A container for all the different MQTT topics that are needed by the application. public struct Topics: Codable, Equatable { - /// The command topics the application can publish to. public var commands: Commands @@ -35,7 +33,6 @@ public struct Topics: Codable, Equatable { /// Represents the sensor topics. public struct Sensors: Codable, Equatable { - public var mixedAirSensor: TemperatureAndHumiditySensor public var postCoilSensor: TemperatureAndHumiditySensor public var returnAirSensor: TemperatureAndHumiditySensor @@ -81,7 +78,6 @@ public struct Topics: Codable, Equatable { /// A container for set point related topics used by the application. public struct SetPoints: Codable, Equatable { - /// The topic for the humidify set point. public var humidify: Humidify @@ -103,7 +99,6 @@ public struct Topics: Codable, Equatable { /// A container for the humidification set point topics used by the application. public struct Humidify: Codable, Equatable { - /// The topic for dew point control mode set point. public var dewPoint: String @@ -126,7 +121,6 @@ public struct Topics: Codable, Equatable { /// A container for dehumidifcation set point topics. public struct Dehumidify: Codable, Equatable { - /// A low setting for dew point control modes. public var lowDewPoint: String @@ -162,7 +156,6 @@ public struct Topics: Codable, Equatable { /// A container for control state topics used by the application. public struct States: Codable, Equatable { - /// The topic for the control mode. public var mode: String @@ -183,7 +176,6 @@ public struct Topics: Codable, Equatable { /// A container for reading the current state of a relay. public struct Relays: Codable, Equatable { - /// The dehumidification stage-1 relay topic. public var dehumdification1: String @@ -213,7 +205,6 @@ public struct Topics: Codable, Equatable { /// A container for commands topics that the application can publish to. public struct Commands: Codable, Equatable { - /// The relay command topics. public var relays: Relays @@ -227,7 +218,6 @@ public struct Topics: Codable, Equatable { /// A container for relay command topics used by the application. public struct Relays: Codable, Equatable { - /// The dehumidification stage-1 relay topic. public var dehumidification1: String @@ -257,8 +247,9 @@ public struct Topics: Codable, Equatable { } // MARK: Helpers -extension Topics.Sensors.TemperatureAndHumiditySensor { - public static func `default`(location: String) -> Self { + +public extension Topics.Sensors.TemperatureAndHumiditySensor { + static func `default`(location: String) -> Self { .init( temperature: "sensors/\(location)/temperature", humidity: "sensors/\(location)/humidity", diff --git a/Tests/ClientTests/AsyncClientTests.swift b/Tests/ClientTests/AsyncClientTests.swift index afd9a1d..847520a 100755 --- a/Tests/ClientTests/AsyncClientTests.swift +++ b/Tests/ClientTests/AsyncClientTests.swift @@ -1,22 +1,24 @@ -import XCTest +@testable import ClientLive import EnvVars import Logging import Models -@testable import ClientLive +import MQTTNIO +import NIO import Psychrometrics +import XCTest final class AsyncClientTests: XCTestCase { - + static let hostname = ProcessInfo.processInfo.environment["MOSQUITTO_SERVER"] ?? "localhost" - + static let logger: Logger = { var logger = Logger(label: "AsyncClientTests") logger.logLevel = .trace return logger }() - + func createClient(identifier: String) -> AsyncClient { - let envVars = EnvVars.init( + let envVars = EnvVars( appEnv: .testing, host: Self.hostname, port: "1883", @@ -26,13 +28,13 @@ final class AsyncClientTests: XCTestCase { ) return .init(envVars: envVars, logger: Self.logger) } - + func testConnectAndShutdown() async throws { let client = createClient(identifier: "testConnectAndShutdown") await client.connect() await client.shutdown() } - + func testPublishingSensor() async throws { let client = createClient(identifier: "testPublishingSensor") await client.connect() @@ -42,4 +44,73 @@ final class AsyncClientTests: XCTestCase { try await client.publishSensor(.mixed(.init(temperature: 72.123, humidity: 50.5, needsProcessed: true))) await client.shutdown() } + + func testNewSensorSyntax() async throws { + let client = createClient(identifier: "testNewSensorSyntax") + let mqtt = client.client + let receivedPublishInfo = PublishInfoContainer() + let payload = ByteBufferAllocator().buffer(string: "75.123") + let sensor = TemperatureAndHumiditySensor(location: .return) + + await client.connect() + + try await mqtt.subscribeToTemperature(sensor: sensor) + + let listener = mqtt.createPublishListener() + + Task { [receivedPublishInfo] in + for await result in listener { + switch result { + case let .failure(error): + XCTFail("\(error)") + case let .success(publish): + await receivedPublishInfo.addPublishInfo(publish) + } + } + } + + try await mqtt.publish(to: sensor.topics.temperature, payload: payload, qos: .atLeastOnce) + + try await Task.sleep(for: .seconds(2)) + + XCTAssertEqual(receivedPublishInfo.count, 1) + + if let publish = receivedPublishInfo.first { + var buffer = publish.payload + let string = buffer.readString(length: buffer.readableBytes) + XCTAssertEqual(string, "75.123") + } else { + XCTFail("Did not receive any publish info.") + } + + try await mqtt.disconnect() + try mqtt.syncShutdownGracefully() + } +} + +// MARK: Helpers for tests, some of these should be able to be removed once the AsyncClient interface is done. + +extension MQTTClient { + + func subscribeToTemperature(sensor: TemperatureAndHumiditySensor) async throws { + _ = try await subscribe(to: [ + .init(topicFilter: sensor.topics.temperature, qos: .atLeastOnce) + ]) + } +} + +class PublishInfoContainer { + private var receivedPublishInfo: [MQTTPublishInfo] + + init() { + self.receivedPublishInfo = [] + } + + func addPublishInfo(_ info: MQTTPublishInfo) async { + receivedPublishInfo.append(info) + } + + var count: Int { receivedPublishInfo.count } + + var first: MQTTPublishInfo? { receivedPublishInfo.first } } diff --git a/Tests/ClientTests/ClientTests.swift b/Tests/ClientTests/ClientTests.swift index e45539e..d5727f8 100755 --- a/Tests/ClientTests/ClientTests.swift +++ b/Tests/ClientTests/ClientTests.swift @@ -12,7 +12,7 @@ import XCTest final class ClientLiveTests: XCTestCase { static let hostname = ProcessInfo.processInfo.environment["MOSQUITTO_SERVER"] ?? "localhost" let topics = Topics() - + // func test_mqtt_subscription() throws { // let mqttClient = createMQTTClient(identifier: "test_subscription") // _ = try mqttClient.connect().wait() @@ -23,102 +23,101 @@ final class ClientLiveTests: XCTestCase { // try mqttClient.disconnect().wait() // try mqttClient.syncShutdownGracefully() // } - + func test_mqtt_listener() throws { let lock = Lock() var publishRecieved: [MQTTPublishInfo] = [] let payloadString = "test" let payload = ByteBufferAllocator().buffer(string: payloadString) - - let client = self.createMQTTClient(identifier: "testMQTTListener_publisher") + + let client = createMQTTClient(identifier: "testMQTTListener_publisher") _ = try client.connect().wait() client.addPublishListener(named: "test") { result in switch result { - case .success(let publish): + case let .success(publish): var buffer = publish.payload let string = buffer.readString(length: buffer.readableBytes) XCTAssertEqual(string, payloadString) lock.withLock { publishRecieved.append(publish) } - case .failure(let error): + case let .failure(error): XCTFail("\(error)") } } - + try client.publish(to: "testMQTTSubscribe", payload: payload, qos: .atLeastOnce, retain: true).wait() let sub = try client.v5.subscribe(to: [.init(topicFilter: "testMQTTSubscribe", qos: .atLeastOnce)]).wait() XCTAssertEqual(sub.reasons[0], .grantedQoS1) - + Thread.sleep(forTimeInterval: 2) lock.withLock { XCTAssertEqual(publishRecieved.count, 1) } - + try client.disconnect().wait() try client.syncShutdownGracefully() - } - + func test_client2_returnTemperature_listener() throws { let mqttClient = createMQTTClient(identifier: "return-temperature-tests") let state = State() let topics = Topics() let client = Client.live(client: mqttClient, state: state, topics: topics) - + client.addListeners() try client.connect().wait() try client.subscribe().wait() - + _ = try mqttClient.publish( to: topics.sensors.returnAirSensor.temperature, payload: ByteBufferAllocator().buffer(string: "75.1234"), qos: .atLeastOnce ).wait() - + Thread.sleep(forTimeInterval: 2) - + XCTAssertEqual(state.sensors.returnAirSensor.temperature, .celsius(75.1234)) try mqttClient.disconnect().wait() try mqttClient.syncShutdownGracefully() // try client.shutdown().wait() } - + func test_client2_returnSensor_publish() throws { let mqttClient = createMQTTClient(identifier: "return-temperature-tests") let state = State() let topics = Topics() let client = Client.live(client: mqttClient, state: state, topics: topics) - + client.addListeners() try client.connect().wait() try client.subscribe().wait() - + _ = try mqttClient.publish( to: topics.sensors.returnAirSensor.temperature, payload: ByteBufferAllocator().buffer(string: "75.1234"), qos: .atLeastOnce ).wait() - + _ = try mqttClient.publish( to: topics.sensors.returnAirSensor.humidity, payload: ByteBufferAllocator().buffer(string: "\(50.0)"), qos: .atLeastOnce ).wait() - + Thread.sleep(forTimeInterval: 2) XCTAssert(state.sensors.returnAirSensor.needsProcessed) - + try client.publishSensor(.return(state.sensors.returnAirSensor)).wait() XCTAssertFalse(state.sensors.returnAirSensor.needsProcessed) - + try mqttClient.disconnect().wait() try mqttClient.syncShutdownGracefully() // try client.shutdown().wait() } - + // func test_fetch_humidity() throws { // let lock = Lock() // let publishClient = createMQTTClient(identifier: "publishHumidity") @@ -144,19 +143,20 @@ final class ClientLiveTests: XCTestCase { // try mqttClient.disconnect().wait() // try mqttClient.syncShutdownGracefully() // } - + // MARK: - Helpers + func createMQTTClient(identifier: String) -> MQTTNIO.MQTTClient { MQTTNIO.MQTTClient( - host: Self.hostname, - port: 1883, - identifier: identifier, - eventLoopGroupProvider: .shared(eventLoopGroup), - logger: self.logger, - configuration: .init(version: .v5_0) - ) + host: Self.hostname, + port: 1883, + identifier: identifier, + eventLoopGroupProvider: .shared(eventLoopGroup), + logger: logger, + configuration: .init(version: .v5_0) + ) } - + // func createWebSocketClient(identifier: String) -> MQTTNIO.MQTTClient { // MQTTNIO.MQTTClient( // host: Self.hostname, @@ -167,7 +167,7 @@ final class ClientLiveTests: XCTestCase { // configuration: .init(useWebSockets: true, webSocketURLPath: "/mqtt") // ) // } - + // Uses default topic names. // func createClient(mqttClient: MQTTNIO.MQTTClient, autoConnect: Bool = true) throws -> Client.MQTTClient { // if autoConnect { @@ -175,12 +175,12 @@ final class ClientLiveTests: XCTestCase { // } // return .live(client: mqttClient, topics: .init()) // } - + let logger: Logger = { - var logger = Logger(label: "MQTTTests") - logger.logLevel = .trace - return logger + var logger = Logger(label: "MQTTTests") + logger.logLevel = .trace + return logger }() - - let eventLoopGroup = MultiThreadedEventLoopGroup.init(numberOfThreads: 1) + + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) } diff --git a/docker-compose.yaml b/docker-compose.yaml index f2edbad..10dc381 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -6,15 +6,15 @@ services: env_file: .env test: - image: swift:5.10 - #build: - #context: ./ + build: + context: . + dockerfile: Dockerfile.test platform: linux/amd64 working_dir: /app networks: - test - volumes: - - .:/app +# volumes: +# - .:/app depends_on: - mosquitto-test environment: @@ -44,4 +44,3 @@ networks: test: driver: bridge external: false -