feat: Working on async integrations.

This commit is contained in:
2024-11-08 11:05:07 -05:00
parent 408e0484cd
commit e6d1d4578d
8 changed files with 245 additions and 116 deletions

View File

@@ -1 +1,9 @@
--self init-only --self init-only
--indent 2
--ifdef indent
--trimwhitespace always
--wraparguments preserve
--wrapcollections preserve
--wrapconditions after-first
--typeblanklines preserve
--commas inline

6
Dockerfile.test Normal file
View File

@@ -0,0 +1,6 @@
FROM swift:5.10
WORKDIR /app
COPY ./Package.* ./
RUN swift package resolve
COPY . .
CMD ["/bin/bash", "-xc", "swift", "test"]

View File

@@ -23,6 +23,8 @@ stop-mosquitto:
@docker-compose rm -f mosquitto || true @docker-compose rm -f mosquitto || true
test-docker: test-docker:
@docker-compose run -i test @docker-compose run --remove-orphans -i --rm test
@docker-compose kill mosquitto-test @docker-compose kill mosquitto-test
@docker-compose rm -f @docker-compose rm -f
test: test-docker

View File

@@ -1,65 +1,117 @@
import Psychrometrics 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 { 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 /// The altitude of the sensor.
public let location: Location public let altitude: Length
/// The current temperature value of the sensor. /// The current humidity value of the sensor.
@TrackedChanges @TrackedChanges
public var temperature: Temperature? public var humidity: RelativeHumidity?
/// The current humidity value of the sensor. /// The location identifier of the sensor
@TrackedChanges public let location: Location
public var humidity: RelativeHumidity?
/// The psychrometric units of the sensor. /// The current temperature value of the sensor.
public let units: PsychrometricEnvironment.Units @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<MoistAir>? {
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( public init(
location: Location, temperature: String,
altitude: Length = .feet(800.0), humidity: String
temperature: Temperature? = nil,
humidity: RelativeHumidity? = nil,
needsProcessed: Bool = false,
units: PsychrometricEnvironment.Units = .imperial
) { ) {
self.altitude = altitude self.temperature = temperature
self.location = location self.humidity = humidity
self._temperature = .init(wrappedValue: temperature, needsProcessed: needsProcessed)
self._humidity = .init(wrappedValue: humidity, needsProcessed: needsProcessed)
self.units = units
} }
/// The calculated dew-point temperature of the sensor. init(location: TemperatureAndHumiditySensor.Location) {
public var dewPoint: DewPoint? { self.temperature = "sensors/\(location.rawValue)/temperature"
guard let temperature = temperature, self.humidity = "sensors/\(location.rawValue)/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
} }
}
} }

View File

@@ -1,7 +1,5 @@
/// A container for all the different MQTT topics that are needed by the application. /// A container for all the different MQTT topics that are needed by the application.
public struct Topics: Codable, Equatable { public struct Topics: Codable, Equatable {
/// The command topics the application can publish to. /// The command topics the application can publish to.
public var commands: Commands public var commands: Commands
@@ -35,7 +33,6 @@ public struct Topics: Codable, Equatable {
/// Represents the sensor topics. /// Represents the sensor topics.
public struct Sensors: Codable, Equatable { public struct Sensors: Codable, Equatable {
public var mixedAirSensor: TemperatureAndHumiditySensor<State.Sensors.MixedAir> public var mixedAirSensor: TemperatureAndHumiditySensor<State.Sensors.MixedAir>
public var postCoilSensor: TemperatureAndHumiditySensor<State.Sensors.PostCoil> public var postCoilSensor: TemperatureAndHumiditySensor<State.Sensors.PostCoil>
public var returnAirSensor: TemperatureAndHumiditySensor<State.Sensors.Return> public var returnAirSensor: TemperatureAndHumiditySensor<State.Sensors.Return>
@@ -81,7 +78,6 @@ public struct Topics: Codable, Equatable {
/// A container for set point related topics used by the application. /// A container for set point related topics used by the application.
public struct SetPoints: Codable, Equatable { public struct SetPoints: Codable, Equatable {
/// The topic for the humidify set point. /// The topic for the humidify set point.
public var humidify: Humidify 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. /// A container for the humidification set point topics used by the application.
public struct Humidify: Codable, Equatable { public struct Humidify: Codable, Equatable {
/// The topic for dew point control mode set point. /// The topic for dew point control mode set point.
public var dewPoint: String public var dewPoint: String
@@ -126,7 +121,6 @@ public struct Topics: Codable, Equatable {
/// A container for dehumidifcation set point topics. /// A container for dehumidifcation set point topics.
public struct Dehumidify: Codable, Equatable { public struct Dehumidify: Codable, Equatable {
/// A low setting for dew point control modes. /// A low setting for dew point control modes.
public var lowDewPoint: String public var lowDewPoint: String
@@ -162,7 +156,6 @@ public struct Topics: Codable, Equatable {
/// A container for control state topics used by the application. /// A container for control state topics used by the application.
public struct States: Codable, Equatable { public struct States: Codable, Equatable {
/// The topic for the control mode. /// The topic for the control mode.
public var mode: String public var mode: String
@@ -183,7 +176,6 @@ public struct Topics: Codable, Equatable {
/// A container for reading the current state of a relay. /// A container for reading the current state of a relay.
public struct Relays: Codable, Equatable { public struct Relays: Codable, Equatable {
/// The dehumidification stage-1 relay topic. /// The dehumidification stage-1 relay topic.
public var dehumdification1: String public var dehumdification1: String
@@ -213,7 +205,6 @@ public struct Topics: Codable, Equatable {
/// A container for commands topics that the application can publish to. /// A container for commands topics that the application can publish to.
public struct Commands: Codable, Equatable { public struct Commands: Codable, Equatable {
/// The relay command topics. /// The relay command topics.
public var relays: Relays public var relays: Relays
@@ -227,7 +218,6 @@ public struct Topics: Codable, Equatable {
/// A container for relay command topics used by the application. /// A container for relay command topics used by the application.
public struct Relays: Codable, Equatable { public struct Relays: Codable, Equatable {
/// The dehumidification stage-1 relay topic. /// The dehumidification stage-1 relay topic.
public var dehumidification1: String public var dehumidification1: String
@@ -257,8 +247,9 @@ public struct Topics: Codable, Equatable {
} }
// MARK: Helpers // 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( .init(
temperature: "sensors/\(location)/temperature", temperature: "sensors/\(location)/temperature",
humidity: "sensors/\(location)/humidity", humidity: "sensors/\(location)/humidity",

View File

@@ -1,22 +1,24 @@
import XCTest @testable import ClientLive
import EnvVars import EnvVars
import Logging import Logging
import Models import Models
@testable import ClientLive import MQTTNIO
import NIO
import Psychrometrics import Psychrometrics
import XCTest
final class AsyncClientTests: XCTestCase { final class AsyncClientTests: XCTestCase {
static let hostname = ProcessInfo.processInfo.environment["MOSQUITTO_SERVER"] ?? "localhost" static let hostname = ProcessInfo.processInfo.environment["MOSQUITTO_SERVER"] ?? "localhost"
static let logger: Logger = { static let logger: Logger = {
var logger = Logger(label: "AsyncClientTests") var logger = Logger(label: "AsyncClientTests")
logger.logLevel = .trace logger.logLevel = .trace
return logger return logger
}() }()
func createClient(identifier: String) -> AsyncClient { func createClient(identifier: String) -> AsyncClient {
let envVars = EnvVars.init( let envVars = EnvVars(
appEnv: .testing, appEnv: .testing,
host: Self.hostname, host: Self.hostname,
port: "1883", port: "1883",
@@ -26,13 +28,13 @@ final class AsyncClientTests: XCTestCase {
) )
return .init(envVars: envVars, logger: Self.logger) return .init(envVars: envVars, logger: Self.logger)
} }
func testConnectAndShutdown() async throws { func testConnectAndShutdown() async throws {
let client = createClient(identifier: "testConnectAndShutdown") let client = createClient(identifier: "testConnectAndShutdown")
await client.connect() await client.connect()
await client.shutdown() await client.shutdown()
} }
func testPublishingSensor() async throws { func testPublishingSensor() async throws {
let client = createClient(identifier: "testPublishingSensor") let client = createClient(identifier: "testPublishingSensor")
await client.connect() 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))) try await client.publishSensor(.mixed(.init(temperature: 72.123, humidity: 50.5, needsProcessed: true)))
await client.shutdown() 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 }
} }

View File

@@ -12,7 +12,7 @@ import XCTest
final class ClientLiveTests: XCTestCase { final class ClientLiveTests: XCTestCase {
static let hostname = ProcessInfo.processInfo.environment["MOSQUITTO_SERVER"] ?? "localhost" static let hostname = ProcessInfo.processInfo.environment["MOSQUITTO_SERVER"] ?? "localhost"
let topics = Topics() let topics = Topics()
// func test_mqtt_subscription() throws { // func test_mqtt_subscription() throws {
// let mqttClient = createMQTTClient(identifier: "test_subscription") // let mqttClient = createMQTTClient(identifier: "test_subscription")
// _ = try mqttClient.connect().wait() // _ = try mqttClient.connect().wait()
@@ -23,102 +23,101 @@ final class ClientLiveTests: XCTestCase {
// try mqttClient.disconnect().wait() // try mqttClient.disconnect().wait()
// try mqttClient.syncShutdownGracefully() // try mqttClient.syncShutdownGracefully()
// } // }
func test_mqtt_listener() throws { func test_mqtt_listener() throws {
let lock = Lock() let lock = Lock()
var publishRecieved: [MQTTPublishInfo] = [] var publishRecieved: [MQTTPublishInfo] = []
let payloadString = "test" let payloadString = "test"
let payload = ByteBufferAllocator().buffer(string: payloadString) let payload = ByteBufferAllocator().buffer(string: payloadString)
let client = self.createMQTTClient(identifier: "testMQTTListener_publisher") let client = createMQTTClient(identifier: "testMQTTListener_publisher")
_ = try client.connect().wait() _ = try client.connect().wait()
client.addPublishListener(named: "test") { result in client.addPublishListener(named: "test") { result in
switch result { switch result {
case .success(let publish): case let .success(publish):
var buffer = publish.payload var buffer = publish.payload
let string = buffer.readString(length: buffer.readableBytes) let string = buffer.readString(length: buffer.readableBytes)
XCTAssertEqual(string, payloadString) XCTAssertEqual(string, payloadString)
lock.withLock { lock.withLock {
publishRecieved.append(publish) publishRecieved.append(publish)
} }
case .failure(let error): case let .failure(error):
XCTFail("\(error)") XCTFail("\(error)")
} }
} }
try client.publish(to: "testMQTTSubscribe", payload: payload, qos: .atLeastOnce, retain: true).wait() 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() let sub = try client.v5.subscribe(to: [.init(topicFilter: "testMQTTSubscribe", qos: .atLeastOnce)]).wait()
XCTAssertEqual(sub.reasons[0], .grantedQoS1) XCTAssertEqual(sub.reasons[0], .grantedQoS1)
Thread.sleep(forTimeInterval: 2) Thread.sleep(forTimeInterval: 2)
lock.withLock { lock.withLock {
XCTAssertEqual(publishRecieved.count, 1) XCTAssertEqual(publishRecieved.count, 1)
} }
try client.disconnect().wait() try client.disconnect().wait()
try client.syncShutdownGracefully() try client.syncShutdownGracefully()
} }
func test_client2_returnTemperature_listener() throws { func test_client2_returnTemperature_listener() throws {
let mqttClient = createMQTTClient(identifier: "return-temperature-tests") let mqttClient = createMQTTClient(identifier: "return-temperature-tests")
let state = State() let state = State()
let topics = Topics() let topics = Topics()
let client = Client.live(client: mqttClient, state: state, topics: topics) let client = Client.live(client: mqttClient, state: state, topics: topics)
client.addListeners() client.addListeners()
try client.connect().wait() try client.connect().wait()
try client.subscribe().wait() try client.subscribe().wait()
_ = try mqttClient.publish( _ = try mqttClient.publish(
to: topics.sensors.returnAirSensor.temperature, to: topics.sensors.returnAirSensor.temperature,
payload: ByteBufferAllocator().buffer(string: "75.1234"), payload: ByteBufferAllocator().buffer(string: "75.1234"),
qos: .atLeastOnce qos: .atLeastOnce
).wait() ).wait()
Thread.sleep(forTimeInterval: 2) Thread.sleep(forTimeInterval: 2)
XCTAssertEqual(state.sensors.returnAirSensor.temperature, .celsius(75.1234)) XCTAssertEqual(state.sensors.returnAirSensor.temperature, .celsius(75.1234))
try mqttClient.disconnect().wait() try mqttClient.disconnect().wait()
try mqttClient.syncShutdownGracefully() try mqttClient.syncShutdownGracefully()
// try client.shutdown().wait() // try client.shutdown().wait()
} }
func test_client2_returnSensor_publish() throws { func test_client2_returnSensor_publish() throws {
let mqttClient = createMQTTClient(identifier: "return-temperature-tests") let mqttClient = createMQTTClient(identifier: "return-temperature-tests")
let state = State() let state = State()
let topics = Topics() let topics = Topics()
let client = Client.live(client: mqttClient, state: state, topics: topics) let client = Client.live(client: mqttClient, state: state, topics: topics)
client.addListeners() client.addListeners()
try client.connect().wait() try client.connect().wait()
try client.subscribe().wait() try client.subscribe().wait()
_ = try mqttClient.publish( _ = try mqttClient.publish(
to: topics.sensors.returnAirSensor.temperature, to: topics.sensors.returnAirSensor.temperature,
payload: ByteBufferAllocator().buffer(string: "75.1234"), payload: ByteBufferAllocator().buffer(string: "75.1234"),
qos: .atLeastOnce qos: .atLeastOnce
).wait() ).wait()
_ = try mqttClient.publish( _ = try mqttClient.publish(
to: topics.sensors.returnAirSensor.humidity, to: topics.sensors.returnAirSensor.humidity,
payload: ByteBufferAllocator().buffer(string: "\(50.0)"), payload: ByteBufferAllocator().buffer(string: "\(50.0)"),
qos: .atLeastOnce qos: .atLeastOnce
).wait() ).wait()
Thread.sleep(forTimeInterval: 2) Thread.sleep(forTimeInterval: 2)
XCTAssert(state.sensors.returnAirSensor.needsProcessed) XCTAssert(state.sensors.returnAirSensor.needsProcessed)
try client.publishSensor(.return(state.sensors.returnAirSensor)).wait() try client.publishSensor(.return(state.sensors.returnAirSensor)).wait()
XCTAssertFalse(state.sensors.returnAirSensor.needsProcessed) XCTAssertFalse(state.sensors.returnAirSensor.needsProcessed)
try mqttClient.disconnect().wait() try mqttClient.disconnect().wait()
try mqttClient.syncShutdownGracefully() try mqttClient.syncShutdownGracefully()
// try client.shutdown().wait() // try client.shutdown().wait()
} }
// func test_fetch_humidity() throws { // func test_fetch_humidity() throws {
// let lock = Lock() // let lock = Lock()
// let publishClient = createMQTTClient(identifier: "publishHumidity") // let publishClient = createMQTTClient(identifier: "publishHumidity")
@@ -144,19 +143,20 @@ final class ClientLiveTests: XCTestCase {
// try mqttClient.disconnect().wait() // try mqttClient.disconnect().wait()
// try mqttClient.syncShutdownGracefully() // try mqttClient.syncShutdownGracefully()
// } // }
// MARK: - Helpers // MARK: - Helpers
func createMQTTClient(identifier: String) -> MQTTNIO.MQTTClient { func createMQTTClient(identifier: String) -> MQTTNIO.MQTTClient {
MQTTNIO.MQTTClient( MQTTNIO.MQTTClient(
host: Self.hostname, host: Self.hostname,
port: 1883, port: 1883,
identifier: identifier, identifier: identifier,
eventLoopGroupProvider: .shared(eventLoopGroup), eventLoopGroupProvider: .shared(eventLoopGroup),
logger: self.logger, logger: logger,
configuration: .init(version: .v5_0) configuration: .init(version: .v5_0)
) )
} }
// func createWebSocketClient(identifier: String) -> MQTTNIO.MQTTClient { // func createWebSocketClient(identifier: String) -> MQTTNIO.MQTTClient {
// MQTTNIO.MQTTClient( // MQTTNIO.MQTTClient(
// host: Self.hostname, // host: Self.hostname,
@@ -167,7 +167,7 @@ final class ClientLiveTests: XCTestCase {
// configuration: .init(useWebSockets: true, webSocketURLPath: "/mqtt") // configuration: .init(useWebSockets: true, webSocketURLPath: "/mqtt")
// ) // )
// } // }
// Uses default topic names. // Uses default topic names.
// func createClient(mqttClient: MQTTNIO.MQTTClient, autoConnect: Bool = true) throws -> Client.MQTTClient { // func createClient(mqttClient: MQTTNIO.MQTTClient, autoConnect: Bool = true) throws -> Client.MQTTClient {
// if autoConnect { // if autoConnect {
@@ -175,12 +175,12 @@ final class ClientLiveTests: XCTestCase {
// } // }
// return .live(client: mqttClient, topics: .init()) // return .live(client: mqttClient, topics: .init())
// } // }
let logger: Logger = { let logger: Logger = {
var logger = Logger(label: "MQTTTests") var logger = Logger(label: "MQTTTests")
logger.logLevel = .trace logger.logLevel = .trace
return logger return logger
}() }()
let eventLoopGroup = MultiThreadedEventLoopGroup.init(numberOfThreads: 1) let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
} }

View File

@@ -6,15 +6,15 @@ services:
env_file: .env env_file: .env
test: test:
image: swift:5.10 build:
#build: context: .
#context: ./ dockerfile: Dockerfile.test
platform: linux/amd64 platform: linux/amd64
working_dir: /app working_dir: /app
networks: networks:
- test - test
volumes: # volumes:
- .:/app # - .:/app
depends_on: depends_on:
- mosquitto-test - mosquitto-test
environment: environment:
@@ -44,4 +44,3 @@ networks:
test: test:
driver: bridge driver: bridge
external: false external: false