diff --git a/.eslintrc.json b/.eslintrc.json index 789d990..9d3744d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -16,5 +16,6 @@ "@typescript-eslint" ], "rules": { + "no-console": "error" } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd8eb34..4640919 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,3 +80,10 @@ jobs: run: npm ci - name: Run eslint run: npm run lint + build-docker: + name: Build Docker image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Build the image + run: docker build -t eachwatt/latest . diff --git a/Dockerfile b/Dockerfile index b142bb5..8ee3c0d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,29 @@ -FROM node:20-bookworm AS builder +FROM node:20-bookworm-slim AS builder WORKDIR /app -COPY . /app -RUN npm install +# Copy all files needed to build the app +COPY package.json /app +COPY package-lock.json /app +COPY tsconfig.json /app +COPY src/ /app/src +COPY webif/ /app/webif -RUN npm run build +# Install dependencies and build the app and web interface +RUN npm install +RUN npm run build-all -FROM node:20-bookworm AS runtime +FROM node:20-bookworm-slim AS runtime WORKDIR /app -COPY . /app -COPY --from=builder /app/dist/* /app -RUN npm install --omit=dev +# Copy everything needed to install dependencies +COPY package.json /app +COPY package-lock.json /app +RUN npm install --omit=dev --ignore-scripts + +# Copy the built apps +COPY --from=builder /app/dist /app/dist +COPY --from=builder /app/webif/build /app/webif/build -ENTRYPOINT ["node", "dist/eachwatt.js"] +ENTRYPOINT ["node", "dist/eachwatt.js", "-c", "/data/config.yml"] diff --git a/README.md b/README.md index a360444..8043dc2 100644 --- a/README.md +++ b/README.md @@ -41,3 +41,20 @@ To run the test suite, use: ``` npm run test ``` + +## Running with Docker + +Build the Docker image: + +```bash +docker build -t eachwatt/latest . +``` + +Run the container: + +```bash +docker run --rm -v $(pwd):/data:ro -p 8080:8080 eachwatt/latest +``` + +The application expects the configuration file to be available as `/data/config.yml`, so in the above example, +`config.yml` should be present in the current directory. diff --git a/examples/config.sample.yml b/examples/config.sample.yml index 62830d5..48455c8 100644 --- a/examples/config.sample.yml +++ b/examples/config.sample.yml @@ -1,3 +1,7 @@ +settings: + pollingInterval: 5000 + httpPort: 8080 + circuits: # mains - name: Main total diff --git a/package-lock.json b/package-lock.json index cc42802..6b45970 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "axios": "^1.5.0", "mqtt": "^5.1.2", "slugify": "^1.6.6", + "winston": "^3.11.0", "ws": "^8.14.2", "yaml": "^2.3.2", "yargs": "^17.7.2" @@ -722,6 +723,14 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -748,6 +757,16 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -1481,6 +1500,11 @@ "integrity": "sha512-g7CK9nHdwjK2n0ymT2CW698FuWJRIx+RP6embAzZ2Qi8/ilIrA1Imt2LVSeHUzKvpoi7BhmmQcXz95eS0f2JXw==", "dev": true }, + "node_modules/@types/triple-beam": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.4.tgz", + "integrity": "sha512-HlJjF3wxV4R2VQkFpKe0YqJLilYNgtRtsqqZtby7RkVsSs+i+vbyzjtUwpFEdUCKcrGzCiEJE7F/0mKjh0sunA==" + }, "node_modules/@types/ws": { "version": "8.5.5", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.5.tgz", @@ -1837,6 +1861,11 @@ "node": ">=8" } }, + "node_modules/async": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2231,6 +2260,15 @@ "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", "dev": true }, + "node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2247,6 +2285,37 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/colorspace": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", + "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "dependencies": { + "color": "^3.1.3", + "text-hex": "1.0.x" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2498,6 +2567,11 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -2817,6 +2891,11 @@ "bser": "2.1.1" } }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -2877,6 +2956,11 @@ "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", "dev": true }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" + }, "node_modules/follow-redirects": { "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", @@ -3301,7 +3385,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, "engines": { "node": ">=8" }, @@ -4029,6 +4112,11 @@ "node": ">=6" } }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -4084,6 +4172,22 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/logform": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.6.0.tgz", + "integrity": "sha512-1ulHeNPp6k/LD8H91o7VYFBng5i1BDE7HoKxVbZiGFidS1Rj65qcywLxX+pVfAPoQJEjRdvKcusKwOupHCVOVQ==", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -4312,6 +4416,14 @@ "wrappy": "1" } }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "dependencies": { + "fn.name": "1.x.x" + } + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -4820,6 +4932,14 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/safe-stable-stringify": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz", + "integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==", + "engines": { + "node": ">=10" + } + }, "node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -4862,6 +4982,19 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -4918,6 +5051,14 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "engines": { + "node": "*" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -5076,6 +5217,11 @@ "node": ">=8" } }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -5109,6 +5255,14 @@ "node": ">=8.0" } }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/ts-api-utils": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", @@ -5358,6 +5512,66 @@ "node": ">= 8" } }, + "node_modules/winston": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.11.0.tgz", + "integrity": "sha512-L3yR6/MzZAOl0DsysUXHVjOwv8mKZ71TrA/41EIduGpOOV5LQVodqN+QdQ6BS6PJ/RdIshZhq84P/fStEZkk7g==", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.4.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.6.0.tgz", + "integrity": "sha512-wbBA9PbPAHxKiygo7ub7BYRiKxms0tpfU2ljtWzb3SjRjv5yl6Ozuy/TkXf00HTAt+Uylo3gSkNwzc4ME0wiIg==", + "dependencies": { + "logform": "^2.3.2", + "readable-stream": "^3.6.0", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport/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/winston/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/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index e6c0466..17d62a4 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "axios": "^1.5.0", "mqtt": "^5.1.2", "slugify": "^1.6.6", + "winston": "^3.11.0", "ws": "^8.14.2", "yaml": "^2.3.2", "yargs": "^17.7.2" diff --git a/src/circuit.ts b/src/circuit.ts index 69f7b1a..47872a3 100644 --- a/src/circuit.ts +++ b/src/circuit.ts @@ -11,6 +11,7 @@ export interface Circuit { parent?: string | Circuit // resolved to the circuit in question children: Circuit[] // resolved from parent phase?: string // resolved from parent + hidden?: boolean // defaults to false sensor: PowerSensor group?: string } diff --git a/src/config.ts b/src/config.ts index 8c28901..52ca333 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,17 +2,17 @@ import YAML from 'yaml' import { getCharacteristicsSensorData as getShellyCharacteristicsSensorData, getSensorData as getShellySensorData, -} from './shelly' +} from './sensor/shelly' import { getCharacteristicsSensorData as getIotawattCharacteristicsSensorData, getSensorData as getIotawattSensorData, -} from './iotawatt' -import { getSensorData as getVirtualSensorData } from './virtual' -import { getSensorData as getUnmeteredSensorData } from './unmetered' +} from './sensor/iotawatt' +import { getSensorData as getVirtualSensorData } from './sensor/virtual' +import { getSensorData as getUnmeteredSensorData } from './sensor/unmetered' import { getCharacteristicsSensorData as getDummyCharacteristicsSensorData, getSensorData as getDummySensorData, -} from './dummy' +} from './sensor/dummy' import { CharacteristicsSensorType, SensorType, @@ -28,18 +28,39 @@ import { ConsolePublisher, ConsolePublisherImpl } from './publisher/console' import { Characteristics } from './characteristics' import { MqttPublisher, MqttPublisherImpl } from './publisher/mqtt' -export interface Config { +type MilliSeconds = number + +type MainSettings = { + pollingInterval?: MilliSeconds + httpPort?: number +} + +export type Config = { + settings: MainSettings characteristics: Characteristics[] circuits: Circuit[] publishers: Publisher[] } +const defaultSettings = (): MainSettings => { + return { + pollingInterval: 5000, + httpPort: 8080, + } +} + +const MINIMUM_POLLING_INTERVAL: MilliSeconds = 2000 + export const parseConfig = (configFileContents: string): Config => { return YAML.parse(configFileContents) as Config } export const resolveAndValidateConfig = (config: Config): Config => { // Set various defaults + if (!config.settings) { + config.settings = defaultSettings() + } + if (!config.characteristics) { config.characteristics = [] } @@ -48,6 +69,11 @@ export const resolveAndValidateConfig = (config: Config): Config => { config.publishers = [] } + // Validate polling interval + if (!config.settings.pollingInterval || config.settings.pollingInterval < MINIMUM_POLLING_INTERVAL) { + throw new Error(`Polling interval is too low, minimum is ${MINIMUM_POLLING_INTERVAL} milliseconds`) + } + for (const circuit of config.circuits) { // Use Circuit as default circuit type if (circuit.type === undefined) { @@ -61,6 +87,11 @@ export const resolveAndValidateConfig = (config: Config): Config => { shellySensor.shelly.type = ShellyType.Gen1 } } + + // Sensors are not hidden by default + if (circuit.hidden === undefined) { + circuit.hidden = false + } } // Resolve parent relationships @@ -104,6 +135,12 @@ export const resolveAndValidateConfig = (config: Config): Config => { unmeteredSensor.unmetered.children as string[], config.circuits, ) + + // Make sure we don't have other unmetered circuits as children + const children = unmeteredSensor.unmetered.children as Circuit[] + if (children.filter((c) => c.sensor.type === SensorType.Unmetered).length > 0) { + throw new Error('Unmetered circuits cannot have other unmetered circuits as children') + } } } diff --git a/src/eachwatt.ts b/src/eachwatt.ts index 8a50992..59e7b2f 100644 --- a/src/eachwatt.ts +++ b/src/eachwatt.ts @@ -8,43 +8,51 @@ import { httpRequestHandler } from './http/server' import { WebSocketPublisherImpl } from './publisher/websocket' import { PublisherType } from './publisher' import { pollCharacteristicsSensors } from './characteristics' +import { createLogger } from './logger' + +// Set up a signal handler, so we can exit on Ctrl + C when run from Docker +process.on('SIGINT', () => { + process.exit() +}) const argv = yargs(process.argv.slice(2)) .usage('node $0 [options]') .options({ 'config': { description: 'The path to the configuration file', - demand: true, + demandOption: true, alias: 'c', }, }) .parseSync() +const logger = createLogger('main') + const mainPollerFunc = async (config: Config) => { const now = Date.now() const circuits = config.circuits - // Poll all normal circuits first + // Poll all normal circuit power sensors first const nonVirtualCircuits = circuits.filter( (c) => c.sensor.type !== SensorType.Virtual && c.sensor.type !== SensorType.Unmetered, ) - let sensorData = await pollPowerSensors(now, nonVirtualCircuits) + let powerSensorData = await pollPowerSensors(now, nonVirtualCircuits) // Poll virtual sensors, giving them the opportunity to act on the real sensor data we've gathered so far const virtualCircuits = circuits.filter((c) => c.sensor.type === SensorType.Virtual) - const virtualSensorData = await pollPowerSensors(now, virtualCircuits, sensorData) - sensorData = sensorData.concat(virtualSensorData) + const virtualSensorData = await pollPowerSensors(now, virtualCircuits, powerSensorData) + powerSensorData = powerSensorData.concat(virtualSensorData) // Poll unmetered sensors const unmeteredCircuits = circuits.filter((c) => c.sensor.type === SensorType.Unmetered) - const unmeteredSensorData = await pollPowerSensors(now, unmeteredCircuits, sensorData) - sensorData = sensorData.concat(unmeteredSensorData) + const unmeteredSensorData = await pollPowerSensors(now, unmeteredCircuits, powerSensorData) + powerSensorData = powerSensorData.concat(unmeteredSensorData) // Poll characteristics sensors const characteristicsSensorData = await pollCharacteristicsSensors(now, config.characteristics) // Round all numbers to one decimal point - for (const data of sensorData) { + for (const data of powerSensorData) { if (data.power !== undefined) { data.power = Number(data.power.toFixed(1)) } @@ -55,12 +63,15 @@ const mainPollerFunc = async (config: Config) => { try { const publisherImpl = publisher.publisherImpl + // Filter out hidden sensors from the sensor data + const filteredSensorData = powerSensorData.filter((psd) => !psd.circuit.hidden) + await Promise.all([ - publisherImpl.publishSensorData(sensorData), + publisherImpl.publishSensorData(filteredSensorData), publisherImpl.publishCharacteristicsSensorData(characteristicsSensorData), ]) } catch (e) { - console.error((e as Error).message) + logger.error((e as Error).message) } } } @@ -68,7 +79,7 @@ const mainPollerFunc = async (config: Config) => { ;(async () => { const configFile = argv.config as string if (!fs.existsSync(configFile)) { - console.error(`Configuration ${configFile} file does not exist or is not readable`) + logger.error(`Configuration ${configFile} file does not exist or is not readable`) process.exit(-1) } @@ -78,8 +89,8 @@ const mainPollerFunc = async (config: Config) => { // Create and start HTTP server const httpServer = http.createServer(httpRequestHandler) - await httpServer.listen(8080, '0.0.0.0', () => { - console.log('Started HTTP server') + httpServer.listen(config.settings.httpPort, '0.0.0.0', () => { + logger.info(`Started HTTP server on port ${config.settings.httpPort}`) }) // Create a WebSocket server and register it as a publisher too @@ -90,6 +101,8 @@ const mainPollerFunc = async (config: Config) => { }) // Start polling sensors + const pollingInterval = config.settings.pollingInterval + logger.info(`Polling sensors with interval ${pollingInterval} milliseconds`) await mainPollerFunc(config) - setInterval(mainPollerFunc, 5000, config) + setInterval(mainPollerFunc, pollingInterval, config) })() diff --git a/src/http/client.ts b/src/http/client.ts index ab3d9b4..27dc5c5 100644 --- a/src/http/client.ts +++ b/src/http/client.ts @@ -4,6 +4,7 @@ import http from 'http' const httpClient = axios.create({ // We keep polling the same hosts over and over so keep-alive is essential httpAgent: new http.Agent({ keepAlive: true }), + timeout: 1000, }) let lastTimestamp = 0 diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..7c6e757 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,23 @@ +import winston, { Logger } from 'winston' + +const DEFAULT_LOG_LEVEL = 'info' + +// Define log transports here, so we can change the log level later +const transports = [new winston.transports.Console()] + +const logFormat = winston.format.printf(({ level, message, label, timestamp }) => { + return `${timestamp} [${label}] ${level}: ${message}` +}) + +// export const setLogLevel = (logger: Logger, level: string) => { +// logger.info(`Setting log level to ${level}`) +// transports[0].level = level +// } + +export const createLogger = (module: string): Logger => { + return winston.createLogger({ + 'level': DEFAULT_LOG_LEVEL, + 'format': winston.format.combine(winston.format.label({ label: module }), winston.format.timestamp(), logFormat), + 'transports': transports, + }) +} diff --git a/src/publisher/console.ts b/src/publisher/console.ts index af58b74..6c9fb9a 100644 --- a/src/publisher/console.ts +++ b/src/publisher/console.ts @@ -1,20 +1,23 @@ import { Publisher, PublisherImpl, PublisherType } from '../publisher' import { CharacteristicsSensorData, PowerSensorData } from '../sensor' +import { createLogger } from '../logger' export interface ConsolePublisher extends Publisher { type: PublisherType.Console } +const logger = createLogger('publisher.console') + export class ConsolePublisherImpl implements PublisherImpl { publishSensorData(sensorData: PowerSensorData[]): void { for (const data of sensorData) { - console.log(`${data.timestamp}: ${data.circuit.name}: ${data.power}W`) + logger.info(`${data.circuit.name}: ${data.power}W`) } } publishCharacteristicsSensorData(sensorData: CharacteristicsSensorData[]): void { for (const data of sensorData) { - console.log(`${data.timestamp}: ${data.characteristics.name}: ${data.voltage}V, ${data.frequency}Hz`) + logger.info(`${data.characteristics.name}: ${data.voltage}V, ${data.frequency}Hz`) } } } diff --git a/src/publisher/mqtt.ts b/src/publisher/mqtt.ts index 4e92aee..6426dfe 100644 --- a/src/publisher/mqtt.ts +++ b/src/publisher/mqtt.ts @@ -4,6 +4,8 @@ import { CharacteristicsSensorData, PowerSensorData } from '../sensor' import { Config } from '../config' import { createCharacteristicsSensorTopicName, createPowerSensorTopicName } from './mqtt/util' import { configureMqttDiscovery } from './mqtt/homeassistant' +import { Logger } from 'winston' +import { createLogger } from '../logger' export const TOPIC_PREFIX = 'eachwatt' export const TOPIC_NAME_STATUS = `${TOPIC_PREFIX}/status` @@ -26,22 +28,24 @@ export class MqttPublisherImpl implements PublisherImpl { config: Config settings: MqttPublisherSettings mqttClient?: MqttClient + logger: Logger constructor(config: Config, settings: MqttPublisherSettings) { this.config = config this.settings = settings + this.logger = createLogger('publisher.mqtt') // Connect to the broker connectAsync(this.settings.brokerUrl) .then((client) => { this.mqttClient = client - console.log(`Connected to MQTT broker at ${this.settings.brokerUrl}`) + this.logger.info(`Connected to MQTT broker at ${this.settings.brokerUrl}`) // Publish Home Assistant MQTT discovery messages if (this.settings.homeAssistant?.autoDiscovery) { configureMqttDiscovery(this.config, this.mqttClient) .then(() => { - console.log(`Configured Home Assistant MQTT discovery`) + this.logger.info(`Configured Home Assistant MQTT discovery`) }) .catch((e) => { throw new Error(`Failed to configure Home Assistant MQTT discovery: ${e}`) diff --git a/src/dummy.ts b/src/sensor/dummy.ts similarity index 86% rename from src/dummy.ts rename to src/sensor/dummy.ts index f87d415..b66fab7 100644 --- a/src/dummy.ts +++ b/src/sensor/dummy.ts @@ -5,9 +5,9 @@ import { emptySensorData, PowerSensorData, PowerSensorPollFunction, -} from './sensor' -import { Circuit } from './circuit' -import { Characteristics } from './characteristics' +} from '../sensor' +import { Circuit } from '../circuit' +import { Characteristics } from '../characteristics' export const getSensorData: PowerSensorPollFunction = async ( timestamp: number, diff --git a/src/iotawatt.ts b/src/sensor/iotawatt.ts similarity index 92% rename from src/iotawatt.ts rename to src/sensor/iotawatt.ts index 2bfc023..74dd04a 100644 --- a/src/iotawatt.ts +++ b/src/sensor/iotawatt.ts @@ -7,10 +7,11 @@ import { IotawattSensor, PowerSensorData, PowerSensorPollFunction, -} from './sensor' -import { Circuit } from './circuit' -import { getDedupedResponse } from './http/client' -import { Characteristics } from './characteristics' +} from '../sensor' +import { Circuit } from '../circuit' +import { getDedupedResponse } from '../http/client' +import { Characteristics } from '../characteristics' +import { createLogger } from '../logger' type IotawattConfigurationInput = { channel: number @@ -44,6 +45,8 @@ type IotawattStatus = { type IotawattCharacteristicsQuery = number[][] +const logger = createLogger('sensor.iotawatt') + const getConfigurationUrl = (sensor: IotawattSensor): string => { return `http://${sensor.iotawatt.address}/config.txt` } @@ -123,7 +126,7 @@ export const getSensorData: PowerSensorPollFunction = async ( powerFactor: getSensorPowerFactorValue(sensor, configuration, status), } } catch (e) { - console.error((e as Error).message) + logger.error((e as Error).message) return emptySensorData(timestamp, circuit) } } @@ -149,7 +152,7 @@ export const getCharacteristicsSensorData: CharacteristicsSensorPollFunction = a frequency: query[0][1], } } catch (e) { - console.error((e as Error).message) + logger.error((e as Error).message) return emptyCharacteristicsSensorData(timestamp, characteristics) } } diff --git a/src/shelly.ts b/src/sensor/shelly.ts similarity index 93% rename from src/shelly.ts rename to src/sensor/shelly.ts index 4925ac9..04a7242 100644 --- a/src/shelly.ts +++ b/src/sensor/shelly.ts @@ -8,11 +8,12 @@ import { ShellyCharacteristicsSensor, ShellySensor, ShellyType, -} from './sensor' -import { Circuit } from './circuit' -import { getDedupedResponse } from './http/client' +} from '../sensor' +import { Circuit } from '../circuit' +import { getDedupedResponse } from '../http/client' import { AxiosResponse } from 'axios' -import { Characteristics } from './characteristics' +import { Characteristics } from '../characteristics' +import { createLogger } from '../logger' type Gen1MeterResult = { power: number @@ -44,6 +45,8 @@ type Gen2EMGetStatusResult = { c_freq: number } +const logger = createLogger('sensor.shelly') + const getSensorDataUrl = (sensor: ShellySensor | ShellyCharacteristicsSensor): string => { const address = sensor.shelly.address const meter = sensor.shelly.meter @@ -134,7 +137,7 @@ export const getSensorData: PowerSensorPollFunction = async ( return parseGen2EMResponse(timestamp, circuit, httpResponse) } } catch (e) { - console.error((e as Error).message) + logger.error((e as Error).message) return emptySensorData(timestamp, circuit) } } @@ -179,7 +182,7 @@ export const getCharacteristicsSensorData: CharacteristicsSensorPollFunction = a frequency: frequency, } } catch (e) { - console.error((e as Error).message) + logger.error((e as Error).message) return emptyCharacteristicsSensorData(timestamp, characteristics) } } diff --git a/src/unmetered.ts b/src/sensor/unmetered.ts similarity index 89% rename from src/unmetered.ts rename to src/sensor/unmetered.ts index c015ca9..f5cc58b 100644 --- a/src/unmetered.ts +++ b/src/sensor/unmetered.ts @@ -1,5 +1,5 @@ -import { emptySensorData, reduceToWatts, PowerSensorData, PowerSensorPollFunction, UnmeteredSensor } from './sensor' -import { Circuit } from './circuit' +import { emptySensorData, reduceToWatts, PowerSensorData, PowerSensorPollFunction, UnmeteredSensor } from '../sensor' +import { Circuit } from '../circuit' export const getSensorData: PowerSensorPollFunction = async ( timestamp: number, diff --git a/src/virtual.ts b/src/sensor/virtual.ts similarity index 90% rename from src/virtual.ts rename to src/sensor/virtual.ts index 0d925a2..1ce98da 100644 --- a/src/virtual.ts +++ b/src/sensor/virtual.ts @@ -1,5 +1,5 @@ -import { emptySensorData, PowerSensorData, PowerSensorPollFunction, VirtualSensor } from './sensor' -import { Circuit } from './circuit' +import { emptySensorData, PowerSensorData, PowerSensorPollFunction, VirtualSensor } from '../sensor' +import { Circuit } from '../circuit' export const getSensorData: PowerSensorPollFunction = async ( timestamp: number, diff --git a/tests/config.test.ts b/tests/config.test.ts index 345d278..32699d9 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,7 +1,13 @@ import { Config, resolveAndValidateConfig } from '../src/config' import { SensorType, ShellySensor, ShellyType, UnmeteredSensor, VirtualSensor } from '../src/sensor' import { CircuitType } from '../src/circuit' -import { createParentChildConfig, createUnmeteredParentChildrenConfig, createVirtualSensorConfig } from './testConfigs' +import { + createNestedUnmeteredConfig, + createParentChildConfig, + createUnmeteredParentChildrenConfig, + createVeryLowPollingIntervalConfig, + createVirtualSensorConfig, +} from './testConfigs' test('defaults are applied', () => { const config = resolveAndValidateConfig({ @@ -21,11 +27,21 @@ test('defaults are applied', () => { ], } as unknown as Config) + expect(config.settings.pollingInterval).toEqual(5000) + expect(config.characteristics.length).toEqual(0) + expect(config.publishers.length).toEqual(0) expect(config.circuits[0].type).toEqual(CircuitType.Circuit) + expect(config.circuits[0].hidden).toEqual(false) const sensor = config.circuits[0].sensor as ShellySensor expect(sensor.shelly.type).toEqual(ShellyType.Gen1) }) +test('polling interval cannot be set too low', () => { + const rawConfig = createVeryLowPollingIntervalConfig() + + expect(() => resolveAndValidateConfig(rawConfig)).toThrow('Polling interval is too low') +}) + test('parent and child circuit is resolved', () => { const config = resolveAndValidateConfig(createParentChildConfig()) @@ -76,3 +92,9 @@ test('throws when unmetered sensor parent or children cannot be resolved', () => expect(() => resolveAndValidateConfig(unknownChildConfig)).toThrow('Failed to resolve') }) + +test('throws when unmetered sensor has unmetered children', () => { + const nestedConfig = createNestedUnmeteredConfig() + + expect(() => resolveAndValidateConfig(nestedConfig)).toThrow('Unmetered circuits cannot have other') +}) diff --git a/tests/testConfigs.ts b/tests/testConfigs.ts index fb5afae..e8e0981 100644 --- a/tests/testConfigs.ts +++ b/tests/testConfigs.ts @@ -2,6 +2,17 @@ import { SensorType } from '../src/sensor' import { Config } from '../src/config' import { CircuitType } from '../src/circuit' +export const createVeryLowPollingIntervalConfig = (): Config => { + return { + settings: { + pollingInterval: 50, + }, + characteristics: [], + circuits: [], + publishers: [], + } +} + export const createParentChildConfig = (): Config => { return { circuits: [ @@ -80,3 +91,42 @@ export const createUnmeteredParentChildrenConfig = (): Config => { ], } as unknown as Config } + +export const createNestedUnmeteredConfig = (): Config => { + return { + circuits: [ + { + name: 'Total', + sensor: { + type: SensorType.Dummy, + }, + }, + { + name: 'Some sub-circuit', + sensor: { + type: SensorType.Dummy, + }, + }, + { + name: 'Some other unmetered circuit', + sensor: { + type: SensorType.Unmetered, + unmetered: { + parent: 'Total', + children: [], + }, + }, + }, + { + name: 'Unmetered', + sensor: { + type: SensorType.Unmetered, + unmetered: { + parent: 'Total', + children: ['Some sub-circuit', 'Some other unmetered circuit'], + }, + }, + }, + ], + } as unknown as Config +}