Compare commits
90 Commits
0.1.2
...
99f39b91af
| Author | SHA1 | Date | |
|---|---|---|---|
|
99f39b91af
|
|||
|
55ea88a29f
|
|||
|
756fd0bccf
|
|||
|
24f2ad63a7
|
|||
|
ce18c44363
|
|||
|
6472d3cd1e
|
|||
|
3416ce1003
|
|||
|
c84427a9b3
|
|||
|
947472f62d
|
|||
|
d16135dd50
|
|||
|
19e97652fd
|
|||
|
1089452212
|
|||
|
5e998a60d0
|
|||
|
9e2af22a36
|
|||
|
89f3601c2c
|
|||
|
d4b6f6ad2b
|
|||
|
ec3cd40fef
|
|||
|
953c9d5b7c
|
|||
|
00bb6ca1a6
|
|||
|
41fb3c5715
|
|||
|
8e4430804c
|
|||
|
a8f689136d
|
|||
|
2607be6658
|
|||
|
b05e18b258
|
|||
|
394b49d1a0
|
|||
|
6bec0d6fa5
|
|||
|
63d65bd7cd
|
|||
|
320f3e792e
|
|||
|
74b73e7534
|
|||
|
7954fc5dcd
|
|||
|
115c4dc252
|
|||
|
853a157ae7
|
|||
|
30b8ea3661
|
|||
|
d26ab714ab
|
|||
|
b45ad76fff
|
|||
|
c4395b9089
|
|||
|
b3874b96c5
|
|||
|
4024bb624f
|
|||
|
6371ffed47
|
|||
|
76b06e86fa
|
|||
|
fccfa4d006
|
|||
|
5df08d6c91
|
|||
|
1c99e4861d
|
|||
|
a0b7053eae
|
|||
|
df3ed6a407
|
|||
|
1d9d8dc449
|
|||
|
9a53d36f4c
|
|||
|
44a6a878eb
|
|||
|
c13a1a14a3
|
|||
|
6c916215ea
|
|||
|
be7442c06a
|
|||
|
26a30c2a07
|
|||
|
5f131d8fa2
|
|||
|
d6e217f556
|
|||
|
b39ccafc92
|
|||
|
8336c56adf
|
|||
|
fac8945386
|
|||
|
5b319cae9b
|
|||
|
ca7024cb60
|
|||
|
ce327a6f1c
|
|||
|
95f8565cde
|
|||
|
163f603b69
|
|||
|
e7a849b003
|
|||
|
bd2a798320
|
|||
|
b8992b89b6
|
|||
|
efd9907b4a
|
|||
|
fbbd65f7ae
|
|||
|
8067331ff8
|
|||
|
b6db9b5322
|
|||
|
bf1126b06a
|
|||
|
ef552fb8bc
|
|||
|
1e62d7aac0
|
|||
|
f68ac528e4
|
|||
|
10294801fc
|
|||
|
a65605e9e7
|
|||
|
320a733d12
|
|||
|
936dd0b816
|
|||
|
a87addaf0b
|
|||
|
e2683d3f06
|
|||
|
6c5115dcde
|
|||
|
90c5b7c77f
|
|||
|
79bb162434
|
|||
|
529b9b0bc5
|
|||
|
48d51419d7
|
|||
|
adc7fc1295
|
|||
|
f40c4ef859
|
|||
|
e6d1d4578d
|
|||
|
408e0484cd
|
|||
|
19b2eb42c5
|
|||
|
7122fc818b
|
0
.dockerignore
Normal file → Executable file
0
.dockerignore
Normal file → Executable file
7
.editorconfig
Normal file
7
.editorconfig
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*.swift]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
tab_width = 2
|
||||||
|
trim_trailing_whitespace = true
|
||||||
21
.gitea/workflows/ci.yaml
Normal file
21
.gitea/workflows/ci.yaml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Run Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
- name: Setup Docker buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Run Test
|
||||||
|
run: make test-docker
|
||||||
|
- name: Cleanup.
|
||||||
|
if: always()
|
||||||
|
run: docker compose --file docker/docker-compose-test.yaml down
|
||||||
0
.github/workflows/release.yml
vendored
Normal file → Executable file
0
.github/workflows/release.yml
vendored
Normal file → Executable file
5
.gitignore
vendored
Normal file → Executable file
5
.gitignore
vendored
Normal file → Executable file
@@ -5,7 +5,10 @@
|
|||||||
xcuserdata/
|
xcuserdata/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
.dewPoint-env
|
.dewPoint-env*
|
||||||
.topics
|
.topics
|
||||||
mqtt_password.txt
|
mqtt_password.txt
|
||||||
.env
|
.env
|
||||||
|
.smbdelete*
|
||||||
|
buildServer.json
|
||||||
|
.nvim/*
|
||||||
|
|||||||
11
.swiftformat
Normal file
11
.swiftformat
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
--self init-only
|
||||||
|
--indent 2
|
||||||
|
--ifdef indent
|
||||||
|
--trimwhitespace always
|
||||||
|
--wraparguments before-first
|
||||||
|
--wrapparameters before-first
|
||||||
|
--wrapcollections preserve
|
||||||
|
--wrapconditions after-first
|
||||||
|
--typeblanklines preserve
|
||||||
|
--commas inline
|
||||||
|
--stripunusedargs closure-only
|
||||||
9
.swiftlint.yml
Normal file
9
.swiftlint.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
disabled_rules:
|
||||||
|
- closing_brace
|
||||||
|
- fuction_body_length
|
||||||
|
|
||||||
|
included:
|
||||||
|
- Sources
|
||||||
|
- Tests
|
||||||
|
|
||||||
|
ignore_multiline_statement_conditions: true
|
||||||
28
.swiftpm/dewpoint-controller-Package.xctestplan
Normal file
28
.swiftpm/dewpoint-controller-Package.xctestplan
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"configurations" : [
|
||||||
|
{
|
||||||
|
"id" : "AFB1047B-4742-43D2-AFB9-680C1CB2D273",
|
||||||
|
"name" : "Test Scheme Action",
|
||||||
|
"options" : {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"defaultOptions" : {
|
||||||
|
"targetForVariableExpansion" : {
|
||||||
|
"containerPath" : "container:",
|
||||||
|
"identifier" : "dewpoint-controller",
|
||||||
|
"name" : "dewpoint-controller"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"testTargets" : [
|
||||||
|
{
|
||||||
|
"target" : {
|
||||||
|
"containerPath" : "container:",
|
||||||
|
"identifier" : "IntegrationTests",
|
||||||
|
"name" : "IntegrationTests"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "1330"
|
|
||||||
version = "1.3">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "Bootstrap"
|
|
||||||
BuildableName = "Bootstrap"
|
|
||||||
BlueprintName = "Bootstrap"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
|
||||||
<Testables>
|
|
||||||
</Testables>
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
<MacroExpansion>
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "Bootstrap"
|
|
||||||
BuildableName = "Bootstrap"
|
|
||||||
BlueprintName = "Bootstrap"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</MacroExpansion>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "1330"
|
|
||||||
version = "1.3">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "Client"
|
|
||||||
BuildableName = "Client"
|
|
||||||
BlueprintName = "Client"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
|
||||||
<Testables>
|
|
||||||
</Testables>
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
<MacroExpansion>
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "Client"
|
|
||||||
BuildableName = "Client"
|
|
||||||
BlueprintName = "Client"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</MacroExpansion>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "1330"
|
|
||||||
version = "1.3">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "ClientLive"
|
|
||||||
BuildableName = "ClientLive"
|
|
||||||
BlueprintName = "ClientLive"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
|
||||||
<Testables>
|
|
||||||
</Testables>
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
<MacroExpansion>
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "ClientLive"
|
|
||||||
BuildableName = "ClientLive"
|
|
||||||
BlueprintName = "ClientLive"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</MacroExpansion>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "1330"
|
|
||||||
version = "1.3">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "DewPointEnvironment"
|
|
||||||
BuildableName = "DewPointEnvironment"
|
|
||||||
BlueprintName = "DewPointEnvironment"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
|
||||||
<Testables>
|
|
||||||
</Testables>
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
<MacroExpansion>
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "DewPointEnvironment"
|
|
||||||
BuildableName = "DewPointEnvironment"
|
|
||||||
BlueprintName = "DewPointEnvironment"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</MacroExpansion>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "1330"
|
|
||||||
version = "1.3">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "EnvVars"
|
|
||||||
BuildableName = "EnvVars"
|
|
||||||
BlueprintName = "EnvVars"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
|
||||||
<Testables>
|
|
||||||
</Testables>
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
<MacroExpansion>
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "EnvVars"
|
|
||||||
BuildableName = "EnvVars"
|
|
||||||
BlueprintName = "EnvVars"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</MacroExpansion>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "1330"
|
|
||||||
version = "1.3">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "Models"
|
|
||||||
BuildableName = "Models"
|
|
||||||
BlueprintName = "Models"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
|
||||||
<Testables>
|
|
||||||
</Testables>
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
<MacroExpansion>
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "Models"
|
|
||||||
BuildableName = "Models"
|
|
||||||
BlueprintName = "Models"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</MacroExpansion>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "1330"
|
|
||||||
version = "1.3">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "Bootstrap"
|
|
||||||
BuildableName = "Bootstrap"
|
|
||||||
BlueprintName = "Bootstrap"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "Client"
|
|
||||||
BuildableName = "Client"
|
|
||||||
BlueprintName = "Client"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "ClientLive"
|
|
||||||
BuildableName = "ClientLive"
|
|
||||||
BlueprintName = "ClientLive"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "DewPointEnvironment"
|
|
||||||
BuildableName = "DewPointEnvironment"
|
|
||||||
BlueprintName = "DewPointEnvironment"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "EnvVars"
|
|
||||||
BuildableName = "EnvVars"
|
|
||||||
BlueprintName = "EnvVars"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "Models"
|
|
||||||
BuildableName = "Models"
|
|
||||||
BlueprintName = "Models"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "dewPoint-controller"
|
|
||||||
BuildableName = "dewPoint-controller"
|
|
||||||
BlueprintName = "dewPoint-controller"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "NO"
|
|
||||||
buildForArchiving = "NO"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "ClientTests"
|
|
||||||
BuildableName = "ClientTests"
|
|
||||||
BlueprintName = "ClientTests"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "NO"
|
|
||||||
buildForArchiving = "NO"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "dewPoint-controllerTests"
|
|
||||||
BuildableName = "dewPoint-controllerTests"
|
|
||||||
BlueprintName = "dewPoint-controllerTests"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "TopicsLive"
|
|
||||||
BuildableName = "TopicsLive"
|
|
||||||
BlueprintName = "TopicsLive"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
|
||||||
<Testables>
|
|
||||||
<TestableReference
|
|
||||||
skipped = "NO">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "ClientTests"
|
|
||||||
BuildableName = "ClientTests"
|
|
||||||
BlueprintName = "ClientTests"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</TestableReference>
|
|
||||||
<TestableReference
|
|
||||||
skipped = "NO">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "dewPoint-controllerTests"
|
|
||||||
BuildableName = "dewPoint-controllerTests"
|
|
||||||
BlueprintName = "dewPoint-controllerTests"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</TestableReference>
|
|
||||||
</Testables>
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
<MacroExpansion>
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "dewPoint-controller"
|
|
||||||
BuildableName = "dewPoint-controller"
|
|
||||||
BlueprintName = "dewPoint-controller"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</MacroExpansion>
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
<MacroExpansion>
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "dewPoint-controller"
|
|
||||||
BuildableName = "dewPoint-controller"
|
|
||||||
BlueprintName = "dewPoint-controller"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</MacroExpansion>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "1330"
|
|
||||||
version = "1.3">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "dewPoint-controller"
|
|
||||||
BuildableName = "dewPoint-controller"
|
|
||||||
BlueprintName = "dewPoint-controller"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "NO"
|
|
||||||
buildForArchiving = "NO"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "ClientTests"
|
|
||||||
BuildableName = "ClientTests"
|
|
||||||
BlueprintName = "ClientTests"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "NO"
|
|
||||||
buildForArchiving = "NO"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "dewPoint-controllerTests"
|
|
||||||
BuildableName = "dewPoint-controllerTests"
|
|
||||||
BlueprintName = "dewPoint-controllerTests"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
|
||||||
<Testables>
|
|
||||||
<TestableReference
|
|
||||||
skipped = "NO">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "ClientTests"
|
|
||||||
BuildableName = "ClientTests"
|
|
||||||
BlueprintName = "ClientTests"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</TestableReference>
|
|
||||||
<TestableReference
|
|
||||||
skipped = "NO">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "dewPoint-controllerTests"
|
|
||||||
BuildableName = "dewPoint-controllerTests"
|
|
||||||
BlueprintName = "dewPoint-controllerTests"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</TestableReference>
|
|
||||||
</Testables>
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "dewPoint-controller"
|
|
||||||
BuildableName = "dewPoint-controller"
|
|
||||||
BlueprintName = "dewPoint-controller"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "dewPoint-controller"
|
|
||||||
BuildableName = "dewPoint-controller"
|
|
||||||
BlueprintName = "dewPoint-controller"
|
|
||||||
ReferencedContainer = "container:">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
0
Bootstrap/dewPoint-env-example
Normal file → Executable file
0
Bootstrap/dewPoint-env-example
Normal file → Executable file
0
Bootstrap/topics-example
Normal file → Executable file
0
Bootstrap/topics-example
Normal file → Executable file
14
Dockerfile
14
Dockerfile
@@ -1,14 +0,0 @@
|
|||||||
|
|
||||||
# Build the executable
|
|
||||||
FROM swift:5.10 AS build
|
|
||||||
WORKDIR /build
|
|
||||||
COPY ./Package.* ./
|
|
||||||
RUN swift package resolve
|
|
||||||
COPY . .
|
|
||||||
RUN swift build --enable-test-discovery -c release -Xswiftc -g
|
|
||||||
|
|
||||||
# Run image
|
|
||||||
FROM swift:5.10-slim
|
|
||||||
WORKDIR /run
|
|
||||||
COPY --from=build /build/.build/release/dewPoint-controller /run
|
|
||||||
CMD ["/bin/bash", "-xc", "./dewPoint-controller"]
|
|
||||||
47
Makefile
Normal file → Executable file
47
Makefile
Normal file → Executable file
@@ -1,24 +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
|
@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:
|
||||||
|
rm -rf .build
|
||||||
|
|
||||||
|
.PHONY: run
|
||||||
run:
|
run:
|
||||||
@swift run dewPoint-controller
|
@swift run dewpoint-controller
|
||||||
|
|
||||||
start-mosquitto:
|
|
||||||
@docker-compose start mosquitto
|
|
||||||
|
|
||||||
stop-mosquitto:
|
|
||||||
@docker-compose rm -f mosquitto || true
|
|
||||||
|
|
||||||
|
.PHONY: test-docker
|
||||||
test-docker:
|
test-docker:
|
||||||
@docker-compose run -i test
|
@docker compose --file docker/docker-compose-test.yaml \
|
||||||
@docker-compose kill mosquitto-test
|
run --build --remove-orphans -i --rm test
|
||||||
|
@docker compose --file docker/docker-compose-test.yaml down
|
||||||
|
|
||||||
|
.PHONY: start-mosquitto
|
||||||
|
start-mosquitto:
|
||||||
|
@docker compose --file docker/docker-compose.yaml \
|
||||||
|
up -d mosquitto
|
||||||
|
|
||||||
|
.PHONY: test-swift
|
||||||
|
test-swift: start-mosquitto
|
||||||
|
@swift test --enable-code-coverage
|
||||||
|
|||||||
250
Package.resolved
Normal file → Executable file
250
Package.resolved
Normal file → Executable file
@@ -1,61 +1,195 @@
|
|||||||
{
|
{
|
||||||
"object": {
|
"originHash" : "486be5d69e4f0ba7b9f42046df31a727c7e394e4ecfae5671e1b194bed7c9e9b",
|
||||||
"pins": [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"package": "mqtt-nio",
|
"identity" : "combine-schedulers",
|
||||||
"repositoryURL": "https://github.com/swift-server-community/mqtt-nio.git",
|
"kind" : "remoteSourceControl",
|
||||||
"state": {
|
"location" : "https://github.com/pointfreeco/combine-schedulers",
|
||||||
"branch": null,
|
"state" : {
|
||||||
"revision": "ca8af7a30c4690456ce7de276cd0f037489ba707",
|
"revision" : "9fa31f4403da54855f1e2aeaeff478f4f0e40b13",
|
||||||
"version": "2.5.3"
|
"version" : "1.0.2"
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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": "b5260a31c2a72a89fa684f5efb3054d8725a2316",
|
|
||||||
"version": "2.18.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"package": "swift-nio-transport-services",
|
|
||||||
"repositoryURL": "https://github.com/apple/swift-nio-transport-services.git",
|
|
||||||
"state": {
|
|
||||||
"branch": null,
|
|
||||||
"revision": "8ab824b140d0ebcd87e9149266ddc353e3705a3e",
|
|
||||||
"version": "1.11.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"package": "swift-psychrometrics",
|
|
||||||
"repositoryURL": "https://github.com/swift-psychrometrics/swift-psychrometrics",
|
|
||||||
"state": {
|
|
||||||
"branch": null,
|
|
||||||
"revision": "03573545c3750b406921eb22a9575c8062beef88",
|
|
||||||
"version": "0.1.2"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
},
|
||||||
},
|
{
|
||||||
"version": 1
|
"identity" : "dotenv",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swiftpackages/DotEnv.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "1f15bb9de727d694af1d003a1a5d7a553752850f",
|
||||||
|
"version" : "3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "mqtt-nio",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swift-server-community/mqtt-nio.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "267b83ab5690d463ff00585a4fd6dc54b698e1d2",
|
||||||
|
"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",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-async-algorithms.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "5c8bd186f48c16af0775972700626f0b74588278",
|
||||||
|
"version" : "1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-atomics",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-atomics.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "cd142fd2f64be2100422d658e7411e39489da985",
|
||||||
|
"version" : "1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-clocks",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-clocks",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "b9b24b69e2adda099a1fa381cda1eeec272d5b53",
|
||||||
|
"version" : "1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-collections",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-collections.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "671108c96644956dddcd89dd59c203dcdb36cec7",
|
||||||
|
"version" : "1.1.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-concurrency-extras",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "163409ef7dae9d960b87f34b51587b6609a76c1f",
|
||||||
|
"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",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-dependencies",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "96eecd47660e8307877acb8c41cc5295ba7350a7",
|
||||||
|
"version" : "1.5.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-log",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-log.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "9cb486020ebf03bfa5b5df985387a14a98744537",
|
||||||
|
"version" : "1.6.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-nio",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-nio",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "914081701062b11e3bb9e21accc379822621995e",
|
||||||
|
"version" : "2.76.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-nio-ssl",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-nio-ssl.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "b5260a31c2a72a89fa684f5efb3054d8725a2316",
|
||||||
|
"version" : "2.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-nio-transport-services",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-nio-transport-services.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "8ab824b140d0ebcd87e9149266ddc353e3705a3e",
|
||||||
|
"version" : "1.11.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-psychrometrics",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swift-psychrometrics/swift-psychrometrics",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "6a457f3cefd9477f7aa76b2fb8ad557988c447bd",
|
||||||
|
"version" : "0.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-service-lifecycle",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swift-server/swift-service-lifecycle.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "f70b838872863396a25694d8b19fe58bcd0b7903",
|
||||||
|
"version" : "2.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-syntax",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/swiftlang/swift-syntax",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "0687f71944021d616d34d922343dcef086855920",
|
||||||
|
"version" : "600.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-system",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-system.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "c8a44d836fe7913603e246acab7c528c2e780168",
|
||||||
|
"version" : "1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-tagged",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/swift-tagged",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "3907a9438f5b57d317001dc99f3f11b46882272b",
|
||||||
|
"version" : "0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "xctest-dynamic-overlay",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "770f990d3e4eececb57ac04a6076e22f8c97daeb",
|
||||||
|
"version" : "1.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
}
|
}
|
||||||
|
|||||||
159
Package.swift
Normal file → Executable file
159
Package.swift
Normal file → Executable file
@@ -2,99 +2,118 @@
|
|||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
|
||||||
|
let swiftSettings: [SwiftSetting] = [
|
||||||
|
.enableExperimentalFeature("StrictConcurrency"),
|
||||||
|
.enableUpcomingFeature("InferSendableCaptures")
|
||||||
|
]
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "dewPoint-controller",
|
name: "dewpoint-controller",
|
||||||
platforms: [
|
platforms: [
|
||||||
.macOS(.v12)
|
.macOS(.v14)
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
.executable(name: "dewPoint-controller", targets: ["dewPoint-controller"]),
|
.executable(name: "dewpoint-controller", targets: ["DewPointController"]),
|
||||||
.library(name: "Bootstrap", targets: ["Bootstrap"]),
|
.library(name: "CliClient", targets: ["CliClient"]),
|
||||||
.library(name: "DewPointEnvironment", targets: ["DewPointEnvironment"]),
|
|
||||||
.library(name: "EnvVars", targets: ["EnvVars"]),
|
|
||||||
.library(name: "Models", targets: ["Models"]),
|
.library(name: "Models", targets: ["Models"]),
|
||||||
.library(name: "Client", targets: ["Client"]),
|
.library(name: "MQTTManager", targets: ["MQTTManager"]),
|
||||||
.library(name: "ClientLive", targets: ["ClientLive"]),
|
.library(name: "MQTTConnectionService", targets: ["MQTTConnectionService"]),
|
||||||
|
.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-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-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-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/apple/swift-nio", 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-psychrometrics/swift-psychrometrics", from: "0.1.0")
|
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(
|
|
||||||
name: "dewPoint-controller",
|
|
||||||
dependencies: [
|
|
||||||
"Bootstrap",
|
|
||||||
"ClientLive",
|
|
||||||
"TopicsLive",
|
|
||||||
.product(name: "MQTTNIO", package: "mqtt-nio"),
|
|
||||||
.product(name: "NIO", package: "swift-nio")
|
|
||||||
]
|
|
||||||
),
|
|
||||||
.testTarget(
|
|
||||||
name: "dewPoint-controllerTests",
|
|
||||||
dependencies: ["dewPoint-controller"]
|
|
||||||
),
|
|
||||||
.target(
|
.target(
|
||||||
name: "Bootstrap",
|
name: "CliClient",
|
||||||
dependencies: [
|
|
||||||
"DewPointEnvironment",
|
|
||||||
"EnvVars",
|
|
||||||
"ClientLive",
|
|
||||||
"Models",
|
|
||||||
.product(name: "MQTTNIO", package: "mqtt-nio"),
|
|
||||||
.product(name: "NIO", package: "swift-nio")
|
|
||||||
]
|
|
||||||
),
|
|
||||||
.target(
|
|
||||||
name: "DewPointEnvironment",
|
|
||||||
dependencies: [
|
|
||||||
"EnvVars",
|
|
||||||
"Client",
|
|
||||||
"Models",
|
|
||||||
.product(name: "MQTTNIO", package: "mqtt-nio"),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
.target(
|
|
||||||
name: "EnvVars",
|
|
||||||
dependencies: []
|
|
||||||
),
|
|
||||||
.target(
|
|
||||||
name: "Models",
|
|
||||||
dependencies: [
|
|
||||||
.product(name: "Psychrometrics", package: "swift-psychrometrics"),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
.target(
|
|
||||||
name: "Client",
|
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"Models",
|
"Models",
|
||||||
.product(name: "CoreUnitTypes", package: "swift-psychrometrics"),
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
.product(name: "NIO", package: "swift-nio"),
|
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||||
.product(name: "Psychrometrics", package: "swift-psychrometrics")
|
.product(name: "DotEnv", package: "DotEnv"),
|
||||||
]
|
|
||||||
),
|
|
||||||
.target(
|
|
||||||
name: "ClientLive",
|
|
||||||
dependencies: [
|
|
||||||
"Client",
|
|
||||||
"EnvVars",
|
|
||||||
.product(name: "MQTTNIO", package: "mqtt-nio")
|
.product(name: "MQTTNIO", package: "mqtt-nio")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "ClientTests",
|
name: "CliClientTests",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"Client",
|
"CliClient"
|
||||||
"ClientLive"
|
],
|
||||||
|
resources: [
|
||||||
|
.copy("test.env"),
|
||||||
|
.copy("test-env.json")
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.executableTarget(
|
||||||
|
name: "DewPointController",
|
||||||
|
dependencies: [
|
||||||
|
"CliClient",
|
||||||
|
"MQTTConnectionService",
|
||||||
|
"SensorsService",
|
||||||
|
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||||
|
.product(name: "CustomDump", package: "swift-custom-dump"),
|
||||||
|
// .product(name: "DotEnv", package: "DotEnv"),
|
||||||
|
.product(name: "PsychrometricClientLive", package: "swift-psychrometrics")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "TopicsLive",
|
name: "Models",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"Models"
|
.product(name: "Logging", package: "swift-log"),
|
||||||
|
.product(name: "PsychrometricClient", package: "swift-psychrometrics")
|
||||||
|
],
|
||||||
|
swiftSettings: swiftSettings
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "MQTTManager",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
|
||||||
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
|
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||||
|
.product(name: "MQTTNIO", package: "mqtt-nio")
|
||||||
|
],
|
||||||
|
swiftSettings: swiftSettings
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "MQTTConnectionService",
|
||||||
|
dependencies: [
|
||||||
|
"Models",
|
||||||
|
"MQTTManager",
|
||||||
|
.product(name: "ServiceLifecycle", package: "swift-service-lifecycle")
|
||||||
|
],
|
||||||
|
swiftSettings: swiftSettings
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "IntegrationTests",
|
||||||
|
dependencies: [
|
||||||
|
"DewPointController",
|
||||||
|
"MQTTConnectionService",
|
||||||
|
"MQTTManager",
|
||||||
|
"SensorsService",
|
||||||
|
.product(name: "PsychrometricClientLive", package: "swift-psychrometrics"),
|
||||||
|
.product(name: "ServiceLifecycleTestKit", package: "swift-service-lifecycle")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
.target(
|
||||||
|
name: "SensorsService",
|
||||||
|
dependencies: [
|
||||||
|
"Models",
|
||||||
|
"MQTTManager",
|
||||||
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
|
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||||
|
.product(name: "MQTTNIO", package: "mqtt-nio"),
|
||||||
|
.product(name: "PsychrometricClient", package: "swift-psychrometrics"),
|
||||||
|
.product(name: "ServiceLifecycle", package: "swift-service-lifecycle")
|
||||||
|
],
|
||||||
|
swiftSettings: swiftSettings
|
||||||
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
8
README.md
Normal file → Executable file
8
README.md
Normal file → Executable file
@@ -1,3 +1,7 @@
|
|||||||
# dewPoint-controller
|
# dewpoint-controller
|
||||||
|
|
||||||
A description of this package.
|

|
||||||
|
|
||||||
|
Listens to an MQTT broker for temperature and humidity sensors and calculates
|
||||||
|
the dew-point temperature and enthalpy for the sensor, then publishes those back
|
||||||
|
to the MQTT broker.
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
import ClientLive
|
|
||||||
import DewPointEnvironment
|
|
||||||
import EnvVars
|
|
||||||
import Logging
|
|
||||||
import Foundation
|
|
||||||
import Models
|
|
||||||
import MQTTNIO
|
|
||||||
import NIO
|
|
||||||
|
|
||||||
/// Sets up the application environment and connections required.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - eventLoopGroup: The event loop group for the application.
|
|
||||||
/// - logger: An optional logger for debugging.
|
|
||||||
/// - autoConnect: A flag whether to auto-connect to the MQTT broker or not.
|
|
||||||
public func bootstrap(
|
|
||||||
eventLoopGroup: EventLoopGroup,
|
|
||||||
logger: Logger? = nil,
|
|
||||||
autoConnect: Bool = true
|
|
||||||
) -> EventLoopFuture<DewPointEnvironment> {
|
|
||||||
|
|
||||||
logger?.debug("Bootstrapping Dew Point Controller...")
|
|
||||||
|
|
||||||
return loadEnvVars(eventLoopGroup: eventLoopGroup, logger: logger)
|
|
||||||
.and(loadTopics(eventLoopGroup: eventLoopGroup, logger: logger))
|
|
||||||
.makeDewPointEnvironment(eventLoopGroup: eventLoopGroup, logger: logger)
|
|
||||||
.connectToMQTTBroker(autoConnect: autoConnect, logger: logger)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads the ``EnvVars`` either using the defualts, from a file in the root directory under `.dewPoint-env` or in the shell / application environment.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - eventLoopGroup: The event loop group for the application.
|
|
||||||
/// - logger: An optional logger for debugging.
|
|
||||||
private func loadEnvVars(
|
|
||||||
eventLoopGroup: EventLoopGroup,
|
|
||||||
logger: Logger?
|
|
||||||
) -> EventLoopFuture<EnvVars> {
|
|
||||||
|
|
||||||
logger?.debug("Loading env vars...")
|
|
||||||
|
|
||||||
// TODO: Need to have the env file path passed in / dynamic.
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: TODO perhaps make loading from file an option passed in when app is launched.
|
|
||||||
/// Load the topics from file in application root directory at `.topics`, if available or fall back to the defualt.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - eventLoopGroup: The event loop group for the application.
|
|
||||||
/// - logger: An optional logger for debugging.
|
|
||||||
private func loadTopics(eventLoopGroup: EventLoopGroup, logger: Logger?) -> EventLoopFuture<Topics> {
|
|
||||||
|
|
||||||
logger?.debug("Loading topics from file...")
|
|
||||||
|
|
||||||
let topicsFilePath = URL(fileURLWithPath: #file)
|
|
||||||
.deletingLastPathComponent()
|
|
||||||
.deletingLastPathComponent()
|
|
||||||
.deletingLastPathComponent()
|
|
||||||
.appendingPathComponent(".topics")
|
|
||||||
|
|
||||||
let decoder = JSONDecoder()
|
|
||||||
|
|
||||||
// Attempt to load the topics from file in root directory.
|
|
||||||
let localTopics = (try? Data.init(contentsOf: topicsFilePath))
|
|
||||||
.flatMap { try? decoder.decode(Topics.self, from: $0) }
|
|
||||||
|
|
||||||
logger?.debug(
|
|
||||||
localTopics == nil
|
|
||||||
? "Failed to load topics from file, falling back to defaults."
|
|
||||||
: "Done loading topics from file."
|
|
||||||
)
|
|
||||||
|
|
||||||
// If we were able to load from file use that, else fallback to the defaults.
|
|
||||||
return eventLoopGroup.next().makeSucceededFuture(localTopics ?? .init())
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EventLoopFuture where Value == (EnvVars, Topics) {
|
|
||||||
|
|
||||||
/// Creates the ``DewPointEnvironment`` for the application after the ``EnvVars`` have been loaded.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - eventLoopGroup: The event loop group for the application.
|
|
||||||
/// - logger: An optional logger for the application.
|
|
||||||
fileprivate func makeDewPointEnvironment(
|
|
||||||
eventLoopGroup: EventLoopGroup,
|
|
||||||
logger: Logger?
|
|
||||||
) -> EventLoopFuture<DewPointEnvironment> {
|
|
||||||
map { envVars, topics in
|
|
||||||
let mqttClient = MQTTClient(envVars: envVars, eventLoopGroup: eventLoopGroup, logger: logger)
|
|
||||||
return DewPointEnvironment.init(
|
|
||||||
envVars: envVars,
|
|
||||||
mqttClient: mqttClient,
|
|
||||||
topics: topics
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EventLoopFuture where Value == DewPointEnvironment {
|
|
||||||
|
|
||||||
/// Connects to the MQTT broker after the ``DewPointEnvironment`` has been setup.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - logger: An optional logger for debugging.
|
|
||||||
fileprivate func connectToMQTTBroker(autoConnect: Bool, logger: Logger?) -> EventLoopFuture<DewPointEnvironment> {
|
|
||||||
guard autoConnect else { return self }
|
|
||||||
return flatMap { environment in
|
|
||||||
logger?.debug("Connecting to MQTT Broker...")
|
|
||||||
return environment.mqttClient.connect()
|
|
||||||
.map { _ in
|
|
||||||
logger?.debug("Successfully connected to MQTT Broker...")
|
|
||||||
return environment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MQTTNIO.MQTTClient {
|
|
||||||
|
|
||||||
fileprivate 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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
224
Sources/CliClient/CliClient.swift
Normal file
224
Sources/CliClient/CliClient.swift
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents the interface needed for the command line application.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
@DependencyClient
|
||||||
|
public struct CliClient {
|
||||||
|
|
||||||
|
/// Parse a log level from the given `EnvVars`.
|
||||||
|
public var logLevel: @Sendable (EnvVars) -> Logger.Level = { _ in .debug }
|
||||||
|
|
||||||
|
/// Generate the `EnvVars` with the given parameters.
|
||||||
|
public var makeEnvVars: @Sendable (EnvVarsRequest) async throws -> EnvVars
|
||||||
|
|
||||||
|
/// Generate the `MQTTClient` with the given parameters.
|
||||||
|
public var makeClient: @Sendable (ClientRequest) throws -> MQTTClient
|
||||||
|
|
||||||
|
/// Attempt to parse a string to an `MQTTClient.Version`.
|
||||||
|
public var parseMqttClientVersion: @Sendable (String) -> MQTTClient.Version?
|
||||||
|
|
||||||
|
/// Represents the parameters needed to create an `MQTTClient`.
|
||||||
|
///
|
||||||
|
public struct ClientRequest: Sendable {
|
||||||
|
|
||||||
|
/// The environment variables used to create the client.
|
||||||
|
public let environment: EnvVars
|
||||||
|
|
||||||
|
/// The event loop group for the client.
|
||||||
|
public let eventLoopGroup: MultiThreadedEventLoopGroup
|
||||||
|
|
||||||
|
/// A logger to use with the client.
|
||||||
|
public let logger: Logger?
|
||||||
|
|
||||||
|
/// Create a new client request.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - environment: The environment variables to use.
|
||||||
|
/// - eventLoopGroup: The event loop group to use on the client.
|
||||||
|
/// - logger: An optional logger to use on the client.
|
||||||
|
public init(
|
||||||
|
environment: EnvVars,
|
||||||
|
eventLoopGroup: MultiThreadedEventLoopGroup,
|
||||||
|
logger: Logger?
|
||||||
|
) {
|
||||||
|
self.environment = environment
|
||||||
|
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 await EnvVars.load(
|
||||||
|
dotEnvFile: $0.envFilePath,
|
||||||
|
logger: $0.logger,
|
||||||
|
version: $0.mqttClientVersion
|
||||||
|
)
|
||||||
|
},
|
||||||
|
makeClient: {
|
||||||
|
MQTTClient(
|
||||||
|
environment: $0.environment,
|
||||||
|
eventLoopGroup: $0.eventLoopGroup,
|
||||||
|
logger: $0.logger
|
||||||
|
)
|
||||||
|
},
|
||||||
|
parseMqttClientVersion: { .init(string: $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
extension EnvironmentDependency {
|
||||||
|
func dotEnvDict(path: String?) async throws -> [String: String] {
|
||||||
|
guard let path,
|
||||||
|
let file = FileType(path: path)
|
||||||
|
else { return [:] }
|
||||||
|
return try await load(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvVars {
|
||||||
|
|
||||||
|
/// Load the `EnvVars` from the environment.
|
||||||
|
///
|
||||||
|
/// - Paramaters:
|
||||||
|
/// - dotEnvFile: An optional environment file to load.
|
||||||
|
/// - 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?
|
||||||
|
) async throws -> EnvVars {
|
||||||
|
@Dependency(\.environment) var environment
|
||||||
|
|
||||||
|
let defaultEnvVars = EnvVars()
|
||||||
|
let coders = environment.coders()
|
||||||
|
|
||||||
|
let defaultEnvDict = (try? coders.encode(defaultEnvVars))
|
||||||
|
.flatMap { try? coders.decode([String: String].self, from: $0) }
|
||||||
|
?? [:]
|
||||||
|
|
||||||
|
let dotEnvDict = try await environment.dotEnvDict(path: dotEnvFile)
|
||||||
|
let envVarsDict = defaultEnvDict
|
||||||
|
.merging(environment.processInfo(), uniquingKeysWith: { $1 })
|
||||||
|
.merging(dotEnvDict, uniquingKeysWith: { $1 })
|
||||||
|
|
||||||
|
var envVars = (try? JSONSerialization.data(withJSONObject: envVarsDict))
|
||||||
|
.flatMap { try? coders.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(
|
||||||
|
environment 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: .parseOrDefault(string: envVars.version),
|
||||||
|
disablePing: false,
|
||||||
|
userName: envVars.userName,
|
||||||
|
password: envVars.password
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@_spi(Internal)
|
||||||
|
public extension MQTTClient.Version {
|
||||||
|
static let `default` = Self.v3_1_1
|
||||||
|
|
||||||
|
static func parseOrDefault(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
Sources/CliClient/EnvironmentDependency.swift
Normal file
134
Sources/CliClient/EnvironmentDependency.swift
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import DotEnv
|
||||||
|
import Foundation
|
||||||
|
import Models
|
||||||
|
|
||||||
|
@_spi(Internal)
|
||||||
|
public extension DependencyValues {
|
||||||
|
|
||||||
|
/// A dependecy responsible for loding environment variables.
|
||||||
|
///
|
||||||
|
/// This is just used internally of this module, but is useful to
|
||||||
|
/// override for testing purposes, so import using `@_spi(Internal)`.
|
||||||
|
var environment: EnvironmentDependency {
|
||||||
|
get { self[EnvironmentDependency.self] }
|
||||||
|
set { self[EnvironmentDependency.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Responsible for loading environment variables and files.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
@_spi(Internal)
|
||||||
|
@DependencyClient
|
||||||
|
public struct EnvironmentDependency: Sendable {
|
||||||
|
|
||||||
|
public var coders: @Sendable () -> any Coderable = { JSONCoders() }
|
||||||
|
|
||||||
|
/// Load the variables based on the request.
|
||||||
|
public var load: @Sendable (FileType) async throws -> [String: String] = { _ in [:] }
|
||||||
|
|
||||||
|
/// Load the environment variables based on the current process environment.
|
||||||
|
///
|
||||||
|
/// You can override this to use an empty environment, which is useful for testing purposes.
|
||||||
|
public var processInfo: @Sendable () -> [String: String] = { [:] }
|
||||||
|
|
||||||
|
/// Represents the types of files that can be loaded and decoded into
|
||||||
|
/// the environment.
|
||||||
|
public enum FileType: Equatable {
|
||||||
|
case dotEnv(path: String)
|
||||||
|
case json(path: String)
|
||||||
|
|
||||||
|
public init?(path: String) {
|
||||||
|
let strings = path.split(separator: ".")
|
||||||
|
guard let ext = strings.last else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch ext {
|
||||||
|
case "env":
|
||||||
|
self = .dotEnv(path: path)
|
||||||
|
case "json":
|
||||||
|
self = .json(path: path)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DecodeError: Error {}
|
||||||
|
|
||||||
|
@_spi(Internal)
|
||||||
|
extension EnvironmentDependency: DependencyKey {
|
||||||
|
|
||||||
|
public static let testValue: EnvironmentDependency = Self()
|
||||||
|
|
||||||
|
public static func live(
|
||||||
|
decoder: JSONDecoder = .init(),
|
||||||
|
encoder: JSONEncoder = .init()
|
||||||
|
) -> Self {
|
||||||
|
Self(
|
||||||
|
coders: { JSONCoders(decoder: decoder, encoder: encoder) },
|
||||||
|
load: { file in
|
||||||
|
switch file {
|
||||||
|
case let .dotEnv(path: path):
|
||||||
|
let file = try DotEnv.read(path: path)
|
||||||
|
return file.lines.reduce(into: [String: String]()) { partialResult, line in
|
||||||
|
partialResult[line.key] = line.value
|
||||||
|
}
|
||||||
|
case let .json(path: path):
|
||||||
|
let url = url(for: path)
|
||||||
|
return try decoder.decode(
|
||||||
|
[String: String].self,
|
||||||
|
from: Data(contentsOf: url)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
processInfo: { ProcessInfo.processInfo.environment }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let liveValue: EnvironmentDependency = .live()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A type that encode and decode values.
|
||||||
|
///
|
||||||
|
/// This is really just here to override tests with coders that will throw an error,
|
||||||
|
/// instead of encoding or decoding data.
|
||||||
|
///
|
||||||
|
@_spi(Internal)
|
||||||
|
public protocol Coderable {
|
||||||
|
func encode<T: Encodable>(_ instance: T) throws -> Data
|
||||||
|
func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T
|
||||||
|
}
|
||||||
|
|
||||||
|
struct JSONCoders: Coderable {
|
||||||
|
|
||||||
|
let decoder: JSONDecoder
|
||||||
|
let encoder: JSONEncoder
|
||||||
|
|
||||||
|
init(
|
||||||
|
decoder: JSONDecoder = .init(),
|
||||||
|
encoder: JSONEncoder = .init()
|
||||||
|
) {
|
||||||
|
self.decoder = decoder
|
||||||
|
self.encoder = encoder
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode<T>(_ instance: T) throws -> Data where T: Encodable {
|
||||||
|
try encoder.encode(instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode<T>(_ type: T.Type, from data: Data) throws -> T where T: Decodable {
|
||||||
|
try decoder.decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func url(for path: String) -> URL {
|
||||||
|
#if os(Linux)
|
||||||
|
return URL(fileURLWithPath: path)
|
||||||
|
#else
|
||||||
|
return URL(filePath: path)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import CoreUnitTypes
|
|
||||||
import Logging
|
|
||||||
import Foundation
|
|
||||||
import Models
|
|
||||||
import NIO
|
|
||||||
import Psychrometrics
|
|
||||||
|
|
||||||
public struct Client {
|
|
||||||
|
|
||||||
/// Add the publish listeners to the MQTT Broker, to be notified of published changes.
|
|
||||||
public var addListeners: () -> Void
|
|
||||||
|
|
||||||
/// Connect to the MQTT Broker.
|
|
||||||
public var connect: () -> EventLoopFuture<Void>
|
|
||||||
|
|
||||||
public var publishSensor: (SensorPublishRequest) -> EventLoopFuture<Void>
|
|
||||||
|
|
||||||
/// Subscribe to appropriate topics / events.
|
|
||||||
public var subscribe: () -> EventLoopFuture<Void>
|
|
||||||
|
|
||||||
/// Disconnect and close the connection to the MQTT Broker.
|
|
||||||
public var shutdown: () -> EventLoopFuture<Void>
|
|
||||||
|
|
||||||
public init(
|
|
||||||
addListeners: @escaping () -> Void,
|
|
||||||
connect: @escaping () -> EventLoopFuture<Void>,
|
|
||||||
publishSensor: @escaping (SensorPublishRequest) -> EventLoopFuture<Void>,
|
|
||||||
shutdown: @escaping () -> EventLoopFuture<Void>,
|
|
||||||
subscribe: @escaping () -> EventLoopFuture<Void>
|
|
||||||
) {
|
|
||||||
self.addListeners = addListeners
|
|
||||||
self.connect = connect
|
|
||||||
self.publishSensor = publishSensor
|
|
||||||
self.shutdown = shutdown
|
|
||||||
self.subscribe = subscribe
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum SensorPublishRequest {
|
|
||||||
case mixed(State.Sensors.TemperatureHumiditySensor<State.Sensors.Mixed>)
|
|
||||||
case postCoil(State.Sensors.TemperatureHumiditySensor<State.Sensors.PostCoil>)
|
|
||||||
case `return`(State.Sensors.TemperatureHumiditySensor<State.Sensors.Return>)
|
|
||||||
case supply(State.Sensors.TemperatureHumiditySensor<State.Sensors.Supply>)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
import CoreUnitTypes
|
|
||||||
import Logging
|
|
||||||
import Models
|
|
||||||
import MQTTNIO
|
|
||||||
import NIO
|
|
||||||
import NIOFoundationCompat
|
|
||||||
import Psychrometrics
|
|
||||||
|
|
||||||
/// Represents a type that can be initialized by a ``ByteBuffer``.
|
|
||||||
protocol BufferInitalizable {
|
|
||||||
init?(buffer: inout ByteBuffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Double: BufferInitalizable {
|
|
||||||
|
|
||||||
/// Attempt to create / parse a double from a byte buffer.
|
|
||||||
init?(buffer: inout ByteBuffer) {
|
|
||||||
guard let string = buffer.readString(
|
|
||||||
length: buffer.readableBytes,
|
|
||||||
encoding: String.Encoding.utf8
|
|
||||||
)
|
|
||||||
else { return nil }
|
|
||||||
self.init(string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Temperature: BufferInitalizable {
|
|
||||||
/// Attempt to create / parse a temperature from a byte buffer.
|
|
||||||
init?(buffer: inout ByteBuffer) {
|
|
||||||
guard let value = Double(buffer: &buffer) else { return nil }
|
|
||||||
self.init(value, units: .celsius)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension RelativeHumidity: BufferInitalizable {
|
|
||||||
/// Attempt to create / parse a relative humidity from a byte buffer.
|
|
||||||
init?(buffer: inout ByteBuffer) {
|
|
||||||
guard let value = Double(buffer: &buffer) else { return nil }
|
|
||||||
self.init(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MQTTNIO.MQTTClient {
|
|
||||||
/// Logs a failure for a given topic and error.
|
|
||||||
func logFailure(topic: String, error: Error) {
|
|
||||||
logger.error("\(topic): \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Result where Success == MQTTPublishInfo {
|
|
||||||
func logIfFailure(client: MQTTNIO.MQTTClient, topic: String) -> ByteBuffer? {
|
|
||||||
switch self {
|
|
||||||
case let .success(value):
|
|
||||||
guard value.topicName == topic else { return nil }
|
|
||||||
return value.payload
|
|
||||||
case let .failure(error):
|
|
||||||
client.logFailure(topic: topic, error: error)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Optional where Wrapped == ByteBuffer {
|
|
||||||
|
|
||||||
func parse<T>(as type: T.Type) -> T? where T: BufferInitalizable {
|
|
||||||
switch self {
|
|
||||||
case var .some(buffer):
|
|
||||||
return T.init(buffer: &buffer)
|
|
||||||
case .none:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct TemperatureAndHumiditySensorKeyPathEnvelope {
|
|
||||||
|
|
||||||
let humidityTopic: KeyPath<Topics.Sensors, String>
|
|
||||||
let temperatureTopic: KeyPath<Topics.Sensors, String>
|
|
||||||
let temperatureState: WritableKeyPath<State.Sensors, Temperature?>
|
|
||||||
let humidityState: WritableKeyPath<State.Sensors, RelativeHumidity?>
|
|
||||||
|
|
||||||
func addListener(to client: MQTTNIO.MQTTClient, topics: Topics, state: State) {
|
|
||||||
|
|
||||||
let temperatureTopic = topics.sensors[keyPath: temperatureTopic]
|
|
||||||
client.logger.trace("Adding listener for topic: \(temperatureTopic)")
|
|
||||||
client.addPublishListener(named: temperatureTopic) { result in
|
|
||||||
result.logIfFailure(client: client, topic: temperatureTopic)
|
|
||||||
.parse(as: Temperature.self)
|
|
||||||
.map { temperature in
|
|
||||||
state.sensors[keyPath: temperatureState] = temperature
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let humidityTopic = topics.sensors[keyPath: humidityTopic]
|
|
||||||
client.logger.trace("Adding listener for topic: \(humidityTopic)")
|
|
||||||
client.addPublishListener(named: humidityTopic) { result in
|
|
||||||
result.logIfFailure(client: client, topic: humidityTopic)
|
|
||||||
.parse(as: RelativeHumidity.self)
|
|
||||||
.map { humidity in
|
|
||||||
state.sensors[keyPath: humidityState] = humidity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Array where Element == TemperatureAndHumiditySensorKeyPathEnvelope {
|
|
||||||
func addListeners(to client: MQTTNIO.MQTTClient, topics: Topics, state: State) {
|
|
||||||
_ = self.map { envelope in
|
|
||||||
envelope.addListener(to: client, topics: topics, state: state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Array where Element == MQTTSubscribeInfo {
|
|
||||||
static func sensors(topics: Topics) -> Self {
|
|
||||||
[
|
|
||||||
.init(topicFilter: topics.sensors.mixedAirSensor.temperature, qos: .atLeastOnce),
|
|
||||||
.init(topicFilter: topics.sensors.mixedAirSensor.humidity, qos: .atLeastOnce),
|
|
||||||
.init(topicFilter: topics.sensors.postCoilSensor.temperature, qos: .atLeastOnce),
|
|
||||||
.init(topicFilter: topics.sensors.postCoilSensor.humidity, qos: .atLeastOnce),
|
|
||||||
.init(topicFilter: topics.sensors.returnAirSensor.temperature, qos: .atLeastOnce),
|
|
||||||
.init(topicFilter: topics.sensors.returnAirSensor.humidity, qos: .atLeastOnce),
|
|
||||||
.init(topicFilter: topics.sensors.supplyAirSensor.temperature, qos: .atLeastOnce),
|
|
||||||
.init(topicFilter: topics.sensors.supplyAirSensor.humidity, qos: .atLeastOnce),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension State {
|
|
||||||
func addSensorListeners(to client: MQTTNIO.MQTTClient, topics: Topics) {
|
|
||||||
let envelopes: [TemperatureAndHumiditySensorKeyPathEnvelope] = [
|
|
||||||
.init(
|
|
||||||
humidityTopic: \.mixedAirSensor.humidity,
|
|
||||||
temperatureTopic: \.mixedAirSensor.temperature,
|
|
||||||
temperatureState: \.mixedAirSensor.temperature,
|
|
||||||
humidityState: \.mixedAirSensor.humidity
|
|
||||||
),
|
|
||||||
.init(
|
|
||||||
humidityTopic: \.postCoilSensor.humidity,
|
|
||||||
temperatureTopic: \.postCoilSensor.temperature,
|
|
||||||
temperatureState: \.postCoilSensor.temperature,
|
|
||||||
humidityState: \.postCoilSensor.humidity
|
|
||||||
),
|
|
||||||
.init(
|
|
||||||
humidityTopic: \.returnAirSensor.humidity,
|
|
||||||
temperatureTopic: \.returnAirSensor.temperature,
|
|
||||||
temperatureState: \.returnAirSensor.temperature,
|
|
||||||
humidityState: \.returnAirSensor.humidity
|
|
||||||
),
|
|
||||||
.init(
|
|
||||||
humidityTopic: \.supplyAirSensor.humidity,
|
|
||||||
temperatureTopic: \.supplyAirSensor.temperature,
|
|
||||||
temperatureState: \.supplyAirSensor.temperature,
|
|
||||||
humidityState: \.supplyAirSensor.humidity
|
|
||||||
),
|
|
||||||
]
|
|
||||||
envelopes.addListeners(to: client, topics: topics, state: self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Client.SensorPublishRequest {
|
|
||||||
|
|
||||||
func dewPointData(topics: Topics, units: PsychrometricEnvironment.Units?) -> (DewPoint, String)? {
|
|
||||||
switch self {
|
|
||||||
case let .mixed(sensor):
|
|
||||||
guard let dp = sensor.dewPoint(units: units) else { return nil }
|
|
||||||
return (dp, topics.sensors.mixedAirSensor.dewPoint)
|
|
||||||
case let .postCoil(sensor):
|
|
||||||
guard let dp = sensor.dewPoint(units: units) else { return nil }
|
|
||||||
return (dp, topics.sensors.postCoilSensor.dewPoint)
|
|
||||||
case let .return(sensor):
|
|
||||||
guard let dp = sensor.dewPoint(units: units) else { return nil }
|
|
||||||
return (dp, topics.sensors.returnAirSensor.dewPoint)
|
|
||||||
case let .supply(sensor):
|
|
||||||
guard let dp = sensor.dewPoint(units: units) else { return nil }
|
|
||||||
return (dp, topics.sensors.supplyAirSensor.dewPoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func enthalpyData(altitude: Length, topics: Topics, units: PsychrometricEnvironment.Units?) -> (EnthalpyOf<MoistAir>, String)? {
|
|
||||||
switch self {
|
|
||||||
case let .mixed(sensor):
|
|
||||||
guard let enthalpy = sensor.enthalpy(altitude: altitude, units: units) else { return nil }
|
|
||||||
return (enthalpy, topics.sensors.mixedAirSensor.enthalpy)
|
|
||||||
case let .postCoil(sensor):
|
|
||||||
guard let enthalpy = sensor.enthalpy(altitude: altitude, units: units) else { return nil }
|
|
||||||
return (enthalpy, topics.sensors.postCoilSensor.enthalpy)
|
|
||||||
case let .return(sensor):
|
|
||||||
guard let enthalpy = sensor.enthalpy(altitude: altitude, units: units) else { return nil }
|
|
||||||
return (enthalpy, topics.sensors.returnAirSensor.enthalpy)
|
|
||||||
case let .supply(sensor):
|
|
||||||
guard let enthalpy = sensor.enthalpy(altitude: altitude, units: units) else { return nil }
|
|
||||||
return (enthalpy, topics.sensors.supplyAirSensor.enthalpy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setHasProcessed(state: State) {
|
|
||||||
switch self {
|
|
||||||
case .mixed:
|
|
||||||
state.sensors.mixedAirSensor.needsProcessed = false
|
|
||||||
case .postCoil:
|
|
||||||
state.sensors.postCoilSensor.needsProcessed = false
|
|
||||||
case .return:
|
|
||||||
state.sensors.returnAirSensor.needsProcessed = false
|
|
||||||
case .supply:
|
|
||||||
state.sensors.supplyAirSensor.needsProcessed = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MQTTNIO.MQTTClient {
|
|
||||||
|
|
||||||
func publishDewPoint(
|
|
||||||
request: Client.SensorPublishRequest,
|
|
||||||
state: State,
|
|
||||||
topics: Topics
|
|
||||||
) -> EventLoopFuture<(MQTTNIO.MQTTClient, Client.SensorPublishRequest, State, Topics)> {
|
|
||||||
guard let (dewPoint, topic) = request.dewPointData(topics: topics, units: state.units)
|
|
||||||
else {
|
|
||||||
logger.trace("No dew point for sensor.")
|
|
||||||
return eventLoopGroup.next().makeSucceededFuture((self, request, state, topics))
|
|
||||||
}
|
|
||||||
let roundedDewPoint = round(dewPoint.rawValue * 100) / 100
|
|
||||||
logger.debug("Publishing dew-point: \(dewPoint), to: \(topic)")
|
|
||||||
return publish(
|
|
||||||
to: topic,
|
|
||||||
payload: ByteBufferAllocator().buffer(string: "\(roundedDewPoint)"),
|
|
||||||
qos: .atLeastOnce,
|
|
||||||
retain: true
|
|
||||||
)
|
|
||||||
.map { (self, request, state, topics) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EventLoopFuture where Value == (Client.SensorPublishRequest, State) {
|
|
||||||
func setHasProcessed() -> EventLoopFuture<Void> {
|
|
||||||
map { request, state in
|
|
||||||
request.setHasProcessed(state: state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EventLoopFuture where Value == (MQTTNIO.MQTTClient, Client.SensorPublishRequest, State, Topics) {
|
|
||||||
func publishEnthalpy() -> EventLoopFuture<(Client.SensorPublishRequest, State)> {
|
|
||||||
flatMap { client, request, state, topics in
|
|
||||||
guard let (enthalpy, topic) = request.enthalpyData(altitude: state.altitude, topics: topics, units: state.units)
|
|
||||||
else {
|
|
||||||
client.logger.trace("No enthalpy for sensor.")
|
|
||||||
return client.eventLoopGroup.next().makeSucceededFuture((request, state))
|
|
||||||
}
|
|
||||||
let roundedEnthalpy = round(enthalpy.rawValue * 100) / 100
|
|
||||||
client.logger.debug("Publishing enthalpy: \(enthalpy), to: \(topic)")
|
|
||||||
return client.publish(
|
|
||||||
to: topic,
|
|
||||||
payload: ByteBufferAllocator().buffer(string: "\(roundedEnthalpy)"),
|
|
||||||
qos: .atLeastOnce
|
|
||||||
)
|
|
||||||
.map { (request, state) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
@_exported import Client
|
|
||||||
import CoreUnitTypes
|
|
||||||
import Models
|
|
||||||
import MQTTNIO
|
|
||||||
import NIO
|
|
||||||
import Psychrometrics
|
|
||||||
|
|
||||||
extension Client {
|
|
||||||
|
|
||||||
// The state passed in here needs to be a class or we get escaping errors in the `addListeners` method.
|
|
||||||
public static func live(
|
|
||||||
client: MQTTNIO.MQTTClient,
|
|
||||||
state: State,
|
|
||||||
topics: Topics
|
|
||||||
) -> Self {
|
|
||||||
.init(
|
|
||||||
addListeners: {
|
|
||||||
state.addSensorListeners(to: client, topics: topics)
|
|
||||||
},
|
|
||||||
connect: {
|
|
||||||
client.connect()
|
|
||||||
.map { _ in }
|
|
||||||
},
|
|
||||||
publishSensor: { request in
|
|
||||||
client.publishDewPoint(request: request, state: state, topics: topics)
|
|
||||||
.publishEnthalpy()
|
|
||||||
.setHasProcessed()
|
|
||||||
},
|
|
||||||
shutdown: {
|
|
||||||
client.disconnect()
|
|
||||||
.map { try? client.syncShutdownGracefully() }
|
|
||||||
},
|
|
||||||
subscribe: {
|
|
||||||
// Sensor subscriptions
|
|
||||||
client.subscribe(to: .sensors(topics: topics))
|
|
||||||
.map { _ in }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
import Logging
|
|
||||||
import NIOTransportServices
|
|
||||||
import EnvVars
|
|
||||||
|
|
||||||
public class AsyncClient {
|
|
||||||
//public static let eventLoopGroup = NIOTSEventLoopGroup()
|
|
||||||
public static let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
|
||||||
public let client: MQTTClient
|
|
||||||
public private(set) var shuttingDown: Bool
|
|
||||||
|
|
||||||
var logger: Logger { client.logger }
|
|
||||||
|
|
||||||
public init(envVars: EnvVars, logger: Logger) {
|
|
||||||
let config = MQTTClient.Configuration.init(
|
|
||||||
version: .v3_1_1,
|
|
||||||
userName: envVars.userName,
|
|
||||||
password: envVars.password,
|
|
||||||
useSSL: false,
|
|
||||||
useWebSockets: false,
|
|
||||||
tlsConfiguration: nil,
|
|
||||||
webSocketURLPath: nil
|
|
||||||
)
|
|
||||||
self.client = .init(
|
|
||||||
host: envVars.host,
|
|
||||||
identifier: envVars.identifier,
|
|
||||||
eventLoopGroupProvider: .shared(Self.eventLoopGroup),
|
|
||||||
logger: logger,
|
|
||||||
configuration: config
|
|
||||||
)
|
|
||||||
self.shuttingDown = false
|
|
||||||
}
|
|
||||||
|
|
||||||
public func connect() async {
|
|
||||||
do {
|
|
||||||
try await self.client.connect()
|
|
||||||
self.client.addCloseListener(named: "AsyncClient") { [self] result in
|
|
||||||
guard !self.shuttingDown else { return }
|
|
||||||
Task {
|
|
||||||
self.logger.debug("Connection closed.")
|
|
||||||
self.logger.debug("Reconnecting...")
|
|
||||||
await self.connect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.debug("Connection successful.")
|
|
||||||
} catch {
|
|
||||||
logger.trace("Connection Failed.\n\(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func shutdown() async {
|
|
||||||
self.shuttingDown = true
|
|
||||||
try? await self.client.disconnect()
|
|
||||||
try? await self.client.shutdown()
|
|
||||||
}
|
|
||||||
|
|
||||||
func addSensorListeners() async {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Need to save the recieved values somewhere.
|
|
||||||
func addPublishListener<T>(
|
|
||||||
topic: String,
|
|
||||||
decoding: T.Type
|
|
||||||
) async throws where T: BufferInitalizable {
|
|
||||||
_ = try await self.client.subscribe(to: [.init(topicFilter: topic, qos: .atLeastOnce)])
|
|
||||||
Task {
|
|
||||||
let listener = self.client.createPublishListener()
|
|
||||||
for await result in listener {
|
|
||||||
switch result {
|
|
||||||
case let .success(packet):
|
|
||||||
var buffer = packet.payload
|
|
||||||
guard let value = T.init(buffer: &buffer) else {
|
|
||||||
logger.debug("Could not decode buffer: \(buffer)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.debug("Recieved value: \(value)")
|
|
||||||
case let .failure(error):
|
|
||||||
logger.trace("Error:\n\(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private func publish(string: String, to topic: String) async throws {
|
|
||||||
try await self.client.publish(
|
|
||||||
to: topic,
|
|
||||||
payload: ByteBufferAllocator().buffer(string: string),
|
|
||||||
qos: .atLeastOnce
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func publish(double: Double, to topic: String) async throws {
|
|
||||||
let rounded = round(double * 100) / 100
|
|
||||||
try await publish(string: "\(rounded)", to: topic)
|
|
||||||
}
|
|
||||||
|
|
||||||
func publishDewPoint(_ request: Client.SensorPublishRequest) async throws {
|
|
||||||
// fix
|
|
||||||
guard let (dewPoint, topic) = request.dewPointData(topics: .init(), units: nil) else { return }
|
|
||||||
try await self.publish(double: dewPoint.rawValue, to: topic)
|
|
||||||
logger.debug("Published dewpoint: \(dewPoint.rawValue), to: \(topic)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func publishEnthalpy(_ request: Client.SensorPublishRequest) async throws {
|
|
||||||
// fix
|
|
||||||
guard let (enthalpy, topic) = request.enthalpyData(altitude: .seaLevel, topics: .init(), units: nil) else { return }
|
|
||||||
try await self.publish(double: enthalpy.rawValue, to: topic)
|
|
||||||
logger.debug("Publihsed enthalpy: \(enthalpy.rawValue), to: \(topic)")
|
|
||||||
}
|
|
||||||
|
|
||||||
public func publishSensor(_ request: Client.SensorPublishRequest) async throws {
|
|
||||||
try await publishDewPoint(request)
|
|
||||||
try await publishEnthalpy(request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
21
Sources/DewPointController/Application.swift
Normal file
21
Sources/DewPointController/Application.swift
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Dependencies
|
||||||
|
import Foundation
|
||||||
|
import Logging
|
||||||
|
import Models
|
||||||
|
import MQTTConnectionService
|
||||||
|
import MQTTManager
|
||||||
|
import MQTTNIO
|
||||||
|
import NIO
|
||||||
|
import PsychrometricClientLive
|
||||||
|
import SensorsService
|
||||||
|
import ServiceLifecycle
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct Application: AsyncParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "dewpoint-controller",
|
||||||
|
abstract: "Command for running the dewpoint mqtt service.",
|
||||||
|
subcommands: [Run.self, Debug.self]
|
||||||
|
)
|
||||||
|
}
|
||||||
74
Sources/DewPointController/DebugCommand.swift
Normal file
74
Sources/DewPointController/DebugCommand.swift
Normal 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 and command line arguments."
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptionGroup
|
||||||
|
var options: 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 = options.envFile {
|
||||||
|
print("Reading env file: \(envFile)")
|
||||||
|
print("--------------------------")
|
||||||
|
} else {
|
||||||
|
print("No env file set.")
|
||||||
|
print("--------------------------")
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Loading EnvVars")
|
||||||
|
print("--------------------------")
|
||||||
|
let envVars = try await client.makeEnvVars(options.envVarsRequest(logger: logger))
|
||||||
|
printEnvVars(envVars: envVars, showPassword: showPassword)
|
||||||
|
print("--------------------------")
|
||||||
|
|
||||||
|
if let logLevel = options.logLevel {
|
||||||
|
print("Log Level option: \(logLevel)")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
Sources/DewPointController/RunCommand.swift
Normal file
90
Sources/DewPointController/RunCommand.swift
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
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 options: SharedOptions
|
||||||
|
|
||||||
|
mutating func run() async throws {
|
||||||
|
@Dependency(\.cliClient) var cliClient
|
||||||
|
|
||||||
|
let (mqtt, logger) = try await cliClient.setupRun(options: options)
|
||||||
|
logger.info("Setting up environment...")
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
// These settings prevent services from running forever after we've
|
||||||
|
// received a shutdown signal. In general it should not needed unless the
|
||||||
|
// services don't shutdown their async streams properly.
|
||||||
|
serviceGroupConfiguration.maximumCancellationDuration = .seconds(5)
|
||||||
|
serviceGroupConfiguration.maximumGracefulShutdownDuration = .seconds(10)
|
||||||
|
|
||||||
|
let serviceGroup = ServiceGroup(configuration: serviceGroupConfiguration)
|
||||||
|
|
||||||
|
logger.info("Starting dewpoint-controller!")
|
||||||
|
try await serviceGroup.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Here we've received a shutdown signal and shutdown all the
|
||||||
|
// services.
|
||||||
|
try await mqtt.shutdown()
|
||||||
|
} catch {
|
||||||
|
// If something fails, shutdown the mqtt client.
|
||||||
|
try await mqtt.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension CliClient {
|
||||||
|
func setupRun(
|
||||||
|
eventLoopGroup: MultiThreadedEventLoopGroup = .init(numberOfThreads: 1),
|
||||||
|
loggerLabel: String = "dewpoint-controller",
|
||||||
|
options: Application.SharedOptions
|
||||||
|
) async throws -> (MQTTClient, Logger) {
|
||||||
|
var logger = Logger(label: loggerLabel)
|
||||||
|
let environment = try await makeEnvVars(options.envVarsRequest(logger: logger))
|
||||||
|
logger.logLevel = logLevel(environment)
|
||||||
|
let client = try makeClient(.init(
|
||||||
|
environment: environment,
|
||||||
|
eventLoopGroup: eventLoopGroup,
|
||||||
|
logger: logger
|
||||||
|
))
|
||||||
|
|
||||||
|
return (client, logger)
|
||||||
|
}
|
||||||
|
}
|
||||||
52
Sources/DewPointController/SharedOptions.swift
Normal file
52
Sources/DewPointController/SharedOptions.swift
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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 logLevelContainer: 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
var logLevel: Logger.Level? { logLevelContainer?.logLevel }
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import Client
|
|
||||||
import EnvVars
|
|
||||||
import Models
|
|
||||||
import MQTTNIO
|
|
||||||
|
|
||||||
public struct DewPointEnvironment {
|
|
||||||
|
|
||||||
public var envVars: EnvVars
|
|
||||||
public var mqttClient: MQTTNIO.MQTTClient
|
|
||||||
public var topics: Topics
|
|
||||||
|
|
||||||
public init(
|
|
||||||
envVars: EnvVars,
|
|
||||||
mqttClient: MQTTNIO.MQTTClient,
|
|
||||||
topics: Topics = .init()
|
|
||||||
) {
|
|
||||||
self.envVars = envVars
|
|
||||||
self.mqttClient = mqttClient
|
|
||||||
self.topics = topics
|
|
||||||
}
|
|
||||||
}
|
|
||||||
38
Sources/MQTTConnectionService/MQTTConnectionService.swift
Normal file
38
Sources/MQTTConnectionService/MQTTConnectionService.swift
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import Dependencies
|
||||||
|
import Logging
|
||||||
|
import Models
|
||||||
|
import MQTTManager
|
||||||
|
import ServiceLifecycle
|
||||||
|
|
||||||
|
public struct MQTTConnectionService: Service {
|
||||||
|
|
||||||
|
private let logger: Logger?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
logger: Logger? = nil
|
||||||
|
) {
|
||||||
|
var logger = logger
|
||||||
|
logger?[metadataKey: "type"] = "mqtt-connection-service"
|
||||||
|
self.logger = logger
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The entry-point of the service which starts the connection
|
||||||
|
/// to the MQTT broker and handles graceful shutdown of the
|
||||||
|
/// connection.
|
||||||
|
public func run() async throws {
|
||||||
|
@Dependency(\.mqtt) var mqtt
|
||||||
|
try await mqtt.connect()
|
||||||
|
|
||||||
|
try await withGracefulShutdownHandler {
|
||||||
|
for await event in try mqtt.connectionStream().cancelOnGracefulShutdown() {
|
||||||
|
// We don't really need to do anything with the events, so just logging
|
||||||
|
// for now. But we need to iterate on an async stream for the service to
|
||||||
|
// continue to run and handle graceful shutdowns.
|
||||||
|
logger?.trace("Received connection event: \(event)")
|
||||||
|
}
|
||||||
|
} onGracefulShutdown: {
|
||||||
|
self.logger?.trace("Received graceful shutdown.")
|
||||||
|
mqtt.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
221
Sources/MQTTManager/Interface.swift
Normal file
221
Sources/MQTTManager/Interface.swift
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import AsyncAlgorithms
|
||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import Foundation
|
||||||
|
import Logging
|
||||||
|
import MQTTNIO
|
||||||
|
import NIO
|
||||||
|
|
||||||
|
public extension DependencyValues {
|
||||||
|
|
||||||
|
/// A dependency that is responsible for managing the connection to
|
||||||
|
/// an MQTT broker, listen to topics, and publish values back to the
|
||||||
|
/// broker.
|
||||||
|
var mqtt: MQTTManager {
|
||||||
|
get { self[MQTTManager.self] }
|
||||||
|
set { self[MQTTManager.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents the interface needed to connect, listen, and publish to an MQTT broker.
|
||||||
|
///
|
||||||
|
@DependencyClient
|
||||||
|
public struct MQTTManager: Sendable {
|
||||||
|
|
||||||
|
public typealias ListenStream = AsyncStream<MQTTPublishInfo>
|
||||||
|
|
||||||
|
/// Connect to the MQTT broker.
|
||||||
|
public var connect: @Sendable () async throws -> Void
|
||||||
|
|
||||||
|
/// Create a stream of connection events.
|
||||||
|
///
|
||||||
|
/// - SeeAlso: ``Event``
|
||||||
|
public var connectionStream: @Sendable () throws -> AsyncStream<Event>
|
||||||
|
|
||||||
|
private var _listen: @Sendable ([String], MQTTQoS) async throws -> ListenStream
|
||||||
|
|
||||||
|
/// Publish a value to the MQTT broker for a given topic.
|
||||||
|
public var publish: @Sendable (PublishRequest) async throws -> Void
|
||||||
|
|
||||||
|
/// Shutdown the connection to the MQTT broker.
|
||||||
|
public var shutdown: @Sendable () -> 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.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - topics: The topics to listen for changes to.
|
||||||
|
/// - qos: The MQTTQoS for the subscription.
|
||||||
|
public func listen(
|
||||||
|
to topics: [String],
|
||||||
|
qos: MQTTQoS = .atLeastOnce
|
||||||
|
) async throws -> ListenStream {
|
||||||
|
try await _listen(topics, qos)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an async stream that listens for changes to the given topics.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - topics: The topics to listen for changes to.
|
||||||
|
/// - qos: The MQTTQoS for the subscription.
|
||||||
|
public func listen(
|
||||||
|
_ topics: String...,
|
||||||
|
qos: MQTTQoS = .atLeastOnce
|
||||||
|
) async throws -> ListenStream {
|
||||||
|
try await listen(to: topics, qos: qos)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish a new value to the given topic.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - payload: The value to publish.
|
||||||
|
/// - topicName: The topic to publish the new value to.
|
||||||
|
/// - qos: The MQTTQoS.
|
||||||
|
/// - retain: The retain flag.
|
||||||
|
public func publish(
|
||||||
|
_ payload: ByteBuffer,
|
||||||
|
to topicName: String,
|
||||||
|
qos: MQTTQoS,
|
||||||
|
retain: Bool = false,
|
||||||
|
properties: MQTTProperties = .init()
|
||||||
|
) async throws {
|
||||||
|
try await publish(.init(
|
||||||
|
topicName: topicName,
|
||||||
|
payload: payload,
|
||||||
|
qos: qos,
|
||||||
|
retain: retain,
|
||||||
|
properties: properties
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform an operation with the underlying MQTTClient, this can be useful in
|
||||||
|
/// tests, so this module needs imported with `@_spi(Internal) import MQTTManager` to use this method.
|
||||||
|
@_spi(Internal)
|
||||||
|
public func withClient(
|
||||||
|
_ callback: @Sendable (MQTTClient) async throws -> Void
|
||||||
|
) async throws {
|
||||||
|
try await _withClient(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents connection events that clients can listen for and
|
||||||
|
/// react accordingly.
|
||||||
|
public enum Event: Equatable, Sendable {
|
||||||
|
case connected
|
||||||
|
case disconnected
|
||||||
|
case shuttingDown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents the parameters required to publish a new value to the
|
||||||
|
/// MQTT broker.
|
||||||
|
public struct PublishRequest: Sendable {
|
||||||
|
|
||||||
|
/// The topic to publish the new value to.
|
||||||
|
public let topicName: String
|
||||||
|
|
||||||
|
/// The value to publish.
|
||||||
|
public let payload: ByteBuffer
|
||||||
|
|
||||||
|
/// The qos of the request.
|
||||||
|
public let qos: MQTTQoS
|
||||||
|
|
||||||
|
/// The retain flag for the request.
|
||||||
|
public let retain: Bool
|
||||||
|
|
||||||
|
public let properties: MQTTProperties
|
||||||
|
|
||||||
|
/// Create a new publish request.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - topicName: The topic to publish to.
|
||||||
|
/// - payload: The value to publish.
|
||||||
|
/// - qos: The qos of the request.
|
||||||
|
/// - retain: The retain flag of the request.
|
||||||
|
public init(
|
||||||
|
topicName: String,
|
||||||
|
payload: ByteBuffer,
|
||||||
|
qos: MQTTQoS,
|
||||||
|
retain: Bool,
|
||||||
|
properties: MQTTProperties
|
||||||
|
) {
|
||||||
|
self.topicName = topicName
|
||||||
|
self.payload = payload
|
||||||
|
self.qos = qos
|
||||||
|
self.retain = retain
|
||||||
|
self.properties = properties
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension MQTTManager {
|
||||||
|
/// Create the live manager.
|
||||||
|
///
|
||||||
|
static func live(
|
||||||
|
client: MQTTClient,
|
||||||
|
cleanSession: Bool = false,
|
||||||
|
logger: Logger? = nil,
|
||||||
|
alwaysReconnect: Bool = true
|
||||||
|
) -> Self {
|
||||||
|
let manager = ConnectionManager(
|
||||||
|
client: client,
|
||||||
|
logger: logger,
|
||||||
|
alwaysReconnect: alwaysReconnect
|
||||||
|
)
|
||||||
|
return .init(
|
||||||
|
connect: { try await manager.connect(cleanSession: cleanSession) },
|
||||||
|
connectionStream: {
|
||||||
|
MQTTConnectionStream(client: client, logger: logger)
|
||||||
|
.start()
|
||||||
|
.removeDuplicates()
|
||||||
|
.eraseToStream()
|
||||||
|
},
|
||||||
|
listen: { topics, qos in
|
||||||
|
try await manager.listen(to: topics, qos: qos)
|
||||||
|
},
|
||||||
|
publish: { request in
|
||||||
|
let topic = request.topicName
|
||||||
|
guard client.isActive() else {
|
||||||
|
logger?.debug("Client is not active, unable to publish to topic: \(topic)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger?.trace("Begin publishing to topic: \(topic)")
|
||||||
|
defer { logger?.debug("Done publishing to topic: \(topic)") }
|
||||||
|
try await client.publish(
|
||||||
|
to: request.topicName,
|
||||||
|
payload: request.payload,
|
||||||
|
qos: request.qos,
|
||||||
|
retain: request.retain,
|
||||||
|
properties: request.properties
|
||||||
|
).get()
|
||||||
|
},
|
||||||
|
shutdown: {
|
||||||
|
Task { try await client.shutdown() }
|
||||||
|
manager.shutdown()
|
||||||
|
},
|
||||||
|
withClient: { callback in
|
||||||
|
try await callback(client)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MQTTManager: TestDependencyKey {
|
||||||
|
public static let testValue: MQTTManager = Self()
|
||||||
|
}
|
||||||
98
Sources/MQTTManager/Internal/ConnectionManager.swift
Normal file
98
Sources/MQTTManager/Internal/ConnectionManager.swift
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import Foundation
|
||||||
|
import Logging
|
||||||
|
import MQTTNIO
|
||||||
|
|
||||||
|
actor ConnectionManager {
|
||||||
|
private let client: MQTTClient
|
||||||
|
private let logger: Logger?
|
||||||
|
private let name: String
|
||||||
|
private let shouldReconnect: Bool
|
||||||
|
private var hasConnected: Bool = false
|
||||||
|
private var listeners: [TopicListenerStream] = []
|
||||||
|
private var isShuttingDown = false
|
||||||
|
|
||||||
|
init(
|
||||||
|
client: MQTTClient,
|
||||||
|
logger: Logger?,
|
||||||
|
alwaysReconnect: Bool
|
||||||
|
) {
|
||||||
|
var logger = logger
|
||||||
|
logger?[metadataKey: "instance"] = "\(Self.self)"
|
||||||
|
self.logger = logger
|
||||||
|
|
||||||
|
self.client = client
|
||||||
|
self.name = UUID().uuidString
|
||||||
|
self.shouldReconnect = alwaysReconnect
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if !isShuttingDown {
|
||||||
|
let message = """
|
||||||
|
Did not properly close the connection manager. This can lead to
|
||||||
|
dangling references.
|
||||||
|
|
||||||
|
Please call `shutdown` to properly close all connections and listener streams.
|
||||||
|
"""
|
||||||
|
logger?.warning("\(message)")
|
||||||
|
self.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setHasConnected() {
|
||||||
|
hasConnected = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func listen(
|
||||||
|
to topics: [String],
|
||||||
|
qos: MQTTQoS
|
||||||
|
) async throws -> MQTTManager.ListenStream {
|
||||||
|
let listener = TopicListenerStream(client: client, logger: logger, topics: topics, qos: qos)
|
||||||
|
listeners.append(listener)
|
||||||
|
await listener.start()
|
||||||
|
return listener.stream
|
||||||
|
}
|
||||||
|
|
||||||
|
func connect(
|
||||||
|
cleanSession: Bool
|
||||||
|
) async throws {
|
||||||
|
guard !hasConnected else { return }
|
||||||
|
do {
|
||||||
|
try await client.connect(cleanSession: cleanSession)
|
||||||
|
setHasConnected()
|
||||||
|
|
||||||
|
client.addCloseListener(named: name) { [weak self] _ in
|
||||||
|
guard let `self` else { return }
|
||||||
|
self.logger?.debug("Connection closed.")
|
||||||
|
if self.shouldReconnect {
|
||||||
|
self.logger?.debug("Reconnecting...")
|
||||||
|
Task {
|
||||||
|
try await self.connect(cleanSession: cleanSession)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.addShutdownListener(named: name) { _ in
|
||||||
|
self.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
logger?.trace("Failed to connect: \(error)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shutdownListeners() {
|
||||||
|
_ = listeners.map { $0.shutdown() }
|
||||||
|
listeners = []
|
||||||
|
isShuttingDown = true
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func shutdown(withLogging: Bool = true) {
|
||||||
|
if withLogging {
|
||||||
|
logger?.trace("Shutting down connection.")
|
||||||
|
}
|
||||||
|
client.removeCloseListener(named: name)
|
||||||
|
client.removeShutdownListener(named: name)
|
||||||
|
Task { await shutdownListeners() }
|
||||||
|
}
|
||||||
|
}
|
||||||
74
Sources/MQTTManager/Internal/ConnectionStream.swift
Normal file
74
Sources/MQTTManager/Internal/ConnectionStream.swift
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import Foundation
|
||||||
|
import Logging
|
||||||
|
import MQTTNIO
|
||||||
|
|
||||||
|
@_spi(Internal)
|
||||||
|
public actor MQTTConnectionStream: Sendable {
|
||||||
|
|
||||||
|
public typealias Element = MQTTManager.Event
|
||||||
|
|
||||||
|
private let client: MQTTClient
|
||||||
|
private let continuation: AsyncStream<Element>.Continuation
|
||||||
|
private let logger: Logger?
|
||||||
|
nonisolated let name: String
|
||||||
|
private let stream: AsyncStream<Element>
|
||||||
|
private var isShuttingDown = false
|
||||||
|
|
||||||
|
public init(client: MQTTClient, logger: Logger?) {
|
||||||
|
var logger = logger
|
||||||
|
logger?[metadataKey: "type"] = "\(Self.self)"
|
||||||
|
self.logger = logger
|
||||||
|
|
||||||
|
let (stream, continuation) = AsyncStream<Element>.makeStream()
|
||||||
|
self.client = client
|
||||||
|
self.continuation = continuation
|
||||||
|
self.name = UUID().uuidString
|
||||||
|
self.stream = stream
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { stop() }
|
||||||
|
|
||||||
|
public nonisolated func start() -> AsyncStream<Element> {
|
||||||
|
// Check if the client is active and yield the initial result.
|
||||||
|
continuation.yield(client.isActive() ? .connected : .disconnected)
|
||||||
|
|
||||||
|
// Continually check if the client is active.
|
||||||
|
let task = Task {
|
||||||
|
let isShuttingDown = await self.isShuttingDown
|
||||||
|
while !Task.isCancelled, !isShuttingDown {
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
continuation.yield(client.isActive() ? .connected : .disconnected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register listener on the client for when the connection
|
||||||
|
// closes.
|
||||||
|
client.addCloseListener(named: name) { _ in
|
||||||
|
self.logger?.trace("Client has disconnected.")
|
||||||
|
self.continuation.yield(.disconnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register listener on the client for when the client
|
||||||
|
// is shutdown.
|
||||||
|
client.addShutdownListener(named: name) { _ in
|
||||||
|
self.logger?.trace("Client is shutting down, ending connection stream: \(self.name)")
|
||||||
|
self.continuation.yield(.shuttingDown)
|
||||||
|
Task { await self.setIsShuttingDown() }
|
||||||
|
task.cancel()
|
||||||
|
self.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setIsShuttingDown() {
|
||||||
|
isShuttingDown = true
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated func stop() {
|
||||||
|
client.removeCloseListener(named: name)
|
||||||
|
client.removeShutdownListener(named: name)
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
141
Sources/MQTTManager/Internal/TopicListenerStream.swift
Normal file
141
Sources/MQTTManager/Internal/TopicListenerStream.swift
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import Foundation
|
||||||
|
import Logging
|
||||||
|
import MQTTNIO
|
||||||
|
|
||||||
|
actor TopicListenerStream {
|
||||||
|
|
||||||
|
typealias Stream = MQTTManager.ListenStream
|
||||||
|
|
||||||
|
private let client: MQTTClient
|
||||||
|
private let configuration: Configuration
|
||||||
|
private let continuation: Stream.Continuation
|
||||||
|
private let logger: Logger?
|
||||||
|
private let name: String
|
||||||
|
let stream: Stream
|
||||||
|
private var shuttingDown: Bool = false
|
||||||
|
private var onShutdownHandler: (@Sendable () -> Void)?
|
||||||
|
|
||||||
|
init(
|
||||||
|
client: MQTTClient,
|
||||||
|
logger: Logger?,
|
||||||
|
topics: [String],
|
||||||
|
qos: MQTTQoS
|
||||||
|
) {
|
||||||
|
// Setup the logger so we can more easily decipher log messages.
|
||||||
|
var logger = logger
|
||||||
|
logger?[metadataKey: "type"] = "\(Self.self)"
|
||||||
|
self.logger = logger
|
||||||
|
|
||||||
|
let (stream, continuation) = Stream.makeStream()
|
||||||
|
self.client = client
|
||||||
|
self.configuration = .init(qos: qos, topics: topics)
|
||||||
|
self.continuation = continuation
|
||||||
|
self.name = UUID().uuidString
|
||||||
|
self.stream = stream
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Configuration: Sendable {
|
||||||
|
let qos: MQTTQoS
|
||||||
|
let topics: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if !shuttingDown {
|
||||||
|
let message = """
|
||||||
|
Shutdown was not called on topic listener. This could lead to potential errors or
|
||||||
|
the stream never ending.
|
||||||
|
|
||||||
|
Please ensure that you call shutdown on the listener.
|
||||||
|
"""
|
||||||
|
client.logger.warning("\(message)")
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
client.removePublishListener(named: name)
|
||||||
|
client.removeShutdownListener(named: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subscribe() async throws {
|
||||||
|
guard !shuttingDown else { return }
|
||||||
|
logger?.debug("Begin subscribing to topics.")
|
||||||
|
do {
|
||||||
|
_ = try await client.subscribe(to: configuration.topics.map {
|
||||||
|
MQTTSubscribeInfo(topicFilter: $0, qos: configuration.qos)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
logger?.error("Received error while subscribing to topics: \(configuration.topics)")
|
||||||
|
throw TopicListenerError.failedToSubscribe
|
||||||
|
}
|
||||||
|
logger?.debug("Done subscribing to topics.")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func start() {
|
||||||
|
logger?.trace("Starting listener for topics: \(configuration.topics)")
|
||||||
|
|
||||||
|
let stream = MQTTConnectionStream(client: client, logger: logger)
|
||||||
|
.start()
|
||||||
|
.removeDuplicates()
|
||||||
|
.eraseToStream()
|
||||||
|
|
||||||
|
let task = Task {
|
||||||
|
// Listen for connection events to restablish the stream upon a
|
||||||
|
// client becoming disconnected / reconnected, and properly shutdown
|
||||||
|
// the stream on the client being shutdown.
|
||||||
|
for await event in stream {
|
||||||
|
logger?.trace("Received event: \(event)")
|
||||||
|
switch event {
|
||||||
|
case .shuttingDown:
|
||||||
|
shutdown()
|
||||||
|
case .disconnected:
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
case .connected:
|
||||||
|
try await subscribe()
|
||||||
|
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 self.configuration.topics.contains(publishInfo.topicName) {
|
||||||
|
self.logger?.debug("Recieved new value for topic: \(publishInfo.topicName)")
|
||||||
|
self.continuation.yield(publishInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onShutdownHandler = { task.cancel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setIsShuttingDown() {
|
||||||
|
shuttingDown = true
|
||||||
|
onShutdownHandler = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated func shutdown() {
|
||||||
|
client.logger.trace("Closing topic listener...")
|
||||||
|
continuation.finish()
|
||||||
|
client.removePublishListener(named: name)
|
||||||
|
client.removeShutdownListener(named: name)
|
||||||
|
Task {
|
||||||
|
await onShutdownHandler?()
|
||||||
|
await self.setIsShuttingDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
public enum TopicListenerError: Error {
|
||||||
|
case connectionTimeout
|
||||||
|
case failedToSubscribe
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct MQTTListenResultError: Error {
|
||||||
|
let underlyingError: any Error
|
||||||
|
|
||||||
|
init(_ underlyingError: any Error) {
|
||||||
|
self.underlyingError = underlyingError
|
||||||
|
}
|
||||||
|
}
|
||||||
23
Sources/EnvVars/EnvVars.swift → Sources/Models/EnvVars.swift
Normal file → Executable file
23
Sources/EnvVars/EnvVars.swift → Sources/Models/EnvVars.swift
Normal file → Executable file
@@ -1,10 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Logging
|
||||||
|
|
||||||
/// Holds common settings for connecting to your MQTT broker. The default values can be used,
|
/// Holds common settings for connecting to your MQTT broker. The default values can be used,
|
||||||
/// they can be loaded from the shell environment, or from a file located in the root directory.
|
/// they can be loaded from the shell environment, or from a file located in the root directory.
|
||||||
///
|
///
|
||||||
/// This allows us to keep sensitve settings out of the repository.
|
/// This allows us to keep sensitve settings out of the repository.
|
||||||
public struct EnvVars: Codable, Equatable {
|
public struct EnvVars: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
/// The current app environment.
|
/// The current app environment.
|
||||||
public var appEnv: AppEnv
|
public var appEnv: AppEnv
|
||||||
@@ -24,6 +25,12 @@ public struct EnvVars: Codable, Equatable {
|
|||||||
/// 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:
|
||||||
@@ -37,16 +44,20 @@ public struct EnvVars: Codable, Equatable {
|
|||||||
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
|
||||||
self.port = port
|
self.port = port
|
||||||
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.
|
||||||
@@ -57,10 +68,12 @@ public struct EnvVars: Codable, Equatable {
|
|||||||
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.
|
||||||
public enum AppEnv: String, Codable {
|
public enum AppEnv: String, Codable, Sendable {
|
||||||
case development
|
case development
|
||||||
case production
|
case production
|
||||||
case staging
|
case staging
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import CoreUnitTypes
|
|
||||||
|
|
||||||
/// Represents the different modes that the controller can be in.
|
|
||||||
public enum Mode: Equatable {
|
|
||||||
|
|
||||||
/// Allows controller to run in humidify or dehumidify mode.
|
|
||||||
case auto
|
|
||||||
|
|
||||||
/// Only handle humidify mode.
|
|
||||||
case humidifyOnly(HumidifyMode)
|
|
||||||
|
|
||||||
/// Only handle dehumidify mode.
|
|
||||||
case dehumidifyOnly(DehumidifyMode)
|
|
||||||
|
|
||||||
/// Don't control humidify or dehumidify modes.
|
|
||||||
case off
|
|
||||||
|
|
||||||
/// Represents the control modes for the humidify control state.
|
|
||||||
public enum HumidifyMode: Equatable {
|
|
||||||
|
|
||||||
/// Control humidifying based off dew-point.
|
|
||||||
case dewPoint(Temperature)
|
|
||||||
|
|
||||||
/// Control humidifying based off relative humidity.
|
|
||||||
case relativeHumidity(RelativeHumidity)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents the control modes for the dehumidify control state.
|
|
||||||
public enum DehumidifyMode: Equatable {
|
|
||||||
|
|
||||||
/// Control dehumidifying based off dew-point.
|
|
||||||
case dewPoint(high: Temperature, low: Temperature)
|
|
||||||
|
|
||||||
/// Control humidifying based off relative humidity.
|
|
||||||
case relativeHumidity(high: RelativeHumidity, low: RelativeHumidity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Psychrometrics
|
|
||||||
|
|
||||||
// TODO: Make this a struct, then create a Store class that holds the state??
|
|
||||||
public final class State {
|
|
||||||
|
|
||||||
public var altitude: Length
|
|
||||||
public var sensors: Sensors
|
|
||||||
public var units: PsychrometricEnvironment.Units {
|
|
||||||
didSet {
|
|
||||||
PsychrometricEnvironment.shared.units = units
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(
|
|
||||||
altitude: Length = .seaLevel,
|
|
||||||
sensors: Sensors = .init(),
|
|
||||||
units: PsychrometricEnvironment.Units = .imperial
|
|
||||||
) {
|
|
||||||
self.altitude = altitude
|
|
||||||
self.sensors = sensors
|
|
||||||
self.units = units
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct Sensors: Equatable {
|
|
||||||
|
|
||||||
public var mixedAirSensor: TemperatureHumiditySensor<Mixed>
|
|
||||||
public var postCoilSensor: TemperatureHumiditySensor<PostCoil>
|
|
||||||
public var returnAirSensor: TemperatureHumiditySensor<Return>
|
|
||||||
public var supplyAirSensor: TemperatureHumiditySensor<Supply>
|
|
||||||
|
|
||||||
public init(
|
|
||||||
mixedAirSensor: TemperatureHumiditySensor<Mixed> = .init(),
|
|
||||||
postCoilSensor: TemperatureHumiditySensor<PostCoil> = .init(),
|
|
||||||
returnAirSensor: TemperatureHumiditySensor<Return> = .init(),
|
|
||||||
supplyAirSensor: TemperatureHumiditySensor<Supply> = .init()
|
|
||||||
) {
|
|
||||||
self.mixedAirSensor = mixedAirSensor
|
|
||||||
self.postCoilSensor = postCoilSensor
|
|
||||||
self.returnAirSensor = returnAirSensor
|
|
||||||
self.supplyAirSensor = supplyAirSensor
|
|
||||||
}
|
|
||||||
|
|
||||||
public var needsProcessed: Bool {
|
|
||||||
mixedAirSensor.needsProcessed
|
|
||||||
|| postCoilSensor.needsProcessed
|
|
||||||
|| returnAirSensor.needsProcessed
|
|
||||||
|| supplyAirSensor.needsProcessed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension State.Sensors {
|
|
||||||
|
|
||||||
public struct TemperatureHumiditySensor<Location>: Equatable {
|
|
||||||
|
|
||||||
@TrackedChanges
|
|
||||||
public var temperature: Temperature?
|
|
||||||
|
|
||||||
@TrackedChanges
|
|
||||||
public var humidity: RelativeHumidity?
|
|
||||||
|
|
||||||
public var needsProcessed: Bool {
|
|
||||||
get { $temperature.needsProcessed || $humidity.needsProcessed }
|
|
||||||
set {
|
|
||||||
$temperature.needsProcessed = newValue
|
|
||||||
$humidity.needsProcessed = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func dewPoint(units: PsychrometricEnvironment.Units? = nil) -> 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func enthalpy(altitude: Length, units: PsychrometricEnvironment.Units? = nil) -> 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(
|
|
||||||
temperature: Temperature? = nil,
|
|
||||||
humidity: RelativeHumidity? = nil,
|
|
||||||
needsProcessed: Bool = false
|
|
||||||
) {
|
|
||||||
self._temperature = .init(wrappedValue: temperature, needsProcessed: needsProcessed)
|
|
||||||
self._humidity = .init(wrappedValue: humidity, needsProcessed: needsProcessed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Temperature / Humidity Sensor Location Namespaces
|
|
||||||
public enum Mixed { }
|
|
||||||
public enum PostCoil { }
|
|
||||||
public enum Return { }
|
|
||||||
public enum Supply { }
|
|
||||||
}
|
|
||||||
149
Sources/Models/TemperatureAndHumiditySensor.swift
Normal file
149
Sources/Models/TemperatureAndHumiditySensor.swift
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import Dependencies
|
||||||
|
import PsychrometricClient
|
||||||
|
|
||||||
|
/// Represents a temperature and humidity sensor that can be used to derive
|
||||||
|
/// the dew-point temperature and enthalpy values.
|
||||||
|
///
|
||||||
|
/// > Note: Temperature values are received in `celsius`.
|
||||||
|
public struct TemperatureAndHumiditySensor: Identifiable, Sendable {
|
||||||
|
|
||||||
|
@Dependency(\.psychrometricClient) private var psychrometrics
|
||||||
|
|
||||||
|
/// The identifier of the sensor, same as the location.
|
||||||
|
public var id: Location { location }
|
||||||
|
|
||||||
|
/// The altitude of the sensor.
|
||||||
|
public let altitude: Length
|
||||||
|
|
||||||
|
/// The current humidity value of the sensor.
|
||||||
|
@TrackedChanges
|
||||||
|
public var humidity: RelativeHumidity?
|
||||||
|
|
||||||
|
/// The location identifier of the sensor
|
||||||
|
public let location: Location
|
||||||
|
|
||||||
|
/// The current temperature value of the sensor.
|
||||||
|
@TrackedChanges
|
||||||
|
public var temperature: DryBulb?
|
||||||
|
|
||||||
|
/// The topics to listen for updated sensor values.
|
||||||
|
public let topics: Topics
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
public init(
|
||||||
|
location: Location,
|
||||||
|
altitude: Length = .feet(800.0),
|
||||||
|
temperature: DryBulb? = nil,
|
||||||
|
humidity: RelativeHumidity? = nil,
|
||||||
|
needsProcessed: Bool = false,
|
||||||
|
topics: Topics? = nil
|
||||||
|
) {
|
||||||
|
self.altitude = altitude
|
||||||
|
self.location = location
|
||||||
|
self._temperature = TrackedChanges(wrappedValue: temperature, needsProcessed: needsProcessed)
|
||||||
|
self._humidity = TrackedChanges(wrappedValue: humidity, needsProcessed: needsProcessed)
|
||||||
|
self.topics = topics ?? .init(location: location)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The calculated dew-point temperature of the sensor.
|
||||||
|
public var dewPoint: DewPoint? {
|
||||||
|
get async {
|
||||||
|
guard let temperature = temperature,
|
||||||
|
let humidity = humidity,
|
||||||
|
!temperature.value.isNaN,
|
||||||
|
!humidity.value.isNaN
|
||||||
|
else { return nil }
|
||||||
|
return try? await psychrometrics.dewPoint(.dryBulb(temperature, relativeHumidity: humidity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The calculated enthalpy of the sensor.
|
||||||
|
public var enthalpy: EnthalpyOf<MoistAir>? {
|
||||||
|
get async {
|
||||||
|
guard let temperature = temperature,
|
||||||
|
let humidity = humidity,
|
||||||
|
!temperature.value.isNaN,
|
||||||
|
!humidity.value.isNaN
|
||||||
|
else { return nil }
|
||||||
|
return try? await psychrometrics.enthalpy.moistAir(
|
||||||
|
.dryBulb(temperature, relativeHumidity: humidity, altitude: altitude)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether any of the sensor values have changed and need processed.
|
||||||
|
///
|
||||||
|
/// - Note: Setting a value will set to both the temperature and humidity properties.
|
||||||
|
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, CaseIterable, Equatable, Hashable, Sendable {
|
||||||
|
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, Hashable, Sendable {
|
||||||
|
|
||||||
|
/// The dew-point temperature topic for the sensor.
|
||||||
|
public let dewPoint: String
|
||||||
|
|
||||||
|
/// The enthalpy topic for the sensor.
|
||||||
|
public let enthalpy: String
|
||||||
|
|
||||||
|
/// The humidity topic of the sensor.
|
||||||
|
public let humidity: String
|
||||||
|
|
||||||
|
/// The temperature topic of the sensor.
|
||||||
|
public let temperature: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
dewPoint: String,
|
||||||
|
enthalpy: String,
|
||||||
|
humidity: String,
|
||||||
|
temperature: String
|
||||||
|
) {
|
||||||
|
self.dewPoint = dewPoint
|
||||||
|
self.enthalpy = enthalpy
|
||||||
|
self.humidity = humidity
|
||||||
|
self.temperature = temperature
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(topicPrefix: String? = "frankensystem", location: TemperatureAndHumiditySensor.Location) {
|
||||||
|
var prefix = topicPrefix ?? ""
|
||||||
|
if prefix.reversed().starts(with: "/") {
|
||||||
|
prefix = "\(prefix.dropLast())"
|
||||||
|
}
|
||||||
|
self.init(
|
||||||
|
dewPoint: "\(prefix)/sensor/\(location.rawValue)_dew_point/state",
|
||||||
|
enthalpy: "\(prefix)/sensor/\(location.rawValue)_enthalpy/state",
|
||||||
|
humidity: "\(prefix)/sensor/\(location.rawValue)_humidity/state",
|
||||||
|
temperature: "\(prefix)/sensor/\(location.rawValue)_temperature/state"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Array where Element == TemperatureAndHumiditySensor {
|
||||||
|
static var live: Self {
|
||||||
|
TemperatureAndHumiditySensor.Location.allCases.map {
|
||||||
|
TemperatureAndHumiditySensor(location: $0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
|
|
||||||
/// A container for all the different topics that are needed by the application.
|
|
||||||
public struct Topics: Codable, Equatable {
|
|
||||||
|
|
||||||
/// The command topics the application can publish to.
|
|
||||||
public var commands: Commands
|
|
||||||
|
|
||||||
/// The sensor topics the application can read from / write to.
|
|
||||||
public var sensors: Sensors
|
|
||||||
|
|
||||||
/// The set point topics the application can read set point values from.
|
|
||||||
public var setPoints: SetPoints
|
|
||||||
|
|
||||||
/// The state topics the application can read state values from.
|
|
||||||
public var states: States
|
|
||||||
|
|
||||||
/// Create the topics required by the application.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - sensors: The sensor topics.
|
|
||||||
/// - setPoints: The set point topics
|
|
||||||
/// - states: The states topics
|
|
||||||
/// - relays: The relay topics
|
|
||||||
public init(
|
|
||||||
commands: Commands = .init(),
|
|
||||||
sensors: Sensors = .init(),
|
|
||||||
setPoints: SetPoints = .init(),
|
|
||||||
states: States = .init()
|
|
||||||
) {
|
|
||||||
self.commands = commands
|
|
||||||
self.sensors = sensors
|
|
||||||
self.setPoints = setPoints
|
|
||||||
self.states = states
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents the sensor topics.
|
|
||||||
public struct Sensors: Codable, Equatable {
|
|
||||||
|
|
||||||
public var mixedAirSensor: TemperatureAndHumiditySensor<State.Sensors.Mixed>
|
|
||||||
public var postCoilSensor: TemperatureAndHumiditySensor<State.Sensors.PostCoil>
|
|
||||||
public var returnAirSensor: TemperatureAndHumiditySensor<State.Sensors.Return>
|
|
||||||
public var supplyAirSensor: TemperatureAndHumiditySensor<State.Sensors.Supply>
|
|
||||||
|
|
||||||
public init(
|
|
||||||
mixedAirSensor: TemperatureAndHumiditySensor<State.Sensors.Mixed> = .default(location: "mixed=air"),
|
|
||||||
postCoilSensor: TemperatureAndHumiditySensor<State.Sensors.PostCoil> = .default(location: "post-coil"),
|
|
||||||
returnAirSensor: TemperatureAndHumiditySensor<State.Sensors.Return> = .default(location: "return"),
|
|
||||||
supplyAirSensor: TemperatureAndHumiditySensor<State.Sensors.Supply> = .default(location: "supply")
|
|
||||||
) {
|
|
||||||
self.mixedAirSensor = mixedAirSensor
|
|
||||||
self.postCoilSensor = postCoilSensor
|
|
||||||
self.returnAirSensor = returnAirSensor
|
|
||||||
self.supplyAirSensor = supplyAirSensor
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct TemperatureAndHumiditySensor<Location>: Codable, Equatable {
|
|
||||||
public var temperature: String
|
|
||||||
public var humidity: String
|
|
||||||
public var dewPoint: String
|
|
||||||
public var enthalpy: String
|
|
||||||
|
|
||||||
/// Create a new sensor topic container.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - temperature: The temperature sensor topic.
|
|
||||||
/// - humidity: The humidity sensor topic.
|
|
||||||
/// - dewPoint: The dew point sensor topic.
|
|
||||||
public init(
|
|
||||||
temperature: String,
|
|
||||||
humidity: String,
|
|
||||||
dewPoint: String,
|
|
||||||
enthalpy: String
|
|
||||||
) {
|
|
||||||
self.temperature = temperature
|
|
||||||
self.humidity = humidity
|
|
||||||
self.dewPoint = dewPoint
|
|
||||||
self.enthalpy = enthalpy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A container for set point related topics used by the application.
|
|
||||||
public struct SetPoints: Codable, Equatable {
|
|
||||||
|
|
||||||
/// The topic for the humidify set point.
|
|
||||||
public var humidify: Humidify
|
|
||||||
|
|
||||||
/// The topics for dehumidification set points.
|
|
||||||
public var dehumidify: Dehumidify
|
|
||||||
|
|
||||||
/// Create a new set point topic container.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - humidify: The topic for humidification set points.
|
|
||||||
/// - dehumidify: The topics for dehumidification set points.
|
|
||||||
public init(
|
|
||||||
humidify: Humidify = .init(),
|
|
||||||
dehumidify: Dehumidify = .init()
|
|
||||||
) {
|
|
||||||
self.humidify = humidify
|
|
||||||
self.dehumidify = dehumidify
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A container for the humidification set point topics used by the application.
|
|
||||||
public struct Humidify: Codable, Equatable {
|
|
||||||
|
|
||||||
/// The topic for dew point control mode set point.
|
|
||||||
public var dewPoint: String
|
|
||||||
|
|
||||||
/// The topic for relative humidity control mode set point.
|
|
||||||
public var relativeHumidity: String
|
|
||||||
|
|
||||||
/// Create a new container for the humidification set point topics.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - dewPoint: The topic for dew point control mode set point.
|
|
||||||
/// - relativeHumidity: The topic for relative humidity control mode set point.
|
|
||||||
public init(
|
|
||||||
dewPoint: String = "set_points/humidify/dew_point",
|
|
||||||
relativeHumidity: String = "set_points/humidify/relative_humidity"
|
|
||||||
) {
|
|
||||||
self.dewPoint = dewPoint
|
|
||||||
self.relativeHumidity = relativeHumidity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A container for dehumidifcation set point topics.
|
|
||||||
public struct Dehumidify: Codable, Equatable {
|
|
||||||
|
|
||||||
/// A low setting for dew point control modes.
|
|
||||||
public var lowDewPoint: String
|
|
||||||
|
|
||||||
/// A high setting for dew point control modes.
|
|
||||||
public var highDewPoint: String
|
|
||||||
|
|
||||||
/// A low setting for relative humidity control modes.
|
|
||||||
public var lowRelativeHumidity: String
|
|
||||||
|
|
||||||
/// A high setting for relative humidity control modes.
|
|
||||||
public var highRelativeHumidity: String
|
|
||||||
|
|
||||||
/// Create a new container for dehumidification set point topics.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - lowDewPoint: A low setting for dew point control modes.
|
|
||||||
/// - highDewPoint: A high setting for dew point control modes.
|
|
||||||
/// - lowRelativeHumidity: A low setting for relative humidity control modes.
|
|
||||||
/// - highRelativeHumidity: A high setting for relative humidity control modes.
|
|
||||||
public init(
|
|
||||||
lowDewPoint: String = "set_points/dehumidify/low_dew_point",
|
|
||||||
highDewPoint: String = "set_points/dehumidify/high_dew_point",
|
|
||||||
lowRelativeHumidity: String = "set_points/dehumidify/low_relative_humidity",
|
|
||||||
highRelativeHumidity: String = "set_points/dehumidify/high_relative_humidity"
|
|
||||||
) {
|
|
||||||
self.lowDewPoint = lowDewPoint
|
|
||||||
self.highDewPoint = highDewPoint
|
|
||||||
self.lowRelativeHumidity = lowRelativeHumidity
|
|
||||||
self.highRelativeHumidity = highRelativeHumidity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A container for control state topics used by the application.
|
|
||||||
public struct States: Codable, Equatable {
|
|
||||||
|
|
||||||
/// The topic for the control mode.
|
|
||||||
public var mode: String
|
|
||||||
|
|
||||||
/// The relay state topics.
|
|
||||||
public var relays: Relays
|
|
||||||
|
|
||||||
/// Create a new container for control state topics.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - mode: The topic for the control mode.
|
|
||||||
public init(
|
|
||||||
mode: String = "states/mode",
|
|
||||||
relays: Relays = .init()
|
|
||||||
) {
|
|
||||||
self.mode = mode
|
|
||||||
self.relays = relays
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A container for reading the current state of a relay.
|
|
||||||
public struct Relays: Codable, Equatable {
|
|
||||||
|
|
||||||
/// The dehumidification stage-1 relay topic.
|
|
||||||
public var dehumdification1: String
|
|
||||||
|
|
||||||
/// The dehumidification stage-2 relay topic.
|
|
||||||
public var dehumidification2: String
|
|
||||||
|
|
||||||
/// The humidification relay topic.
|
|
||||||
public var humdification: String
|
|
||||||
|
|
||||||
/// Create a new container for relay state topics.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - dehumidification1: The dehumidification stage-1 relay topic.
|
|
||||||
/// - dehumidification2: The dehumidification stage-2 relay topic.
|
|
||||||
/// - humidification: The humidification relay topic.
|
|
||||||
public init(
|
|
||||||
dehumidefication1: String = "states/relays/dehumidification_1",
|
|
||||||
dehumidification2: String = "states/relays/dehumidification_2",
|
|
||||||
humidification: String = "states/relays/humidification"
|
|
||||||
) {
|
|
||||||
self.dehumdification1 = dehumidefication1
|
|
||||||
self.dehumidification2 = dehumidification2
|
|
||||||
self.humdification = humidification
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A container for commands topics that the application can publish to.
|
|
||||||
public struct Commands: Codable, Equatable {
|
|
||||||
|
|
||||||
/// The relay command topics.
|
|
||||||
public var relays: Relays
|
|
||||||
|
|
||||||
/// Create a new command topics container.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - relays: The relay command topics.
|
|
||||||
public init(relays: Relays = .init()) {
|
|
||||||
self.relays = relays
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A container for relay command topics used by the application.
|
|
||||||
public struct Relays: Codable, Equatable {
|
|
||||||
|
|
||||||
/// The dehumidification stage-1 relay topic.
|
|
||||||
public var dehumidification1: String
|
|
||||||
|
|
||||||
/// The dehumidification stage-2 relay topic.
|
|
||||||
public var dehumidification2: String
|
|
||||||
|
|
||||||
/// The humidification relay topic.
|
|
||||||
public var humidification: String
|
|
||||||
|
|
||||||
/// Create a new container for commanding relays.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - dehumidification1: The dehumidification stage-1 relay topic.
|
|
||||||
/// - dehumidification2: The dehumidification stage-2 relay topic.
|
|
||||||
/// - humidification: The humidification relay topic.
|
|
||||||
public init(
|
|
||||||
dehumidification1: String = "relays/dehumidification_1",
|
|
||||||
dehumidification2: String = "relays/dehumidification_2",
|
|
||||||
humidification: String = "relays/humidification"
|
|
||||||
) {
|
|
||||||
self.dehumidification1 = dehumidification1
|
|
||||||
self.dehumidification2 = dehumidification2
|
|
||||||
self.humidification = humidification
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Helpers
|
|
||||||
extension Topics.Sensors.TemperatureAndHumiditySensor {
|
|
||||||
public static func `default`(location: String) -> Self {
|
|
||||||
.init(
|
|
||||||
temperature: "sensors/\(location)/temperature",
|
|
||||||
humidity: "sensors/\(location)/humidity",
|
|
||||||
dewPoint: "sensors/\(location)/dew-point",
|
|
||||||
enthalpy: "sensors/\(location)/enthalpy"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
48
Sources/Models/TrackedChanges.swift
Normal file → Executable file
48
Sources/Models/TrackedChanges.swift
Normal file → Executable file
@@ -1,11 +1,20 @@
|
|||||||
|
/// A property wrapper that tracks changes of a property.
|
||||||
|
///
|
||||||
|
/// This allows values to only publish changes if they have changed since the
|
||||||
|
/// last time they were recieved.
|
||||||
@propertyWrapper
|
@propertyWrapper
|
||||||
public struct TrackedChanges<Value> {
|
public struct TrackedChanges<Value: Sendable>: Sendable {
|
||||||
|
|
||||||
|
/// The current tracking state.
|
||||||
private var tracking: TrackingState
|
private var tracking: TrackingState
|
||||||
private var value: Value
|
|
||||||
private var isEqual: (Value, Value) -> Bool
|
|
||||||
|
|
||||||
|
/// The current wrapped value.
|
||||||
|
private var value: Value
|
||||||
|
|
||||||
|
/// Used to check if a new value is equal to an old value.
|
||||||
|
private var isEqual: @Sendable (Value, Value) -> Bool
|
||||||
|
|
||||||
|
/// Access to the underlying property that we are wrapping.
|
||||||
public var wrappedValue: Value {
|
public var wrappedValue: Value {
|
||||||
get { value }
|
get { value }
|
||||||
set {
|
set {
|
||||||
@@ -17,21 +26,33 @@ public struct TrackedChanges<Value> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new property that tracks it's changes.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - wrappedValue: The value that we are wrapping.
|
||||||
|
/// - needsProcessed: Whether this value needs processed (default = false).
|
||||||
|
/// - isEqual: Method to compare old values against new values.
|
||||||
public init(
|
public init(
|
||||||
wrappedValue: Value,
|
wrappedValue: Value,
|
||||||
needsProcessed: Bool = false,
|
needsProcessed: Bool = false,
|
||||||
isEqual: @escaping (Value, Value) -> Bool
|
isEqual: @escaping @Sendable (Value, Value) -> Bool
|
||||||
) {
|
) {
|
||||||
self.value = wrappedValue
|
self.value = wrappedValue
|
||||||
self.tracking = needsProcessed ? .needsProcessed : .hasProcessed
|
self.tracking = needsProcessed ? .needsProcessed : .hasProcessed
|
||||||
self.isEqual = isEqual
|
self.isEqual = isEqual
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Represents whether a wrapped value has changed and needs processed or not.
|
||||||
enum TrackingState {
|
enum TrackingState {
|
||||||
|
|
||||||
|
/// The state when nothing has changed and we've already processed the current value.
|
||||||
case hasProcessed
|
case hasProcessed
|
||||||
|
|
||||||
|
/// The state when the value has changed and has not been processed yet.
|
||||||
case needsProcessed
|
case needsProcessed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the value needs processed.
|
||||||
public var needsProcessed: Bool {
|
public var needsProcessed: Bool {
|
||||||
get { tracking == .needsProcessed }
|
get { tracking == .needsProcessed }
|
||||||
set {
|
set {
|
||||||
@@ -55,10 +76,25 @@ extension TrackedChanges: Equatable where Value: Equatable {
|
|||||||
&& lhs.needsProcessed == rhs.needsProcessed
|
&& lhs.needsProcessed == rhs.needsProcessed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new property that tracks it's changes, using the default equality check.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - wrappedValue: The value that we are wrapping.
|
||||||
|
/// - needsProcessed: Whether this value needs processed (default = false).
|
||||||
public init(
|
public init(
|
||||||
wrappedValue: Value,
|
wrappedValue: Value,
|
||||||
needsProcessed: Bool = false
|
needsProcessed: Bool = false
|
||||||
) {
|
) {
|
||||||
self.init(wrappedValue: wrappedValue, needsProcessed: needsProcessed, isEqual: ==)
|
self.init(wrappedValue: wrappedValue, needsProcessed: needsProcessed) {
|
||||||
|
$0 == $1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TrackedChanges: Hashable where Value: Hashable {
|
||||||
|
|
||||||
|
public func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(wrappedValue)
|
||||||
|
hasher.combine(needsProcessed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
212
Sources/SensorsService/SensorsService.swift
Normal file
212
Sources/SensorsService/SensorsService.swift
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import Foundation
|
||||||
|
import Logging
|
||||||
|
import Models
|
||||||
|
import MQTTManager
|
||||||
|
import MQTTNIO
|
||||||
|
import NIO
|
||||||
|
import PsychrometricClient
|
||||||
|
import ServiceLifecycle
|
||||||
|
|
||||||
|
/// Service that is responsible for listening to changes of the temperature and humidity
|
||||||
|
/// sensors, then publishing back the calculated dew-point temperature and enthalpy for
|
||||||
|
/// the sensor location.
|
||||||
|
///
|
||||||
|
///
|
||||||
|
public actor SensorsService: Service {
|
||||||
|
|
||||||
|
@Dependency(\.mqtt) var mqtt
|
||||||
|
|
||||||
|
/// The logger to use for the service.
|
||||||
|
private let logger: Logger?
|
||||||
|
|
||||||
|
/// The sensors that we are listening for updates to, so
|
||||||
|
/// that we can calculate the dew-point temperature and enthalpy
|
||||||
|
/// values to publish back to the MQTT broker.
|
||||||
|
private var sensors: [TemperatureAndHumiditySensor]
|
||||||
|
|
||||||
|
private var topics: [String] {
|
||||||
|
sensors.reduce(into: [String]()) { array, sensor in
|
||||||
|
array.append(sensor.topics.temperature)
|
||||||
|
array.append(sensor.topics.humidity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new sensors service that listens to the passed in
|
||||||
|
/// sensors.
|
||||||
|
///
|
||||||
|
/// - Note: The service will fail to start if the array of sensors is not greater than 0.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - sensors: The sensors to listen for changes to.
|
||||||
|
/// - logger: An optional logger to use.
|
||||||
|
public init(
|
||||||
|
sensors: [TemperatureAndHumiditySensor],
|
||||||
|
logger: Logger? = nil
|
||||||
|
) {
|
||||||
|
self.sensors = sensors
|
||||||
|
self.logger = logger
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the service with graceful shutdown, which will attempt to publish
|
||||||
|
/// any pending changes to the MQTT broker, upon a shutdown signal.
|
||||||
|
public func run() async throws {
|
||||||
|
precondition(sensors.count > 0, "Sensors should not be empty.")
|
||||||
|
|
||||||
|
let stream = try await makeStream()
|
||||||
|
|
||||||
|
await withGracefulShutdownHandler {
|
||||||
|
for await result in stream.cancelOnGracefulShutdown() {
|
||||||
|
logger?.debug("Received result for topic: \(result.topic)")
|
||||||
|
await handleResult(result)
|
||||||
|
}
|
||||||
|
} onGracefulShutdown: {
|
||||||
|
self.logger?.debug("Received graceful shutdown.")
|
||||||
|
Task {
|
||||||
|
try await self.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@_spi(Internal)
|
||||||
|
public func shutdown() async throws {
|
||||||
|
try await publishUpdates()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeStream() async throws -> AsyncStream<(buffer: ByteBuffer, topic: String)> {
|
||||||
|
// ignore duplicate values, to prevent publishing dew-point and enthalpy
|
||||||
|
// changes to frequently.
|
||||||
|
try await mqtt.listen(to: topics)
|
||||||
|
.map { ($0.payload, $0.topicName) }
|
||||||
|
.removeDuplicates { lhs, rhs in
|
||||||
|
lhs.buffer == rhs.buffer
|
||||||
|
&& lhs.topic == rhs.topic
|
||||||
|
}
|
||||||
|
.eraseToStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleResult(_ result: (buffer: ByteBuffer, topic: String)) async {
|
||||||
|
do {
|
||||||
|
let topic = result.topic
|
||||||
|
assert(topics.contains(topic))
|
||||||
|
logger?.debug("Begin handling result for topic: \(topic)")
|
||||||
|
|
||||||
|
func decode<V: BufferInitalizable>(_: V.Type) -> V? {
|
||||||
|
return V(buffer: result.buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if topic.contains("temperature") {
|
||||||
|
logger?.debug("Begin handling temperature result.")
|
||||||
|
guard let temperature = decode(DryBulb.self) else {
|
||||||
|
logger?.debug("Failed to decode temperature: \(result.buffer)")
|
||||||
|
throw DecodingError()
|
||||||
|
}
|
||||||
|
logger?.debug("Decoded temperature: \(temperature)")
|
||||||
|
try sensors.update(topic: topic, keyPath: \.temperature, with: temperature)
|
||||||
|
|
||||||
|
} else if topic.contains("humidity") {
|
||||||
|
logger?.debug("Begin handling humidity result.")
|
||||||
|
guard let humidity = decode(RelativeHumidity.self) else {
|
||||||
|
logger?.debug("Failed to decode humidity: \(result.buffer)")
|
||||||
|
throw DecodingError()
|
||||||
|
}
|
||||||
|
logger?.debug("Decoded humidity: \(humidity)")
|
||||||
|
try sensors.update(topic: topic, keyPath: \.humidity, with: humidity)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await publishUpdates()
|
||||||
|
logger?.debug("Done handling result for topic: \(topic)")
|
||||||
|
} catch {
|
||||||
|
logger?.error("Received error while handling result: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func publish(_ double: Double?, to topic: String) async throws {
|
||||||
|
guard let double else { return }
|
||||||
|
try await mqtt.publish(
|
||||||
|
ByteBufferAllocator().buffer(string: "\(double)"),
|
||||||
|
to: topic,
|
||||||
|
qos: .exactlyOnce,
|
||||||
|
retain: true
|
||||||
|
)
|
||||||
|
logger?.debug("Published update to topic: \(topic)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func publishUpdates() async throws {
|
||||||
|
for sensor in sensors.filter(\.needsProcessed) {
|
||||||
|
try await publish(sensor.dewPoint?.value, to: sensor.topics.dewPoint)
|
||||||
|
try await publish(sensor.enthalpy?.value, to: sensor.topics.enthalpy)
|
||||||
|
try sensors.hasProcessed(sensor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
struct DecodingError: Error {}
|
||||||
|
struct SensorNotFoundError: Error {}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private extension TemperatureAndHumiditySensor.Topics {
|
||||||
|
func contains(_ topic: String) -> Bool {
|
||||||
|
temperature == topic || humidity == topic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Array where Element == TemperatureAndHumiditySensor {
|
||||||
|
|
||||||
|
mutating func update<V>(
|
||||||
|
topic: String,
|
||||||
|
keyPath: WritableKeyPath<TemperatureAndHumiditySensor, V>,
|
||||||
|
with value: V
|
||||||
|
) throws {
|
||||||
|
guard let index = firstIndex(where: { $0.topics.contains(topic) }) else {
|
||||||
|
throw SensorNotFoundError()
|
||||||
|
}
|
||||||
|
self[index][keyPath: keyPath] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func hasProcessed(_ sensor: TemperatureAndHumiditySensor) throws {
|
||||||
|
guard let index = firstIndex(where: { $0.id == sensor.id }) else {
|
||||||
|
throw SensorNotFoundError()
|
||||||
|
}
|
||||||
|
self[index].needsProcessed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a type that can be initialized by a ``ByteBuffer``.
|
||||||
|
protocol BufferInitalizable {
|
||||||
|
init?(buffer: ByteBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Double: BufferInitalizable {
|
||||||
|
|
||||||
|
/// Attempt to create / parse a double from a byte buffer.
|
||||||
|
init?(buffer: ByteBuffer) {
|
||||||
|
let string = String(buffer: buffer)
|
||||||
|
self.init(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Tagged: BufferInitalizable where RawValue: BufferInitalizable {
|
||||||
|
init?(buffer: ByteBuffer) {
|
||||||
|
guard let value = RawValue(buffer: buffer) else { return nil }
|
||||||
|
self.init(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Humidity<Relative>: BufferInitalizable {
|
||||||
|
init?(buffer: ByteBuffer) {
|
||||||
|
guard let value = Double(buffer: buffer) else { return nil }
|
||||||
|
self.init(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Temperature<DryAir>: BufferInitalizable {
|
||||||
|
init?(buffer: ByteBuffer) {
|
||||||
|
guard let value = Double(buffer: buffer) else { return nil }
|
||||||
|
self.init(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import Models
|
|
||||||
|
|
||||||
// TODO: Fix other live topics
|
|
||||||
extension Topics {
|
|
||||||
|
|
||||||
public static let live = Self.init(
|
|
||||||
commands: .init(),
|
|
||||||
sensors: .init(
|
|
||||||
mixedAirSensor: .live(location: .mixedAir),
|
|
||||||
postCoilSensor: .live(location: .postCoil),
|
|
||||||
returnAirSensor: .live(location: .return),
|
|
||||||
supplyAirSensor: .live(location: .supply)),
|
|
||||||
setPoints: .init(),
|
|
||||||
states: .init()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Topics.Sensors {
|
|
||||||
fileprivate enum Location: CustomStringConvertible {
|
|
||||||
case mixedAir
|
|
||||||
case postCoil
|
|
||||||
case `return`
|
|
||||||
case supply
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
switch self {
|
|
||||||
case .mixedAir:
|
|
||||||
return "mixed_air"
|
|
||||||
case .postCoil:
|
|
||||||
return "post_coil"
|
|
||||||
case .return:
|
|
||||||
return "return"
|
|
||||||
case .supply:
|
|
||||||
return "supply"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Topics.Sensors.TemperatureAndHumiditySensor {
|
|
||||||
fileprivate static func live(
|
|
||||||
prefix: String = "frankensystem",
|
|
||||||
location: Topics.Sensors.Location
|
|
||||||
) -> Self {
|
|
||||||
.init(
|
|
||||||
temperature: "\(prefix)/sensor/\(location.description)_temperature/state",
|
|
||||||
humidity: "\(prefix)/sensor/\(location.description)_humidity/state",
|
|
||||||
dewPoint: "\(prefix)/sensor/\(location.description)_dew_point/state",
|
|
||||||
enthalpy: "\(prefix)/sensor/\(location.description)_enthalpy/state"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import Bootstrap
|
|
||||||
import ClientLive
|
|
||||||
import CoreUnitTypes
|
|
||||||
import Logging
|
|
||||||
import Models
|
|
||||||
import MQTTNIO
|
|
||||||
import NIO
|
|
||||||
import TopicsLive
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
var logger: Logger = {
|
|
||||||
var logger = Logger(label: "dewPoint-logger")
|
|
||||||
logger.logLevel = .debug
|
|
||||||
return logger
|
|
||||||
}()
|
|
||||||
|
|
||||||
logger.info("Starting Swift Dew Point Controller!")
|
|
||||||
|
|
||||||
let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
|
||||||
var environment = try bootstrap(eventLoopGroup: eventLoopGroup, logger: logger, autoConnect: false).wait()
|
|
||||||
|
|
||||||
// Set the log level to info only in production mode.
|
|
||||||
if environment.envVars.appEnv == .production {
|
|
||||||
logger.logLevel = .info
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up the client, topics and state.
|
|
||||||
environment.topics = .live
|
|
||||||
let state = State()
|
|
||||||
let client = Client.live(client: environment.mqttClient, state: state, topics: environment.topics)
|
|
||||||
|
|
||||||
defer {
|
|
||||||
logger.debug("Disconnecting")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add topic listeners.
|
|
||||||
client.addListeners()
|
|
||||||
|
|
||||||
while true {
|
|
||||||
if !environment.mqttClient.isActive() {
|
|
||||||
logger.trace("Connecting to MQTT broker...")
|
|
||||||
try client.connect().wait()
|
|
||||||
try client.subscribe().wait()
|
|
||||||
Thread.sleep(forTimeInterval: 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if sensors need processed.
|
|
||||||
if state.sensors.needsProcessed {
|
|
||||||
logger.debug("Sensor state has changed...")
|
|
||||||
if state.sensors.mixedAirSensor.needsProcessed {
|
|
||||||
logger.trace("Publishing mixed air sensor.")
|
|
||||||
try client.publishSensor(.mixed(state.sensors.mixedAirSensor)).wait()
|
|
||||||
}
|
|
||||||
if state.sensors.postCoilSensor.needsProcessed {
|
|
||||||
logger.trace("Publishing post coil sensor.")
|
|
||||||
try client.publishSensor(.postCoil(state.sensors.postCoilSensor)).wait()
|
|
||||||
}
|
|
||||||
if state.sensors.returnAirSensor.needsProcessed {
|
|
||||||
logger.trace("Publishing return air sensor.")
|
|
||||||
try client.publishSensor(.return(state.sensors.returnAirSensor)).wait()
|
|
||||||
}
|
|
||||||
if state.sensors.supplyAirSensor.needsProcessed {
|
|
||||||
logger.trace("Publishing supply air sensor.")
|
|
||||||
try client.publishSensor(.supply(state.sensors.supplyAirSensor)).wait()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// logger.debug("Fetching dew point...")
|
|
||||||
//
|
|
||||||
// logger.debug("Published dew point...")
|
|
||||||
|
|
||||||
Thread.sleep(forTimeInterval: 5)
|
|
||||||
}
|
|
||||||
212
Tests/CliClientTests/CliClientTests.swift
Normal file
212
Tests/CliClientTests/CliClientTests.swift
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
@_spi(Internal) import CliClient
|
||||||
|
import Dependencies
|
||||||
|
import Foundation
|
||||||
|
import Logging
|
||||||
|
import Models
|
||||||
|
import MQTTNIO
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class CliClientTests: XCTestCase {
|
||||||
|
|
||||||
|
override func invokeTest() {
|
||||||
|
withDependencies {
|
||||||
|
$0.cliClient = .liveValue
|
||||||
|
$0.environment = .liveValue
|
||||||
|
$0.environment.processInfo = { [:] }
|
||||||
|
} operation: {
|
||||||
|
super.invokeTest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testParsingMQTTVersion() {
|
||||||
|
@Dependency(\.cliClient) var cliClient
|
||||||
|
|
||||||
|
let arguments = [
|
||||||
|
(MQTTClient.Version.v3_1_1, ["3", "3.1", "3.1.1", "00367894"]),
|
||||||
|
(MQTTClient.Version.v5_0, ["5", "5.1", "5.1.1", "00000500012"]),
|
||||||
|
(nil, ["0", "0.1", "0.1.1", "0000000001267894", "blob"])
|
||||||
|
]
|
||||||
|
|
||||||
|
for (version, strings) in arguments {
|
||||||
|
for string in strings {
|
||||||
|
XCTAssertEqual(cliClient.parseMqttClientVersion(string), version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(MQTTClient.Version.parseOrDefault(string: nil), .v3_1_1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLogLevelFromEnvironment() {
|
||||||
|
@Dependency(\.cliClient) var cliClient
|
||||||
|
|
||||||
|
let arguments = [
|
||||||
|
(Logger.Level.debug, EnvVars(appEnv: .staging, logLevel: nil)),
|
||||||
|
(Logger.Level.debug, EnvVars(appEnv: .development, logLevel: nil)),
|
||||||
|
(Logger.Level.info, EnvVars(appEnv: .production, logLevel: nil)),
|
||||||
|
(Logger.Level.trace, EnvVars(appEnv: .testing, logLevel: nil)),
|
||||||
|
(Logger.Level.info, EnvVars(appEnv: .staging, logLevel: .info)),
|
||||||
|
(Logger.Level.trace, EnvVars(appEnv: .development, logLevel: .trace)),
|
||||||
|
(Logger.Level.warning, EnvVars(appEnv: .production, logLevel: .warning)),
|
||||||
|
(Logger.Level.debug, EnvVars(appEnv: .testing, logLevel: .debug))
|
||||||
|
]
|
||||||
|
|
||||||
|
for (expected, envVars) in arguments {
|
||||||
|
XCTAssertEqual(expected, cliClient.logLevel(envVars))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMakeEnvVars() async throws {
|
||||||
|
@Dependency(\.cliClient) var cliClient
|
||||||
|
@Dependency(\.environment) var environment
|
||||||
|
|
||||||
|
let arguments = [
|
||||||
|
(
|
||||||
|
CliClient.EnvVarsRequest(envFilePath: nil, logger: nil, version: nil),
|
||||||
|
EnvVars()
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CliClient.EnvVarsRequest(envFilePath: nil, logger: nil, version: "3"),
|
||||||
|
EnvVars(version: "3")
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CliClient.EnvVarsRequest(
|
||||||
|
envFilePath: "test.env", // Needs to be a bundled resource.
|
||||||
|
logger: nil,
|
||||||
|
version: nil
|
||||||
|
),
|
||||||
|
EnvVars.test
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CliClient.EnvVarsRequest(
|
||||||
|
envFilePath: "test-env.json", // Needs to be a bundled resource.
|
||||||
|
logger: nil,
|
||||||
|
version: nil
|
||||||
|
),
|
||||||
|
EnvVars.test
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
for (request, expectedEnvVars) in arguments {
|
||||||
|
var request = request
|
||||||
|
if let file = request.envFilePath {
|
||||||
|
request = .init(
|
||||||
|
envFilePath: cleanFilePath(file),
|
||||||
|
logger: request.logger,
|
||||||
|
version: request.mqttClientVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let result = try await cliClient.makeEnvVars(request)
|
||||||
|
XCTAssertEqual(result, expectedEnvVars)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMakeEnvVarsWithFailingDecoder() async throws {
|
||||||
|
try await withDependencies {
|
||||||
|
$0.environment.coders = { ThrowingDecoder() }
|
||||||
|
} operation: {
|
||||||
|
@Dependency(\.cliClient) var cliClient
|
||||||
|
let envVars = try await cliClient.makeEnvVars(.init())
|
||||||
|
XCTAssertEqual(envVars, EnvVars())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMakeEnvVarsWithFailingEncoder() async throws {
|
||||||
|
try await withDependencies {
|
||||||
|
$0.environment.coders = { ThrowingEncoder() }
|
||||||
|
} operation: {
|
||||||
|
@Dependency(\.cliClient) var cliClient
|
||||||
|
let envVars = try await cliClient.makeEnvVars(.init())
|
||||||
|
XCTAssertEqual(envVars, EnvVars())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFileType() {
|
||||||
|
let arguments = [
|
||||||
|
(EnvironmentDependency.FileType.dotEnv(path: "test.env"), "test.env"),
|
||||||
|
(EnvironmentDependency.FileType.json(path: "test.json"), "test.json"),
|
||||||
|
(nil, "test"),
|
||||||
|
(nil, "")
|
||||||
|
]
|
||||||
|
|
||||||
|
for (expected, file) in arguments {
|
||||||
|
XCTAssertEqual(EnvironmentDependency.FileType(path: file), expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEnvironmentLiveValueProcessInfo() {
|
||||||
|
let environment = EnvironmentDependency.liveValue
|
||||||
|
XCTAssertEqual(environment.processInfo(), ProcessInfo.processInfo.environment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMakeClient() throws {
|
||||||
|
@Dependency(\.cliClient) var cliClient
|
||||||
|
let envVars = EnvVars.test
|
||||||
|
let client = try cliClient.makeClient(.init(
|
||||||
|
environment: envVars,
|
||||||
|
eventLoopGroup: .init(numberOfThreads: 1),
|
||||||
|
logger: nil
|
||||||
|
))
|
||||||
|
|
||||||
|
XCTAssertEqual(client.host, envVars.host)
|
||||||
|
XCTAssertEqual(client.port, Int(envVars.port!))
|
||||||
|
XCTAssertEqual(client.identifier, envVars.identifier)
|
||||||
|
XCTAssertEqual(client.configuration.version, .v5_0)
|
||||||
|
XCTAssertEqual(client.configuration.userName, envVars.userName)
|
||||||
|
XCTAssertEqual(client.configuration.password, envVars.password)
|
||||||
|
|
||||||
|
try client.syncShutdownGracefully()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// - MARK: Helper
|
||||||
|
|
||||||
|
private func cleanFilePath(_ path: String) -> String {
|
||||||
|
#if os(Linux)
|
||||||
|
return "Tests/CliClientTests/\(path)"
|
||||||
|
#else
|
||||||
|
let split = path.split(separator: ".")
|
||||||
|
let fileName = split.first!
|
||||||
|
let ext = split.last!
|
||||||
|
let url = Bundle.module.url(forResource: String(fileName), withExtension: String(ext))!.absoluteString
|
||||||
|
let cleaned = url.split(separator: "file://").last!
|
||||||
|
return String(cleaned)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvVars {
|
||||||
|
static let test = EnvVars(
|
||||||
|
appEnv: .testing,
|
||||||
|
host: "test.mqtt",
|
||||||
|
port: "1234",
|
||||||
|
identifier: "testing-mqtt",
|
||||||
|
userName: "test-user",
|
||||||
|
password: "super-secret",
|
||||||
|
logLevel: .debug,
|
||||||
|
version: "5.0"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ThrowingDecoder: Coderable {
|
||||||
|
|
||||||
|
func encode<T>(_ instance: T) throws -> Data where T: Encodable {
|
||||||
|
try JSONEncoder().encode(instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode<T>(_ type: T.Type, from data: Data) throws -> T where T: Decodable {
|
||||||
|
throw DecodeError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ThrowingEncoder: Coderable {
|
||||||
|
|
||||||
|
func encode<T>(_ instance: T) throws -> Data where T: Encodable {
|
||||||
|
throw EncodeError()
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode<T>(_ type: T.Type, from data: Data) throws -> T where T: Decodable {
|
||||||
|
try JSONDecoder().decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DecodeError: Error {}
|
||||||
|
struct EncodeError: Error {}
|
||||||
10
Tests/CliClientTests/test-env.json
Executable file
10
Tests/CliClientTests/test-env.json
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"APP_ENV": "testing",
|
||||||
|
"MQTT_HOST": "test.mqtt",
|
||||||
|
"MQTT_PORT": "1234",
|
||||||
|
"MQTT_IDENTIFIER": "testing-mqtt",
|
||||||
|
"MQTT_USERNAME": "test-user",
|
||||||
|
"MQTT_PASSWORD": "super-secret",
|
||||||
|
"LOG_LEVEL": "debug",
|
||||||
|
"MQTT_VERSION": "5.0"
|
||||||
|
}
|
||||||
8
Tests/CliClientTests/test.env
Normal file
8
Tests/CliClientTests/test.env
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
APP_ENV="testing"
|
||||||
|
MQTT_HOST="test.mqtt"
|
||||||
|
MQTT_PORT="1234"
|
||||||
|
MQTT_IDENTIFIER="testing-mqtt"
|
||||||
|
MQTT_USERNAME="test-user"
|
||||||
|
MQTT_PASSWORD="super-secret"
|
||||||
|
LOG_LEVEL="debug"
|
||||||
|
MQTT_VERSION="5.0"
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
import EnvVars
|
|
||||||
import Logging
|
|
||||||
import Models
|
|
||||||
@testable import ClientLive
|
|
||||||
import Psychrometrics
|
|
||||||
|
|
||||||
final class AsyncClientTests: XCTestCase {
|
|
||||||
|
|
||||||
static let hostname = ProcessInfo.processInfo.environment["MOSQUITTO_SERVER"] ?? "localhost"
|
|
||||||
|
|
||||||
static let logger: Logger = {
|
|
||||||
var logger = Logger(label: "AsyncClientTests")
|
|
||||||
logger.logLevel = .trace
|
|
||||||
return logger
|
|
||||||
}()
|
|
||||||
|
|
||||||
func createClient(identifier: String) -> AsyncClient {
|
|
||||||
let envVars = EnvVars.init(
|
|
||||||
appEnv: .testing,
|
|
||||||
host: Self.hostname,
|
|
||||||
port: "1883",
|
|
||||||
identifier: identifier,
|
|
||||||
userName: nil,
|
|
||||||
password: nil
|
|
||||||
)
|
|
||||||
return .init(envVars: envVars, logger: Self.logger)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testConnectAndShutdown() async throws {
|
|
||||||
let client = createClient(identifier: "testConnectAndShutdown")
|
|
||||||
await client.connect()
|
|
||||||
await client.shutdown()
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPublishingSensor() async throws {
|
|
||||||
let client = createClient(identifier: "testPublishingSensor")
|
|
||||||
await client.connect()
|
|
||||||
let topic = Topics().sensors.mixedAirSensor.dewPoint
|
|
||||||
try await client.addPublishListener(topic: topic, decoding: Temperature.self)
|
|
||||||
try await client.publishSensor(.mixed(.init(temperature: 71.123, humidity: 50.5, needsProcessed: true)))
|
|
||||||
try await client.publishSensor(.mixed(.init(temperature: 72.123, humidity: 50.5, needsProcessed: true)))
|
|
||||||
await client.shutdown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import Client
|
|
||||||
@testable import ClientLive
|
|
||||||
import CoreUnitTypes
|
|
||||||
import Foundation
|
|
||||||
import Logging
|
|
||||||
import Models
|
|
||||||
import MQTTNIO
|
|
||||||
import NIO
|
|
||||||
import NIOConcurrencyHelpers
|
|
||||||
import XCTest
|
|
||||||
|
|
||||||
final class ClientLiveTests: XCTestCase {
|
|
||||||
static let hostname = ProcessInfo.processInfo.environment["MOSQUITTO_SERVER"] ?? "localhost"
|
|
||||||
let topics = Topics()
|
|
||||||
|
|
||||||
// func test_mqtt_subscription() throws {
|
|
||||||
// let mqttClient = createMQTTClient(identifier: "test_subscription")
|
|
||||||
// _ = try mqttClient.connect().wait()
|
|
||||||
// let sub = try mqttClient.v5.subscribe(
|
|
||||||
// to: [mqttClient.mqttSubscription(topic: "test/subscription")]
|
|
||||||
// ).wait()
|
|
||||||
// XCTAssertEqual(sub.reasons[0], .grantedQoS1)
|
|
||||||
// try mqttClient.disconnect().wait()
|
|
||||||
// try mqttClient.syncShutdownGracefully()
|
|
||||||
// }
|
|
||||||
|
|
||||||
func test_mqtt_listener() throws {
|
|
||||||
let lock = Lock()
|
|
||||||
var publishRecieved: [MQTTPublishInfo] = []
|
|
||||||
let payloadString = "test"
|
|
||||||
let payload = ByteBufferAllocator().buffer(string: payloadString)
|
|
||||||
|
|
||||||
let client = self.createMQTTClient(identifier: "testMQTTListener_publisher")
|
|
||||||
_ = try client.connect().wait()
|
|
||||||
client.addPublishListener(named: "test") { result in
|
|
||||||
switch result {
|
|
||||||
case .success(let publish):
|
|
||||||
var buffer = publish.payload
|
|
||||||
let string = buffer.readString(length: buffer.readableBytes)
|
|
||||||
XCTAssertEqual(string, payloadString)
|
|
||||||
lock.withLock {
|
|
||||||
publishRecieved.append(publish)
|
|
||||||
}
|
|
||||||
case .failure(let error):
|
|
||||||
XCTFail("\(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
XCTAssertEqual(sub.reasons[0], .grantedQoS1)
|
|
||||||
|
|
||||||
Thread.sleep(forTimeInterval: 2)
|
|
||||||
lock.withLock {
|
|
||||||
XCTAssertEqual(publishRecieved.count, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
try client.disconnect().wait()
|
|
||||||
try client.syncShutdownGracefully()
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_client2_returnTemperature_listener() throws {
|
|
||||||
let mqttClient = createMQTTClient(identifier: "return-temperature-tests")
|
|
||||||
let state = State()
|
|
||||||
let topics = Topics()
|
|
||||||
let client = Client.live(client: mqttClient, state: state, topics: topics)
|
|
||||||
|
|
||||||
client.addListeners()
|
|
||||||
try client.connect().wait()
|
|
||||||
try client.subscribe().wait()
|
|
||||||
|
|
||||||
_ = try mqttClient.publish(
|
|
||||||
to: topics.sensors.returnAirSensor.temperature,
|
|
||||||
payload: ByteBufferAllocator().buffer(string: "75.1234"),
|
|
||||||
qos: .atLeastOnce
|
|
||||||
).wait()
|
|
||||||
|
|
||||||
Thread.sleep(forTimeInterval: 2)
|
|
||||||
|
|
||||||
XCTAssertEqual(state.sensors.returnAirSensor.temperature, .celsius(75.1234))
|
|
||||||
try mqttClient.disconnect().wait()
|
|
||||||
try mqttClient.syncShutdownGracefully()
|
|
||||||
|
|
||||||
// try client.shutdown().wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_client2_returnSensor_publish() throws {
|
|
||||||
let mqttClient = createMQTTClient(identifier: "return-temperature-tests")
|
|
||||||
let state = State()
|
|
||||||
let topics = Topics()
|
|
||||||
let client = Client.live(client: mqttClient, state: state, topics: topics)
|
|
||||||
|
|
||||||
client.addListeners()
|
|
||||||
try client.connect().wait()
|
|
||||||
try client.subscribe().wait()
|
|
||||||
|
|
||||||
_ = try mqttClient.publish(
|
|
||||||
to: topics.sensors.returnAirSensor.temperature,
|
|
||||||
payload: ByteBufferAllocator().buffer(string: "75.1234"),
|
|
||||||
qos: .atLeastOnce
|
|
||||||
).wait()
|
|
||||||
|
|
||||||
_ = try mqttClient.publish(
|
|
||||||
to: topics.sensors.returnAirSensor.humidity,
|
|
||||||
payload: ByteBufferAllocator().buffer(string: "\(50.0)"),
|
|
||||||
qos: .atLeastOnce
|
|
||||||
).wait()
|
|
||||||
|
|
||||||
Thread.sleep(forTimeInterval: 2)
|
|
||||||
XCTAssert(state.sensors.returnAirSensor.needsProcessed)
|
|
||||||
|
|
||||||
try client.publishSensor(.return(state.sensors.returnAirSensor)).wait()
|
|
||||||
XCTAssertFalse(state.sensors.returnAirSensor.needsProcessed)
|
|
||||||
|
|
||||||
try mqttClient.disconnect().wait()
|
|
||||||
try mqttClient.syncShutdownGracefully()
|
|
||||||
|
|
||||||
// try client.shutdown().wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// func test_fetch_humidity() throws {
|
|
||||||
// let lock = Lock()
|
|
||||||
// let publishClient = createMQTTClient(identifier: "publishHumidity")
|
|
||||||
// let mqttClient = createMQTTClient(identifier: "fetchHumidity")
|
|
||||||
// _ = try publishClient.connect().wait()
|
|
||||||
// let client = try createClient(mqttClient: mqttClient)
|
|
||||||
// var humidityRecieved: [RelativeHumidity] = []
|
|
||||||
//
|
|
||||||
// _ = try publishClient.publish(
|
|
||||||
// to: topics.sensors.humidity,
|
|
||||||
// payload: ByteBufferAllocator().buffer(string: "\(50.0)"),
|
|
||||||
// qos: .atLeastOnce
|
|
||||||
// ).wait()
|
|
||||||
//
|
|
||||||
// Thread.sleep(forTimeInterval: 2)
|
|
||||||
// try publishClient.disconnect().wait()
|
|
||||||
// let humidity = try client.fetchHumidity(.init(topic: self.topics.sensors.humidity)).wait()
|
|
||||||
// XCTAssertEqual(humidity, 50)
|
|
||||||
// Thread.sleep(forTimeInterval: 2)
|
|
||||||
// lock.withLock {
|
|
||||||
// humidityRecieved.append(humidity)
|
|
||||||
// }
|
|
||||||
// try mqttClient.disconnect().wait()
|
|
||||||
// try mqttClient.syncShutdownGracefully()
|
|
||||||
// }
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
func createMQTTClient(identifier: String) -> MQTTNIO.MQTTClient {
|
|
||||||
MQTTNIO.MQTTClient(
|
|
||||||
host: Self.hostname,
|
|
||||||
port: 1883,
|
|
||||||
identifier: identifier,
|
|
||||||
eventLoopGroupProvider: .shared(eventLoopGroup),
|
|
||||||
logger: self.logger,
|
|
||||||
configuration: .init(version: .v5_0)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// func createWebSocketClient(identifier: String) -> MQTTNIO.MQTTClient {
|
|
||||||
// MQTTNIO.MQTTClient(
|
|
||||||
// host: Self.hostname,
|
|
||||||
// port: 8080,
|
|
||||||
// identifier: identifier,
|
|
||||||
// eventLoopGroupProvider: .createNew,
|
|
||||||
// logger: self.logger,
|
|
||||||
// configuration: .init(useWebSockets: true, webSocketURLPath: "/mqtt")
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Uses default topic names.
|
|
||||||
// func createClient(mqttClient: MQTTNIO.MQTTClient, autoConnect: Bool = true) throws -> Client.MQTTClient {
|
|
||||||
// if autoConnect {
|
|
||||||
// _ = try mqttClient.connect().wait()
|
|
||||||
// }
|
|
||||||
// return .live(client: mqttClient, topics: .init())
|
|
||||||
// }
|
|
||||||
|
|
||||||
let logger: Logger = {
|
|
||||||
var logger = Logger(label: "MQTTTests")
|
|
||||||
logger.logLevel = .trace
|
|
||||||
return logger
|
|
||||||
}()
|
|
||||||
|
|
||||||
let eventLoopGroup = MultiThreadedEventLoopGroup.init(numberOfThreads: 1)
|
|
||||||
}
|
|
||||||
214
Tests/IntegrationTests/IntegrationTests.swift
Normal file
214
Tests/IntegrationTests/IntegrationTests.swift
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import Dependencies
|
||||||
|
|
||||||
|
// @_spi(Internal) import dewpoint_controller
|
||||||
|
import Logging
|
||||||
|
import Models
|
||||||
|
import MQTTConnectionService
|
||||||
|
@_spi(Internal) import MQTTManager
|
||||||
|
import MQTTNIO
|
||||||
|
import NIO
|
||||||
|
import PsychrometricClientLive
|
||||||
|
@_spi(Internal) import SensorsService
|
||||||
|
import ServiceLifecycle
|
||||||
|
import ServiceLifecycleTestKit
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class IntegrationTests: XCTestCase {
|
||||||
|
static let hostname = ProcessInfo.processInfo.environment["MOSQUITTO_SERVER"] ?? "localhost"
|
||||||
|
|
||||||
|
static let logger: Logger = {
|
||||||
|
var logger = Logger(label: "IntegrationTests")
|
||||||
|
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
|
||||||
|
do {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
mqtt.shutdown()
|
||||||
|
try await Task.sleep(for: .milliseconds(500))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
struct TimeoutError: Error {}
|
||||||
|
|
||||||
|
let sensor = TemperatureAndHumiditySensor(location: .return)
|
||||||
|
let results = ResultContainer()
|
||||||
|
|
||||||
|
try await withDependencies {
|
||||||
|
$0.mqtt.publish = results.append
|
||||||
|
} operation: {
|
||||||
|
@Dependency(\.mqtt) var manager
|
||||||
|
|
||||||
|
let sensorsService = SensorsService(sensors: [sensor], logger: Self.logger)
|
||||||
|
let task = Task { try await sensorsService.run() }
|
||||||
|
defer { task.cancel() }
|
||||||
|
|
||||||
|
try await manager.connect()
|
||||||
|
defer { manager.shutdown() }
|
||||||
|
|
||||||
|
try await manager.withClient { client in
|
||||||
|
try await client.disconnect()
|
||||||
|
try await client.connect()
|
||||||
|
|
||||||
|
while !client.isActive() {
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give time to re-subscribe.
|
||||||
|
try await Task.sleep(for: .milliseconds(200))
|
||||||
|
|
||||||
|
try await client.publish(
|
||||||
|
to: sensor.topics.temperature,
|
||||||
|
payload: ByteBufferAllocator().buffer(string: "25"),
|
||||||
|
qos: .atLeastOnce,
|
||||||
|
retain: false
|
||||||
|
)
|
||||||
|
try await client.publish(
|
||||||
|
to: sensor.topics.humidity,
|
||||||
|
payload: ByteBufferAllocator().buffer(string: "50"),
|
||||||
|
qos: .atLeastOnce,
|
||||||
|
retain: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeoutCount = 0
|
||||||
|
while !(await results.count == 2) {
|
||||||
|
guard timeoutCount < 20 else {
|
||||||
|
throw TimeoutError()
|
||||||
|
}
|
||||||
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
|
timeoutCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = await results.results()
|
||||||
|
|
||||||
|
XCTAssertEqual(results.count, 2)
|
||||||
|
XCTAssert(results.contains(where: { $0.topicName == sensor.topics.dewPoint }))
|
||||||
|
XCTAssert(results.contains(where: { $0.topicName == sensor.topics.enthalpy }))
|
||||||
|
try await sensorsService.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createClient(identifier: String) -> MQTTClient {
|
||||||
|
let envVars = EnvVars(
|
||||||
|
appEnv: .testing,
|
||||||
|
host: Self.hostname,
|
||||||
|
port: "1883",
|
||||||
|
identifier: identifier,
|
||||||
|
userName: nil,
|
||||||
|
password: nil
|
||||||
|
)
|
||||||
|
let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
||||||
|
// return .init(envVars: envVars, eventLoopGroup: eventLoopGroup, logger: Self.logger)
|
||||||
|
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(eventLoopGroup),
|
||||||
|
logger: Self.logger,
|
||||||
|
configuration: config
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// - MARK: Helpers
|
||||||
|
struct TopicNotFoundError: Error {}
|
||||||
|
|
||||||
|
actor ResultContainer: Sendable {
|
||||||
|
|
||||||
|
private var storage = [MQTTManager.PublishRequest]()
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
@Sendable func append(_ result: MQTTManager.PublishRequest) async {
|
||||||
|
storage.append(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
get async { storage.count }
|
||||||
|
}
|
||||||
|
|
||||||
|
func results() async -> [MQTTManager.PublishRequest] {
|
||||||
|
storage
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# run this with docker-compose -f docker/docker-compose.yml run test
|
|
||||||
services:
|
|
||||||
server:
|
|
||||||
image: swift-mqtt-dewpoint:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file: .env
|
|
||||||
|
|
||||||
test:
|
|
||||||
image: swift:latest
|
|
||||||
#build:
|
|
||||||
#context: ./
|
|
||||||
platform: linux/amd64
|
|
||||||
working_dir: /app
|
|
||||||
networks:
|
|
||||||
- test
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
depends_on:
|
|
||||||
- mosquitto-test
|
|
||||||
environment:
|
|
||||||
- MOSQUITTO_SERVER=mosquitto-test
|
|
||||||
command: /bin/bash -xc "swift package clean && swift test"
|
|
||||||
|
|
||||||
mosquitto-test:
|
|
||||||
image: eclipse-mosquitto
|
|
||||||
networks:
|
|
||||||
- test
|
|
||||||
volumes:
|
|
||||||
- ./mosquitto/config:/mosquitto/config
|
|
||||||
- ./mosquitto/certs:/mosquitto/certs
|
|
||||||
|
|
||||||
mosquitto:
|
|
||||||
image: eclipse-mosquitto
|
|
||||||
volumes:
|
|
||||||
- ./mosquitto/config:/mosquitto/config
|
|
||||||
- ./mosquitto/certs:/mosquitto/certs
|
|
||||||
ports:
|
|
||||||
- "1883:1883"
|
|
||||||
- "8883:8883"
|
|
||||||
- "8080:8080"
|
|
||||||
- "8081:8081"
|
|
||||||
|
|
||||||
networks:
|
|
||||||
test:
|
|
||||||
driver: bridge
|
|
||||||
external: false
|
|
||||||
|
|
||||||
15
docker/Dockerfile
Executable file
15
docker/Dockerfile
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
# Used this to build the release version of the image.
|
||||||
|
# Build the executable
|
||||||
|
ARG SWIFT_IMAGE_VERSION="5.10"
|
||||||
|
|
||||||
|
FROM swift:${SWIFT_IMAGE_VERSION} AS build
|
||||||
|
WORKDIR /build
|
||||||
|
COPY ./Package.* ./
|
||||||
|
RUN swift package resolve
|
||||||
|
COPY . .
|
||||||
|
RUN swift build -c release -Xswiftc -g
|
||||||
|
|
||||||
|
# Run image
|
||||||
|
FROM swift:${SWIFT_IMAGE_VERSION}-slim
|
||||||
|
COPY --from=build /build/.build/release/dewpoint-controller /usr/local/bin
|
||||||
|
CMD ["/bin/bash", "-xc", "/usr/local/bin/dewpoint-controller run"]
|
||||||
5
docker/Dockerfile.mosquitto
Normal file
5
docker/Dockerfile.mosquitto
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Used to build a local MQTT broker for development and
|
||||||
|
# testing.
|
||||||
|
FROM eclipse-mosquitto:latest
|
||||||
|
COPY ./mosquitto/config/mosquitto.conf /mosquitto/config/mosquitto.conf
|
||||||
|
EXPOSE 1883
|
||||||
9
docker/Dockerfile.test
Normal file
9
docker/Dockerfile.test
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Used to build a test image.
|
||||||
|
ARG SWIFT_IMAGE_VERSION="5.10"
|
||||||
|
FROM swift:${SWIFT_IMAGE_VERSION}
|
||||||
|
WORKDIR /app
|
||||||
|
COPY ./Package.* ./
|
||||||
|
RUN swift package resolve
|
||||||
|
COPY . .
|
||||||
|
RUN swift build
|
||||||
|
CMD ["/bin/bash", "-xc", "swift", "test"]
|
||||||
17
docker/docker-compose-test.yaml
Executable file
17
docker/docker-compose-test.yaml
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
# run this with docker-compose run test
|
||||||
|
services:
|
||||||
|
test:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile.test
|
||||||
|
working_dir: /app
|
||||||
|
depends_on:
|
||||||
|
- mosquitto
|
||||||
|
environment:
|
||||||
|
- MOSQUITTO_SERVER=mosquitto
|
||||||
|
command: /bin/bash -xc "swift test"
|
||||||
|
|
||||||
|
mosquitto:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile.mosquitto
|
||||||
18
docker/docker-compose.yaml
Executable file
18
docker/docker-compose.yaml
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
# run this with docker-compose run dewpoint_controller
|
||||||
|
services:
|
||||||
|
dewpoint_controller:
|
||||||
|
container_name: dewpoint-controller
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
depends_on:
|
||||||
|
- mosquitto
|
||||||
|
environment:
|
||||||
|
- MQTT_HOST=mosquitto
|
||||||
|
|
||||||
|
mosquitto:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile.mosquitto
|
||||||
|
ports:
|
||||||
|
- "1883:1883"
|
||||||
0
mosquitto/config/mosquitto.conf
Normal file → Executable file
0
mosquitto/config/mosquitto.conf
Normal file → Executable file
Reference in New Issue
Block a user