commit 42c46d9d8401f43274abf922dee20379e67653a8 Author: Michael Housh Date: Sat Oct 16 15:01:47 2021 -0400 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71f55a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.dewPoint-env diff --git a/Bootstrap/dewPoint-env-example b/Bootstrap/dewPoint-env-example new file mode 100644 index 0000000..48be2bc --- /dev/null +++ b/Bootstrap/dewPoint-env-example @@ -0,0 +1,8 @@ +{ + "APP_ENV": "development", + "MQTT_HOST": "127.0.0.1", + "MQTT_PORT": "1883", + "MQTT_IDENTIFIER": "dewPoint-controller", + "MQTT_USERNAME": "mqtt_user", + "MQTT_PASSWORD": "secret!" +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a70a7b8 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ + +bootstrap-env: + @cp Bootstrap/dewPoint-env-example .dewPoint-env + +build: + @swift build + +run: + @swift run dewPoint-controller diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..376196a --- /dev/null +++ b/Package.resolved @@ -0,0 +1,61 @@ +{ + "object": { + "pins": [ + { + "package": "mqtt-nio", + "repositoryURL": "https://github.com/adam-fowler/mqtt-nio.git", + "state": { + "branch": null, + "revision": "976a4b6019e1dd365a0da793babadd5cc958ce2b", + "version": "2.1.1" + } + }, + { + "package": "swift-log", + "repositoryURL": "https://github.com/apple/swift-log.git", + "state": { + "branch": null, + "revision": "5d66f7ba25daf4f94100e7022febf3c75e37a6c7", + "version": "1.4.2" + } + }, + { + "package": "swift-nio", + "repositoryURL": "https://github.com/apple/swift-nio", + "state": { + "branch": null, + "revision": "6aa9347d9bc5bbfe6a84983aec955c17ffea96ef", + "version": "2.33.0" + } + }, + { + "package": "swift-nio-ssl", + "repositoryURL": "https://github.com/apple/swift-nio-ssl.git", + "state": { + "branch": null, + "revision": "5e68c1ded15619bb281b273fa8c2d8fd7f7b2b7d", + "version": "2.16.1" + } + }, + { + "package": "swift-nio-transport-services", + "repositoryURL": "https://github.com/apple/swift-nio-transport-services.git", + "state": { + "branch": null, + "revision": "e7f5278a26442dc46783ba7e063643d524e414a0", + "version": "1.11.3" + } + }, + { + "package": "swift-psychrometrics", + "repositoryURL": "https://github.com/swift-psychrometrics/swift-psychrometrics", + "state": { + "branch": null, + "revision": "03573545c3750b406921eb22a9575c8062beef88", + "version": "0.1.2" + } + } + ] + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..a2e6743 --- /dev/null +++ b/Package.swift @@ -0,0 +1,83 @@ +// swift-tools-version:5.5 + +import PackageDescription + +let package = Package( + name: "dewPoint-controller", + platforms: [ + .macOS(.v10_14) + ], + products: [ + .executable(name: "dewPoint-controller", targets: ["dewPoint-controller"]), + .library(name: "Bootstrap", targets: ["Bootstrap"]), + .library(name: "DewPointEnvironment", targets: ["DewPointEnvironment"]), + .library(name: "EnvVars", targets: ["EnvVars"]), + .library(name: "Models", targets: ["Models"]), + .library(name: "RelayClient", targets: ["RelayClient"]), + .library(name: "TemperatureSensorClient", targets: ["TemperatureSensorClient"]), + ], + dependencies: [ + .package(url: "https://github.com/adam-fowler/mqtt-nio.git", from: "2.0.0"), + .package(url: "https://github.com/apple/swift-nio", from: "2.0.0"), + .package(url: "https://github.com/swift-psychrometrics/swift-psychrometrics", from: "0.1.0") + ], + targets: [ + .executableTarget( + name: "dewPoint-controller", + dependencies: [ + "Bootstrap", + .product(name: "MQTTNIO", package: "mqtt-nio"), + .product(name: "NIO", package: "swift-nio") + ] + ), + .testTarget( + name: "dewPoint-controllerTests", + dependencies: ["dewPoint-controller"] + ), + .target( + name: "Bootstrap", + dependencies: [ + "DewPointEnvironment", + "EnvVars", + "RelayClient", + "TemperatureSensorClient", + .product(name: "MQTTNIO", package: "mqtt-nio"), + .product(name: "NIO", package: "swift-nio") + ] + ), + .target( + name: "DewPointEnvironment", + dependencies: [ + "EnvVars", + "RelayClient", + "TemperatureSensorClient", + .product(name: "MQTTNIO", package: "mqtt-nio"), + ] + ), + .target( + name: "EnvVars", + dependencies: [] + ), + .target( + name: "Models", + dependencies: [] + ), + .target( + name: "RelayClient", + dependencies: [ + "Models", + .product(name: "NIO", package: "swift-nio"), + .product(name: "MQTTNIO", package: "mqtt-nio") + ] + ), + .target( + name: "TemperatureSensorClient", + dependencies: [ + "Models", + .product(name: "NIO", package: "swift-nio"), + .product(name: "MQTTNIO", package: "mqtt-nio"), + .product(name: "CoreUnitTypes", package: "swift-psychrometrics") + ] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9af620 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# dewPoint-controller + +A description of this package. diff --git a/Sources/Bootstrap/Bootstrap.swift b/Sources/Bootstrap/Bootstrap.swift new file mode 100644 index 0000000..4cee1d2 --- /dev/null +++ b/Sources/Bootstrap/Bootstrap.swift @@ -0,0 +1,87 @@ +import DewPointEnvironment +import EnvVars +import Logging +import Foundation +import MQTTNIO +import NIO +import RelayClient +import TemperatureSensorClient + +public func bootstrap( + eventLoopGroup: EventLoopGroup, + logger: Logger? = nil +) -> EventLoopFuture { + logger?.debug("Bootstrapping Dew Point Controller...") + return loadEnvVars(eventLoopGroup: eventLoopGroup, logger: logger) + .map { (envVars) -> DewPointEnvironment in + let mqttClient = MQTTClient(envVars: envVars, eventLoopGroup: eventLoopGroup, logger: logger) + return DewPointEnvironment.init( + mqttClient: mqttClient, + envVars: envVars, + relayClient: .live(client: mqttClient), + temperatureSensorClient: .live(client: mqttClient) + ) + } + .flatMap { environment in + environment.mqttClient.logger.debug("Connecting to MQTT broker...") + return environment.mqttClient.connect() + .map { _ in environment } + } +} + +private func loadEnvVars(eventLoopGroup: EventLoopGroup, logger: Logger?) -> EventLoopFuture { + + logger?.debug("Loading env vars...") + + let envFilePath = URL(fileURLWithPath: #file) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent(".dewPoint-env") + + let decoder = JSONDecoder() + let encoder = JSONEncoder() + + let defaultEnvVars = EnvVars() + + let defaultEnvDict = (try? encoder.encode(defaultEnvVars)) + .flatMap { try? decoder.decode([String: String].self, from: $0) } + ?? [:] + + // Read from file `.dewPoint-env` file if it exists. + let localEnvVarsDict = (try? Data(contentsOf: envFilePath)) + .flatMap { try? decoder.decode([String: String].self, from: $0) } + ?? [:] + + // Merge with variables in the shell environment. + let envVarsDict = defaultEnvDict + .merging(localEnvVarsDict, uniquingKeysWith: { $1 }) + .merging(ProcessInfo.processInfo.environment, uniquingKeysWith: { $1 }) + + // Produces the final env vars from the merged items or uses defaults if something + // went wrong. + let envVars = (try? JSONSerialization.data(withJSONObject: envVarsDict)) + .flatMap { try? decoder.decode(EnvVars.self, from: $0) } + ?? defaultEnvVars + + logger?.debug("Done loading env vars...") + return eventLoopGroup.next().makeSucceededFuture(envVars) +} + +extension MQTTClient { + + convenience init(envVars: EnvVars, eventLoopGroup: EventLoopGroup, logger: Logger?) { + self.init( + host: envVars.host, + port: envVars.port != nil ? Int(envVars.port!) : nil, + identifier: envVars.identifier, + eventLoopGroupProvider: .shared(eventLoopGroup), + logger: logger, + configuration: .init( + version: .v5_0, + userName: envVars.userName, + password: envVars.password + ) + ) + } +} diff --git a/Sources/DewPointEnvironment/Environment.swift b/Sources/DewPointEnvironment/Environment.swift new file mode 100644 index 0000000..ae5cc5c --- /dev/null +++ b/Sources/DewPointEnvironment/Environment.swift @@ -0,0 +1,24 @@ +import EnvVars +import MQTTNIO +import RelayClient +import TemperatureSensorClient + +public struct DewPointEnvironment { + + public var mqttClient: MQTTClient + public var envVars: EnvVars + public var relayClient: RelayClient + public var temperatureSensorClient: TemperatureSensorClient + + public init( + mqttClient: MQTTClient, + envVars: EnvVars, + relayClient: RelayClient, + temperatureSensorClient: TemperatureSensorClient + ) { + self.mqttClient = mqttClient + self.envVars = envVars + self.relayClient = relayClient + self.temperatureSensorClient = temperatureSensorClient + } +} diff --git a/Sources/EnvVars/EnvVars.swift b/Sources/EnvVars/EnvVars.swift new file mode 100644 index 0000000..855ebaf --- /dev/null +++ b/Sources/EnvVars/EnvVars.swift @@ -0,0 +1,43 @@ +import Foundation + +public struct EnvVars: Codable, Equatable { + + public var appEnv: AppEnv + public var host: String + public var port: String? + public var identifier: String + public var userName: String? + public var password: String? + + public init( + appEnv: AppEnv = .development, + host: String = "127.0.0.1", + port: String? = "1883", + identifier: String = "dewPoint-controller", + userName: String? = "mqtt_user", + password: String? = "secret!" + ){ + self.appEnv = appEnv + self.host = host + self.port = port + self.identifier = identifier + self.userName = userName + self.password = password + } + + private enum CodingKeys: String, CodingKey { + case appEnv = "APP_ENV" + case host = "MQTT_HOST" + case port = "MQTT_PORT" + case identifier = "MQTT_IDENTIFIER" + case userName = "MQTT_USERNAME" + case password = "MQTT_PASSWORD" + } + + public enum AppEnv: String, Codable { + case development + case production + case staging + case testing + } +} diff --git a/Sources/Models/HumiditySensor.swift b/Sources/Models/HumiditySensor.swift new file mode 100644 index 0000000..5cd04ca --- /dev/null +++ b/Sources/Models/HumiditySensor.swift @@ -0,0 +1,8 @@ + +public struct HumiditySensor: Equatable { + public var topic: String + + public init(topic: String) { + self.topic = topic + } +} diff --git a/Sources/Models/Relay.swift b/Sources/Models/Relay.swift new file mode 100644 index 0000000..728d6d7 --- /dev/null +++ b/Sources/Models/Relay.swift @@ -0,0 +1,8 @@ + +public struct Relay { + public var topic: String + + public init(topic: String) { + self.topic = topic + } +} diff --git a/Sources/Models/TemperatureSensor.swift b/Sources/Models/TemperatureSensor.swift new file mode 100644 index 0000000..63a6493 --- /dev/null +++ b/Sources/Models/TemperatureSensor.swift @@ -0,0 +1,8 @@ + +public struct TemperatureSensor: Equatable { + public var topic: String + + public init(topic: String) { + self.topic = topic + } +} diff --git a/Sources/RelayClient/Interface.swift b/Sources/RelayClient/Interface.swift new file mode 100644 index 0000000..ceb1373 --- /dev/null +++ b/Sources/RelayClient/Interface.swift @@ -0,0 +1,20 @@ +import Foundation +import Models +import NIO + +public struct RelayClient { + public var toggle: (Relay) -> EventLoopFuture + public var turnOn: (Relay) -> EventLoopFuture + public var turnOff: (Relay) -> EventLoopFuture + + public init( + toggle: @escaping (Relay) -> EventLoopFuture, + turnOn: @escaping (Relay) -> EventLoopFuture, + turnOff: @escaping (Relay) -> EventLoopFuture + ) { + self.toggle = toggle + self.turnOn = turnOn + self.turnOff = turnOff + } +} + diff --git a/Sources/RelayClient/Live.swift b/Sources/RelayClient/Live.swift new file mode 100644 index 0000000..0b322a0 --- /dev/null +++ b/Sources/RelayClient/Live.swift @@ -0,0 +1,37 @@ +import Models +import MQTTNIO +import NIO + +extension RelayClient { + + public static func live(client: MQTTClient) -> RelayClient { + .init( + toggle: { relay in + client.publish(relay: relay, state: .toggle) + }, + turnOn: { relay in + client.publish(relay: relay, state: .on) + }, + turnOff: { relay in + client.publish(relay: relay, state: .off) + } + ) + } +} + +extension Relay { + enum State: String { + case toggle, on, off + } +} + +extension MQTTClient { + + func publish(relay: Relay, state: Relay.State, qos: MQTTQoS = .atLeastOnce) -> EventLoopFuture { + publish( + to: relay.topic, + payload: ByteBufferAllocator().buffer(string: state.rawValue), + qos: qos + ) + } +} diff --git a/Sources/TemperatureSensorClient/Interface.swift b/Sources/TemperatureSensorClient/Interface.swift new file mode 100644 index 0000000..5783ea4 --- /dev/null +++ b/Sources/TemperatureSensorClient/Interface.swift @@ -0,0 +1,14 @@ +import Foundation +import CoreUnitTypes +import Models +import NIO + +public struct TemperatureSensorClient { + public var state: (TemperatureSensor, PsychrometricEnvironment.Units?) -> EventLoopFuture + + public init( + state: @escaping (TemperatureSensor, PsychrometricEnvironment.Units?) -> EventLoopFuture + ) { + self.state = state + } +} diff --git a/Sources/TemperatureSensorClient/Live.swift b/Sources/TemperatureSensorClient/Live.swift new file mode 100644 index 0000000..aaee961 --- /dev/null +++ b/Sources/TemperatureSensorClient/Live.swift @@ -0,0 +1,51 @@ +import CoreUnitTypes +import Foundation +import MQTTNIO + +extension TemperatureSensorClient { + + public static func live(client: MQTTClient) -> TemperatureSensorClient { + .init( + state: { sensor, units in + client.logger.debug("Adding listener for temperature sensor...") + let subscription = MQTTSubscribeInfoV5.init(topicFilter: sensor.topic, qos: .atLeastOnce) + return client.v5.subscribe(to: [subscription]) + .flatMap { _ in + let promise = client.eventLoopGroup.next().makePromise(of: Temperature.self) + client.addPublishListener(named: "temperature-sensor", { result in + switch result.temperature() { + case let .success(celsius): + let userUnits = units ?? PsychrometricEnvironment.shared.units + let temperatureUnits = Temperature.Units.defaultFor(units: userUnits) + promise.succeed(.init(celsius[temperatureUnits], units: temperatureUnits)) + case let .failure(error): + promise.fail(error) + } + }) + + return promise.futureResult + } + } + ) + } +} + +public enum TemperatureError: Error { + case invalidTemperature +} + +// MARK: - Helpers +extension Result where Success == MQTTPublishInfo, Failure == Error { + + fileprivate func temperature() -> Result { + flatMap { info in + var buffer = info.payload + guard let string = buffer.readString(length: buffer.readableBytes), + let temperatureValue = Double(string) + else { + return .failure(TemperatureError.invalidTemperature) + } + return .success(.celsius(temperatureValue)) + } + } +} diff --git a/Sources/dewPoint-controller/main.swift b/Sources/dewPoint-controller/main.swift new file mode 100644 index 0000000..dea1907 --- /dev/null +++ b/Sources/dewPoint-controller/main.swift @@ -0,0 +1,34 @@ +import Bootstrap +import Logging +import Models +import MQTTNIO +import NIO +import RelayClient +import Foundation + +var logger = Logger(label: "dewPoint-logger") +logger.logLevel = .debug +logger.debug("Swift Dew Point Controller!") + +let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) +let environment = try bootstrap(eventLoopGroup: eventLoopGroup, logger: logger).wait() +let relayClient = environment.relayClient +let relay = Relay(topic: "frankensystem/relays/switch/relay_1/command") +let tempSensor = TemperatureSensor(topic: "frankensystem/relays/sensor/temperature_-_1/state") + +defer { + logger.debug("Disconnecting") + _ = try? environment.mqttClient.disconnect().wait() + try? environment.mqttClient.syncShutdownGracefully() +} + +while true { + logger.debug("Toggling relay.") + _ = try relayClient.toggle(relay).wait() + + logger.debug("Reading temperature sensor.") + let temp = try environment.temperatureSensorClient.state(tempSensor, .imperial).wait() + logger.debug("Temperature: \(temp)") + + Thread.sleep(forTimeInterval: 5) +} diff --git a/Tests/dewPoint-controllerTests/dewPoint_controllerTests.swift b/Tests/dewPoint-controllerTests/dewPoint_controllerTests.swift new file mode 100644 index 0000000..6eb2937 --- /dev/null +++ b/Tests/dewPoint-controllerTests/dewPoint_controllerTests.swift @@ -0,0 +1,47 @@ +import XCTest +import class Foundation.Bundle + +final class dewPoint_controllerTests: XCTestCase { + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct + // results. + + // Some of the APIs that we use below are available in macOS 10.13 and above. + guard #available(macOS 10.13, *) else { + return + } + + // Mac Catalyst won't have `Process`, but it is supported for executables. + #if !targetEnvironment(macCatalyst) + + let fooBinary = productsDirectory.appendingPathComponent("dewPoint-controller") + + let process = Process() + process.executableURL = fooBinary + + let pipe = Pipe() + process.standardOutput = pipe + + try process.run() + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) + + XCTAssertEqual(output, "Hello, world!\n") + #endif + } + + /// Returns path to the built products directory. + var productsDirectory: URL { + #if os(macOS) + for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { + return bundle.bundleURL.deletingLastPathComponent() + } + fatalError("couldn't find the products directory") + #else + return Bundle.main.bundleURL + #endif + } +}