Initial Commit
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.dewPoint-env
|
||||
8
Bootstrap/dewPoint-env-example
Normal file
8
Bootstrap/dewPoint-env-example
Normal 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
9
Makefile
Normal 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
61
Package.resolved
Normal 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
83
Package.swift
Normal 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
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# dewPoint-controller
|
||||
|
||||
A description of this package.
|
||||
87
Sources/Bootstrap/Bootstrap.swift
Normal file
87
Sources/Bootstrap/Bootstrap.swift
Normal 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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
24
Sources/DewPointEnvironment/Environment.swift
Normal file
24
Sources/DewPointEnvironment/Environment.swift
Normal 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
|
||||
}
|
||||
}
|
||||
43
Sources/EnvVars/EnvVars.swift
Normal file
43
Sources/EnvVars/EnvVars.swift
Normal 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
|
||||
}
|
||||
}
|
||||
8
Sources/Models/HumiditySensor.swift
Normal file
8
Sources/Models/HumiditySensor.swift
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
public struct HumiditySensor: Equatable {
|
||||
public var topic: String
|
||||
|
||||
public init(topic: String) {
|
||||
self.topic = topic
|
||||
}
|
||||
}
|
||||
8
Sources/Models/Relay.swift
Normal file
8
Sources/Models/Relay.swift
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
public struct Relay {
|
||||
public var topic: String
|
||||
|
||||
public init(topic: String) {
|
||||
self.topic = topic
|
||||
}
|
||||
}
|
||||
8
Sources/Models/TemperatureSensor.swift
Normal file
8
Sources/Models/TemperatureSensor.swift
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
public struct TemperatureSensor: Equatable {
|
||||
public var topic: String
|
||||
|
||||
public init(topic: String) {
|
||||
self.topic = topic
|
||||
}
|
||||
}
|
||||
20
Sources/RelayClient/Interface.swift
Normal file
20
Sources/RelayClient/Interface.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
37
Sources/RelayClient/Live.swift
Normal file
37
Sources/RelayClient/Live.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
14
Sources/TemperatureSensorClient/Interface.swift
Normal file
14
Sources/TemperatureSensorClient/Interface.swift
Normal 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
|
||||
}
|
||||
}
|
||||
51
Sources/TemperatureSensorClient/Live.swift
Normal file
51
Sources/TemperatureSensorClient/Live.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
34
Sources/dewPoint-controller/main.swift
Normal file
34
Sources/dewPoint-controller/main.swift
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user