From 2c875caa0da89cf2d3894f15fc71671700675fc9 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Thu, 19 Oct 2023 15:56:34 +0300 Subject: [PATCH 01/13] Add initial MQTT publisher Closes #5, only missing thing is Home Assistant integration --- package-lock.json | 437 ++++++++++++++++++++++++++++++++++++++++-- package.json | 1 + src/config.ts | 8 +- src/publisher.ts | 1 + src/publisher/mqtt.ts | 84 ++++++++ 5 files changed, 512 insertions(+), 19 deletions(-) create mode 100644 src/publisher/mqtt.ts diff --git a/package-lock.json b/package-lock.json index 6d12b9a..e512558 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@influxdata/influxdb-client": "^1.33.2", "axios": "^1.5.0", + "mqtt": "^5.1.2", "ws": "^8.14.2", "yaml": "^2.3.2", "yargs": "^17.7.2" @@ -1455,8 +1456,16 @@ "node_modules/@types/node": { "version": "20.5.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.9.tgz", - "integrity": "sha512-PcGNd//40kHAS3sTlzKB9C9XL4K0sTup8nbG5lC14kzEteTNuAFh9u5nA0o5TWnSG2r/JNPRXFVcHJIIeRlmqQ==", - "dev": true + "integrity": "sha512-PcGNd//40kHAS3sTlzKB9C9XL4K0sTup8nbG5lC14kzEteTNuAFh9u5nA0o5TWnSG2r/JNPRXFVcHJIIeRlmqQ==" + }, + "node_modules/@types/readable-stream": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.4.tgz", + "integrity": "sha512-NSAiePj3Iq3kBArfpUWRNX/mRw8DibYD6YhNCIJDfUP/iIOQYsNJgtHyjpbuZlcbL7TxILS8qYjY/nXXvtcFQg==", + "dependencies": { + "@types/node": "*", + "safe-buffer": "~5.1.1" + } }, "node_modules/@types/semver": { "version": "7.5.2", @@ -1474,7 +1483,6 @@ "version": "8.5.5", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.5.tgz", "integrity": "sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==", - "dev": true, "dependencies": { "@types/node": "*" } @@ -1683,6 +1691,17 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.10.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", @@ -1950,8 +1969,49 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/bl": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", + "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==", + "dependencies": { + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } }, "node_modules/brace-expansion": { "version": "1.1.11", @@ -2028,11 +2088,33 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, "node_modules/callsites": { "version": "3.1.0", @@ -2174,12 +2256,44 @@ "node": ">= 0.8" } }, + "node_modules/commist": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz", + "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2233,7 +2347,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -2336,6 +2449,30 @@ "node": ">=6.0.0" } }, + "node_modules/duplexify": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", + "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.0" + } + }, + "node_modules/duplexify/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.557", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.557.tgz", @@ -2359,6 +2496,14 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -2542,6 +2687,22 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -2749,8 +2910,7 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { "version": "2.3.3", @@ -2901,6 +3061,65 @@ "node": ">=8" } }, + "node_modules/help-me": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-4.2.0.tgz", + "integrity": "sha512-TAOnTB8Tz5Dw8penUuzHVrKNKlCIbwwbHnXraNJxPwf8LRtE2HlM84RYuezMFcwOJmoYOCWVDyJ8TQGxn9PgxA==", + "dependencies": { + "glob": "^8.0.0", + "readable-stream": "^3.6.0" + } + }, + "node_modules/help-me/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/help-me/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/help-me/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/help-me/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -2916,6 +3135,25 @@ "node": ">=10.17.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", @@ -2973,7 +3211,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -2982,8 +3219,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/is-arrayish": { "version": "0.2.1", @@ -3698,6 +3934,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/js-sdsl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", + "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3947,11 +4192,67 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mqtt": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.1.2.tgz", + "integrity": "sha512-jEyrJGj3qkyTWx/7t5p+u6BY1rpikcl0ydlaHPGJ6rjeCkHVCFcTK+ZP5hVqAei5rwn7h4qtjTezhXHWkSZOHg==", + "dependencies": { + "@types/readable-stream": "^4.0.1", + "@types/ws": "^8.5.5", + "commist": "^3.2.0", + "concat-stream": "^2.0.0", + "debug": "^4.3.4", + "duplexify": "^4.1.2", + "help-me": "^4.2.0", + "lru-cache": "^7.18.3", + "minimist": "^1.2.8", + "mqtt-packet": "^8.2.1", + "number-allocator": "^1.0.14", + "readable-stream": "^4.4.2", + "reinterval": "^1.1.0", + "rfdc": "^1.3.0", + "split2": "^4.2.0", + "ws": "^8.13.0" + }, + "bin": { + "mqtt": "build/bin/mqtt.js", + "mqtt_pub": "build/bin/pub.js", + "mqtt_sub": "build/bin/sub.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/mqtt-packet": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-8.2.1.tgz", + "integrity": "sha512-vrHHjwhmuxzQIe3fJWoOLQHF4H5FETUrQGYD5g1qGfEmpjkQUkPONfygA0cI8Wtb3IUCfu66WmZiVSCgGm8oUw==", + "dependencies": { + "bl": "^5.0.0", + "debug": "^4.1.1", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/mqtt/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "engines": { + "node": ">=12" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/natural-compare": { "version": "1.4.0", @@ -3992,11 +4293,19 @@ "node": ">=8" } }, + "node_modules/number-allocator": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz", + "integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==", + "dependencies": { + "debug": "^4.3.1", + "js-sdsl": "4.3.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -4285,6 +4594,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -4354,6 +4676,26 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "dev": true }, + "node_modules/readable-stream": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.4.2.tgz", + "integrity": "sha512-Lk/fICSyIhodxy1IDK2HazkeGjSmezAWX2egdtJnYhtzKEsBPJowlI6F6LPb5tqIQILrMbx22S5o3GuJavPusA==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/reinterval": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reinterval/-/reinterval-1.1.0.tgz", + "integrity": "sha512-QIRet3SYrGp0HUHO88jVskiG6seqUGC5iAG7AwI/BV4ypGcuqk9Du6YQBUOUqm9c8pw1eyLoIaONifRua1lsEQ==" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4428,6 +4770,11 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", + "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -4466,6 +4813,11 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -4542,6 +4894,14 @@ "source-map": "^0.6.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -4569,6 +4929,38 @@ "node": ">=8" } }, + "node_modules/stream-shift": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", + "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -4848,6 +5240,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, "node_modules/typescript": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", @@ -4900,6 +5297,11 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -4965,8 +5367,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/write-file-atomic": { "version": "4.0.2", diff --git a/package.json b/package.json index 72bc942..e208750 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "dependencies": { "@influxdata/influxdb-client": "^1.33.2", "axios": "^1.5.0", + "mqtt": "^5.1.2", "ws": "^8.14.2", "yaml": "^2.3.2", "yargs": "^17.7.2" diff --git a/src/config.ts b/src/config.ts index d19091e..2f4d309 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,6 +23,7 @@ import { Publisher, PublisherType } from './publisher' import { InfluxDBPublisher, InfluxDBPublisherImpl } from './publisher/influxdb' import { ConsolePublisher, ConsolePublisherImpl } from './publisher/console' import { Characteristics } from './characteristics' +import { MqttPublisher, MqttPublisherImpl } from './publisher/mqtt' export interface Config { characteristics: Characteristics[] @@ -134,7 +135,7 @@ export const resolveAndValidateConfig = (config: Config): Config => { } // Create publishers. Ignore any manually defined WebSocket publishers, we only support - // one right now and it's added separately during application startup. + // one right now, and it's added separately during application startup. for (const publisher of config.publishers) { switch (publisher.type) { case PublisherType.InfluxDB: { @@ -147,6 +148,11 @@ export const resolveAndValidateConfig = (config: Config): Config => { consolePublisher.publisherImpl = new ConsolePublisherImpl() break } + case PublisherType.MQTT: { + const mqttPublisher = publisher as MqttPublisher + mqttPublisher.publisherImpl = new MqttPublisherImpl(config, mqttPublisher.settings) + break + } } } diff --git a/src/publisher.ts b/src/publisher.ts index 6411816..c44633c 100644 --- a/src/publisher.ts +++ b/src/publisher.ts @@ -4,6 +4,7 @@ export enum PublisherType { InfluxDB = 'influxdb', Console = 'console', WebSocket = 'websocket', + MQTT = 'mqtt', } export interface PublisherImpl { diff --git a/src/publisher/mqtt.ts b/src/publisher/mqtt.ts new file mode 100644 index 0000000..3433b79 --- /dev/null +++ b/src/publisher/mqtt.ts @@ -0,0 +1,84 @@ +import { connectAsync, MqttClient } from 'mqtt' +import { Publisher, PublisherImpl, PublisherType } from '../publisher' +import { CharacteristicsSensorData, PowerSensorData } from '../sensor' +import { Config } from '../config' + +const TOPIC_PREFIX = 'eachwatt' + +export type MqttPublisherSettings = { + host: string + port: number +} + +export interface MqttPublisher extends Publisher { + type: PublisherType.MQTT + settings: MqttPublisherSettings +} + +type TopicValueMap = Map + +export class MqttPublisherImpl implements PublisherImpl { + config: Config + settings: MqttPublisherSettings + client?: MqttClient + + constructor(config: Config, settings: MqttPublisherSettings) { + this.config = config + this.settings = settings + + const brokerUrl = this.getBrokerUrl() + connectAsync(brokerUrl) + .then((client) => { + this.client = client + console.log(`Connected to MQTT broker at ${brokerUrl}`) + }) + .catch((e) => { + throw new Error(`Failed to connect to MQTT broker: ${e}`) + }) + } + + private getBrokerUrl(): string { + const host = this.settings.host + const port = this.settings.port ?? 1883 + + return `mqtt://${host}:${port}` + } + + async publishCharacteristicsSensorData(sensorData: CharacteristicsSensorData[]): Promise { + for (const data of sensorData) { + const topicValueMap: TopicValueMap = new Map( + Object.entries({ + [`${TOPIC_PREFIX}/characteristic/${data.characteristics.name}/voltage`]: data.voltage, + [`${TOPIC_PREFIX}/characteristic/${data.characteristics.name}/frequency`]: data.frequency, + }), + ) + + await this.publishTopicValues(topicValueMap) + } + } + + async publishSensorData(sensorData: PowerSensorData[]): Promise { + for (const data of sensorData) { + const topicValueMap: TopicValueMap = new Map( + Object.entries({ + [`${TOPIC_PREFIX}/circuit/${data.circuit.name}/power`]: data.watts, + }), + ) + + await this.publishTopicValues(topicValueMap) + } + } + + private async publishTopicValues(topicValueMap: TopicValueMap): Promise { + const promises = [] + + for (const [topic, value] of topicValueMap.entries()) { + const message = String(value) + + // noinspection TypeScriptValidateTypes + promises.push(this.client?.publishAsync(topic, message)) + } + + await Promise.all(promises) + } +} From 1f4387813db5aa4700088d2b0bbc093341bf313d Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Thu, 19 Oct 2023 19:30:14 +0300 Subject: [PATCH 02/13] Let the users configure the brokerUrl themselves --- src/publisher/mqtt.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/publisher/mqtt.ts b/src/publisher/mqtt.ts index 3433b79..6d993c8 100644 --- a/src/publisher/mqtt.ts +++ b/src/publisher/mqtt.ts @@ -6,8 +6,7 @@ import { Config } from '../config' const TOPIC_PREFIX = 'eachwatt' export type MqttPublisherSettings = { - host: string - port: number + brokerUrl: string } export interface MqttPublisher extends Publisher { @@ -26,24 +25,16 @@ export class MqttPublisherImpl implements PublisherImpl { this.config = config this.settings = settings - const brokerUrl = this.getBrokerUrl() - connectAsync(brokerUrl) + connectAsync(this.settings.brokerUrl) .then((client) => { this.client = client - console.log(`Connected to MQTT broker at ${brokerUrl}`) + console.log(`Connected to MQTT broker at ${this.settings.brokerUrl}`) }) .catch((e) => { throw new Error(`Failed to connect to MQTT broker: ${e}`) }) } - private getBrokerUrl(): string { - const host = this.settings.host - const port = this.settings.port ?? 1883 - - return `mqtt://${host}:${port}` - } - async publishCharacteristicsSensorData(sensorData: CharacteristicsSensorData[]): Promise { for (const data of sensorData) { const topicValueMap: TopicValueMap = new Map( From 95baa5dd03649df114993ff6b622f424946170ab Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Thu, 19 Oct 2023 20:42:44 +0300 Subject: [PATCH 03/13] Slugify circuit names in MQTT topics --- package-lock.json | 9 +++++++++ package.json | 1 + src/publisher/mqtt.ts | 9 +++++---- src/publisher/mqtt/util.ts | 17 +++++++++++++++++ 4 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 src/publisher/mqtt/util.ts diff --git a/package-lock.json b/package-lock.json index e512558..9ac6da4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@influxdata/influxdb-client": "^1.33.2", "axios": "^1.5.0", "mqtt": "^5.1.2", + "slugify": "^1.6.6", "ws": "^8.14.2", "yaml": "^2.3.2", "yargs": "^17.7.2" @@ -4875,6 +4876,14 @@ "node": ">=8" } }, + "node_modules/slugify": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz", + "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index e208750..b63ea7c 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@influxdata/influxdb-client": "^1.33.2", "axios": "^1.5.0", "mqtt": "^5.1.2", + "slugify": "^1.6.6", "ws": "^8.14.2", "yaml": "^2.3.2", "yargs": "^17.7.2" diff --git a/src/publisher/mqtt.ts b/src/publisher/mqtt.ts index 6d993c8..4454bb5 100644 --- a/src/publisher/mqtt.ts +++ b/src/publisher/mqtt.ts @@ -2,8 +2,9 @@ import { connectAsync, MqttClient } from 'mqtt' import { Publisher, PublisherImpl, PublisherType } from '../publisher' import { CharacteristicsSensorData, PowerSensorData } from '../sensor' import { Config } from '../config' +import { createCharacteristicsSensorTopicName, createPowerSensorTopicName } from './mqtt/util' -const TOPIC_PREFIX = 'eachwatt' +export const TOPIC_PREFIX = 'eachwatt' export type MqttPublisherSettings = { brokerUrl: string @@ -39,8 +40,8 @@ export class MqttPublisherImpl implements PublisherImpl { for (const data of sensorData) { const topicValueMap: TopicValueMap = new Map( Object.entries({ - [`${TOPIC_PREFIX}/characteristic/${data.characteristics.name}/voltage`]: data.voltage, - [`${TOPIC_PREFIX}/characteristic/${data.characteristics.name}/frequency`]: data.frequency, + [createCharacteristicsSensorTopicName(data.characteristics, 'voltage')]: data.voltage, + [createCharacteristicsSensorTopicName(data.characteristics, 'frequency')]: data.frequency, }), ) @@ -52,7 +53,7 @@ export class MqttPublisherImpl implements PublisherImpl { for (const data of sensorData) { const topicValueMap: TopicValueMap = new Map( Object.entries({ - [`${TOPIC_PREFIX}/circuit/${data.circuit.name}/power`]: data.watts, + [createPowerSensorTopicName(data.circuit, 'power')]: data.watts, }), ) diff --git a/src/publisher/mqtt/util.ts b/src/publisher/mqtt/util.ts new file mode 100644 index 0000000..64fe8a3 --- /dev/null +++ b/src/publisher/mqtt/util.ts @@ -0,0 +1,17 @@ +import { Characteristics } from '../../characteristics' +import { Circuit } from '../../circuit' +import { TOPIC_PREFIX } from '../mqtt' +import slugify from 'slugify' + +const slugifyName = (name: string): string => { + // We can't have "+" in MQTT topic names + return slugify(name, { remove: /[+]/ }) +} + +export const createCharacteristicsSensorTopicName = (characteristics: Characteristics, value: string): string => { + return `${TOPIC_PREFIX}/characteristic/${slugifyName(characteristics.name)}/${value}` +} + +export const createPowerSensorTopicName = (circuit: Circuit, value: string): string => { + return `${TOPIC_PREFIX}/circuit/${slugifyName(circuit.name)}/${value}` +} From 455e006f3549663d37c65024fe5423d9465b3056 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Thu, 19 Oct 2023 20:43:27 +0300 Subject: [PATCH 04/13] Add initial Home Assistant MQTT discovery support Only power sensors for now --- src/publisher/mqtt.ts | 16 +++++++++++++ src/publisher/mqtt/homeassistant.ts | 35 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/publisher/mqtt/homeassistant.ts diff --git a/src/publisher/mqtt.ts b/src/publisher/mqtt.ts index 4454bb5..cbed80b 100644 --- a/src/publisher/mqtt.ts +++ b/src/publisher/mqtt.ts @@ -3,11 +3,15 @@ import { Publisher, PublisherImpl, PublisherType } from '../publisher' import { CharacteristicsSensorData, PowerSensorData } from '../sensor' import { Config } from '../config' import { createCharacteristicsSensorTopicName, createPowerSensorTopicName } from './mqtt/util' +import { configureMqttDiscovery } from './mqtt/homeassistant' export const TOPIC_PREFIX = 'eachwatt' export type MqttPublisherSettings = { brokerUrl: string + homeAssistant?: { + autoDiscovery: boolean + } } export interface MqttPublisher extends Publisher { @@ -26,10 +30,22 @@ export class MqttPublisherImpl implements PublisherImpl { this.config = config this.settings = settings + // Connect to the broker connectAsync(this.settings.brokerUrl) .then((client) => { this.client = client console.log(`Connected to MQTT broker at ${this.settings.brokerUrl}`) + + // Publish Home Assistant MQTT discovery messages + if (this.settings.homeAssistant?.autoDiscovery) { + configureMqttDiscovery(this.config, this.client) + .then(() => { + console.log(`Configured Home Assistant MQTT discovery`) + }) + .catch((e) => { + throw new Error(`Failed to configure Home Assistant MQTT discovery: ${e}`) + }) + } }) .catch((e) => { throw new Error(`Failed to connect to MQTT broker: ${e}`) diff --git a/src/publisher/mqtt/homeassistant.ts b/src/publisher/mqtt/homeassistant.ts new file mode 100644 index 0000000..ad30135 --- /dev/null +++ b/src/publisher/mqtt/homeassistant.ts @@ -0,0 +1,35 @@ +import { MqttClient } from 'mqtt' +import slugify from 'slugify' +import { Config } from '../../config' +import { TOPIC_NAME_STATUS } from '../mqtt' +import { createPowerSensorTopicName } from './util' + +export const configureMqttDiscovery = async (config: Config, mqttClient: MqttClient): Promise => { + const configurationBase = { + 'platform': 'mqtt', + 'availability_topic': TOPIC_NAME_STATUS, + } + + for (const circuit of config.circuits) { + // Add power sensors + const entityName = slugify(circuit.name) + + const configuration = { + ...configurationBase, + 'state_class': 'measurement', + 'device_class': 'power', + 'unit_of_measurement': 'W', + 'name': `${circuit.name} power`, + 'unique_id': `eachwatt_${entityName}_power`, + 'object_id': `eachwatt_${entityName}_power`, + 'state_topic': createPowerSensorTopicName(circuit, 'power'), + } + + // "retain" is used so that the entities will be available immediately after a Home Assistant restart + console.log(`Publishing Home Assistant auto-discovery configuration for power sensor "${entityName}"...`) + const configurationTopicName = `homeassistant/sensor/eachwatt/${entityName}/config` + await mqttClient.publishAsync(configurationTopicName, JSON.stringify(configuration), { + retain: true, + }) + } +} From 521146b2510d5f74c6cadecfab34958e1e20b163 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Thu, 19 Oct 2023 20:43:58 +0300 Subject: [PATCH 05/13] Add a status topic for indicating that the MQTT publisher is "online" --- src/publisher/mqtt.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/publisher/mqtt.ts b/src/publisher/mqtt.ts index cbed80b..2d54a1d 100644 --- a/src/publisher/mqtt.ts +++ b/src/publisher/mqtt.ts @@ -6,6 +6,7 @@ import { createCharacteristicsSensorTopicName, createPowerSensorTopicName } from import { configureMqttDiscovery } from './mqtt/homeassistant' export const TOPIC_PREFIX = 'eachwatt' +export const TOPIC_NAME_STATUS = `${TOPIC_PREFIX}/status` export type MqttPublisherSettings = { brokerUrl: string @@ -63,6 +64,8 @@ export class MqttPublisherImpl implements PublisherImpl { await this.publishTopicValues(topicValueMap) } + + await this.publishStatus() } async publishSensorData(sensorData: PowerSensorData[]): Promise { @@ -75,6 +78,8 @@ export class MqttPublisherImpl implements PublisherImpl { await this.publishTopicValues(topicValueMap) } + + await this.publishStatus() } private async publishTopicValues(topicValueMap: TopicValueMap): Promise { @@ -89,4 +94,9 @@ export class MqttPublisherImpl implements PublisherImpl { await Promise.all(promises) } + + private async publishStatus(): Promise { + // noinspection TypeScriptValidateTypes + await this.client?.publishAsync(TOPIC_NAME_STATUS, 'online') + } } From 2f72b98c084563d7131e492480214c3b256b9a97 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Thu, 19 Oct 2023 21:17:46 +0300 Subject: [PATCH 06/13] Add a configurable device identifier so we can have an "MQTT device" show up in Home Assistant Without this all we're getting is a bunch of sensor entities --- src/publisher/mqtt.ts | 3 ++- src/publisher/mqtt/homeassistant.ts | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/publisher/mqtt.ts b/src/publisher/mqtt.ts index 2d54a1d..856ae49 100644 --- a/src/publisher/mqtt.ts +++ b/src/publisher/mqtt.ts @@ -12,6 +12,7 @@ export type MqttPublisherSettings = { brokerUrl: string homeAssistant?: { autoDiscovery: boolean + deviceIdentifier: string } } @@ -39,7 +40,7 @@ export class MqttPublisherImpl implements PublisherImpl { // Publish Home Assistant MQTT discovery messages if (this.settings.homeAssistant?.autoDiscovery) { - configureMqttDiscovery(this.config, this.client) + configureMqttDiscovery(this.config, this.settings.homeAssistant.deviceIdentifier, this.client) .then(() => { console.log(`Configured Home Assistant MQTT discovery`) }) diff --git a/src/publisher/mqtt/homeassistant.ts b/src/publisher/mqtt/homeassistant.ts index ad30135..29ff257 100644 --- a/src/publisher/mqtt/homeassistant.ts +++ b/src/publisher/mqtt/homeassistant.ts @@ -4,10 +4,22 @@ import { Config } from '../../config' import { TOPIC_NAME_STATUS } from '../mqtt' import { createPowerSensorTopicName } from './util' -export const configureMqttDiscovery = async (config: Config, mqttClient: MqttClient): Promise => { +export const configureMqttDiscovery = async ( + config: Config, + deviceIdentifier: string, + mqttClient: MqttClient, +): Promise => { + // The "device" object that is part of each sensor's configuration payload + const mqttDeviceInformation = { + 'name': deviceIdentifier, + 'model': 'Eachwatt', + 'identifiers': deviceIdentifier, + } + const configurationBase = { 'platform': 'mqtt', 'availability_topic': TOPIC_NAME_STATUS, + 'device': mqttDeviceInformation, } for (const circuit of config.circuits) { @@ -26,7 +38,6 @@ export const configureMqttDiscovery = async (config: Config, mqttClient: MqttCli } // "retain" is used so that the entities will be available immediately after a Home Assistant restart - console.log(`Publishing Home Assistant auto-discovery configuration for power sensor "${entityName}"...`) const configurationTopicName = `homeassistant/sensor/eachwatt/${entityName}/config` await mqttClient.publishAsync(configurationTopicName, JSON.stringify(configuration), { retain: true, From 55fde836d27b58e75c1b46249409bdbce243f2fd Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Fri, 20 Oct 2023 09:15:58 +0300 Subject: [PATCH 07/13] Rename client to mqttClient --- src/publisher/mqtt.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/publisher/mqtt.ts b/src/publisher/mqtt.ts index 856ae49..1a2db95 100644 --- a/src/publisher/mqtt.ts +++ b/src/publisher/mqtt.ts @@ -26,7 +26,7 @@ type TopicValueMap = Map export class MqttPublisherImpl implements PublisherImpl { config: Config settings: MqttPublisherSettings - client?: MqttClient + mqttClient?: MqttClient constructor(config: Config, settings: MqttPublisherSettings) { this.config = config @@ -35,12 +35,12 @@ export class MqttPublisherImpl implements PublisherImpl { // Connect to the broker connectAsync(this.settings.brokerUrl) .then((client) => { - this.client = client + this.mqttClient = client console.log(`Connected to MQTT broker at ${this.settings.brokerUrl}`) // Publish Home Assistant MQTT discovery messages if (this.settings.homeAssistant?.autoDiscovery) { - configureMqttDiscovery(this.config, this.settings.homeAssistant.deviceIdentifier, this.client) + configureMqttDiscovery(this.config, this.settings.homeAssistant.deviceIdentifier, this.mqttClient) .then(() => { console.log(`Configured Home Assistant MQTT discovery`) }) @@ -90,7 +90,7 @@ export class MqttPublisherImpl implements PublisherImpl { const message = String(value) // noinspection TypeScriptValidateTypes - promises.push(this.client?.publishAsync(topic, message)) + promises.push(this.mqttClient?.publishAsync(topic, message)) } await Promise.all(promises) @@ -98,6 +98,6 @@ export class MqttPublisherImpl implements PublisherImpl { private async publishStatus(): Promise { // noinspection TypeScriptValidateTypes - await this.client?.publishAsync(TOPIC_NAME_STATUS, 'online') + await this.mqttClient?.publishAsync(TOPIC_NAME_STATUS, 'online') } } From f90196b9c3dd8f1a1c238cdbcfb1dd170e225cda Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Fri, 20 Oct 2023 09:43:39 +0300 Subject: [PATCH 08/13] Add a dummy characteristics sensor, helps with testing --- src/config.ts | 8 +++++++- src/dummy.ts | 17 ++++++++++++++++- src/sensor.ts | 1 + 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index 2f4d309..5f99342 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,7 +9,10 @@ import { } from './iotawatt' import { getSensorData as getVirtualSensorData } from './virtual' import { getSensorData as getUnmeteredSensorData } from './unmetered' -import { getSensorData as getDummySensorData } from './dummy' +import { + getCharacteristicsSensorData as getDummyCharacteristicsSensorData, + getSensorData as getDummySensorData, +} from './dummy' import { CharacteristicsSensorType, SensorType, @@ -131,6 +134,9 @@ export const resolveAndValidateConfig = (config: Config): Config => { case CharacteristicsSensorType.Shelly: c.sensor.pollFunc = getShellyCharacteristicsSensorData break + case CharacteristicsSensorType.Dummy: + c.sensor.pollFunc = getDummyCharacteristicsSensorData + break } } diff --git a/src/dummy.ts b/src/dummy.ts index fe81e01..f87d415 100644 --- a/src/dummy.ts +++ b/src/dummy.ts @@ -1,5 +1,13 @@ -import { emptySensorData, PowerSensorData, PowerSensorPollFunction } from './sensor' +import { + CharacteristicsSensorData, + CharacteristicsSensorPollFunction, + emptyCharacteristicsSensorData, + emptySensorData, + PowerSensorData, + PowerSensorPollFunction, +} from './sensor' import { Circuit } from './circuit' +import { Characteristics } from './characteristics' export const getSensorData: PowerSensorPollFunction = async ( timestamp: number, @@ -9,3 +17,10 @@ export const getSensorData: PowerSensorPollFunction = async ( ): Promise => { return emptySensorData(timestamp, circuit) } + +export const getCharacteristicsSensorData: CharacteristicsSensorPollFunction = async ( + timestamp: number, + characteristics: Characteristics, +): Promise => { + return emptyCharacteristicsSensorData(timestamp, characteristics) +} diff --git a/src/sensor.ts b/src/sensor.ts index 625bc46..7068f6b 100644 --- a/src/sensor.ts +++ b/src/sensor.ts @@ -18,6 +18,7 @@ export enum ShellyType { export enum CharacteristicsSensorType { Iotawatt = 'iotawatt', Shelly = 'shelly', + Dummy = 'dummy', } export type PowerSensorPollFunction = ( From c2602ef60dba1ceff5671d0541d8cfddc009ee81 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Fri, 20 Oct 2023 09:43:52 +0300 Subject: [PATCH 09/13] Add unit tests for topic naming functions, switch to lower-case --- src/publisher/mqtt/util.ts | 8 ++++++-- tests/publisher/mqtt/util.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 tests/publisher/mqtt/util.test.ts diff --git a/src/publisher/mqtt/util.ts b/src/publisher/mqtt/util.ts index 64fe8a3..7baf742 100644 --- a/src/publisher/mqtt/util.ts +++ b/src/publisher/mqtt/util.ts @@ -4,8 +4,12 @@ import { TOPIC_PREFIX } from '../mqtt' import slugify from 'slugify' const slugifyName = (name: string): string => { - // We can't have "+" in MQTT topic names - return slugify(name, { remove: /[+]/ }) + return slugify(name, { + // We can't have "+" in MQTT topic names + remove: /[+]/, + // Since the rest of the topic name is lower-case, just lower-case everything + lower: true, + }) } export const createCharacteristicsSensorTopicName = (characteristics: Characteristics, value: string): string => { diff --git a/tests/publisher/mqtt/util.test.ts b/tests/publisher/mqtt/util.test.ts new file mode 100644 index 0000000..459198a --- /dev/null +++ b/tests/publisher/mqtt/util.test.ts @@ -0,0 +1,19 @@ +import { Characteristics } from '../../../src/characteristics' +import { createCharacteristicsSensorTopicName, createPowerSensorTopicName } from '../../../src/publisher/mqtt/util' +import { Circuit } from '../../../src/circuit' + +test('creates correct topic names', () => { + const characteristics = { + name: 'Some characteristic', + } as unknown as Characteristics + + let topic = createCharacteristicsSensorTopicName(characteristics, 'voltage') + expect(topic).toEqual('eachwatt/characteristic/some-characteristic/voltage') + + const circuit = { + name: 'Some circuit + with plus sign', + } as unknown as Circuit + + topic = createPowerSensorTopicName(circuit, 'power') + expect(topic).toEqual('eachwatt/circuit/some-circuit-with-plus-sign/power') +}) From e83a4d0fd1c4a9fa30eac1d630d34ff936d71392 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Fri, 20 Oct 2023 12:16:46 +0300 Subject: [PATCH 10/13] Make device identifier optional, default to "eachwatt" Most people will probably only have one instance running anyway --- src/publisher/mqtt.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/publisher/mqtt.ts b/src/publisher/mqtt.ts index 1a2db95..e374b23 100644 --- a/src/publisher/mqtt.ts +++ b/src/publisher/mqtt.ts @@ -12,7 +12,7 @@ export type MqttPublisherSettings = { brokerUrl: string homeAssistant?: { autoDiscovery: boolean - deviceIdentifier: string + deviceIdentifier?: string } } @@ -40,7 +40,7 @@ export class MqttPublisherImpl implements PublisherImpl { // Publish Home Assistant MQTT discovery messages if (this.settings.homeAssistant?.autoDiscovery) { - configureMqttDiscovery(this.config, this.settings.homeAssistant.deviceIdentifier, this.mqttClient) + configureMqttDiscovery(this.config, this.getHomeAssistantDeviceIdentifier(), this.mqttClient) .then(() => { console.log(`Configured Home Assistant MQTT discovery`) }) @@ -100,4 +100,8 @@ export class MqttPublisherImpl implements PublisherImpl { // noinspection TypeScriptValidateTypes await this.mqttClient?.publishAsync(TOPIC_NAME_STATUS, 'online') } + + private getHomeAssistantDeviceIdentifier = (): string => { + return this.settings.homeAssistant?.deviceIdentifier ?? 'eachwatt' + } } From ef34176aff9b543d3c8b9fdab2a87411dce36114 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Fri, 20 Oct 2023 12:17:31 +0300 Subject: [PATCH 11/13] Use the same "slugification" logic everywhere --- src/publisher/mqtt/homeassistant.ts | 10 +++++----- src/publisher/mqtt/util.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/publisher/mqtt/homeassistant.ts b/src/publisher/mqtt/homeassistant.ts index 29ff257..fc9f196 100644 --- a/src/publisher/mqtt/homeassistant.ts +++ b/src/publisher/mqtt/homeassistant.ts @@ -1,8 +1,7 @@ import { MqttClient } from 'mqtt' -import slugify from 'slugify' import { Config } from '../../config' import { TOPIC_NAME_STATUS } from '../mqtt' -import { createPowerSensorTopicName } from './util' +import { createPowerSensorTopicName, slugifyName } from './util' export const configureMqttDiscovery = async ( config: Config, @@ -24,7 +23,8 @@ export const configureMqttDiscovery = async ( for (const circuit of config.circuits) { // Add power sensors - const entityName = slugify(circuit.name) + const entityName = slugifyName(circuit.name) + const uniqueId = `${deviceIdentifier}_${entityName}_power` const configuration = { ...configurationBase, @@ -32,8 +32,8 @@ export const configureMqttDiscovery = async ( 'device_class': 'power', 'unit_of_measurement': 'W', 'name': `${circuit.name} power`, - 'unique_id': `eachwatt_${entityName}_power`, - 'object_id': `eachwatt_${entityName}_power`, + 'unique_id': uniqueId, + 'object_id': uniqueId, 'state_topic': createPowerSensorTopicName(circuit, 'power'), } diff --git a/src/publisher/mqtt/util.ts b/src/publisher/mqtt/util.ts index 7baf742..f97c847 100644 --- a/src/publisher/mqtt/util.ts +++ b/src/publisher/mqtt/util.ts @@ -3,7 +3,7 @@ import { Circuit } from '../../circuit' import { TOPIC_PREFIX } from '../mqtt' import slugify from 'slugify' -const slugifyName = (name: string): string => { +export const slugifyName = (name: string): string => { return slugify(name, { // We can't have "+" in MQTT topic names remove: /[+]/, From c5246722452f4e9fd2b232f9750b3c7110c15377 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Fri, 20 Oct 2023 12:17:45 +0300 Subject: [PATCH 12/13] Use Promise.all() instead of await in loop --- src/publisher/mqtt/homeassistant.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/publisher/mqtt/homeassistant.ts b/src/publisher/mqtt/homeassistant.ts index fc9f196..cee28bc 100644 --- a/src/publisher/mqtt/homeassistant.ts +++ b/src/publisher/mqtt/homeassistant.ts @@ -21,6 +21,8 @@ export const configureMqttDiscovery = async ( 'device': mqttDeviceInformation, } + const promises = [] + for (const circuit of config.circuits) { // Add power sensors const entityName = slugifyName(circuit.name) @@ -39,8 +41,12 @@ export const configureMqttDiscovery = async ( // "retain" is used so that the entities will be available immediately after a Home Assistant restart const configurationTopicName = `homeassistant/sensor/eachwatt/${entityName}/config` - await mqttClient.publishAsync(configurationTopicName, JSON.stringify(configuration), { - retain: true, - }) + promises.push( + mqttClient.publishAsync(configurationTopicName, JSON.stringify(configuration), { + retain: true, + }), + ) } + + await Promise.all(promises) } From 132b6834b2e22f3a7dd02c98001a91e8a2f2bce0 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Fri, 20 Oct 2023 14:08:18 +0300 Subject: [PATCH 13/13] Update example configuration --- examples/config.sample.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/examples/config.sample.yml b/examples/config.sample.yml index 128d677..62830d5 100644 --- a/examples/config.sample.yml +++ b/examples/config.sample.yml @@ -321,3 +321,9 @@ publishers: bucket: eachwatt apiToken: token - type: console + - type: mqtt + settings: + brokerUrl: mqtt://10.110.1.3:1883 + homeAssistant: + autoDiscovery: true + deviceIdentifier: eachwatt-dev