feat: Begins using swift argument parser and creating cli client dependency
All checks were successful
CI / Run Tests (push) Successful in 4m27s

This commit is contained in:
2024-11-16 22:32:32 -05:00
parent 3416ce1003
commit 6472d3cd1e
19 changed files with 657 additions and 342 deletions

2
.swiftlint.yml Normal file
View File

@@ -0,0 +1,2 @@
disabled_rules:
- closing_brace

View File

@@ -17,17 +17,11 @@
}, },
"testTargets" : [ "testTargets" : [
{ {
"parallelizable" : true,
"target" : { "target" : {
"containerPath" : "container:", "containerPath" : "container:",
"identifier" : "MQTTConnectionServiceTests", "identifier" : "IntegrationTests",
"name" : "MQTTConnectionServiceTests" "name" : "IntegrationTests"
}
},
{
"target" : {
"containerPath" : "container:",
"identifier" : "SensorsServiceTests",
"name" : "SensorsServiceTests"
} }
} }
], ],

View File

@@ -77,6 +77,34 @@
ReferencedContainer = "container:"> ReferencedContainer = "container:">
</BuildableReference> </BuildableReference>
</BuildActionEntry> </BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "IntegrationTests"
BuildableName = "IntegrationTests"
BlueprintName = "IntegrationTests"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CliClient"
BuildableName = "CliClient"
BlueprintName = "CliClient"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries> </BuildActionEntries>
</BuildAction> </BuildAction>
<TestAction <TestAction
@@ -95,19 +123,9 @@
skipped = "NO"> skipped = "NO">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "MQTTConnectionServiceTests" BlueprintIdentifier = "IntegrationTests"
BuildableName = "MQTTConnectionServiceTests" BuildableName = "IntegrationTests"
BlueprintName = "MQTTConnectionServiceTests" BlueprintName = "IntegrationTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "SensorsServiceTests"
BuildableName = "SensorsServiceTests"
BlueprintName = "SensorsServiceTests"
ReferencedContainer = "container:"> ReferencedContainer = "container:">
</BuildableReference> </BuildableReference>
</TestableReference> </TestableReference>

View File

@@ -1,29 +1,39 @@
DOCKER_IMAGE_NAME?="swift-mqtt-dewpoint"
DOCKER_TAG_NAME?="latest"
bootstrap-env: .PHONY: bootstrap
bootstrap:
@cp Bootstrap/dewPoint-env-example .dewPoint-env @cp Bootstrap/dewPoint-env-example .dewPoint-env
bootstrap-topics: .PHONY: build
@cp Bootstrap/topics-example .topics
bootstrap: bootstrap-env bootstrap-topics
build: build:
@swift build -Xswiftc -strict-concurrency=complete @swift build -Xswiftc -strict-concurrency=complete
.PHONY: build-docker
build-docker:
@docker build \
--file docker/Dockerfile \
--tag "${DOCKER_IMAGE_NAME}:${DOCKER_TAG_NAME}" .
.PHONY: clean
clean: clean:
rm -rf .build rm -rf .build
.PHONY: run
run: run:
@swift run dewpoint-controller @swift run dewpoint-controller
.PHONY: test-docker
test-docker: test-docker:
@docker compose --file docker/docker-compose-test.yaml \ @docker compose --file docker/docker-compose-test.yaml \
run --build --remove-orphans -i --rm test run --build --remove-orphans -i --rm test
@docker compose --file docker/docker-compose-test.yaml down @docker compose --file docker/docker-compose-test.yaml down
.PHONY: start-mosquitto
start-mosquitto: start-mosquitto:
@docker compose --file docker/docker-compose.yaml \ @docker compose --file docker/docker-compose.yaml \
up -d mosquitto up -d mosquitto
.PHONY: test-swift
test-swift: start-mosquitto test-swift: start-mosquitto
@swift test --enable-code-coverage @swift test --enable-code-coverage

View File

@@ -1,5 +1,5 @@
{ {
"originHash" : "33fdcea7245de36c7e638047a16bba6605bc9bac0117aab7cb9397289a33214e", "originHash" : "486be5d69e4f0ba7b9f42046df31a727c7e394e4ecfae5671e1b194bed7c9e9b",
"pins" : [ "pins" : [
{ {
"identity" : "combine-schedulers", "identity" : "combine-schedulers",
@@ -10,6 +10,15 @@
"version" : "1.0.2" "version" : "1.0.2"
} }
}, },
{
"identity" : "dotenv",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftpackages/DotEnv.git",
"state" : {
"revision" : "1f15bb9de727d694af1d003a1a5d7a553752850f",
"version" : "3.0.0"
}
},
{ {
"identity" : "mqtt-nio", "identity" : "mqtt-nio",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -19,6 +28,15 @@
"version" : "2.11.0" "version" : "2.11.0"
} }
}, },
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser.git",
"state" : {
"revision" : "41982a3656a71c768319979febd796c6fd111d5c",
"version" : "1.5.0"
}
},
{ {
"identity" : "swift-async-algorithms", "identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -64,6 +82,15 @@
"version" : "1.3.0" "version" : "1.3.0"
} }
}, },
{
"identity" : "swift-custom-dump",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-custom-dump",
"state" : {
"revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
"version" : "1.3.3"
}
},
{ {
"identity" : "swift-dependencies", "identity" : "swift-dependencies",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View File

@@ -14,30 +14,43 @@ let package = Package(
], ],
products: [ products: [
.executable(name: "dewpoint-controller", targets: ["DewPointController"]), .executable(name: "dewpoint-controller", targets: ["DewPointController"]),
.library(name: "CliClient", targets: ["CliClient"]),
.library(name: "Models", targets: ["Models"]), .library(name: "Models", targets: ["Models"]),
.library(name: "MQTTManager", targets: ["MQTTManager"]), .library(name: "MQTTManager", targets: ["MQTTManager"]),
.library(name: "MQTTConnectionService", targets: ["MQTTConnectionService"]), .library(name: "MQTTConnectionService", targets: ["MQTTConnectionService"]),
.library(name: "SensorsService", targets: ["SensorsService"]) .library(name: "SensorsService", targets: ["SensorsService"])
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"), .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-nio", from: "2.0.0"),
.package(url: "https://github.com/apple/swift-log", from: "1.6.0"), .package(url: "https://github.com/apple/swift-log", from: "1.6.0"),
.package(url: "https://github.com/swiftpackages/DotEnv.git", from: "3.0.0"),
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.5.2"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.5.2"),
.package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0"),
.package(url: "https://github.com/swift-psychrometrics/swift-psychrometrics", exact: "0.2.3"), .package(url: "https://github.com/swift-psychrometrics/swift-psychrometrics", exact: "0.2.3"),
.package(url: "https://github.com/swift-server-community/mqtt-nio.git", from: "2.0.0"), .package(url: "https://github.com/swift-server-community/mqtt-nio.git", from: "2.0.0"),
.package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.3.0") .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.3.0")
], ],
targets: [ targets: [
.target(
name: "CliClient",
dependencies: [
"Models",
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "DotEnv", package: "DotEnv"),
.product(name: "MQTTNIO", package: "mqtt-nio")
]
),
.executableTarget( .executableTarget(
name: "DewPointController", name: "DewPointController",
dependencies: [ dependencies: [
"Models", "CliClient",
"MQTTManager",
"MQTTConnectionService", "MQTTConnectionService",
"SensorsService", "SensorsService",
.product(name: "MQTTNIO", package: "mqtt-nio"), .product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "NIO", package: "swift-nio"), .product(name: "CustomDump", package: "swift-custom-dump"),
// .product(name: "DotEnv", package: "DotEnv"),
.product(name: "PsychrometricClientLive", package: "swift-psychrometrics") .product(name: "PsychrometricClientLive", package: "swift-psychrometrics")
] ]
), ),
@@ -69,10 +82,12 @@ let package = Package(
swiftSettings: swiftSettings swiftSettings: swiftSettings
), ),
.testTarget( .testTarget(
name: "MQTTConnectionServiceTests", name: "IntegrationTests",
dependencies: [ dependencies: [
"DewPointController",
"MQTTConnectionService", "MQTTConnectionService",
"MQTTManager", "MQTTManager",
"SensorsService",
.product(name: "PsychrometricClientLive", package: "swift-psychrometrics"), .product(name: "PsychrometricClientLive", package: "swift-psychrometrics"),
.product(name: "ServiceLifecycleTestKit", package: "swift-service-lifecycle") .product(name: "ServiceLifecycleTestKit", package: "swift-service-lifecycle")
] ]
@@ -89,13 +104,6 @@ let package = Package(
.product(name: "ServiceLifecycle", package: "swift-service-lifecycle") .product(name: "ServiceLifecycle", package: "swift-service-lifecycle")
], ],
swiftSettings: swiftSettings swiftSettings: swiftSettings
),
.testTarget(
name: "SensorsServiceTests",
dependencies: [
"SensorsService",
.product(name: "PsychrometricClientLive", package: "swift-psychrometrics")
]
) )
] ]
) )

View File

@@ -0,0 +1,188 @@
import Dependencies
import DependenciesMacros
import DotEnv
import Foundation
import Logging
import Models
import MQTTNIO
import NIO
public extension DependencyValues {
var cliClient: CliClient {
get { self[CliClient.self] }
set { self[CliClient.self] = newValue }
}
}
@DependencyClient
public struct CliClient {
public var logLevel: @Sendable (EnvVars) -> Logger.Level = { _ in .debug }
public var makeEnvVars: @Sendable (EnvVarsRequest) async throws -> EnvVars
public var makeClient: @Sendable (ClientRequest) throws -> MQTTClient
public var parseMqttClientVersion: @Sendable (String) -> MQTTClient.Version?
public struct ClientRequest: Sendable {
public let envVars: EnvVars
public let eventLoopGroup: MultiThreadedEventLoopGroup
public let logger: Logger?
public init(
envVars: EnvVars,
eventLoopGroup: MultiThreadedEventLoopGroup,
logger: Logger?
) {
self.envVars = envVars
self.eventLoopGroup = eventLoopGroup
self.logger = logger
}
}
public struct EnvVarsRequest: Sendable {
public let envFilePath: String?
public let logger: Logger?
public let mqttClientVersion: String?
public init(
envFilePath: String? = nil,
logger: Logger? = nil,
version mqttClientVersion: String? = nil
) {
self.envFilePath = envFilePath
self.logger = logger
self.mqttClientVersion = mqttClientVersion
}
}
}
extension CliClient: DependencyKey {
public static let testValue: CliClient = Self()
public static var liveValue: CliClient {
Self(
logLevel: { Logger.Level.from(environment: $0) },
makeEnvVars: {
try EnvVars.load(
dotEnvFile: $0.envFilePath,
logger: $0.logger,
version: $0.mqttClientVersion
)
},
makeClient: {
MQTTClient(
envVars: $0.envVars,
eventLoopGroup: $0.eventLoopGroup,
logger: $0.logger
)
},
parseMqttClientVersion: { .init(string: $0) }
)
}
}
extension EnvVars {
/// Load the `EnvVars` from the environment.
///
/// - Paramaters:
/// - logger: An optional logger to use for debugging.
/// - version: A version that is specified from command line, ignoring any environment variable.
static func load(
dotEnvFile: String?,
logger: Logger?,
version: String?
) throws -> EnvVars {
let defaultEnvVars = EnvVars()
let encoder = JSONEncoder()
let decoder = JSONDecoder()
if let dotEnvFile {
try DotEnv.load(path: dotEnvFile)
}
let defaultEnvDict = (try? encoder.encode(defaultEnvVars))
.flatMap { try? decoder.decode([String: String].self, from: $0) }
?? [:]
let envVarsDict = defaultEnvDict
.merging(ProcessInfo.processInfo.environment, uniquingKeysWith: { $1 })
var envVars = (try? JSONSerialization.data(withJSONObject: envVarsDict))
.flatMap { try? decoder.decode(EnvVars.self, from: $0) }
?? defaultEnvVars
if let version {
envVars.version = version
}
logger?.debug("Done loading EnvVars...")
return envVars
}
}
@_spi(Internal)
public 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: .parseOrDefualt(string: envVars.version),
disablePing: false,
userName: envVars.userName,
password: envVars.password
)
)
}
}
extension MQTTClient.Version {
static let `default` = Self.v3_1_1
static func parseOrDefualt(string: String?) -> Self {
guard let string, let value = Self(string: string) else {
return .default
}
return value
}
init?(string: String) {
if string.contains("5") {
self = .v5_0
} else if string.contains("3") {
self = .v3_1_1
} else {
return nil
}
}
}
@_spi(Internal)
public extension Logger.Level {
/// Parse a `Logger.Level` from the loaded `EnvVars`.
static func from(environment envVars: EnvVars) -> Self {
// If the log level was set via an environment variable.
if let logLevel = envVars.logLevel {
return logLevel
}
// Parse the appEnv to derive an log level.
switch envVars.appEnv {
case .staging, .development:
return .debug
case .production:
return .info
case .testing:
return .trace
}
}
}

View File

@@ -1,3 +1,4 @@
import ArgumentParser
import Dependencies import Dependencies
import Foundation import Foundation
import Logging import Logging
@@ -11,106 +12,10 @@ import SensorsService
import ServiceLifecycle import ServiceLifecycle
@main @main
struct Application { struct Application: AsyncParsableCommand {
static let configuration = CommandConfiguration(
/// The main entry point of the application. commandName: "dewpoint-controller",
static func main() async throws { abstract: "Command for running the dewpoint mqtt service.",
let eventloopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) subcommands: [Run.self, Debug.self]
var logger = Logger(label: "dewpoint-controller")
logger.logLevel = .trace
logger.info("Starting dewpoint-controller!")
let environment = loadEnvVars(logger: logger)
if environment.appEnv == .production {
logger.debug("Updating logging level to info.")
logger.logLevel = .info
}
let mqtt = MQTTClient(
envVars: environment,
eventLoopGroup: eventloopGroup,
logger: logger
) )
do {
try await withDependencies {
$0.psychrometricClient = .liveValue
$0.mqtt = .live(client: mqtt, logger: logger)
} operation: {
let mqttConnection = MQTTConnectionService(logger: logger)
let sensors = SensorsService(sensors: .live, logger: logger)
var serviceGroupConfiguration = ServiceGroupConfiguration(
services: [
mqttConnection,
sensors
],
gracefulShutdownSignals: [.sigterm, .sigint],
logger: logger
)
serviceGroupConfiguration.maximumCancellationDuration = .seconds(5)
serviceGroupConfiguration.maximumGracefulShutdownDuration = .seconds(10)
let serviceGroup = ServiceGroup(configuration: serviceGroupConfiguration)
try await serviceGroup.run()
}
try await mqtt.shutdown()
try await eventloopGroup.shutdownGracefully()
} catch {
try await eventloopGroup.shutdownGracefully()
}
}
}
// MARK: - Helpers
private func loadEnvVars(logger: Logger) -> EnvVars {
let defaultEnvVars = EnvVars()
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let defaultEnvDict = (try? encoder.encode(defaultEnvVars))
.flatMap { try? decoder.decode([String: String].self, from: $0) }
?? [:]
let envVarsDict = defaultEnvDict
.merging(ProcessInfo.processInfo.environment, uniquingKeysWith: { $1 })
let envVars = (try? JSONSerialization.data(withJSONObject: envVarsDict))
.flatMap { try? decoder.decode(EnvVars.self, from: $0) }
?? defaultEnvVars
logger.debug("Done loading EnvVars...")
return envVars
}
private extension MQTTNIO.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: .v3_1_1,
disablePing: false,
userName: envVars.userName,
password: envVars.password
)
)
}
}
private extension Array where Element == TemperatureAndHumiditySensor {
static var live: Self {
TemperatureAndHumiditySensor.Location.allCases.map { location in
TemperatureAndHumiditySensor(location: location)
}
}
} }

View File

@@ -0,0 +1,74 @@
import ArgumentParser
import CliClient
import CustomDump
import Dependencies
import DotEnv
import Foundation
import Logging
import Models
extension Application {
struct Debug: AsyncParsableCommand {
static let configuration: CommandConfiguration = .init(
commandName: "debug",
abstract: "Debug the environment variables."
)
@OptionGroup
var shared: SharedOptions
@Flag(
name: [.customLong("show-password")],
help: "Don't redact the password from the console."
)
var showPassword: Bool = false
mutating func run() async throws {
@Dependency(\.cliClient) var client
let logger = Logger(label: "debug-command")
print("--------------------------")
print("Running debug command...")
if let envFile = shared.envFile {
print("Reading env file: \(envFile)")
print("--------------------------")
} else {
print("No env file set.")
print("--------------------------")
}
print("Loading EnvVars")
print("--------------------------")
let envVars = try await client.makeEnvVars(shared.envVarsRequest(logger: logger))
printEnvVars(envVars: envVars, showPassword: showPassword)
print("--------------------------")
if let logLevel = shared.logLevel, let level = logLevel() {
print("Log Level option: \(level)")
print("--------------------------")
} else {
print("Log Level option: nil")
print("--------------------------")
}
}
private func printEnvVars(envVars: EnvVars, showPassword: Bool) {
// show the proper password to show depending on if it exists
// and if we should redact it or not.
var passwordString: String?
switch (showPassword, envVars.password) {
case (true, .none), (_, .none):
break
case (true, let .some(password)):
passwordString = password
case (false, .some):
passwordString = "<redacted>"
}
var envVars = envVars
envVars.password = passwordString
customDump(envVars)
}
}
}

View File

@@ -0,0 +1,86 @@
import ArgumentParser
import CliClient
import Dependencies
import Foundation
import Logging
import Models
import MQTTConnectionService
import MQTTManager
import MQTTNIO
import NIO
import PsychrometricClientLive
import SensorsService
import ServiceLifecycle
extension Application {
/// Run the controller.
///
struct Run: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "run",
abstract: "Run the controller."
)
@OptionGroup
var shared: SharedOptions
mutating func run() async throws {
@Dependency(\.cliClient) var cliClient
let eventloopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
var logger = Logger(label: "dewpoint-controller")
let mqtt = try await setup(eventLoopGroup: eventloopGroup, logger: &logger)
do {
try await withDependencies {
$0.psychrometricClient = .liveValue
$0.mqtt = .live(client: mqtt, logger: logger)
} operation: {
let mqttConnection = MQTTConnectionService(logger: logger)
let sensors = SensorsService(sensors: .live, logger: logger)
var serviceGroupConfiguration = ServiceGroupConfiguration(
services: [
mqttConnection,
sensors
],
gracefulShutdownSignals: [.sigterm, .sigint],
logger: logger
)
serviceGroupConfiguration.maximumCancellationDuration = .seconds(5)
serviceGroupConfiguration.maximumGracefulShutdownDuration = .seconds(10)
let serviceGroup = ServiceGroup(configuration: serviceGroupConfiguration)
logger.info("Starting dewpoint-controller!")
try await serviceGroup.run()
}
try await mqtt.shutdown()
try await eventloopGroup.shutdownGracefully()
} catch {
try await eventloopGroup.shutdownGracefully()
}
}
private func setup(
eventLoopGroup: MultiThreadedEventLoopGroup,
logger: inout Logger
) async throws -> MQTTClient {
@Dependency(\.cliClient) var cliClient
let environment = try await cliClient.makeEnvVars(shared.envVarsRequest(logger: logger))
logger.logLevel = cliClient.logLevel(environment)
return try cliClient.makeClient(.init(
envVars: environment,
eventLoopGroup: eventLoopGroup,
logger: logger
))
}
}
}
// MARK: - Helpers

View File

@@ -0,0 +1,50 @@
import ArgumentParser
import CliClient
import Logging
import Models
import MQTTNIO
extension Application {
struct SharedOptions: ParsableArguments {
@Option(
name: [.short, .customLong("env-file")],
help: "A file path to an env file."
)
var envFile: String?
@Option(
name: [.short, .customLong("log-level")],
help: "Set the logging level."
)
var logLevel: LogLevelContainer?
@Option(
name: [.short, .long],
help: "Set the MQTT connecition version."
)
var version: String?
func envVarsRequest(logger: Logger?) -> CliClient.EnvVarsRequest {
.init(envFilePath: envFile, logger: logger, version: version)
}
}
}
/// A container type for making `Logger.Level` into a type
/// that can be parsed as a command line argument. This is
/// to suppress warnings vs. having `Logger.Level` adopt the
/// protocol.
@_spi(Internal)
public struct LogLevelContainer: ExpressibleByArgument {
public let logLevel: Logger.Level?
public init?(argument: String) {
self.logLevel = .init(rawValue: argument.lowercased())
}
public func callAsFunction() -> Logger.Level? {
logLevel
}
}

View File

@@ -0,0 +1,10 @@
import Models
@_spi(Internal)
public extension Array where Element == TemperatureAndHumiditySensor {
static var live: Self {
TemperatureAndHumiditySensor.Location.allCases.map { location in
TemperatureAndHumiditySensor(location: location)
}
}
}

View File

@@ -5,13 +5,14 @@ import MQTTManager
import ServiceLifecycle import ServiceLifecycle
public struct MQTTConnectionService: Service { public struct MQTTConnectionService: Service {
@Dependency(\.mqtt) var mqtt
private let logger: Logger? private let logger: Logger?
public init( public init(
logger: Logger? = nil logger: Logger? = nil
) { ) {
var logger = logger
logger?[metadataKey: "type"] = "mqtt-connection-service"
self.logger = logger self.logger = logger
} }
@@ -19,6 +20,7 @@ public struct MQTTConnectionService: Service {
/// to the MQTT broker and handles graceful shutdown of the /// to the MQTT broker and handles graceful shutdown of the
/// connection. /// connection.
public func run() async throws { public func run() async throws {
@Dependency(\.mqtt) var mqtt
try await mqtt.connect() try await mqtt.connect()
try await withGracefulShutdownHandler { try await withGracefulShutdownHandler {
@@ -30,11 +32,7 @@ public struct MQTTConnectionService: Service {
} }
} onGracefulShutdown: { } onGracefulShutdown: {
self.logger?.trace("Received graceful shutdown.") self.logger?.trace("Received graceful shutdown.")
shutdown()
}
}
public func shutdown() {
mqtt.shutdown() mqtt.shutdown()
} }
}
} }

View File

@@ -9,16 +9,16 @@ import NIO
public extension DependencyValues { public extension DependencyValues {
/// A dependency that is responsible for managing the connection to /// A dependency that is responsible for managing the connection to
/// an MQTT broker. /// an MQTT broker, listen to topics, and publish values back to the
/// broker.
var mqtt: MQTTManager { var mqtt: MQTTManager {
get { self[MQTTManager.self] } get { self[MQTTManager.self] }
set { self[MQTTManager.self] = newValue } set { self[MQTTManager.self] = newValue }
} }
} }
/// Represents the interface needed for the ``MQTTConnectionService``. /// Represents the interface needed to connect, listen, and publish to an MQTT broker.
/// ///
/// See ``MQTTConnectionManagerLive`` module for live implementation.
@DependencyClient @DependencyClient
public struct MQTTManager: Sendable { public struct MQTTManager: Sendable {
@@ -28,6 +28,8 @@ public struct MQTTManager: Sendable {
public var connect: @Sendable () async throws -> Void public var connect: @Sendable () async throws -> Void
/// Create a stream of connection events. /// Create a stream of connection events.
///
/// - SeeAlso: ``Event``
public var connectionStream: @Sendable () throws -> AsyncStream<Event> public var connectionStream: @Sendable () throws -> AsyncStream<Event>
private var _listen: @Sendable ([String], MQTTQoS) async throws -> ListenStream private var _listen: @Sendable ([String], MQTTQoS) async throws -> ListenStream
@@ -38,10 +40,24 @@ public struct MQTTManager: Sendable {
/// Shutdown the connection to the MQTT broker. /// Shutdown the connection to the MQTT broker.
public var shutdown: @Sendable () -> Void public var shutdown: @Sendable () -> Void
/// Perform an operation with the underlying MQTTClient, this can be useful in
/// tests, so this module needs imported with `@_spi(Testing) import` to use this method.
private var _withClient: @Sendable ((MQTTClient) async throws -> Void) async throws -> Void private var _withClient: @Sendable ((MQTTClient) async throws -> Void) async throws -> Void
public init(
connect: @escaping @Sendable () async throws -> Void,
connectionStream: @escaping @Sendable () throws -> AsyncStream<MQTTManager.Event>,
listen: @escaping @Sendable ([String], MQTTQoS) async throws -> MQTTManager.ListenStream,
publish: @escaping @Sendable (MQTTManager.PublishRequest) async throws -> Void,
shutdown: @escaping @Sendable () -> Void,
withClient: @escaping @Sendable ((MQTTClient) async throws -> Void) async throws -> Void = { _ in unimplemented() }
) {
self.connect = connect
self.connectionStream = connectionStream
self._listen = listen
self.publish = publish
self.shutdown = shutdown
self._withClient = withClient
}
/// Create an async stream that listens for changes to the given topics. /// Create an async stream that listens for changes to the given topics.
/// ///
/// - Parameters: /// - Parameters:
@@ -77,18 +93,20 @@ public struct MQTTManager: Sendable {
_ payload: ByteBuffer, _ payload: ByteBuffer,
to topicName: String, to topicName: String,
qos: MQTTQoS, qos: MQTTQoS,
retain: Bool = false retain: Bool = false,
properties: MQTTProperties = .init()
) async throws { ) async throws {
try await publish(.init( try await publish(.init(
topicName: topicName, topicName: topicName,
payload: payload, payload: payload,
qos: qos, qos: qos,
retain: retain retain: retain,
properties: properties
)) ))
} }
/// Perform an operation with the underlying MQTTClient, this can be useful in /// Perform an operation with the underlying MQTTClient, this can be useful in
/// tests, so this module needs imported with `@_spi(Testing) import` to use this method. /// tests, so this module needs imported with `@_spi(Internal) import MQTTManager` to use this method.
@_spi(Internal) @_spi(Internal)
public func withClient( public func withClient(
_ callback: @Sendable (MQTTClient) async throws -> Void _ callback: @Sendable (MQTTClient) async throws -> Void
@@ -98,7 +116,7 @@ public struct MQTTManager: Sendable {
/// Represents connection events that clients can listen for and /// Represents connection events that clients can listen for and
/// react accordingly. /// react accordingly.
public enum Event: Sendable { public enum Event: Equatable, Sendable {
case connected case connected
case disconnected case disconnected
case shuttingDown case shuttingDown
@@ -106,7 +124,7 @@ public struct MQTTManager: Sendable {
/// Represents the parameters required to publish a new value to the /// Represents the parameters required to publish a new value to the
/// MQTT broker. /// MQTT broker.
public struct PublishRequest: Equatable, Sendable { public struct PublishRequest: Sendable {
/// The topic to publish the new value to. /// The topic to publish the new value to.
public let topicName: String public let topicName: String
@@ -120,6 +138,8 @@ public struct MQTTManager: Sendable {
/// The retain flag for the request. /// The retain flag for the request.
public let retain: Bool public let retain: Bool
public let properties: MQTTProperties
/// Create a new publish request. /// Create a new publish request.
/// ///
/// - Parameters: /// - Parameters:
@@ -131,12 +151,14 @@ public struct MQTTManager: Sendable {
topicName: String, topicName: String,
payload: ByteBuffer, payload: ByteBuffer,
qos: MQTTQoS, qos: MQTTQoS,
retain: Bool retain: Bool,
properties: MQTTProperties
) { ) {
self.topicName = topicName self.topicName = topicName
self.payload = payload self.payload = payload
self.qos = qos self.qos = qos
self.retain = retain self.retain = retain
self.properties = properties
} }
} }
@@ -164,7 +186,7 @@ public extension MQTTManager {
.removeDuplicates() .removeDuplicates()
.eraseToStream() .eraseToStream()
}, },
_listen: { topics, qos in listen: { topics, qos in
try await manager.listen(to: topics, qos: qos) try await manager.listen(to: topics, qos: qos)
}, },
publish: { request in publish: { request in
@@ -174,19 +196,20 @@ public extension MQTTManager {
return return
} }
logger?.trace("Begin publishing to topic: \(topic)") logger?.trace("Begin publishing to topic: \(topic)")
defer { logger?.trace("Done publishing to topic: \(topic)") } defer { logger?.debug("Done publishing to topic: \(topic)") }
try await client.publish( try await client.publish(
to: request.topicName, to: request.topicName,
payload: request.payload, payload: request.payload,
qos: request.qos, qos: request.qos,
retain: request.retain retain: request.retain,
) properties: request.properties
).get()
}, },
shutdown: { shutdown: {
Task { try await client.shutdown() } Task { try await client.shutdown() }
manager.shutdown() manager.shutdown()
}, },
_withClient: { callback in withClient: { callback in
try await callback(client) try await callback(client)
} }
) )

View File

@@ -107,42 +107,6 @@ actor TopicListenerStream {
onShutdownHandler = { task.cancel() } onShutdownHandler = { task.cancel() }
} }
// TODO: remove.
func listen(
_ topics: [String],
_ qos: MQTTQoS = .atLeastOnce
) async throws -> Stream {
var sleepTimes = 0
while !client.isActive() {
guard sleepTimes < 10 else {
throw TopicListenerError.connectionTimeout
}
try? await Task.sleep(for: .milliseconds(100))
sleepTimes += 1
}
client.logger.trace("Client is active, begin subscribing to topics.")
try await subscribe()
client.logger.trace("Done subscribing, begin listening to topics.")
client.addPublishListener(named: name) { result in
switch result {
case let .failure(error):
self.logger?.error("Received error while listening: \(error)")
case let .success(publishInfo):
if topics.contains(publishInfo.topicName) {
self.logger?.debug("Recieved new value for topic: \(publishInfo.topicName)")
self.continuation.yield(publishInfo)
}
}
}
return stream
}
private func setIsShuttingDown() { private func setIsShuttingDown() {
shuttingDown = true shuttingDown = true
onShutdownHandler = nil onShutdownHandler = nil

View File

@@ -25,6 +25,12 @@ public struct EnvVars: Codable, Equatable, Sendable {
/// The MQTT user password. /// The MQTT user password.
public var password: String? public var password: String?
/// Set a custom logging level.
public var logLevel: Logger.Level?
/// Set the mqtt broker version.
public var version: String?
/// Create a new ``EnvVars`` /// Create a new ``EnvVars``
/// ///
/// - Parameters: /// - Parameters:
@@ -38,9 +44,11 @@ public struct EnvVars: Codable, Equatable, Sendable {
appEnv: AppEnv = .development, appEnv: AppEnv = .development,
host: String = "127.0.0.1", host: String = "127.0.0.1",
port: String? = "1883", port: String? = "1883",
identifier: String = "dewPoint-controller", identifier: String = "dewpoint-controller",
userName: String? = "mqtt_user", userName: String? = "mqtt_user",
password: String? = "secret!" password: String? = "secret!",
logLevel: Logger.Level? = nil,
version: String? = nil
) { ) {
self.appEnv = appEnv self.appEnv = appEnv
self.host = host self.host = host
@@ -48,6 +56,8 @@ public struct EnvVars: Codable, Equatable, Sendable {
self.identifier = identifier self.identifier = identifier
self.userName = userName self.userName = userName
self.password = password self.password = password
self.logLevel = logLevel
self.version = version
} }
/// Custom coding keys. /// Custom coding keys.
@@ -58,6 +68,8 @@ public struct EnvVars: Codable, Equatable, Sendable {
case identifier = "MQTT_IDENTIFIER" case identifier = "MQTT_IDENTIFIER"
case userName = "MQTT_USERNAME" case userName = "MQTT_USERNAME"
case password = "MQTT_PASSWORD" case password = "MQTT_PASSWORD"
case logLevel = "LOG_LEVEL"
case version = "MQTT_VERSION"
} }
/// Represents the different app environments. /// Represents the different app environments.

View File

@@ -1,20 +1,25 @@
import Dependencies import Dependencies
// @_spi(Internal) import dewpoint_controller
import Logging import Logging
import Models import Models
import MQTTConnectionService
@_spi(Internal) import MQTTManager @_spi(Internal) import MQTTManager
import MQTTNIO import MQTTNIO
import NIO import NIO
import PsychrometricClientLive import PsychrometricClientLive
@_spi(Internal) import SensorsService @_spi(Internal) import SensorsService
import ServiceLifecycle
import ServiceLifecycleTestKit
import XCTest import XCTest
final class SensorsClientTests: XCTestCase { final class IntegrationTests: 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: "SensorsClientTests") var logger = Logger(label: "IntegrationTests")
logger.logLevel = .trace logger.logLevel = .info
return logger return logger
}() }()
@@ -29,6 +34,71 @@ final class SensorsClientTests: XCTestCase {
} }
} }
func testConnectionServiceShutdown() async throws {
@Dependency(\.mqtt) var mqtt
let service = MQTTConnectionService(logger: Self.logger)
let task = Task { try await service.run() }
defer { task.cancel() }
try await Task.sleep(for: .milliseconds(200))
// check the connection is active here.
try await mqtt.withClient { client in
XCTAssert(client.isActive())
}
mqtt.shutdown()
try await Task.sleep(for: .milliseconds(500))
// check the connection is active here.
try await mqtt.withClient { client in
XCTAssertFalse(client.isActive())
}
}
func testMQTTConnectionStream() async throws {
let client = createClient(identifier: "testNonManagedStream")
let manager = MQTTManager.live(
client: client,
logger: Self.logger,
alwaysReconnect: false
)
defer { manager.shutdown() }
let connectionStream1 = MQTTConnectionStream(client: client, logger: Self.logger)
let connectionStream2 = MQTTConnectionStream(client: client, logger: Self.logger)
var events1 = [MQTTManager.Event]()
var events2 = [MQTTManager.Event]()
let stream1 = connectionStream1.start()
let stream2 = connectionStream2.start()
_ = try await manager.connect()
Task {
while !client.isActive() {
try await Task.sleep(for: .milliseconds(100))
}
try await Task.sleep(for: .milliseconds(200))
try await client.disconnect()
try await Task.sleep(for: .milliseconds(500))
manager.shutdown()
try await Task.sleep(for: .milliseconds(500))
connectionStream1.stop()
connectionStream2.stop()
}
for await event in stream1.removeDuplicates() {
events1.append(event)
}
for await event in stream2.removeDuplicates() {
events2.append(event)
}
XCTAssertEqual(events1, [.disconnected, .connected, .disconnected, .shuttingDown])
XCTAssertEqual(events2, [.disconnected, .connected, .disconnected, .shuttingDown])
}
func testListeningResumesAfterDisconnectThenReconnect() async throws { func testListeningResumesAfterDisconnectThenReconnect() async throws {
struct TimeoutError: Error {} struct TimeoutError: Error {}
@@ -99,6 +169,8 @@ final class SensorsClientTests: XCTestCase {
userName: nil, userName: nil,
password: nil password: nil
) )
let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
// return .init(envVars: envVars, eventLoopGroup: eventLoopGroup, logger: Self.logger)
let config = MQTTClient.Configuration( let config = MQTTClient.Configuration(
version: .v3_1_1, version: .v3_1_1,
userName: envVars.userName, userName: envVars.userName,
@@ -111,14 +183,15 @@ final class SensorsClientTests: XCTestCase {
return .init( return .init(
host: Self.hostname, host: Self.hostname,
identifier: identifier, identifier: identifier,
eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup(numberOfThreads: 1)), eventLoopGroupProvider: .shared(eventLoopGroup),
logger: Self.logger, logger: Self.logger,
configuration: config configuration: config
) )
} }
} }
// MARK: Helpers for tests. // - MARK: Helpers
struct TopicNotFoundError: Error {} struct TopicNotFoundError: Error {}

View File

@@ -1,128 +0,0 @@
import Dependencies
import Logging
import Models
import MQTTConnectionService
@_spi(Internal) import MQTTManager
import MQTTNIO
import NIO
import PsychrometricClientLive
import ServiceLifecycle
import ServiceLifecycleTestKit
import XCTest
// TODO: Rename to integration test when other tests are moved.
final class MQTTConnectionServiceTests: XCTestCase {
static let hostname = ProcessInfo.processInfo.environment["MOSQUITTO_SERVER"] ?? "localhost"
static let logger: Logger = {
var logger = Logger(label: "MQTTConnectionServiceTests")
logger.logLevel = .info
return logger
}()
override func invokeTest() {
let client = createClient(identifier: "\(Self.self)")
withDependencies {
$0.mqtt = .live(client: client, logger: Self.logger)
$0.psychrometricClient = PsychrometricClient.liveValue
} operation: {
super.invokeTest()
}
}
func testConnectionServiceShutdown() async throws {
@Dependency(\.mqtt) var mqtt
let service = MQTTConnectionService(logger: Self.logger)
let task = Task { try await service.run() }
defer { task.cancel() }
try await Task.sleep(for: .milliseconds(200))
// check the connection is active here.
try await mqtt.withClient { client in
XCTAssert(client.isActive())
}
service.shutdown()
try await Task.sleep(for: .milliseconds(500))
// check the connection is active here.
try await mqtt.withClient { client in
XCTAssertFalse(client.isActive())
}
}
// TODO: Move to integration tests.
func testMQTTConnectionStream() async throws {
let client = createClient(identifier: "testNonManagedStream")
let manager = MQTTManager.live(
client: client,
logger: Self.logger,
alwaysReconnect: false
)
defer { manager.shutdown() }
let connectionStream1 = MQTTConnectionStream(client: client, logger: Self.logger)
let connectionStream2 = MQTTConnectionStream(client: client, logger: Self.logger)
var events1 = [MQTTManager.Event]()
var events2 = [MQTTManager.Event]()
let stream1 = connectionStream1.start()
let stream2 = connectionStream2.start()
_ = try await manager.connect()
Task {
while !client.isActive() {
try await Task.sleep(for: .milliseconds(100))
}
try await Task.sleep(for: .milliseconds(200))
try await client.disconnect()
try await Task.sleep(for: .milliseconds(500))
manager.shutdown()
try await Task.sleep(for: .milliseconds(500))
connectionStream1.stop()
connectionStream2.stop()
}
for await event in stream1.removeDuplicates() {
events1.append(event)
}
for await event in stream2.removeDuplicates() {
events2.append(event)
}
XCTAssertEqual(events1, [.disconnected, .connected, .disconnected, .shuttingDown])
XCTAssertEqual(events2, [.disconnected, .connected, .disconnected, .shuttingDown])
}
func createClient(identifier: String) -> MQTTClient {
let envVars = EnvVars(
appEnv: .testing,
host: Self.hostname,
port: "1883",
identifier: identifier,
userName: nil,
password: nil
)
let config = MQTTClient.Configuration(
version: .v3_1_1,
userName: envVars.userName,
password: envVars.password,
useSSL: false,
useWebSockets: false,
tlsConfiguration: nil,
webSocketURLPath: nil
)
return .init(
host: Self.hostname,
identifier: identifier,
eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup(numberOfThreads: 1)),
logger: Self.logger,
configuration: config
)
}
}

View File

@@ -1,6 +1,8 @@
# Used this to build the release version of the image. # Used this to build the release version of the image.
# Build the executable # Build the executable
FROM swift:5.10 AS build ARG SWIFT_IMAGE_VERSION="5.10"
FROM swift:${SWIFT_IMAGE_VERSION} AS build
WORKDIR /build WORKDIR /build
COPY ./Package.* ./ COPY ./Package.* ./
RUN swift package resolve RUN swift package resolve
@@ -8,7 +10,6 @@ COPY . .
RUN swift build -c release -Xswiftc -g RUN swift build -c release -Xswiftc -g
# Run image # Run image
FROM swift:5.10-slim FROM swift:${SWIFT_IMAGE_VERSION}-slim
WORKDIR /run COPY --from=build /build/.build/release/dewpoint-controller /usr/local/bin
COPY --from=build /build/.build/release/dewpoint-controller /run CMD ["/bin/bash", "-xc", "/usr/local/bin/dewpoint-controller run"]
CMD ["/bin/bash", "-xc", "./dewpoint-controller"]