Initial Commit

This commit is contained in:
2021-10-16 15:01:47 -04:00
commit 42c46d9d84
18 changed files with 553 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.dewPoint-env

View File

@@ -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!"
}

9
Makefile Normal file
View File

@@ -0,0 +1,9 @@
bootstrap-env:
@cp Bootstrap/dewPoint-env-example .dewPoint-env
build:
@swift build
run:
@swift run dewPoint-controller

61
Package.resolved Normal file
View File

@@ -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
}

83
Package.swift Normal file
View File

@@ -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")
]
),
]
)

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# dewPoint-controller
A description of this package.

View File

@@ -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<DewPointEnvironment> {
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<EnvVars> {
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
)
)
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,8 @@
public struct HumiditySensor: Equatable {
public var topic: String
public init(topic: String) {
self.topic = topic
}
}

View File

@@ -0,0 +1,8 @@
public struct Relay {
public var topic: String
public init(topic: String) {
self.topic = topic
}
}

View File

@@ -0,0 +1,8 @@
public struct TemperatureSensor: Equatable {
public var topic: String
public init(topic: String) {
self.topic = topic
}
}

View File

@@ -0,0 +1,20 @@
import Foundation
import Models
import NIO
public struct RelayClient {
public var toggle: (Relay) -> EventLoopFuture<Void>
public var turnOn: (Relay) -> EventLoopFuture<Void>
public var turnOff: (Relay) -> EventLoopFuture<Void>
public init(
toggle: @escaping (Relay) -> EventLoopFuture<Void>,
turnOn: @escaping (Relay) -> EventLoopFuture<Void>,
turnOff: @escaping (Relay) -> EventLoopFuture<Void>
) {
self.toggle = toggle
self.turnOn = turnOn
self.turnOff = turnOff
}
}

View File

@@ -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<Void> {
publish(
to: relay.topic,
payload: ByteBufferAllocator().buffer(string: state.rawValue),
qos: qos
)
}
}

View File

@@ -0,0 +1,14 @@
import Foundation
import CoreUnitTypes
import Models
import NIO
public struct TemperatureSensorClient {
public var state: (TemperatureSensor, PsychrometricEnvironment.Units?) -> EventLoopFuture<Temperature>
public init(
state: @escaping (TemperatureSensor, PsychrometricEnvironment.Units?) -> EventLoopFuture<Temperature>
) {
self.state = state
}
}

View File

@@ -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<Temperature, Error> {
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))
}
}
}

View File

@@ -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)
}

View File

@@ -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
}
}