From 4c174e0b936e45f656c29559f5a19e8cd1aae0d2 Mon Sep 17 00:00:00 2001 From: Rob Griffiths Date: Tue, 12 Nov 2024 13:06:46 +0000 Subject: [PATCH 01/18] version changes --- .github/workflows/stale.yml | 3 +++ package-lock.json | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/stale.yml diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..c912f97 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,3 @@ +- name: Close Stale Issues + uses: actions/stale@v9.0.0 + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b1acc2d..fc0f046 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meross", - "version": "1.0.12", + "version": "1.0.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meross", - "version": "1.0.12", + "version": "1.0.15", "license": "ISC", "dependencies": { "axios": "^0.21.1", From b44fae3611104dcb1a9d609660186d045a29a399 Mon Sep 17 00:00:00 2001 From: Rob Griffiths Date: Mon, 13 Jan 2025 14:27:18 +0000 Subject: [PATCH 02/18] update node version requirement --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ab340b9..9d9c657 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Tools to help configure the Meross devices for purpose of utilising our =18.0.0 <19.0.0-0 >=20.0.0 <21.0.0-0.). ## Home Assistant From d8aedab0f45298487eb22f9f3430f15bb9f8d77d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Jan 2025 14:32:41 +0000 Subject: [PATCH 03/18] Bump axios from 0.21.4 to 0.28.0 Bumps [axios](https://github.com/axios/axios) from 0.21.4 to 0.28.0. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v0.28.0/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v0.21.4...v0.28.0) --- updated-dependencies: - dependency-name: axios dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package-lock.json | 73 +++++++++++++++++++++++++++++++++++++++++++---- package.json | 2 +- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index fc0f046..f202fd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.15", "license": "ISC", "dependencies": { - "axios": "^0.21.1", + "axios": "^0.28.0", "commander": "^7.2", "got": "^13.0.0", "md5": "^2.2.1", @@ -63,12 +63,19 @@ "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.3.tgz", "integrity": "sha512-V46MYLFp08Wf2mmaBhvgjStM3tPa+2GAdy/iqoX+noX1//zje2x4XmrIU0cAwyClATsTmahbtoQ2EwP7I5WSiA==" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/axios": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", - "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.28.0.tgz", + "integrity": "sha512-Tu7NYoGY4Yoc7I+Npf9HhUMtEEpV7ZiLH9yndTCoNhcpBH0kwcvFbzYN9/u5QKI5A6uefjsNNWaz5olJVYS62Q==", "dependencies": { - "follow-redirects": "^1.14.0" + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" } }, "node_modules/cacheable-lookup": { @@ -109,6 +116,17 @@ "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz", "integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -166,6 +184,14 @@ "node": ">=10" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/follow-redirects": { "version": "1.15.3", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", @@ -185,6 +211,19 @@ } } }, + "node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/form-data-encoder": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", @@ -302,6 +341,25 @@ "is-buffer": "~1.1.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-response": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", @@ -379,6 +437,11 @@ "node": ">=12.13.0" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", diff --git a/package.json b/package.json index 4cef242..482f941 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "repository": "https://github.com/bytespider/Meross/tree/master", "license": "ISC", "dependencies": { - "axios": "^0.21.1", + "axios": "^0.28.0", "commander": "^7.2", "got": "^13.0.0", "md5": "^2.2.1", From 4679c78595ea4690f503d02dda06f3dbfa897049 Mon Sep 17 00:00:00 2001 From: Rob Griffiths Date: Mon, 13 Jan 2025 16:05:00 +0000 Subject: [PATCH 04/18] mosquitto example dockerfile --- .npmignore | 4 ---- Dockerfile | 13 +++++++++++++ VERSION | 1 - certs/ca.crt | 23 ----------------------- certs/server.crt | 21 --------------------- mosquitto/basic.conf | 6 +++--- package-lock.json | 2 +- package.json | 2 +- 8 files changed, 18 insertions(+), 54 deletions(-) create mode 100644 Dockerfile delete mode 100644 VERSION delete mode 100644 certs/ca.crt delete mode 100644 certs/server.crt diff --git a/.npmignore b/.npmignore index 344ca8f..82e6f7c 100644 --- a/.npmignore +++ b/.npmignore @@ -1,6 +1,2 @@ -certs/ -cmd/ -dumps/ mosquito/ -old_certs/ teardown/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..07e6c9e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM eclipse-mosquitto:1.6.15-openssl + +COPY mosquitto/basic.conf ./mosquitto/config/ +RUN apk add --update --no-cache openssl && \ + mkdir ./mosquitto/config/certs && \ + cd ./mosquitto/config/certs && \ + openssl req -nodes -new -x509 -keyout ca.key -out ca.crt -subj "/CN=mosquitto" -days 3650 && \ + openssl req -sha256 -nodes -newkey rsa:2048 -keyout server.key -out server.csr -subj "/CN=localhost-mosquitto" && \ + openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 3650 +RUN chown -R mosquitto:mosquitto ./mosquitto + +EXPOSE 1883 +EXPOSE 8883 \ No newline at end of file diff --git a/VERSION b/VERSION deleted file mode 100644 index 492b167..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -1.0.12 \ No newline at end of file diff --git a/certs/ca.crt b/certs/ca.crt deleted file mode 100644 index ffec438..0000000 --- a/certs/ca.crt +++ /dev/null @@ -1,23 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDxTCCAq2gAwIBAgIUPH3VrxuvmxuP1sgIWlqi9dGMSuQwDQYJKoZIhvcNAQEL -BQAwcjELMAkGA1UEBhMCVUsxEzARBgNVBAgMCkdsb3VjZXN0ZXIxEzARBgNVBAcM -Ckdsb3VjZXN0ZXIxCzAJBgNVBAoMAkNBMQswCQYDVQQLDAJDQTEfMB0GA1UEAwwW -Um9icy1NYWNCb29rLVByby5sb2NhbDAeFw0yMDEwMDkxNTE2MDNaFw0yNTEwMDkx -NTE2MDNaMHIxCzAJBgNVBAYTAlVLMRMwEQYDVQQIDApHbG91Y2VzdGVyMRMwEQYD -VQQHDApHbG91Y2VzdGVyMQswCQYDVQQKDAJDQTELMAkGA1UECwwCQ0ExHzAdBgNV -BAMMFlJvYnMtTWFjQm9vay1Qcm8ubG9jYWwwggEiMA0GCSqGSIb3DQEBAQUAA4IB -DwAwggEKAoIBAQDH/V1EuumONBJtXzsqsrfZ0cyHfXl2GrdmBszvw6ehBIQITegD -R8C8h1U1igjeyzdckTQsAw+BwVu9mwpUXI3xmYhFDgnxM5FkUvBkPMvTLEBT4nFR -YbWDOniW0C8TWNpxjD7qPm7OhMlL8nWjtn3xNt6vVvvgWLBo9d3W37fcQYALmf9n -K5mhx+8UUtBUU/mMjKjHGCkidzZQVnkaFyLSL7P0eAZOySxmQ8LgT6+cwkF/neIA -oyLCVeQQfB7e5Bw26uAMfOCPXubS8d8bjW+CPtCCWT9l5F5I7Ris7nVm1Oj+gO/a -/Ob2HlpaNygbacLPHjYQRcKnYvL3EIGplJCNAgMBAAGjUzBRMB0GA1UdDgQWBBTl -zbEWkyopzNw84h3nw6AgCgbN6TAfBgNVHSMEGDAWgBTlzbEWkyopzNw84h3nw6Ag -CgbN6TAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBpZ5laeRqz -uMHiLJhp/4IXwi+qkbFFk2TC18Yx3NMwvnGA7WMMb74130XL9X/6+PO4XdMP3VAn -D+lxTJV6V4Iyq84URaH0+pwj1FAfBjwaYJ8YszAFeWcMCEbtNSxEOk1cvZSWwf1A -5I0/FEsjYoOKBFq11lbWqY8+ukfFihAMBcjFebcKH6J42Zu0x0CmPwOSQ0/dwJUj -tVSa2GUlPr9TJ78mRUeEXKQf+f+MUpSTJpg5DzoL2gMJMpJkMQzTsLlMGYAo9gYy -tvpfIcGHidepGaSowddm7F7A6c8n2ZadyE8edxv30mY9XVCM5SXC44HcOl4cQ8oB -3Q0OVM0oGLa7 ------END CERTIFICATE----- diff --git a/certs/server.crt b/certs/server.crt deleted file mode 100644 index 179b278..0000000 --- a/certs/server.crt +++ /dev/null @@ -1,21 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDeDCCAmACFCaTPe44FcR397h+CxtRrqlGU+YOMA0GCSqGSIb3DQEBCwUAMHIx -CzAJBgNVBAYTAlVLMRMwEQYDVQQIDApHbG91Y2VzdGVyMRMwEQYDVQQHDApHbG91 -Y2VzdGVyMQswCQYDVQQKDAJDQTELMAkGA1UECwwCQ0ExHzAdBgNVBAMMFlJvYnMt -TWFjQm9vay1Qcm8ubG9jYWwwHhcNMjAxMDA5MTUxNzEyWhcNMjExMDA0MTUxNzEy -WjB/MQswCQYDVQQGEwJVSzEYMBYGA1UECAwPR2xvdWNlc3RlcnNoaXJlMRMwEQYD -VQQHDApHbG91Y2VzdGVyMQ8wDQYDVQQKDAZTZXJ2ZXIxDzANBgNVBAsMBlNlcnZl -cjEfMB0GA1UEAwwWUm9icy1NYWNCb29rLVByby5sb2NhbDCCASIwDQYJKoZIhvcN -AQEBBQADggEPADCCAQoCggEBANATy1RzkNjmsWh9x3S8HfiqSc63pYWvrkPJyX8W -goj8YnaA066Eht30zmTDZ/13YhFweqxV2Oi1gbfRRTHMVUmdRuz5ToekDGBpSUiO -dwj4kQKp/RrezNir4lYzm7tA5yJ++TOBDlK0WaQHURu2jfz2tnBTim6LL8drv9Rm -xOgi0tzamqXaGIgHjQ26jH2Cf/u3ZbpPe+hVap2fdkj2M0ZUyU0jwS0CWDfNrntU -V2IaCeNOuV0VkSNYgagFlOjAPa3sHjbIevEGBtmRYHMjY8W50J7hzClcN2q6ZPWD -CJpE2efvqwarfE9I4GfkLRsIxUfbVVDkXnyYbs95yKlrC98CAwEAATANBgkqhkiG -9w0BAQsFAAOCAQEAqZ7Z58MI+847kZDsBQWWK9tKXOnBZFjuUqM/MbnKfy684GeZ -yX5RdZGa+qzcW781J+4XLhkcp/OSNzZn9R93jzxyv4/LsUCX9Ctk5gthcElRkA0h -lPfzbcW0X+JgOS6WQmHwIizmoKrPWfCnXRe3texTUS+OJul2RYNqLZVZ4qEkwiur -F5/j0xYtv9CkYwixMfgo3ZLRh76NwDsGz/9UFubSeB985lDNSIj8SxpaOHPVysjM -IP0WMyIDIVOPwlJ+miKkd1kMjDsOhB2zCBXKd7kuq0AkDSzJsE9XJneGVK419KZR -EIbn+AXfETN6t4/EtyRN6xUxbYmibSNT2Z3Vww== ------END CERTIFICATE----- diff --git a/mosquitto/basic.conf b/mosquitto/basic.conf index 19c4d30..80eb502 100644 --- a/mosquitto/basic.conf +++ b/mosquitto/basic.conf @@ -8,8 +8,8 @@ allow_anonymous true listener 8883 # replace with your CA Root -cafile ../certs/ca.crt +cafile certs/ca.crt # replace with your server certificate and key paths -certfile ../certs/server.crt -keyfile ../certs/server.key +certfile certs/server.crt +keyfile certs/server.key diff --git a/package-lock.json b/package-lock.json index f202fd9..2b76914 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "meross": "bin/meross" }, "engines": { - "node": ">=18.0.0" + "node": ">=18.20 ^22.11" } }, "node_modules/@cronvel/get-pixels": { diff --git a/package.json b/package.json index 482f941..9c0d398 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,6 @@ "uuid4": "^2.0.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=18.20 ^22.11" } } \ No newline at end of file From 703ab75af10d0de5fe6c3859c1ea5c1c9dbfb7ee Mon Sep 17 00:00:00 2001 From: Rob Griffiths Date: Mon, 13 Jan 2025 16:07:18 +0000 Subject: [PATCH 05/18] use fixed engine range, remove unused deps --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 9c0d398..aa7125e 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "dependencies": { "axios": "^0.28.0", "commander": "^7.2", - "got": "^13.0.0", "md5": "^2.2.1", "terminal-kit": "^3.0.1", "uuid4": "^2.0.2" From 0c7f61090e30264675b33e42c7590ccd315b207a Mon Sep 17 00:00:00 2001 From: Rob Griffiths Date: Mon, 13 Jan 2025 16:08:37 +0000 Subject: [PATCH 06/18] removed old directories from .gitignore --- .gitignore | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index ccce27f..c7f6c7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -bin/src/node_modules -cmd/ -dumps/ -old_certs/ +# directories +node_modules/ + +#files \ No newline at end of file From 47c8a568e389d1950ec1995409c88ac920cd1f24 Mon Sep 17 00:00:00 2001 From: Rob Griffiths Date: Mon, 13 Jan 2025 19:27:32 +0000 Subject: [PATCH 07/18] updated node version --- .github/workflows/npm-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 648cd6b..d7c4624 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 20 - run: npm ci - run: npm test @@ -26,7 +26,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 20 registry-url: https://registry.npmjs.org/ - run: npm ci - run: npm publish From 3a74fce92eb9988fcdd588f9deb344195b3b9f3c Mon Sep 17 00:00:00 2001 From: Rob Griffiths Date: Mon, 13 Jan 2025 19:28:09 +0000 Subject: [PATCH 08/18] add vscode workspace settings --- .vscode/settings.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..10af2e8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "editor.tabSize": 2, + "editor.defaultFormatter": "esbenp.prettier-vscode" +} From aade0b53bccead20276fc2ac3d7129a9781b1782 Mon Sep 17 00:00:00 2001 From: Rob Griffiths Date: Mon, 13 Jan 2025 19:29:11 +0000 Subject: [PATCH 09/18] updated dockerfile example --- Dockerfile | 17 ++++++++++------- mosquitto/basic.conf | 12 ++++++------ 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index 07e6c9e..a1209fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,16 @@ FROM eclipse-mosquitto:1.6.15-openssl -COPY mosquitto/basic.conf ./mosquitto/config/ +COPY mosquitto/basic.conf ./mosquitto/config/mosquitto.conf RUN apk add --update --no-cache openssl && \ - mkdir ./mosquitto/config/certs && \ - cd ./mosquitto/config/certs && \ - openssl req -nodes -new -x509 -keyout ca.key -out ca.crt -subj "/CN=mosquitto" -days 3650 && \ - openssl req -sha256 -nodes -newkey rsa:2048 -keyout server.key -out server.csr -subj "/CN=localhost-mosquitto" && \ - openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 3650 -RUN chown -R mosquitto:mosquitto ./mosquitto + mkdir /mosquitto/config/certs && \ + cd /mosquitto/config/certs && \ + openssl genrsa -out ca.key 2048 && \ + openssl req -x509 -new -nodes -key ca.key -days 3650 -out ca.crt -subj '/CN=My Root' && \ + openssl req -new -nodes -out server.csr -newkey rsa:2048 -keyout server.key -subj '/CN=Mosquitto' && \ + openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 3650 && \ + c_rehash . && \ + chown -R mosquitto:mosquitto /mosquitto && \ + chmod 600 /mosquitto/config/certs/* EXPOSE 1883 EXPOSE 8883 \ No newline at end of file diff --git a/mosquitto/basic.conf b/mosquitto/basic.conf index 80eb502..f4dbc98 100644 --- a/mosquitto/basic.conf +++ b/mosquitto/basic.conf @@ -2,14 +2,14 @@ log_type all log_dest stdout use_username_as_clientid true -require_certificate false - -allow_anonymous true listener 8883 # replace with your CA Root -cafile certs/ca.crt +cafile /mosquitto/config/certs/ca.crt # replace with your server certificate and key paths -certfile certs/server.crt -keyfile certs/server.key +keyfile /mosquitto/config/certs/server.key +certfile /mosquitto/config/certs/server.crt + +allow_anonymous true +require_certificate false \ No newline at end of file From a342da73d1a74afaa2adfe87961a61dc9596cafa Mon Sep 17 00:00:00 2001 From: Rob Griffiths Date: Mon, 13 Jan 2025 19:29:47 +0000 Subject: [PATCH 10/18] coding style --- .prettierrc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.prettierrc b/.prettierrc index 4dc8b16..9571dda 100644 --- a/.prettierrc +++ b/.prettierrc @@ -3,5 +3,7 @@ "useTabs": false, "semi": true, "arrowParens": "always", - "singleQuote": true + "singleQuote": true, + "trailingComma": "all", + "printWidth": 80 } \ No newline at end of file From 7023fdd15218e7a4005200189b565b6ebfb401b9 Mon Sep 17 00:00:00 2001 From: Rob Griffiths Date: Mon, 13 Jan 2025 19:31:14 +0000 Subject: [PATCH 11/18] information about CVE-2022-32214 resolution in node >21 that affects parsing device responses --- README.md | 3 +- lib/api.js | 767 +++++++++++++++++++++++++++-------------------------- 2 files changed, 400 insertions(+), 370 deletions(-) diff --git a/README.md b/README.md index 9d9c657..0a4121e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ Tools to help configure the Meross devices for purpose of utilising our =18.0.0 <19.0.0-0 >=20.0.0 <21.0.0-0.). +Requires `node` >=18 +For Node.js >=21 you need to prepend commands with `NODE_OPTIONS='--insecure-http-parser'`. This is because the responses from some (if not all) versions of the Meross firmware incorrectly terminate headers with LF instead of CRLF. [CVE-2022-32214](https://nvd.nist.gov/vuln/detail/CVE-2022-32214) ## Home Assistant diff --git a/lib/api.js b/lib/api.js index 67f52c7..b119828 100644 --- a/lib/api.js +++ b/lib/api.js @@ -1,422 +1,451 @@ -'use strict' +'use strict'; -if (typeof (URL) === 'undefined') { - var URL = class URL { - constructor(url) { - return require('url').parse(url) - } - } -} +const { URL } = require('url'); -const util = require('util') -const uuid4 = require('uuid4') -const md5 = require('md5') -const term = require('terminal-kit').terminal -const axios = require('axios') -const crypto = require('crypto') +const util = require('util'); +const uuid = require('uuid'); +const md5 = require('md5'); +const term = require('terminal-kit').terminal; +const axios = require('axios'); +const crypto = require('crypto'); -const axiosInstanace = axios.create(); -axiosInstanace.defaults.timeout = 10000; +const axiosInstance = axios.create(); +axiosInstance.defaults.timeout = 10000; const cleanServerUrl = (server) => { - server = /mqtts?:\/\//.test(server) ? server : 'mqtt://' + server // add protocol - server = /:(?:6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{1,3}|[0-9])/.test(server) ? server : (server + ':' + (server.indexOf('mqtts://') > -1 ? 8883 : 1883)) + server = /mqtts?:\/\//.test(server) ? server : 'mqtt://' + server; // add protocol + server = + /:(?:6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{1,3}|[0-9])/.test( + server, + ) + ? server + : server + ':' + (server.indexOf('mqtts://') > -1 ? 8883 : 1883); - return server -} + return server; +}; -const serverRegex = /((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])|(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])):(?:6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{1,3}|[0-9])$/ - -const base64Encode = str => Buffer.from(str).toString('base64') -const base64Decode = str => Buffer.from(str, 'base64').toString('utf8') +const base64Encode = (str) => Buffer.from(str).toString('base64'); +const base64Decode = (str) => Buffer.from(str, 'base64').toString('utf8'); const tableOptions = { - hasBorder: true, - borderChars: 'light', - contentHasMarkup: true, - fit: true, - width: 95, - firstColumnTextAttr: { color: 'yellow' } -} + hasBorder: true, + borderChars: 'light', + contentHasMarkup: true, + fit: true, + width: 95, + firstColumnTextAttr: { color: 'yellow' }, +}; -const percentToColor = percent => percent > .7 ? '^G' : (percent > .5 ? '^Y' : (percent > .30 ? '^y' : '^r')) +const percentToColor = (percent) => + percent > 0.7 ? '^G' : percent > 0.5 ? '^Y' : percent > 0.3 ? '^y' : '^r'; const bar = (percent, width) => { - const partials = ['▏', '▎', '▍', '▌', '▋', '▊', '▉'] - let ticks = percent * width - if (ticks < 0) { - ticks = 0 - } - let filled = Math.floor(ticks) - let open = bar.width - filled - 1 - return (percentToColor(percent) + '▉').repeat(filled) + partials[Math.floor((ticks - filled) * partials.length)] + ' '.repeat(open) -} + const partials = ['▏', '▎', '▍', '▌', '▋', '▊', '▉']; + let ticks = percent * width; + if (ticks < 0) { + ticks = 0; + } + let filled = Math.floor(ticks); + let open = bar.width - filled - 1; + return ( + (percentToColor(percent) + '▉').repeat(filled) + + partials[Math.floor((ticks - filled) * partials.length)] + + ' '.repeat(open) + ); +}; const filterUndefined = (obj) => { - for (const key in obj) { - if (undefined === obj[key]) { - delete obj[key] - } + for (const key in obj) { + if (undefined === obj[key]) { + delete obj[key]; } + } - return obj -} + return obj; +}; function logRequest(request) { - let url = new URL(request.url); - console.log(`> ${request.method.toUpperCase()} ${url.path}`) - console.log(`> Host: ${url.host}`) - - let headers = {} - headers = Object.assign(headers, request.headers.common); - headers = Object.assign(headers, request.headers[request.method]); - headers = Object.assign(headers, Object.fromEntries( - Object.entries(request.headers).filter( - ([header]) => !['common', 'delete', 'get', 'head', 'post', 'put', 'patch'].includes(header) - ) - )); + const url = new URL(request.url, 'http://unknown'); + const method = request.method ? request.method.toUpperCase() : 'GET'; + + console.log(`> ${method} ${url.path}`); + console.log(`> Host: ${url.host}`); + + if (request.headers) { + let headers = { + ...request.headers.common, + ...request.headers[method], + ...Object.fromEntries( + Object.entries(request?.headers).filter(([header]) => !['common', 'delete', 'get', 'head', 'post', 'put', 'patch'].includes(header) + )), + }; + for (let [header, value] of Object.entries(headers)) { - console.log(`> ${header}: ${value}`) + console.log(`> ${header}: ${value}`); } + } - console.log('>') - console.log(util.inspect(request.data, { showHidden: false, depth: null })) - console.log('') + console.log('>'); + console.log(util.inspect(request.data, { showHidden: false, depth: null })); + console.log(''); } function logResponse(response) { - console.log(`< ${response.status} ${response.statusText}`) - for (const [header, value] of Object.entries(response.headers)) { - console.log(`< ${header}: ${value}`) - } - console.log('<') - console.log(util.inspect(response.data, { showHidden: false, depth: null })) - console.log('') + console.log(`< ${response.status} ${response.statusText}`); + for (const [header, value] of Object.entries(response.headers)) { + console.log(`< ${header}: ${value}`); + } + console.log('<'); + console.log(util.inspect(response.data, { showHidden: false, depth: null })); + console.log(''); } function handleRequestError(error, verbose) { - if (verbose) { - if (error.code === 'ECONNRESET' || error.code === 'ECONNABORTED') { - let hint = ''; - if (error.config.url === 'http://10.10.10.1/config') { - hint = "\nAre you connected to the device's Access Point?"; - } - console.error('Error', 'Unable to connect to device' + hint); - process.exit(); - } else if (error.response) { - logResponse(error.response) - } else if (error.request) { - logRequest(error.request) - } else { - console.error('Error', error.message); - } + if (error.code === 'HPE_CR_EXPECTED') { + console.error(`Please append NODE_OPTIONS='--insecure-parser' to your command.`); + process.exit(1); + } + + if (error.code === 'ECONNRESET' || error.code === 'ECONNABORTED') { + let hint = ''; + if (error.config.url === 'http://10.10.10.1/config') { + hint = "\nAre you connected to the device's Access Point?"; + } + console.error('Error', 'Unable to connect to device' + hint); + process.exit(1); + } + + if (verbose) { + if (error.response) { + logResponse(error.response); + } else if (error.request) { + logRequest(error.request); } else { - console.error('Error', 'Unable to connect to device'); + console.error('Error', error.message); } + } else { + console.error('Error', 'Unable to connect to device'); + } } module.exports = class API { - constructor(host, key, userId, verbose = false) { - this.host = host - this.key = key - this.userId = userId - this.verbose = verbose - - axiosInstanace.interceptors.request.use(request => { - if (verbose) { - logRequest(request) - } - return request - }) - - axiosInstanace.interceptors.response.use(response => { - if (verbose) { - logResponse(response) - } - return response - }) + constructor(host, key, userId, verbose = false) { + this.host = host; + this.key = key; + this.userId = userId; + this.verbose = verbose; + + axiosInstance.interceptors.request.use((request) => { + if (verbose) { + logRequest(request); + } + return request; + }); + + axiosInstance.interceptors.response.use((response) => { + if (verbose) { + logResponse(response); + } + return response; + }); + } + + signPacket(packet) { + const messageId = md5(uuid.v4()); + const timestamp = Math.floor(Date.now() / 1000); + const signature = md5(messageId + this.key + timestamp); + + packet.header.messageId = messageId; + packet.header.timestamp = timestamp; + packet.header.sign = signature; + + return packet; + } + + async deviceInformation() { + const data = await this.deviceInformationData(); + + const system = data.system; + const digest = data.digest; + const hw = system.hardware; + const fw = system.firmware; + + let rows = [ + [ + 'Device', + `${hw.type} ${hw.subType} ${hw.chipType} (hardware:${hw.version} firmware:${fw.version})`, + ], + ['UUID', hw.uuid], + ['Mac address', hw.macAddress], + ['IP address', fw.innerIp], + ]; + + if (fw.server) { + rows.push(['Current MQTT broker', `${fw.server}:${fw.port}`]); } - signPacket(packet) { - const messageId = md5(uuid4()) - const timestamp = Math.floor(Date.now() / 1000) - const signature = md5(messageId + this.key + timestamp) - - packet.header.messageId = messageId - packet.header.timestamp = timestamp - packet.header.sign = signature - - return packet - } - - async deviceInformation() { - const data = await this.deviceInformationData() - - const system = data.system - const digest = data.digest - const hw = system.hardware - const fw = system.firmware - - let rows = [ - ['Device', `${hw.type} ${hw.subType} ${hw.chipType} (hardware:${hw.version} firmware:${fw.version})`], - ['UUID', hw.uuid], - ['Mac address', hw.macAddress], - ['IP address', fw.innerIp], - ]; - - if (fw.server) { - rows.push( - ['Current MQTT broker', `${fw.server}:${fw.port}`] - ) + rows.push( + [ + 'Credentials', + `User: ^C${hw.macAddress}\nPassword: ^C${this.calculateDevicePassword( + hw.macAddress, + fw.userId, + )}`, + ], + [ + 'MQTT topics', + `Publishes to: ^C/appliance/${hw.uuid}/publish\nSubscribes to: ^C/appliance/${hw.uuid}/subscribe`, + ], + ); + + term.table(rows, tableOptions); + } + + async deviceInformationData() { + const packet = this.signPacket({ + header: { + from: '', + method: 'GET', + namespace: 'Appliance.System.All', + }, + payload: {}, + }); + + try { + const response = await axiosInstance.post( + `http://${this.host}/config`, + packet, + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + const data = response.data; + + if ('error' in data.payload) { + let { code, message } = data.payload.error; + + switch (code) { + case 5001: + console.error('Incorrect shared key provided.'); + break; } - rows.push( - ['Credentials', `User: ^C${hw.macAddress}\nPassword: ^C${this.calculateDevicePassword(hw.macAddress, fw.userId)}`], - ['MQTT topics', `Publishes to: ^C/appliance/${hw.uuid}/publish\nSubscribes to: ^C/appliance/${hw.uuid}/subscribe`] - ) + return; + } - term.table( - rows, - tableOptions - ) + return data.payload.all; + } catch (error) { + handleRequestError(error, this.verbose); } - - async deviceInformationData() { - const packet = this.signPacket({ - 'header': { - 'from': '', - 'method': 'GET', - 'namespace': 'Appliance.System.All' - }, - 'payload': {} - }) - - try { - const response = await axiosInstanace.post( - `http://${this.host}/config`, - packet, - { - headers: { - 'Content-Type': 'application/json' - }, - } - ) - - const data = response.data; - - if ('error' in data.payload) { - let { code, message } = data.payload.error; - - switch (code) { - case 5001: - console.error('Incorrect shared key provided.') - break; - } - - return - } - - return data.payload.all - } catch (error) { - handleRequestError(error, this.verbose) + } + + async deviceWifiList() { + const packet = this.signPacket({ + header: { + from: '', + method: 'GET', + namespace: 'Appliance.Config.WifiList', + }, + payload: {}, + }); + + try { + let spinner = await term.spinner({ + animation: 'dotSpinner', + rightPadding: ' ', + }); + term('Getting WIFI list…\n'); + + const response = await axiosInstance.post( + `http://${this.host}/config`, + packet, + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + spinner.animate(false); + + const data = response.data; + + if ('error' in data.payload) { + let { code, message } = data.payload.error; + + switch (code) { + case 5001: + console.error('Incorrect shared key provided.'); + break; } - } - async deviceWifiList() { - const packet = this.signPacket({ - 'header': { - 'from': '', - 'method': 'GET', - 'namespace': 'Appliance.Config.WifiList' - }, - 'payload': {} - }) - - try { - let spinner = await term.spinner({ animation: 'dotSpinner', rightPadding: ' ' }) - term('Getting WIFI list…\n') - - const response = await axiosInstanace.post( - `http://${this.host}/config`, - packet, - { - headers: { - 'Content-Type': 'application/json' - }, - } - ) - - - spinner.animate(false) - - const data = response.data; - - if ('error' in data.payload) { - let { code, message } = data.payload.error; - - switch (code) { - case 5001: - console.error('Incorrect shared key provided.') - break; - } - - return - } + return; + } - const wifiList = data.payload.wifiList + const wifiList = data.payload.wifiList; - let rows = [ - ['WIFI', 'Signal strength'], - ]; + let rows = [['WIFI', 'Signal strength']]; - for (const ap of wifiList) { - const decodedSsid = base64Decode(ap.ssid); - rows.push([ - `${decodedSsid ? decodedSsid : ''}\n^B${ap.bssid}^ ^+^YCh:^ ${ap.channel} ^+^YEncryption:^ ${ap.encryption} ^+^YCipher:^ ${ap.cipher}`, - bar((ap.signal / 100), 20) - ]) - } + for (const ap of wifiList) { + const decodedSsid = base64Decode(ap.ssid); + rows.push([ + `${decodedSsid ? decodedSsid : ''}\n^B${ap.bssid}^ ^+^YCh:^ ${ap.channel + } ^+^YEncryption:^ ${ap.encryption} ^+^YCipher:^ ${ap.cipher}`, + bar(ap.signal / 100, 20), + ]); + } - let thisTableOptions = tableOptions - thisTableOptions.firstColumnTextAttr = { color: 'cyan' } - thisTableOptions.firstRowTextAttr = { color: 'yellow' } + let thisTableOptions = tableOptions; + thisTableOptions.firstColumnTextAttr = { color: 'cyan' }; + thisTableOptions.firstRowTextAttr = { color: 'yellow' }; - term.table( - rows, - tableOptions - ) - } catch (error) { - handleRequestError(error, this.verbose) - } + term.table(rows, tableOptions); + } catch (error) { + handleRequestError(error, this.verbose); } - - async configureMqttServers(mqtt) { - const servers = mqtt.map((server) => { - server = cleanServerUrl(server) - - const url = new URL(server) - return { - host: url.hostname, - port: url.port + '' - } - }).slice(0, 2) - - // make sure we set a failover server - if (servers.length == 1) { - servers.push(servers[0]); - } - - let rows = []; - for (let s = 0; s < servers.length; s++) { - let server = servers[s]; - rows.push([ - `${s > 0 ? 'Failover' : 'Primary'} MQTT broker`, - `${server.host}:${server.port}` - ]) - } - - term.table(rows, tableOptions) - - const packet = this.signPacket({ - 'header': { - 'from': '', - 'method': 'SET', - 'namespace': 'Appliance.Config.Key' - }, - 'payload': { - 'key': { - 'userId': this.userId + '', - 'key': this.key + '', - 'gateway': ((servers) => { - const gateway = servers[0] - - if (servers.length > 1) { - gateway.secondHost = servers[1].host - gateway.secondPort = servers[1].port - } - - gateway.redirect = 1; - - return gateway - })(servers) - } - } - }) - - try { - const response = await axiosInstanace.post( - `http://${this.host}/config`, - packet, - { - headers: { - 'Content-Type': 'application/json' - }, - } - ) - } catch (error) { - handleRequestError(error, this.verbose) - } + } + + async configureMqttServers(mqtt) { + const servers = mqtt + .map((server) => { + server = cleanServerUrl(server); + + const url = new URL(server); + return { + host: url.hostname, + port: url.port + '', + }; + }) + .slice(0, 2); + + // make sure we set a failover server + if (servers.length == 1) { + servers.push(servers[0]); } - async configureWifiCredentials(credentials, useWifiX = null) { - const ssid = base64Encode(credentials.ssid) - const namespace = useWifiX ? 'Appliance.Config.WifiX' : 'Appliance.Config.Wifi' - const password = useWifiX ? await this.encryptPassword(credentials.password) : base64Encode(credentials.password) - - const packet = this.signPacket({ - 'header': { - 'from': '', - 'method': 'SET', - 'namespace': namespace - }, - 'payload': { - 'wifi': { - ...filterUndefined(credentials), - ssid, - password, - } - } - }) - - try { - const response = await axiosInstanace.post( - `http://${this.host}/config`, - packet, - { - headers: { - 'Content-Type': 'application/json' - }, - } - ) - } catch (error) { - handleRequestError(error, this.verbose) - } + let rows = []; + for (let s = 0; s < servers.length; s++) { + let server = servers[s]; + rows.push([ + `${s > 0 ? 'Failover' : 'Primary'} MQTT broker`, + `${server.host}:${server.port}`, + ]); } - async encryptPassword(password) { - const data = await this.deviceInformationData(); - - return this.calculateWifiXPassword(password, - data.system.hardware.type, - data.system.hardware.uuid, - data.system.hardware.macAddress) - } + term.table(rows, tableOptions); + + const packet = this.signPacket({ + header: { + from: '', + method: 'SET', + namespace: 'Appliance.Config.Key', + }, + payload: { + key: { + userId: this.userId + '', + key: this.key + '', + gateway: ((servers) => { + const gateway = servers[0]; + + if (servers.length > 1) { + gateway.secondHost = servers[1].host; + gateway.secondPort = servers[1].port; + } - calculateDevicePassword(macAddress, userId = 0) { - return `${userId}_${md5(macAddress + '' + this.key)}` + gateway.redirect = 1; + + return gateway; + })(servers), + }, + }, + }); + + try { + const response = await axiosInstance.post( + `http://${this.host}/config`, + packet, + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + } catch (error) { + handleRequestError(error, this.verbose); } - - calculateWifiXPassword(password, type, uuid, macAddress) { - const key = Buffer.from(md5(type + uuid + macAddress).toString('hex'), 'utf8') - const iv = Buffer.from('0000000000000000', 'utf8') - const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); - - const count = Math.ceil(password.length / 16) * 16; - const padded = password.padEnd(count, '\0') - - let encrypted = cipher.update(padded, 'utf8', 'base64'); - encrypted += cipher.final('base64') - - return encrypted + } + + async configureWifiCredentials(credentials, useWifiX = null) { + const ssid = base64Encode(credentials.ssid); + const namespace = useWifiX + ? 'Appliance.Config.WifiX' + : 'Appliance.Config.Wifi'; + const password = useWifiX + ? await this.encryptPassword(credentials.password) + : base64Encode(credentials.password); + + const packet = this.signPacket({ + header: { + from: '', + method: 'SET', + namespace: namespace, + }, + payload: { + wifi: { + ...filterUndefined(credentials), + ssid, + password, + }, + }, + }); + + try { + const response = await axiosInstance.post( + `http://${this.host}/config`, + packet, + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + } catch (error) { + handleRequestError(error, this.verbose); } -} + } + + async encryptPassword(password) { + const data = await this.deviceInformationData(); + + return this.calculateWifiXPassword( + password, + data.system.hardware.type, + data.system.hardware.uuid, + data.system.hardware.macAddress, + ); + } + + calculateDevicePassword(macAddress, userId = 0) { + return `${userId}_${md5(macAddress + '' + this.key)}`; + } + + calculateWifiXPassword(password, type, uuid, macAddress) { + const key = Buffer.from( + md5(type + uuid + macAddress).toString('hex'), + 'utf8', + ); + const iv = Buffer.from('0000000000000000', 'utf8'); + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); + + const count = Math.ceil(password.length / 16) * 16; + const padded = password.padEnd(count, '\0'); + + let encrypted = cipher.update(padded, 'utf8', 'base64'); + encrypted += cipher.final('base64'); + + return encrypted; + } +}; From ed25d148e84c30ab8f151c4b73178765092b5e42 Mon Sep 17 00:00:00 2001 From: Rob Griffiths Date: Mon, 13 Jan 2025 19:31:59 +0000 Subject: [PATCH 12/18] update packages and engines --- package-lock.json | 267 ++++------------------------------------------ package.json | 6 +- 2 files changed, 26 insertions(+), 247 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2b76914..b2f81a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,18 +9,17 @@ "version": "1.0.15", "license": "ISC", "dependencies": { - "axios": "^0.28.0", + "axios": "^1.7.9", "commander": "^7.2", - "got": "^13.0.0", "md5": "^2.2.1", "terminal-kit": "^3.0.1", - "uuid4": "^2.0.2" + "uuid": "^11.0.5" }, "bin": { "meross": "bin/meross" }, "engines": { - "node": ">=18.20 ^22.11" + "node": "^18.12 || ^20.9.0 || ^22.11" } }, "node_modules/@cronvel/get-pixels": { @@ -36,73 +35,21 @@ "pngjs": "^6.0.0" } }, - "node_modules/@sindresorhus/is": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", - "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@szmarczak/http-timer": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", - "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", - "dependencies": { - "defer-to-connect": "^2.0.1" - }, - "engines": { - "node": ">=14.16" - } - }, - "node_modules/@types/http-cache-semantics": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.3.tgz", - "integrity": "sha512-V46MYLFp08Wf2mmaBhvgjStM3tPa+2GAdy/iqoX+noX1//zje2x4XmrIU0cAwyClATsTmahbtoQ2EwP7I5WSiA==" - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.28.0.tgz", - "integrity": "sha512-Tu7NYoGY4Yoc7I+Npf9HhUMtEEpV7ZiLH9yndTCoNhcpBH0kwcvFbzYN9/u5QKI5A6uefjsNNWaz5olJVYS62Q==", + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", "dependencies": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } }, - "node_modules/cacheable-lookup": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", - "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", - "engines": { - "node": ">=14.16" - } - }, - "node_modules/cacheable-request": { - "version": "10.2.14", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz", - "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==", - "dependencies": { - "@types/http-cache-semantics": "^4.0.2", - "get-stream": "^6.0.1", - "http-cache-semantics": "^4.1.1", - "keyv": "^4.5.3", - "mimic-response": "^4.0.0", - "normalize-url": "^8.0.0", - "responselike": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - } - }, "node_modules/charenc": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", @@ -151,39 +98,6 @@ "uniq": "^1.0.0" } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decompress-response/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "engines": { - "node": ">=10" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -193,15 +107,16 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -224,66 +139,6 @@ "node": ">= 6" } }, - "node_modules/form-data-encoder": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", - "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", - "engines": { - "node": ">= 14.17" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/got": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/got/-/got-13.0.0.tgz", - "integrity": "sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA==", - "dependencies": { - "@sindresorhus/is": "^5.2.0", - "@szmarczak/http-timer": "^5.0.1", - "cacheable-lookup": "^7.0.0", - "cacheable-request": "^10.2.8", - "decompress-response": "^6.0.0", - "form-data-encoder": "^2.1.2", - "get-stream": "^6.0.1", - "http2-wrapper": "^2.1.10", - "lowercase-keys": "^3.0.0", - "p-cancelable": "^3.0.0", - "responselike": "^3.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, - "node_modules/http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" - }, - "node_modules/http2-wrapper": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.0.tgz", - "integrity": "sha512-kZB0wxMo0sh1PehyjJUWRFEd99KC5TLjZ2cULC4f9iqJBAmKQQXEICjxl5iPJRwP40dpeHFqqhm7tYCvODpqpQ==", - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.2.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, "node_modules/iota-array": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz", @@ -299,19 +154,6 @@ "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==" }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dependencies": { - "json-buffer": "3.0.1" - } - }, "node_modules/lazyness": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/lazyness/-/lazyness-1.2.0.tgz", @@ -320,17 +162,6 @@ "node": ">=6.0.0" } }, - "node_modules/lowercase-keys": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", - "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/md5": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", @@ -360,17 +191,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-response": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", - "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ndarray": { "version": "1.0.19", "resolved": "https://registry.npmjs.org/ndarray/-/ndarray-1.0.19.tgz", @@ -405,30 +225,11 @@ "node": ">=v0.6.5" } }, - "node_modules/normalize-url": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.0.tgz", - "integrity": "sha512-uVFpKhj5MheNBJRTiMZ9pE/7hD1QTeEvugSJW/OmLzAp78PB5O6adfMNTvmfKhXBkvCzC+rqifWcVYpGFwTjnw==", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/omggif": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==" }, - "node_modules/p-cancelable": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", - "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", - "engines": { - "node": ">=12.20" - } - }, "node_modules/pngjs": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", @@ -442,36 +243,6 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/resolve-alpn": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" - }, - "node_modules/responselike": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", - "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", - "dependencies": { - "lowercase-keys": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -527,10 +298,18 @@ "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", "integrity": "sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==" }, - "node_modules/uuid4": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/uuid4/-/uuid4-2.0.3.tgz", - "integrity": "sha512-CTpAkEVXMNJl2ojgtpLXHgz23dh8z81u6/HEPiQFOvBc/c2pde6TVHmH4uwY0d/GLF3tb7+VDAj4+2eJaQSdZQ==" + "node_modules/uuid": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } } } } diff --git a/package.json b/package.json index aa7125e..59ab6c2 100644 --- a/package.json +++ b/package.json @@ -18,13 +18,13 @@ "repository": "https://github.com/bytespider/Meross/tree/master", "license": "ISC", "dependencies": { - "axios": "^0.28.0", + "axios": "^1.7.9", "commander": "^7.2", "md5": "^2.2.1", "terminal-kit": "^3.0.1", - "uuid4": "^2.0.2" + "uuid": "^11.0.5" }, "engines": { - "node": ">=18.20 ^22.11" + "node": "^18.12 || ^20.9.0 || >22.11" } } \ No newline at end of file From d73502ce3281dc05ee39c36d17c6c088ad73d766 Mon Sep 17 00:00:00 2001 From: Rob Griffiths Date: Mon, 13 Jan 2025 19:39:28 +0000 Subject: [PATCH 13/18] update to new versions --- .github/workflows/npm-publish.yml | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index d7c4624..e7ff733 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -1,34 +1,25 @@ # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages -name: Node.js Package +name: Publish Package to npmjs on: release: - types: [created] - workflow_dispatch: + types: [published] + branches: + - main jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: '20.x' + registry-url: https://registry.npmjs.org - run: npm ci - run: npm test - - publish-npm: - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - with: - node-version: 20 - registry-url: https://registry.npmjs.org/ - - run: npm ci - - run: npm publish + - run: npm publish --access public env: NODE_AUTH_TOKEN: ${{secrets.npm_token}} From 0d9dd4392ab7b5180555e76112ed87697f97a25d Mon Sep 17 00:00:00 2001 From: Rob Griffiths Date: Mon, 13 Jan 2025 20:05:08 +0000 Subject: [PATCH 14/18] 1.0.16 --- package-lock.json | 4 ++-- package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index b2f81a3..4a54b93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meross", - "version": "1.0.15", + "version": "1.0.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meross", - "version": "1.0.15", + "version": "1.0.16", "license": "ISC", "dependencies": { "axios": "^1.7.9", diff --git a/package.json b/package.json index 59ab6c2..6230f3e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meross", - "version": "1.0.15", + "version": "1.0.16", "description": "Utility to configure Meross devices for local MQTT", "keywords": [ "smarthome", @@ -27,4 +27,4 @@ "engines": { "node": "^18.12 || ^20.9.0 || >22.11" } -} \ No newline at end of file +} From e8b32b9479ef2dc5a58114f7e68e6beb54a4d976 Mon Sep 17 00:00:00 2001 From: Rob Griffiths Date: Sat, 2 Aug 2025 17:08:09 +0100 Subject: [PATCH 15/18] Squashed commit of the following: commit f7f3056398222e68f1cd0495e5bd6893b4ce6f00 Author: Rob Griffiths Date: Sat Aug 2 17:01:27 2025 +0100 Namespace property is part of the message header not the message commit f1515bd4ed273881582dbc20d61ee8eef812a3b6 Author: Rob Griffiths Date: Sat Aug 2 16:58:40 2025 +0100 be consistent with imports commit 6d111f832d4e0bded47dd669417abd9f7825f8ea Author: Rob Griffiths Date: Sat Aug 2 16:55:09 2025 +0100 be consistent with imports commit 2d6d04f31c4bc3eb81f0c167a346d280c0a725b3 Author: Rob Griffiths Date: Sat Aug 2 16:43:56 2025 +0100 updated stale config commit 159da96bc46bec011e8559b18006f5e03032f275 Author: Rob Griffiths Date: Sat Aug 2 16:38:55 2025 +0100 updated version commit 271dababca3c9a63b95c729575a39dff2ea5fa7d Author: Rob Griffiths Date: Sat Aug 2 16:38:37 2025 +0100 updated version commit e0fa8d14bffba976947069e0457b83cf34623413 Author: Rob Griffiths Date: Mon Jan 13 19:31:59 2025 +0000 update packages and engines commit cedc7811cfd24c7e38a2c2c441f7c44918e679c5 Author: Rob Griffiths Date: Mon Jan 13 19:29:11 2025 +0000 updated dockerfile example commit 5479784e5a8b2fdcb7d18d9a83c068ce5edb7524 Author: Rob Griffiths Date: Thu Nov 2 08:52:13 2023 +0000 Update and rename npm-ghr-publish.yml to npm-publish.yml Remove GHR commit 9edf85593dda10d001c5aa819b88d02a9adce53a Author: Rob Griffiths Date: Wed Nov 1 12:20:41 2023 +0000 Update npm-ghr-publish.yml commit d72b193f639b03e3b0336cc1fef12247cf94b538 Author: Rob Griffiths Date: Wed Nov 1 12:18:55 2023 +0000 Update npm-ghr-publish.yml commit 30d18df445b6eb03db343300ac1a6c0fd7fff95c Author: Rob Griffiths Date: Wed Nov 1 12:14:33 2023 +0000 Update README.md Update status badge commit a7bfb16f4854a5e9249be4cc4b831cfeda6a5de8 Author: Rob Griffiths Date: Wed Nov 1 12:08:32 2023 +0000 Update npm-ghr-publish.yml added manual run option commit 6c3a0025bf0fc67dcd3d83e3cd094f9bb67a2098 Author: Rob Griffiths Date: Wed Nov 1 11:52:28 2023 +0000 Create npm-ghr-publish.yml commit 30e35c629db82245f0203fc9db92c805b766685c Author: Rob Griffiths Date: Wed Nov 1 11:46:00 2023 +0000 added further error message commit c2226af44417b7bd400d8d1ad01e280eb3de3c70 Author: Rob Griffiths Date: Sat Aug 2 16:21:36 2025 +0100 add npm-publish.yml. Merge issue commit 02eb5aa77fd6d3f5644fed4969a5a3e3c00d3901 Author: Rob Griffiths Date: Sat Aug 2 16:15:48 2025 +0100 remove old files commit 27d746fb03f0320a7f9c06855d464736fcb51e2d Author: Rob Griffiths Date: Sat Aug 2 16:13:20 2025 +0100 fix merge error commit a49de4c443ffa8eafff333cdb3dffdbc1f9002ff Author: Rob Griffiths Date: Sat Aug 2 15:30:07 2025 +0100 updated version commit 5e1da3be548affd215951c0abf9e505196d183cb Author: Rob Griffiths Date: Wed Apr 23 15:53:33 2025 +0100 WIP commit 97c20c7a9c219a006236ed8e12518b52212b348f Author: Rob Griffiths Date: Sat Apr 5 17:58:43 2025 +0100 removed dist commit 330e6810c21a31ee94b9c6eec1e0597cae18bf34 Author: Rob Griffiths Date: Sat Apr 5 17:30:50 2025 +0100 complete typescript rewrite. Added encrypted communication for devices that require it commit 407cb27fe4672fced3910e5e151f9e4482fc4922 Author: Rob Griffiths Date: Sat Jan 25 14:35:05 2025 +0000 remove VERSION file commit fe44f1cd3bed7f1761f9ff7bffd7dcdbe36bdb5b Author: Rob Griffiths Date: Wed Jan 22 09:13:06 2025 +0000 add docker test environment commit 6056061a6c7a693d461e89d92ba682ae8724fe84 Author: Rob Griffiths Date: Wed Dec 27 18:22:21 2023 +0000 updated github action commit 153f32d46c5c8786367a59b2abe937a37e8f7318 Author: Rob Griffiths Date: Wed Dec 27 17:46:41 2023 +0000 Update readme commit 01c2e3e547c29c271b36da694ee75c3375ec964e Author: Rob Griffiths Date: Wed Dec 27 17:20:07 2023 +0000 updated deps commit b8b500eb031995648505dbcf511eeb185778c500 Author: Rob Griffiths Date: Wed Dec 27 17:16:56 2023 +0000 rename functions and document commit a0181b164239bbe781fff47234b4b540e9443aed Author: Rob Griffiths Date: Wed Nov 1 10:50:28 2023 +0000 WIP commit 04c1ac68645b8df2d6ef5d0a3a572f5278296ded Author: Rob Griffiths Date: Tue Oct 31 14:38:41 2023 +0000 rewrite as ESM commit 40a9e49bfc8fb750ce0ab0b5f652ad982dd6171d Author: Rob Griffiths Date: Mon Jan 13 20:26:23 2025 +0000 1.0.16 (#94) * version changes * update node version requirement * Bump axios from 0.21.4 to 0.28.0 Bumps [axios](https://github.com/axios/axios) from 0.21.4 to 0.28.0. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v0.28.0/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v0.21.4...v0.28.0) --- updated-dependencies: - dependency-name: axios dependency-type: direct:production ... Signed-off-by: dependabot[bot] * mosquitto example dockerfile * use fixed engine range, remove unused deps * removed old directories from .gitignore * updated node version * add vscode workspace settings * updated dockerfile example * coding style * information about CVE-2022-32214 resolution in node >21 that affects parsing device responses * update packages and engines * update to new versions * 1.0.16 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> commit 9c5c15495468e990b9791c589829c65e57b0f170 Author: Rob Griffiths Date: Wed Nov 1 11:56:39 2023 +0000 Delete .github/workflows/npm-publish.yml Duplicate workflow commit f78b80b1b99b10fccf9e5571402a417a54a8fbe1 Author: Rob Griffiths Date: Wed Nov 1 11:46:00 2023 +0000 added further error message commit 2d6a1f0cce3508c01d4709d72018cc66f203470f Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu Sep 9 16:51:53 2021 +0000 Bump axios from 0.21.1 to 0.21.2 Bumps [axios](https://github.com/axios/axios) from 0.21.1 to 0.21.2. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/master/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v0.21.1...v0.21.2) --- updated-dependencies: - dependency-name: axios dependency-type: direct:production ... Signed-off-by: dependabot[bot] commit ca7b457b4768ba03089a7069cafe363b6ffb30ad Author: Rob Griffiths Date: Sat Aug 2 15:30:07 2025 +0100 updated version commit 5bd097eedfc203bb68fe0837915095dff4f57df2 Author: Rob Griffiths Date: Mon Jan 13 19:39:28 2025 +0000 update to new versions commit d6239751e56ccbd0a7e7c166a228efc1edef6914 Author: Rob Griffiths Date: Mon Jan 13 19:31:59 2025 +0000 update packages and engines commit bfd40065f32c4dfb1a4579b4bae7c33ae8f2598b Author: Rob Griffiths Date: Mon Jan 13 19:29:11 2025 +0000 updated dockerfile example commit 9a8893e4820ae46513baaa8c8290b007e40952a6 Author: Rob Griffiths Date: Mon Jan 13 19:27:32 2025 +0000 updated node version commit 26766044af93cfc19a9ee1f6f3e8d205b1e2fe16 Author: Rob Griffiths Date: Tue Nov 12 13:06:46 2024 +0000 version changes commit dfc59932bfb3bd9c16b19183511cde90d4bfab83 Author: Rob Griffiths Date: Thu Nov 2 08:52:13 2023 +0000 Update and rename npm-ghr-publish.yml to npm-publish.yml Remove GHR commit c7077cf65f60b59e045a991fdc5d32fd6fdcf1b1 Author: Rob Griffiths Date: Wed Nov 1 12:20:41 2023 +0000 Update npm-ghr-publish.yml commit 68d990e096b289f100856fe0a59ddef9e4372648 Author: Rob Griffiths Date: Wed Nov 1 12:18:55 2023 +0000 Update npm-ghr-publish.yml commit 4ae14d98d51a793cf8b4e95a43def5a8d58a71a4 Author: Rob Griffiths Date: Wed Nov 1 12:14:33 2023 +0000 Update README.md Update status badge commit 0ec01829fb6732d97419cdc9ec8070e82f16ab6e Author: Rob Griffiths Date: Wed Nov 1 12:08:32 2023 +0000 Update npm-ghr-publish.yml added manual run option commit 09bbec1c1036b5f48f31765fd1b2b9068c06b9e5 Author: Rob Griffiths Date: Wed Nov 1 11:56:39 2023 +0000 Delete .github/workflows/npm-publish.yml Duplicate workflow commit 36eee738678a8499c5b42fba5609e0990c00fc13 Author: Rob Griffiths Date: Wed Nov 1 11:52:28 2023 +0000 Create npm-ghr-publish.yml commit 6037b3f20a3b2b1859df850606c99d0a1144165c Author: Rob Griffiths Date: Wed Nov 1 11:46:00 2023 +0000 added further error message commit c64ce569165c981544ba87c85f3a4cf1e9b82213 Author: Simon Porter Date: Wed Jan 11 19:02:04 2023 +0000 add mention of node requirement to docs and enforce via npm commit a553a1a67066825e46db71fd335206e813b26a42 Author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu Sep 9 16:51:53 2021 +0000 Bump axios from 0.21.1 to 0.21.2 Bumps [axios](https://github.com/axios/axios) from 0.21.1 to 0.21.2. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/master/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v0.21.1...v0.21.2) --- updated-dependencies: - dependency-name: axios dependency-type: direct:production ... Signed-off-by: dependabot[bot] commit 85265961f33952891e752351bb332b5e1fcfef8e Author: Rob Griffiths Date: Fri Aug 6 13:33:04 2021 +0100 Added information about factory mode commit 99c0688ff4c0253e19539f69c5de40e3f68a7ce1 Author: Rob Griffiths Date: Sat Aug 2 14:36:27 2025 +0100 Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> commit 2a686c19ab00fdb8e8b6996ac80b86df9753cdc9 Author: Rob Griffiths Date: Sat Aug 2 14:36:18 2025 +0100 Update packages/cli/src/cli.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> commit 179521dab894ca09de34114990a952467e5dfaac Author: Rob Griffiths Date: Wed Apr 23 15:56:35 2025 +0100 update version commit e94d6d8d93d35ef29cfa6ede1600a30f5d434750 Author: Rob Griffiths Date: Wed Apr 23 15:53:33 2025 +0100 WIP commit 3e0a7c8a147e9be94f3a4c0c7c644341ac2b337f Author: Rob Griffiths Date: Sat Apr 12 19:21:42 2025 +0100 2.0.0 commit 97bb1d400c522a2c1210a707eb98953a9173b377 Author: Rob Griffiths Date: Sat Apr 12 19:21:08 2025 +0100 make sure node std libraries are imported with node: prefix commit ba2a46f4f971074e68af3a0efed81190ee05530b Author: Rob Griffiths Date: Sun Apr 6 11:46:26 2025 +0100 fix packages commit 4e67458623372a3343b46cd4165d1f8e56673c1f Author: Rob Griffiths Date: Sun Apr 6 11:40:44 2025 +0100 for build and publishing commit 29862dab600677263e94d85b4848d8a96fe5acc2 Author: Rob Griffiths Date: Sun Apr 6 11:40:26 2025 +0100 for build and publishing commit bfd5d05bba60c776f4550a36c39bf14b2b759604 Author: Rob Griffiths Date: Sun Apr 6 11:40:03 2025 +0100 fix license commit a65d52bc8d987864a11071a86da22d8790cc78b4 Author: Rob Griffiths Date: Sun Apr 6 11:20:35 2025 +0100 ip flag is not required as we default it commit a4244bdedf8be6eb0786ef140c1fa3c2e6e32581 Author: Rob Griffiths Date: Sun Apr 6 11:19:50 2025 +0100 added readme and license commit 1a315a50df23d86379a59e1cb147e7df440c78d2 Author: Rob Griffiths Date: Sat Apr 5 18:03:14 2025 +0100 remove commented reference code commit b2bc8c05db011309af704303023f8a54134b7f57 Author: Rob Griffiths Date: Sat Apr 5 18:03:04 2025 +0100 use strings not buffers commit 011d83b252adf014fcace5bd1ae04fe5fa73717f Author: Rob Griffiths Date: Sat Apr 5 18:01:27 2025 +0100 decide the protocol and the port based on the input commit b6a5e2306124ba96fc844267c3e6e2c95394ec84 Author: Rob Griffiths Date: Sat Apr 5 18:00:37 2025 +0100 allow strings to be passed to base64 encode commit e0e65a9cb479554764ca10c489ad32757c50de38 Author: Rob Griffiths Date: Sat Apr 5 17:59:55 2025 +0100 added dist to ignore commit 43bea72324ab68ccfb4b8abaa1ca5922221bfbdb Author: Rob Griffiths Date: Sat Apr 5 17:58:43 2025 +0100 removed dist commit 920d5ac283f2a457c3b46701635ac825de928509 Author: Rob Griffiths Date: Sat Apr 5 17:30:50 2025 +0100 complete typescript rewrite. Added encrypted communication for devices that require it commit cf1c3d717b4483c9db2e8cd14f701fd50c423d1c Author: Rob Griffiths Date: Sat Jan 25 14:39:05 2025 +0000 add license commit eb4fa814b1b2751463f79c0264c37f50c8efc83f Author: Rob Griffiths Date: Sat Jan 25 14:35:05 2025 +0000 remove VERSION file commit 8938f93aa347789a379369db7070d9448abfe829 Author: Rob Griffiths Date: Wed Jan 22 09:16:15 2025 +0000 clean up ignore files commit bf0d3d522542a35553c420d6b632d98d4b684c35 Author: Rob Griffiths Date: Wed Jan 22 09:13:30 2025 +0000 remove parameters from shebang commit b4ccfb3a081268e5e1cf434eccb37a1d6e60e406 Author: Rob Griffiths Date: Wed Jan 22 09:13:06 2025 +0000 add docker test environment commit a4020c6c9dff60cc72bd61d2aee1dc8ffd8c6122 Author: Rob Griffiths Date: Fri Dec 13 14:33:20 2024 +0000 update to use import attributes rather than import assertions commit e276c8be59a78384488ec8df49efd2790b088cfb Author: Rob Griffiths Date: Fri Dec 13 14:31:52 2024 +0000 update versions commit 3a95cb6e2ef4ffcd98597547ba0130dfc108c193 Author: Rob Griffiths Date: Wed Dec 27 18:22:45 2023 +0000 updated version commit 0c42ba1d56393f3eb579b881864cb62853553a2c Author: Rob Griffiths Date: Wed Dec 27 18:22:21 2023 +0000 updated github action commit c2f3c89dae9261732be07581928587e4bd0728b1 Author: Rob Griffiths Date: Wed Dec 27 18:17:23 2023 +0000 removed old api file commit faeaba1f0482e301a3196e8e513691af155615db Author: Rob Griffiths Date: Wed Dec 27 17:54:12 2023 +0000 Update readme with requirements commit 881381b6d02d85d4a66c7f63cea75a3ffadf0ed0 Author: Rob Griffiths Date: Wed Dec 27 17:47:44 2023 +0000 Update readme commit 0ba0811686abc4139011744b8a371cb672c8e428 Author: Rob Griffiths Date: Wed Dec 27 17:46:41 2023 +0000 Update readme commit ec2eb55df2408193373602764c0557efe9479d5c Author: Rob Griffiths Date: Wed Dec 27 17:46:27 2023 +0000 Proof read options commit eb2e609b9e4c37970b8a3c6804e6aac8d7d48a86 Author: Rob Griffiths Date: Wed Dec 27 17:20:07 2023 +0000 updated deps commit a30ab905862eecc187ffc33962743d2052579b7d Author: Rob Griffiths Date: Wed Dec 27 17:16:56 2023 +0000 rename functions and document commit 3ff072dbd2b41f82b1f5244cc3b4bc345f0da2b5 Author: Rob Griffiths Date: Fri Nov 3 10:23:15 2023 +0000 Use `configure` rather then `set` to imply action over the network commit bb94969e8bbb36a970a7975ae5dc9accd4f3dde2 Author: Rob Griffiths Date: Fri Nov 3 10:01:57 2023 +0000 Remove debuging commit cfaed319e5646a3b8ade31fcc664d33e2125e331 Author: Rob Griffiths Date: Fri Nov 3 09:45:49 2023 +0000 Remove unneeded imports commit 9b1de7f8ee20743eb229810f02fffa2659dd94ee Author: Rob Griffiths Date: Fri Nov 3 09:39:45 2023 +0000 format to standard commit 827ad31d848de3852786a0da0f6f9a6a8df4849c Author: Rob Griffiths Date: Thu Nov 2 18:55:23 2023 +0000 keep code clean commit 5f5d2d95502d6bf7ab52e8a5d33b83c74f879bcc Author: Rob Griffiths Date: Thu Nov 2 18:54:43 2023 +0000 componentise the library commit 244295af2dc1e656c513025d2c64861eb0c10bb7 Author: Rob Griffiths Date: Wed Nov 1 15:04:42 2023 +0000 added missing files commit 631e46bd2a1df1fbf75268cf00d0b362875824a7 Author: Rob Griffiths Date: Wed Nov 1 10:50:28 2023 +0000 WIP commit de3b10e4d46e3e48962d339605dd18693fd54a9d Author: Rob Griffiths Date: Tue Oct 31 14:38:41 2023 +0000 rewrite as ESM --- .github/workflows/stale.yml | 18 +- .gitignore | 6 +- .npmignore | 4 + .prettierrc | 8 +- .vscode/extensions.json | 6 +- .vscode/settings.json | 5 +- LICENSE.md | 15 + README.md | 53 +- bin/meross | 15 - bin/meross-info | 47 -- bin/meross-setup | 115 ---- lib/api.js | 451 ------------- mosquitto/authenticated.conf | 2 +- package-lock.json | 583 +++++++++++----- package.json | 35 +- packages/cli/.npmignore | 6 + packages/cli/LICENSE.md | 15 + packages/cli/README.md | 141 ++++ packages/cli/package.json | 37 + packages/cli/src/cli.ts | 136 ++++ packages/cli/src/meross-info.ts | 115 ++++ packages/cli/src/meross-setup.ts | 204 ++++++ packages/cli/tsconfig.json | 13 + packages/lib/.npmignore | 6 + packages/lib/LICENSE.md | 15 + packages/lib/README.md | 340 ++++++++++ packages/lib/debug.log | 0 packages/lib/package.json | 69 ++ packages/lib/src/cloudCredentials.ts | 36 + packages/lib/src/device.ts | 635 ++++++++++++++++++ packages/lib/src/deviceManager.test.ts | 156 +++++ packages/lib/src/deviceManager.ts | 71 ++ packages/lib/src/encryption.test.ts | 56 ++ packages/lib/src/encryption.ts | 124 ++++ packages/lib/src/index.ts | 8 + .../lib/src/message/configureDeviceTime.ts | 31 + packages/lib/src/message/configureECDH.ts | 29 + .../configureMQTTBrokersAndCredentials.ts | 47 ++ .../lib/src/message/configureWifiMessage.ts | 38 ++ .../lib/src/message/configureWifiXMessage.ts | 25 + packages/lib/src/message/header.test.ts | 42 ++ packages/lib/src/message/header.ts | 132 ++++ packages/lib/src/message/index.ts | 2 + packages/lib/src/message/message.ts | 26 + packages/lib/src/message/messages.ts | 9 + .../lib/src/message/queryDeviceAbilities.ts | 18 + .../lib/src/message/queryDeviceInformation.ts | 18 + packages/lib/src/message/queryDeviceTime.ts | 18 + packages/lib/src/message/queryWifiList.ts | 22 + packages/lib/src/transport/http.test.ts | 89 +++ packages/lib/src/transport/http.ts | 113 ++++ packages/lib/src/transport/index.ts | 2 + packages/lib/src/transport/transport.test.ts | 104 +++ packages/lib/src/transport/transport.ts | 79 +++ packages/lib/src/utils/base64.test.ts | 23 + packages/lib/src/utils/base64.ts | 17 + packages/lib/src/utils/buffer.test.ts | 53 ++ packages/lib/src/utils/buffer.ts | 52 ++ .../src/utils/computeDevicePassword.test.ts | 59 ++ .../lib/src/utils/computeDevicePassword.ts | 13 + .../lib/src/utils/computePresharedKey.test.ts | 72 ++ .../src/utils/computePresharedPrivateKey.ts | 22 + .../lib/src/utils/filterUndefined.test.ts | 48 ++ packages/lib/src/utils/filterUndefined.ts | 5 + packages/lib/src/utils/generateTimestamp.ts | 3 + packages/lib/src/utils/index.ts | 7 + packages/lib/src/utils/logger.ts | 40 ++ packages/lib/src/utils/md5.test.ts | 58 ++ packages/lib/src/utils/md5.ts | 25 + .../lib/src/utils/protocolFromPort.test.ts | 25 + packages/lib/src/utils/protocolFromPort.ts | 38 ++ packages/lib/src/utils/randomId.test.ts | 19 + packages/lib/src/utils/randomId.ts | 7 + packages/lib/src/wifi.test.ts | 99 +++ packages/lib/src/wifi.ts | 105 +++ packages/lib/tsconfig.json | 13 + tsconfig.json | 1 + 77 files changed, 4362 insertions(+), 832 deletions(-) create mode 100644 LICENSE.md delete mode 100755 bin/meross delete mode 100755 bin/meross-info delete mode 100755 bin/meross-setup delete mode 100644 lib/api.js create mode 100644 packages/cli/.npmignore create mode 100644 packages/cli/LICENSE.md create mode 100644 packages/cli/README.md create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/cli.ts create mode 100755 packages/cli/src/meross-info.ts create mode 100755 packages/cli/src/meross-setup.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/lib/.npmignore create mode 100644 packages/lib/LICENSE.md create mode 100644 packages/lib/README.md create mode 100644 packages/lib/debug.log create mode 100644 packages/lib/package.json create mode 100644 packages/lib/src/cloudCredentials.ts create mode 100644 packages/lib/src/device.ts create mode 100644 packages/lib/src/deviceManager.test.ts create mode 100644 packages/lib/src/deviceManager.ts create mode 100644 packages/lib/src/encryption.test.ts create mode 100644 packages/lib/src/encryption.ts create mode 100644 packages/lib/src/index.ts create mode 100644 packages/lib/src/message/configureDeviceTime.ts create mode 100644 packages/lib/src/message/configureECDH.ts create mode 100644 packages/lib/src/message/configureMQTTBrokersAndCredentials.ts create mode 100644 packages/lib/src/message/configureWifiMessage.ts create mode 100644 packages/lib/src/message/configureWifiXMessage.ts create mode 100644 packages/lib/src/message/header.test.ts create mode 100644 packages/lib/src/message/header.ts create mode 100644 packages/lib/src/message/index.ts create mode 100644 packages/lib/src/message/message.ts create mode 100644 packages/lib/src/message/messages.ts create mode 100644 packages/lib/src/message/queryDeviceAbilities.ts create mode 100644 packages/lib/src/message/queryDeviceInformation.ts create mode 100644 packages/lib/src/message/queryDeviceTime.ts create mode 100644 packages/lib/src/message/queryWifiList.ts create mode 100644 packages/lib/src/transport/http.test.ts create mode 100644 packages/lib/src/transport/http.ts create mode 100644 packages/lib/src/transport/index.ts create mode 100644 packages/lib/src/transport/transport.test.ts create mode 100644 packages/lib/src/transport/transport.ts create mode 100644 packages/lib/src/utils/base64.test.ts create mode 100644 packages/lib/src/utils/base64.ts create mode 100644 packages/lib/src/utils/buffer.test.ts create mode 100644 packages/lib/src/utils/buffer.ts create mode 100644 packages/lib/src/utils/computeDevicePassword.test.ts create mode 100644 packages/lib/src/utils/computeDevicePassword.ts create mode 100644 packages/lib/src/utils/computePresharedKey.test.ts create mode 100644 packages/lib/src/utils/computePresharedPrivateKey.ts create mode 100644 packages/lib/src/utils/filterUndefined.test.ts create mode 100644 packages/lib/src/utils/filterUndefined.ts create mode 100644 packages/lib/src/utils/generateTimestamp.ts create mode 100644 packages/lib/src/utils/index.ts create mode 100644 packages/lib/src/utils/logger.ts create mode 100644 packages/lib/src/utils/md5.test.ts create mode 100644 packages/lib/src/utils/md5.ts create mode 100644 packages/lib/src/utils/protocolFromPort.test.ts create mode 100644 packages/lib/src/utils/protocolFromPort.ts create mode 100644 packages/lib/src/utils/randomId.test.ts create mode 100644 packages/lib/src/utils/randomId.ts create mode 100644 packages/lib/src/wifi.test.ts create mode 100644 packages/lib/src/wifi.ts create mode 100644 packages/lib/tsconfig.json create mode 100644 tsconfig.json diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index c912f97..8252e45 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,3 +1,15 @@ -- name: Close Stale Issues - uses: actions/stale@v9.0.0 - \ No newline at end of file +name: 'Close stale issues' +on: + schedule: + - cron: '30 1 * * *' + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' + days-before-stale: 30 + days-before-close: 5 + only: issues diff --git a/.gitignore b/.gitignore index c7f6c7b..2d822ed 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ -# directories +# Directories node_modules/ +dist/ -#files \ No newline at end of file +# Files +*.log \ No newline at end of file diff --git a/.npmignore b/.npmignore index 82e6f7c..205fac5 100644 --- a/.npmignore +++ b/.npmignore @@ -1,2 +1,6 @@ +# Directories +certs/ mosquito/ teardown/ + +#Files \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index 9571dda..334b796 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,9 +1,7 @@ { - "tabWidth": 2, - "useTabs": false, - "semi": true, "arrowParens": "always", + "semi": true, "singleQuote": true, - "trailingComma": "all", - "printWidth": 80 + "tabWidth": 2, + "useTabs": false } \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 0f148de..c83e263 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,3 @@ { - "recommendations": [ - "esbenp.prettier-vscode" - ] -} \ No newline at end of file + "recommendations": ["esbenp.prettier-vscode"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 10af2e8..6ff6621 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { - "editor.tabSize": 2, - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.detectIndentation": false, + "editor.tabSize": 2 } diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..f6f16a9 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2025 Rob Griffiths + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md index 0a4121e..4b31be7 100644 --- a/README.md +++ b/README.md @@ -2,28 +2,57 @@ [![Node.js Package](https://github.com/bytespider/Meross/actions/workflows/npm-ghr-publish.yml/badge.svg)](https://github.com/bytespider/Meross/actions/workflows/npm-ghr-publish.yml) -Tools to help configure the Meross devices for purpose of utilising our own MQTT servers. +Tools to help configure the Meross devices to use private MQTT servers. -Before you can use the tool to setup your device you need to put it into paring mode and connect to it's Access Point. It's IP address is known as the `--gateway` parameter and is typically `10.10.10.1`. +## Requirements -Requires `node` >=18 -For Node.js >=21 you need to prepend commands with `NODE_OPTIONS='--insecure-http-parser'`. This is because the responses from some (if not all) versions of the Meross firmware incorrectly terminate headers with LF instead of CRLF. [CVE-2022-32214](https://nvd.nist.gov/vuln/detail/CVE-2022-32214) +NodeJS: ^21.0.0, ^20.10.0, ^18.20.0 +NPM: ^10.0.0 -## Home Assistant +## Setup -It's possible to get these devices to work with Home Assistant (HASSIO). -Setup Home Assistant MQTT +TODO: +[Devices with WIFI pairing]() -Once paired and linked to your broker, you can use the Meross Lan integration to control the devices. +[Devices with Bluetooth pairing]() ## Tools ### Info -`npx meross info [--include-wifi]` -Gets information from the device you are connected to in setup mode and optionally the WIFI SSID's it can see. +``` +npx meross-info [options] + +Options: + -V, --version output the version number + -a, --ip Send command to device with this IP address (default: "10.10.10.1") + -u, --user Integer id. Used by devices connected to the Meross Cloud + -k, --key Shared key for generating signatures (default: "") + --include-wifi List WIFI Access Points near the device + --include-ability List device ability list + --include-time List device time + -v, --verbose Show debugging messages + -h, --help display help for command +``` ### Setup -`npx meross setup [options]` -Setup device you are connected to in setup mode +``` +npx meross-setup [options] + +Options: + -V, --version output the version number + -a, --ip Send command to device with this IP address (default: "10.10.10.1") + --wifi-ssid WIFI Access Point name + --wifi-pass WIFI Access Point password + --wifi-encryption WIFI Access Point encryption (this can be found using meross info --include-wifi) + --wifi-cipher WIFI Access Point cipher (this can be found using meross info --include-wifi) + --wifi-bssid WIFI Access Point BSSID (each octet separated by a colon `:`) + --wifi-channel WIFI Access Point 2.5GHz channel number [1-13] (this can be found using meross info --include-wifi) + --mqtt MQTT server address + -u, --user Integer id. Used by devices connected to the Meross Cloud (default: 0) + -k, --key Shared key for generating signatures (default: "") + -t, --set-time Configure device time with time and timezone of current host + -v, --verbose Show debugging messages (default: "") + -h, --help display help for command +``` diff --git a/bin/meross b/bin/meross deleted file mode 100755 index 4676163..0000000 --- a/bin/meross +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env node - -'use strict' - -const {version} = require('../package.json') -const program = require('commander') - -program - .version(version) - -program - .command('info [options]', 'get information about compatable Meross smart device') - .command('setup [options]', 'setup compatable Meross smart device') - -program.parse(process.argv) diff --git a/bin/meross-info b/bin/meross-info deleted file mode 100755 index 2b310f3..0000000 --- a/bin/meross-info +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env node - -'use strict' - -const {version} = require('../package.json') -const program = require('commander') -const util = require('util') - -const API = require('../lib/api') - -const collection = (value, store = []) => { - store.push(value) - return store -} - -const unique = (array) => [...new Set(array)] - -program - .version(version) - .arguments('') - .option('-g, --gateway ', 'Set the gateway address', '10.10.10.1') - .option('-u, --user ', 'Integer id. Only useful for connecting to Meross Cloud.', parseInt) - .option('-k, --key ', 'Shared key for generating signatures', '') - .option('--include-wifi', 'Ask device for Nearby WIFI AP list') - .option('-v, --verbose', 'Show debugging messages', '') - .parse(process.argv) - -const options = program.opts(); -if (!options.gateway) { - console.error('Gateway must be specified') - process.exit(1) -} - -(async () => { - const gateway = options.gateway - const key = options.key - const includeWifiList = options.includeWifi - const verbose = options.verbose - const api = new API(gateway, key, null, verbose) - - console.log(`Getting info about device with IP ${gateway}`) - await api.deviceInformation() - - if (includeWifiList) { - await api.deviceWifiList() - } -})() diff --git a/bin/meross-setup b/bin/meross-setup deleted file mode 100755 index c5e7db8..0000000 --- a/bin/meross-setup +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env node - -'use strict' - -const {version} = require('../package.json') -const program = require('commander') -const util = require('util') - -const API = require('../lib/api') - -const collection = (value, store = []) => { - store.push(value) - return store -} - -const numberInRange = (min, max) => (value) => { - if (value < min || value > max) { - throw new program.InvalidOptionArgumentError(`Value is out of range (${min}-${max})`); - } - return parseInt(value); -} - -const parseIntWithValidation = (value) => { - const i = parseInt(value); - if (isNaN(i)) { - throw new program.InvalidOptionArgumentError(`Value should be an integer`); - } - - return i; -} - -program - .version(version) - .arguments('[options]') - .option('-g, --gateway ', 'Set the gateway address', '10.10.10.1') - .option('--nowifi', 'Do not configure WIFI') - .option('--wifi-ssid ', 'WIFI AP name') - .option('--wifi-pass ', 'WIFI AP password') - .option('--use-wifi-x', 'Use newer protocol on WifiX namespace with encrypted password') - .option('--wifi-encryption ', 'WIFI AP encryption(this can be found using meross info --include-wifi)', parseIntWithValidation) - .option('--wifi-cipher ', 'WIFI AP cipher (this can be found using meross info --include-wifi)', parseIntWithValidation) - .option('--wifi-bssid ', 'WIFI AP BSSID (each octet seperated by a colon `:`)') - .option('--wifi-channel ', 'WIFI AP 2.5GHz channel number [1-13] (this can be found using meross info --include-wifi)', numberInRange(1, 13)) - .option('--mqtt ', 'MQTT server address', collection) - .option('-u, --user ', 'Integer id. Only useful for connecting to Meross Cloud.', parseIntWithValidation, 0) - .option('-k, --key ', 'Shared key for generating signatures', '') - .option('-v, --verbose', 'Show debugging messages', '') - .parse(process.argv) - -const options = program.opts(); -if (!options.gateway) { - console.error('Gateway must be specified') - process.exit(1) -} - -if (!options.nowifi){ - if (!options.wifiSsid) { - console.error('WIFI ssid must be specified') - process.exit(1) - } - - if (!options.wifiPass) { - console.error('WIFI password must be specified') - process.exit(1) - } - - if (undefined !== options.wifiChannel && isNaN(options.wifiChannel)) { - console.error('WIFI channel must be a number between 1-13') - process.exit(1) - } - - if (undefined !== options.wifiEncryption && isNaN(options.wifiEncryption)) { - console.error('WIFI encryption must be a number') - process.exit(1) - } - - if (undefined !== options.wifiCipher && isNaN(options.wifiCipher)) { - console.error('WIFI cipher must be a number') - process.exit(1) - } -} - - -(async () => { - const gateway = options.gateway - const key = options.key - const userId = options.user - const verbose = options.verbose - - const api = new API(gateway, key, userId, verbose) - - console.log(`Setting up device with IP ${gateway}`) - if (options.mqtt && options.mqtt.length) { - await api.configureMqttServers(options.mqtt) - } - - await api.deviceInformation(); - - if (!options.nowifi) { - await api.configureWifiCredentials({ - ssid: options.wifiSsid, - password: options.wifiPass, - channel: options.wifiChannel, - encryption: options.wifiEncryption, - cipher: options.wifiCipher, - bssid: options.wifiBssid, - }, options.useWifiX) - console.log(`Device will reboot...`) - } - else{ - console.log(`Device has been configured.`) - } - - -})() diff --git a/lib/api.js b/lib/api.js deleted file mode 100644 index b119828..0000000 --- a/lib/api.js +++ /dev/null @@ -1,451 +0,0 @@ -'use strict'; - -const { URL } = require('url'); - -const util = require('util'); -const uuid = require('uuid'); -const md5 = require('md5'); -const term = require('terminal-kit').terminal; -const axios = require('axios'); -const crypto = require('crypto'); - -const axiosInstance = axios.create(); -axiosInstance.defaults.timeout = 10000; - -const cleanServerUrl = (server) => { - server = /mqtts?:\/\//.test(server) ? server : 'mqtt://' + server; // add protocol - server = - /:(?:6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{1,3}|[0-9])/.test( - server, - ) - ? server - : server + ':' + (server.indexOf('mqtts://') > -1 ? 8883 : 1883); - - return server; -}; - -const base64Encode = (str) => Buffer.from(str).toString('base64'); -const base64Decode = (str) => Buffer.from(str, 'base64').toString('utf8'); - -const tableOptions = { - hasBorder: true, - borderChars: 'light', - contentHasMarkup: true, - fit: true, - width: 95, - firstColumnTextAttr: { color: 'yellow' }, -}; - -const percentToColor = (percent) => - percent > 0.7 ? '^G' : percent > 0.5 ? '^Y' : percent > 0.3 ? '^y' : '^r'; - -const bar = (percent, width) => { - const partials = ['▏', '▎', '▍', '▌', '▋', '▊', '▉']; - let ticks = percent * width; - if (ticks < 0) { - ticks = 0; - } - let filled = Math.floor(ticks); - let open = bar.width - filled - 1; - return ( - (percentToColor(percent) + '▉').repeat(filled) + - partials[Math.floor((ticks - filled) * partials.length)] + - ' '.repeat(open) - ); -}; - -const filterUndefined = (obj) => { - for (const key in obj) { - if (undefined === obj[key]) { - delete obj[key]; - } - } - - return obj; -}; - -function logRequest(request) { - const url = new URL(request.url, 'http://unknown'); - const method = request.method ? request.method.toUpperCase() : 'GET'; - - console.log(`> ${method} ${url.path}`); - console.log(`> Host: ${url.host}`); - - if (request.headers) { - let headers = { - ...request.headers.common, - ...request.headers[method], - ...Object.fromEntries( - Object.entries(request?.headers).filter(([header]) => !['common', 'delete', 'get', 'head', 'post', 'put', 'patch'].includes(header) - )), - }; - - for (let [header, value] of Object.entries(headers)) { - console.log(`> ${header}: ${value}`); - } - } - - console.log('>'); - console.log(util.inspect(request.data, { showHidden: false, depth: null })); - console.log(''); -} - -function logResponse(response) { - console.log(`< ${response.status} ${response.statusText}`); - for (const [header, value] of Object.entries(response.headers)) { - console.log(`< ${header}: ${value}`); - } - console.log('<'); - console.log(util.inspect(response.data, { showHidden: false, depth: null })); - console.log(''); -} - -function handleRequestError(error, verbose) { - if (error.code === 'HPE_CR_EXPECTED') { - console.error(`Please append NODE_OPTIONS='--insecure-parser' to your command.`); - process.exit(1); - } - - if (error.code === 'ECONNRESET' || error.code === 'ECONNABORTED') { - let hint = ''; - if (error.config.url === 'http://10.10.10.1/config') { - hint = "\nAre you connected to the device's Access Point?"; - } - console.error('Error', 'Unable to connect to device' + hint); - process.exit(1); - } - - if (verbose) { - if (error.response) { - logResponse(error.response); - } else if (error.request) { - logRequest(error.request); - } else { - console.error('Error', error.message); - } - } else { - console.error('Error', 'Unable to connect to device'); - } -} - -module.exports = class API { - constructor(host, key, userId, verbose = false) { - this.host = host; - this.key = key; - this.userId = userId; - this.verbose = verbose; - - axiosInstance.interceptors.request.use((request) => { - if (verbose) { - logRequest(request); - } - return request; - }); - - axiosInstance.interceptors.response.use((response) => { - if (verbose) { - logResponse(response); - } - return response; - }); - } - - signPacket(packet) { - const messageId = md5(uuid.v4()); - const timestamp = Math.floor(Date.now() / 1000); - const signature = md5(messageId + this.key + timestamp); - - packet.header.messageId = messageId; - packet.header.timestamp = timestamp; - packet.header.sign = signature; - - return packet; - } - - async deviceInformation() { - const data = await this.deviceInformationData(); - - const system = data.system; - const digest = data.digest; - const hw = system.hardware; - const fw = system.firmware; - - let rows = [ - [ - 'Device', - `${hw.type} ${hw.subType} ${hw.chipType} (hardware:${hw.version} firmware:${fw.version})`, - ], - ['UUID', hw.uuid], - ['Mac address', hw.macAddress], - ['IP address', fw.innerIp], - ]; - - if (fw.server) { - rows.push(['Current MQTT broker', `${fw.server}:${fw.port}`]); - } - - rows.push( - [ - 'Credentials', - `User: ^C${hw.macAddress}\nPassword: ^C${this.calculateDevicePassword( - hw.macAddress, - fw.userId, - )}`, - ], - [ - 'MQTT topics', - `Publishes to: ^C/appliance/${hw.uuid}/publish\nSubscribes to: ^C/appliance/${hw.uuid}/subscribe`, - ], - ); - - term.table(rows, tableOptions); - } - - async deviceInformationData() { - const packet = this.signPacket({ - header: { - from: '', - method: 'GET', - namespace: 'Appliance.System.All', - }, - payload: {}, - }); - - try { - const response = await axiosInstance.post( - `http://${this.host}/config`, - packet, - { - headers: { - 'Content-Type': 'application/json', - }, - }, - ); - - const data = response.data; - - if ('error' in data.payload) { - let { code, message } = data.payload.error; - - switch (code) { - case 5001: - console.error('Incorrect shared key provided.'); - break; - } - - return; - } - - return data.payload.all; - } catch (error) { - handleRequestError(error, this.verbose); - } - } - - async deviceWifiList() { - const packet = this.signPacket({ - header: { - from: '', - method: 'GET', - namespace: 'Appliance.Config.WifiList', - }, - payload: {}, - }); - - try { - let spinner = await term.spinner({ - animation: 'dotSpinner', - rightPadding: ' ', - }); - term('Getting WIFI list…\n'); - - const response = await axiosInstance.post( - `http://${this.host}/config`, - packet, - { - headers: { - 'Content-Type': 'application/json', - }, - }, - ); - - spinner.animate(false); - - const data = response.data; - - if ('error' in data.payload) { - let { code, message } = data.payload.error; - - switch (code) { - case 5001: - console.error('Incorrect shared key provided.'); - break; - } - - return; - } - - const wifiList = data.payload.wifiList; - - let rows = [['WIFI', 'Signal strength']]; - - for (const ap of wifiList) { - const decodedSsid = base64Decode(ap.ssid); - rows.push([ - `${decodedSsid ? decodedSsid : ''}\n^B${ap.bssid}^ ^+^YCh:^ ${ap.channel - } ^+^YEncryption:^ ${ap.encryption} ^+^YCipher:^ ${ap.cipher}`, - bar(ap.signal / 100, 20), - ]); - } - - let thisTableOptions = tableOptions; - thisTableOptions.firstColumnTextAttr = { color: 'cyan' }; - thisTableOptions.firstRowTextAttr = { color: 'yellow' }; - - term.table(rows, tableOptions); - } catch (error) { - handleRequestError(error, this.verbose); - } - } - - async configureMqttServers(mqtt) { - const servers = mqtt - .map((server) => { - server = cleanServerUrl(server); - - const url = new URL(server); - return { - host: url.hostname, - port: url.port + '', - }; - }) - .slice(0, 2); - - // make sure we set a failover server - if (servers.length == 1) { - servers.push(servers[0]); - } - - let rows = []; - for (let s = 0; s < servers.length; s++) { - let server = servers[s]; - rows.push([ - `${s > 0 ? 'Failover' : 'Primary'} MQTT broker`, - `${server.host}:${server.port}`, - ]); - } - - term.table(rows, tableOptions); - - const packet = this.signPacket({ - header: { - from: '', - method: 'SET', - namespace: 'Appliance.Config.Key', - }, - payload: { - key: { - userId: this.userId + '', - key: this.key + '', - gateway: ((servers) => { - const gateway = servers[0]; - - if (servers.length > 1) { - gateway.secondHost = servers[1].host; - gateway.secondPort = servers[1].port; - } - - gateway.redirect = 1; - - return gateway; - })(servers), - }, - }, - }); - - try { - const response = await axiosInstance.post( - `http://${this.host}/config`, - packet, - { - headers: { - 'Content-Type': 'application/json', - }, - }, - ); - } catch (error) { - handleRequestError(error, this.verbose); - } - } - - async configureWifiCredentials(credentials, useWifiX = null) { - const ssid = base64Encode(credentials.ssid); - const namespace = useWifiX - ? 'Appliance.Config.WifiX' - : 'Appliance.Config.Wifi'; - const password = useWifiX - ? await this.encryptPassword(credentials.password) - : base64Encode(credentials.password); - - const packet = this.signPacket({ - header: { - from: '', - method: 'SET', - namespace: namespace, - }, - payload: { - wifi: { - ...filterUndefined(credentials), - ssid, - password, - }, - }, - }); - - try { - const response = await axiosInstance.post( - `http://${this.host}/config`, - packet, - { - headers: { - 'Content-Type': 'application/json', - }, - }, - ); - } catch (error) { - handleRequestError(error, this.verbose); - } - } - - async encryptPassword(password) { - const data = await this.deviceInformationData(); - - return this.calculateWifiXPassword( - password, - data.system.hardware.type, - data.system.hardware.uuid, - data.system.hardware.macAddress, - ); - } - - calculateDevicePassword(macAddress, userId = 0) { - return `${userId}_${md5(macAddress + '' + this.key)}`; - } - - calculateWifiXPassword(password, type, uuid, macAddress) { - const key = Buffer.from( - md5(type + uuid + macAddress).toString('hex'), - 'utf8', - ); - const iv = Buffer.from('0000000000000000', 'utf8'); - const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); - - const count = Math.ceil(password.length / 16) * 16; - const padded = password.padEnd(count, '\0'); - - let encrypted = cipher.update(padded, 'utf8', 'base64'); - encrypted += cipher.final('base64'); - - return encrypted; - } -}; diff --git a/mosquitto/authenticated.conf b/mosquitto/authenticated.conf index a59b3c0..f7e6d80 100644 --- a/mosquitto/authenticated.conf +++ b/mosquitto/authenticated.conf @@ -25,4 +25,4 @@ auth_opt_user mosquitto auth_opt_pass mosquitto auth_opt_userquery SELECT password_hash FROM users WHERE username = '%s' auth_opt_aclquery SELECT topic FROM acls WHERE (username = '%s') AND (rw >= %d) -auth_opt_superquery SELECT IFNULL(COUNT(*), 0) FROM users WHERE username = '%s' AND is_super = 1 +auth_opt_superquery SELECT IFNULL(COUNT(*), 0) FROM users WHERE username = '%s' AND is_super = 1 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4a54b93..989e999 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,31 +1,35 @@ { "name": "meross", - "version": "1.0.16", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meross", - "version": "1.0.16", + "version": "2.0.0", "license": "ISC", - "dependencies": { - "axios": "^1.7.9", - "commander": "^7.2", - "md5": "^2.2.1", - "terminal-kit": "^3.0.1", - "uuid": "^11.0.5" - }, + "workspaces": [ + "packages/lib", + "packages/cli", + "packages/*" + ], "bin": { - "meross": "bin/meross" + "meross": "packages/cli/bin/meross.js" }, "engines": { - "node": "^18.12 || ^20.9.0 || ^22.11" + "node": ">=18" + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "license": "MIT", + "engines": { + "node": ">=0.1.90" } }, "node_modules/@cronvel/get-pixels": { "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@cronvel/get-pixels/-/get-pixels-3.4.1.tgz", - "integrity": "sha512-gB5C5nDIacLUdsMuW8YsM9SzK3vaFANe4J11CVXpovpy7bZUGrcJKmc6m/0gWG789pKr6XSZY2aEetjFvSRw5g==", + "license": "MIT", "dependencies": { "jpeg-js": "^0.4.4", "ndarray": "^1.0.19", @@ -35,166 +39,242 @@ "pngjs": "^6.0.0" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/axios": { - "version": "1.7.9", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", - "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" } }, - "node_modules/charenc": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", - "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.2", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "*" + "node": ">=18" } }, + "node_modules/@meross/lib": { + "resolved": "packages/lib", + "link": true + }, + "node_modules/@types/nextgen-events": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/terminal-kit": { + "version": "2.5.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/nextgen-events": "*" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.4", + "license": "MIT" + }, "node_modules/chroma-js": { "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz", - "integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==" + "license": "(BSD-3-Clause AND Apache-2.0)" + }, + "node_modules/color": { + "version": "3.2.1", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "node_modules/color-convert": { + "version": "1.9.3", + "license": "MIT", "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" + "color-name": "1.1.3" } }, - "node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "engines": { - "node": ">= 10" + "node_modules/color-name": { + "version": "1.1.3", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" } }, - "node_modules/crypt": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", - "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", - "engines": { - "node": "*" + "node_modules/colorspace": { + "version": "1.1.4", + "license": "MIT", + "dependencies": { + "color": "^3.1.3", + "text-hex": "1.0.x" } }, "node_modules/cwise-compiler": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/cwise-compiler/-/cwise-compiler-1.1.3.tgz", - "integrity": "sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ==", + "license": "MIT", "dependencies": { "uniq": "^1.0.0" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "node_modules/enabled": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.2", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, "engines": { - "node": ">=0.4.0" + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.2", + "@esbuild/android-arm": "0.25.2", + "@esbuild/android-arm64": "0.25.2", + "@esbuild/android-x64": "0.25.2", + "@esbuild/darwin-arm64": "0.25.2", + "@esbuild/darwin-x64": "0.25.2", + "@esbuild/freebsd-arm64": "0.25.2", + "@esbuild/freebsd-x64": "0.25.2", + "@esbuild/linux-arm": "0.25.2", + "@esbuild/linux-arm64": "0.25.2", + "@esbuild/linux-ia32": "0.25.2", + "@esbuild/linux-loong64": "0.25.2", + "@esbuild/linux-mips64el": "0.25.2", + "@esbuild/linux-ppc64": "0.25.2", + "@esbuild/linux-riscv64": "0.25.2", + "@esbuild/linux-s390x": "0.25.2", + "@esbuild/linux-x64": "0.25.2", + "@esbuild/netbsd-arm64": "0.25.2", + "@esbuild/netbsd-x64": "0.25.2", + "@esbuild/openbsd-arm64": "0.25.2", + "@esbuild/openbsd-x64": "0.25.2", + "@esbuild/sunos-x64": "0.25.2", + "@esbuild/win32-arm64": "0.25.2", + "@esbuild/win32-ia32": "0.25.2", + "@esbuild/win32-x64": "0.25.2" } }, - "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], + "node_modules/fecha": { + "version": "4.2.3", + "license": "MIT" + }, + "node_modules/fn.name": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/form-data": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", - "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "node_modules/get-tsconfig": { + "version": "4.10.0", + "dev": true, + "license": "MIT", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" + "resolve-pkg-maps": "^1.0.0" }, - "engines": { - "node": ">= 6" + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, "node_modules/iota-array": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz", - "integrity": "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==" + "license": "MIT" + }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "license": "MIT" }, "node_modules/is-buffer": { "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/jpeg-js": { "version": "0.4.4", - "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", - "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==" + "license": "BSD-3-Clause" + }, + "node_modules/kuler": { + "version": "2.0.0", + "license": "MIT" }, "node_modules/lazyness": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/lazyness/-/lazyness-1.2.0.tgz", - "integrity": "sha512-KenL6EFbwxBwRxG93t0gcUyi0Nw0Ub31FJKN1laA4UscdkL1K1AxUd0gYZdcLU3v+x+wcFi4uQKS5hL+fk500g==", + "license": "MIT", "engines": { "node": ">=6.0.0" } }, - "node_modules/md5": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", - "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", - "dependencies": { - "charenc": "0.0.2", - "crypt": "0.0.2", - "is-buffer": "~1.1.6" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/logform": { + "version": "2.7.0", + "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "@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": ">= 0.6" + "node": ">= 12.0.0" } }, + "node_modules/meross": { + "resolved": "packages/cli", + "link": true + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, "node_modules/ndarray": { "version": "1.0.19", - "resolved": "https://registry.npmjs.org/ndarray/-/ndarray-1.0.19.tgz", - "integrity": "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==", + "license": "MIT", "dependencies": { "iota-array": "^1.0.0", "is-buffer": "^1.0.2" @@ -202,8 +282,7 @@ }, "node_modules/ndarray-pack": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ndarray-pack/-/ndarray-pack-1.2.1.tgz", - "integrity": "sha512-51cECUJMT0rUZNQa09EoKsnFeDL4x2dHRT0VR5U2H5ZgEcm95ZDWcMA5JShroXjHOejmAD/fg8+H+OvUnVXz2g==", + "license": "MIT", "dependencies": { "cwise-compiler": "^1.1.2", "ndarray": "^1.0.13" @@ -211,47 +290,87 @@ }, "node_modules/nextgen-events": { "version": "1.5.3", - "resolved": "https://registry.npmjs.org/nextgen-events/-/nextgen-events-1.5.3.tgz", - "integrity": "sha512-P6qw6kenNXP+J9XlKJNi/MNHUQ+Lx5K8FEcSfX7/w8KJdZan5+BB5MKzuNgL2RTjHG1Svg8SehfseVEp8zAqwA==", + "license": "MIT", "engines": { "node": ">=6.0.0" } }, "node_modules/node-bitmap": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/node-bitmap/-/node-bitmap-0.0.1.tgz", - "integrity": "sha512-Jx5lPaaLdIaOsj2mVLWMWulXF6GQVdyLvNSxmiYCvZ8Ma2hfKX0POoR2kgKOqz+oFsRreq0yYZjQ2wjE9VNzCA==", "engines": { "node": ">=v0.6.5" } }, "node_modules/omggif": { "version": "1.0.10", - "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", - "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==" + "license": "MIT" + }, + "node_modules/one-time": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } }, "node_modules/pngjs": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", - "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "license": "MIT", "engines": { "node": ">=12.13.0" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "node_modules/readable-stream": { + "version": "3.6.2", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/setimmediate": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + "license": "MIT" }, "node_modules/seventh": { "version": "0.9.2", - "resolved": "https://registry.npmjs.org/seventh/-/seventh-0.9.2.tgz", - "integrity": "sha512-C+dnbBXIEycnrN6/CpFt/Rt8ccMzAX3wbwJU61RTfC8lYPMzSkKkAVWnUEMTZDHdvtlrTupZeCUK4G+uP4TmRQ==", + "license": "MIT", "dependencies": { "setimmediate": "^1.0.5" }, @@ -259,18 +378,37 @@ "node": ">=16.13.0" } }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-kit": { - "version": "0.17.10", - "resolved": "https://registry.npmjs.org/string-kit/-/string-kit-0.17.10.tgz", - "integrity": "sha512-n3/2BeEJrlzztoxeBTt9DVh0dfHordBuZoFsSJs59tk1JoPVvtvNsvAgqu0Nlpj5Y/qoQbnT8jCnfuoHcsfGnw==", + "version": "0.19.2", + "license": "MIT", "engines": { "node": ">=14.15.0" } }, "node_modules/terminal-kit": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/terminal-kit/-/terminal-kit-3.0.1.tgz", - "integrity": "sha512-KvscEh/893Qza4+1wW9BOYAYFFS3uy8JfuMpyxNS1Rw+bw2Qx33RjVkjzPkfY2hfzAcTEw9KGko4XZuX2scsQw==", + "version": "3.1.2", + "license": "MIT", "dependencies": { "@cronvel/get-pixels": "^3.4.1", "chroma-js": "^2.4.2", @@ -278,38 +416,165 @@ "ndarray": "^1.0.19", "nextgen-events": "^1.5.3", "seventh": "^0.9.2", - "string-kit": "^0.17.10", - "tree-kit": "^0.8.1" + "string-kit": "^0.19.0", + "tree-kit": "^0.8.7" }, "engines": { "node": ">=16.13.0" } }, + "node_modules/text-hex": { + "version": "1.0.0", + "license": "MIT" + }, "node_modules/tree-kit": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/tree-kit/-/tree-kit-0.8.1.tgz", - "integrity": "sha512-z29rTLxHce770M/3PzKkBqiIANg+YQwdtdcuYHP9qcgI1ZSaL9LBStWpxY1F/3BmFMqm+1OYdkIdbD45tUgO3Q==", + "version": "0.8.8", + "license": "MIT", "engines": { "node": ">=16.13.0" } }, + "node_modules/triple-beam": { + "version": "1.4.1", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/tsx": { + "version": "4.19.3", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.8.2", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uniq": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", - "integrity": "sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==" + "license": "MIT" }, - "node_modules/uuid": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", - "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], + "node_modules/util-deprecate": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/winston": { + "version": "3.17.0", "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.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.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "packages/cli": { + "name": "meross", + "version": "2.0.0", + "license": "ISC", + "dependencies": { + "@meross/lib": "*", + "commander": "^13.1.0", + "terminal-kit": "^3.1.2" + }, "bin": { - "uuid": "dist/esm/bin/uuid" + "meross": "dist/meross.js" + }, + "devDependencies": { + "@types/node": "^22.13.16", + "@types/terminal-kit": "^2.5.7", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + } + }, + "packages/cli/node_modules/@types/node": { + "version": "22.13.16", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "packages/cli/node_modules/commander": { + "version": "13.1.0", + "license": "MIT", + "engines": { + "node": ">=18" } + }, + "packages/cli/node_modules/undici-types": { + "version": "6.20.0", + "dev": true, + "license": "MIT" + }, + "packages/lib": { + "name": "@meross/lib", + "version": "2.0.0", + "license": "ISC", + "dependencies": { + "winston": "^3.17.0" + }, + "devDependencies": { + "@types/node": "^22.13.16", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + } + }, + "packages/lib/node_modules/@types/node": { + "version": "22.13.16", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "packages/lib/node_modules/undici-types": { + "version": "6.20.0", + "dev": true, + "license": "MIT" } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 6230f3e..9b1dd1c 100644 --- a/package.json +++ b/package.json @@ -1,30 +1,33 @@ { "name": "meross", - "version": "1.0.16", + "version": "2.0.0", "description": "Utility to configure Meross devices for local MQTT", "keywords": [ "smarthome", "mqtt", "meross", + "refoss", "cli" ], - "bin": { - "meross": "./bin/meross" + "type": "module", + "engines": { + "node": ">=18" }, "scripts": { - "test": "exit 0" + "test": "npm run test --workspaces --if-present", + "build": "npm run build --workspaces --if-present" }, "author": "Rob Griffiths ", - "repository": "https://github.com/bytespider/Meross/tree/master", - "license": "ISC", - "dependencies": { - "axios": "^1.7.9", - "commander": "^7.2", - "md5": "^2.2.1", - "terminal-kit": "^3.0.1", - "uuid": "^11.0.5" + "contributors": [], + "repository": { + "type": "git", + "url": "https://github.com/bytespider/meross.git" }, - "engines": { - "node": "^18.12 || ^20.9.0 || >22.11" - } -} + "license": "ISC", + "workspaces": [ + "packages/lib", + "packages/cli", + "packages/*" + ], + "bin": "packages/cli/bin/meross.js" +} \ No newline at end of file diff --git a/packages/cli/.npmignore b/packages/cli/.npmignore new file mode 100644 index 0000000..12ee65f --- /dev/null +++ b/packages/cli/.npmignore @@ -0,0 +1,6 @@ +# Directories +src/ + +# Files +*.log +*.test.* \ No newline at end of file diff --git a/packages/cli/LICENSE.md b/packages/cli/LICENSE.md new file mode 100644 index 0000000..f6f16a9 --- /dev/null +++ b/packages/cli/LICENSE.md @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2025 Rob Griffiths + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..a5aecc2 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,141 @@ +# Meross CLI + +A command-line tool for configuring and managing Meross smart home devices. + +## Installation + +```bash +npm install -g meross +``` + +You can also run the commands without installing the package globally by using `npx`. For example: + +```bash +npx meross info -a 192.168.1.100 +``` + +## Commands + +### Info + +Get information about compatible Meross smart devices. + +```bash +meross info [options] +``` + +Options: + +- `-a, --ip ` - Send command to device with this IP address (default: 10.10.10.1) +- `-u, --user ` - Integer ID used by devices connected to Meross Cloud (default: 0) +- `-k, --key ` - Shared key for generating signatures (default: meross) +- `--private-key [private-key]` - Specify a private key for ECDH key exchange. If omitted, a new private key will be generated automatically. If this flag is not used, a pre-calculated private key will be applied by default. +- `--with-wifi` - List WIFI Access Points near the device +- `--with-ability` - List device ability list +- `-q, --quiet` - Suppress standard output + +Example: + +```bash +# Get basic information about a device +meross info -a 192.168.1.100 + +# Get device info and nearby WiFi networks +meross info -a 192.168.1.100 --with-wifi +``` + +### Setup + +Setup and configure compatible Meross smart devices. + +```bash +meross setup [options] +``` + +Options: + +- `-a, --ip ` - Send command to device with this IP address (default: 10.10.10.1) +- `--wifi-ssid ` - WIFI Access Point name +- `--wifi-pass ` - WIFI Access Point password +- `--wifi-encryption ` - WIFI Access Point encryption +- `--wifi-cipher ` - WIFI Access Point cipher +- `--wifi-bssid ` - WIFI Access Point BSSID +- `--wifi-channel ` - WIFI Access Point 2.4GHz channel number [1-13] +- `--mqtt ` - MQTT server address (can be used multiple times). Supports protocols like `mqtt://` for non-secure connections and `mqtts://` for secure connections using TLS. Note that Meross MQTT requires the use of TLS. +- `-u, --user ` - Integer ID for devices connected to Meross Cloud (default: 0) +- `-k, --key ` - Shared key for generating signatures (default: meross) +- `--private-key [private-key]` - Specify a private key for ECDH key exchange. If omitted, a new private key will be generated automatically. If this flag is not used, a pre-calculated private key will be applied by default. +- `-t, --set-time` - Configure device time with current host time and timezone +- `-q, --quiet` - Suppress standard output + +Example: + +```bash +# Configure device WiFi settings +meross setup -a 10.10.10.1 --wifi-ssid 'MyHomeNetwork' --wifi-pass 'MySecurePassword' --wifi-encryption 3 --wifi-cipher 1 --wifi-channel 6 + +# Configure device MQTT and time settings +meross setup -a 192.168.1.100 --mqtt 'mqtt://broker.example.com' -t +``` + +## Workflow Examples + +### Initial Device Setup + +Before starting, ensure the device is in pairing mode. To do this, press and hold the device's button for 5 seconds until the LED starts alternating between colors. This indicates the device is ready for setup. + +1. Connect to the device's AP mode: + +```bash +# Connect to the device's WiFi network (typically Meross_XXXXXX) +``` + +2. Get device information: + +```bash +meross info -a 10.10.10.1 --with-wifi +``` + +3. Configure the device with your home WiFi: + +```bash +meross setup -a 10.10.10.1 --wifi-ssid 'YourHomeWifi' --wifi-pass 'YourPassword' --mqtt 'mqtts://192.168.1.2' +``` + +### Managing Existing Devices + +1. Get device information: + +```bash +meross info -a 192.168.1.100 +``` + +2. Update MQTT server configuration: + +```bash +meross setup -a 192.168.1.100 --mqtt 'mqtt://192.168.1.10' --mqtt 'mqtt://backup.example.com' +``` + +## Troubleshooting + +- If you're having trouble connecting to a device, make sure you're using the correct IP address +- For WiFi configuration, use the `info` command with `--with-wifi` to get the correct encryption, cipher, and channel values if SSID and password alone are not working. +- Set the `LOG_LEVEL` environment variable, in combination with `--quiet` for more detailed error messages + +## Reporting Issues + +If you encounter any issues or have feature requests, please report them on the [GitHub Issues page](https://github.com/bytespider/meross/issues). When submitting an issue, include the following details to help us resolve it faster: + +- A clear description of the problem or feature request +- Steps to reproduce the issue (if applicable) +- The version of the CLI you are using +- Any relevant logs or error messages (use the `LOG_LEVEL` environment variable for detailed logs). + +We appreciate your feedback and contributions! + +> **Note**: When reporting issues or sharing examples, ensure that you obfuscate sensitive information such as private keys, passwords, or any other confidential data to protect your privacy and security. +> We appreciate your feedback and contributions! + +## License + +ISC diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..c7b15f8 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,37 @@ +{ + "name": "meross", + "version": "2.0.0", + "main": "index.js", + "type": "module", + "scripts": { + "test": "tsx --test", + "build": "tsc", + "prepublishOnly": "npm run build" + }, + "bin": { + "meross": "dist/meross.js" + }, + "keywords": [ + "meross", + "automation", + "smarthome" + ], + "author": "Rob Griffiths ", + "license": "ISC", + "repository": { + "type": "git", + "url": "git+https://github.com/bytespider/meross.git" + }, + "dependencies": { + "@meross/lib": "*", + "commander": "^13.1.0", + "terminal-kit": "^3.1.2" + }, + "description": "", + "devDependencies": { + "@types/node": "^22.13.16", + "@types/terminal-kit": "^2.5.7", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + } +} \ No newline at end of file diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 0000000..8f0ad85 --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,136 @@ +import TerminalKit from 'terminal-kit'; +import { WifiAccessPoint } from '@meross/lib'; +import { TextTableOptions } from 'terminal-kit/Terminal.js'; + +const { terminal } = TerminalKit; + +const tableOptions: TextTableOptions = { + hasBorder: true, + borderChars: 'light', + contentHasMarkup: true, + fit: true, + width: 80, + firstColumnTextAttr: { color: 'yellow' }, +}; + +/** + * Converts a decimal between zero and one to TerminalKit color code + */ +export const percentToColor = (percent: number): string => + percent > 0.7 ? '^G' : percent > 0.5 ? '^Y' : percent > 0.3 ? '^y' : '^r'; + +/** + * Draws a coloured bar of specified width + */ +export const bar = (percent: number, width: number): string => { + const partials = ['▏', '▎', '▍', '▌', '▋', '▊', '▉']; + let ticks = percent * width; + if (ticks < 0) { + ticks = 0; + } + let filled = Math.floor(ticks); + let open = width - filled; + + return ( + (percentToColor(percent) + '▉').repeat(filled) + + partials[Math.floor((ticks - filled) * partials.length)] + + ' '.repeat(open) + ); +}; + +/** + * Draws a spinner and a message that is updated on success or failure + */ +export async function progressFunctionWithMessage( + callback: () => Promise, + message: string +): Promise { + let spinner = await terminal.spinner({ + animation: 'dotSpinner', + attr: { color: 'cyan' }, + }); + terminal(`${message}…`); + + try { + const response = await callback(); + spinner.animate(false); + terminal.saveCursor().column(0).green('✓').restoreCursor(); + terminal('\n'); + return response; + } catch (e) { + terminal.saveCursor().column(0).red('✗').restoreCursor(); + terminal('\n'); + throw e; + } finally { + spinner.animate(false); + } +} + +export async function printDeviceTable( + deviceInformation: Record, + deviceAbility?: Record, + devicePassword?: string +): Promise { + const { + system: { hardware: hw, firmware: fw }, + } = deviceInformation; + + const rows = [ + [ + 'Device', + `${hw.type} ${hw.subType} ${hw.chipType} (hardware:${hw.version} firmware:${fw.version})`, + ], + ['UUID', hw.uuid], + ['Mac address', hw.macAddress], + ['IP address', fw.innerIp], + ]; + + if (fw.server) { + rows.push(['Current MQTT broker', `${fw.server}:${fw.port}`]); + } + + rows.push( + ['Credentials', `User: ^C${hw.macAddress}\nPassword: ^C${devicePassword}`], + [ + 'MQTT topics', + `Publishes to: ^C/appliance/${hw.uuid}/publish\nSubscribes to: ^C/appliance/${hw.uuid}/subscribe`, + ] + ); + + if (deviceAbility) { + const abilityRows = []; + for (const [ability, params] of Object.entries(deviceAbility)) { + abilityRows.push(`${ability.padEnd(38)}\t${JSON.stringify(params)}`); + } + + rows.push(['Ability', abilityRows.join('\n')]); + } + + terminal.table(rows, tableOptions); +} + +/** + * Displays a list of WIFI Access Points + * @param {object[]} wifiList + */ +export async function printWifiListTable( + wifiList: WifiAccessPoint[] +): Promise { + const rows = [['WIFI', 'Signal strength']]; + + for (const { ssid, bssid, channel, encryption, cipher, signal } of wifiList) { + rows.push([ + `${ + ssid ? ssid : '' + }\n^B${bssid}^ ^+^YCh:^ ${channel} ^+^YEncryption:^ ${encryption} ^+^YCipher:^ ${cipher}`, + bar(signal / 100, 20), + ]); + } + + const thisTableOptions = tableOptions; + thisTableOptions.firstColumnVoidAttr = { contentWidth: 55 }; + thisTableOptions.firstColumnTextAttr = { color: 'cyan' }; + thisTableOptions.firstRowTextAttr = { color: 'yellow' }; + + terminal.table(rows, thisTableOptions); +} diff --git a/packages/cli/src/meross-info.ts b/packages/cli/src/meross-info.ts new file mode 100755 index 0000000..0a24424 --- /dev/null +++ b/packages/cli/src/meross-info.ts @@ -0,0 +1,115 @@ +#!/usr/bin/env node + +'use strict'; + +import pkg from '../package.json' with { type: 'json' }; +import { program } from 'commander'; +import TerminalKit from 'terminal-kit'; +const { terminal } = TerminalKit; + +import { printDeviceTable, printWifiListTable, progressFunctionWithMessage } from './cli.js'; + +import { HTTPTransport, Device, computeDevicePassword, Namespace, computePresharedPrivateKey, generateKeyPair } from '@meross/lib'; + +type Options = { + ip: string; + user: number; + key: string; + privateKey: string | boolean; + withWifi: boolean; + withAbility: boolean; + includeTime: boolean; + quiet: boolean; +}; + +program + .version(pkg.version) + .arguments('[options]') + .option( + '-a, --ip ', + 'Send command to device with this IP address', + '10.10.10.1' + ) + .option( + '-u, --user ', + 'Integer id. Used by devices connected to the Meross Cloud', + parseInt, + 0 + ) + .option( + '-k, --key ', + 'Shared key for generating signatures', + 'meross' + ) + .option('--private-key [private-key]', `Private key for ECDH key exchange. If not provided a new one will be generated`) + .option('--with-wifi', 'List WIFI Access Points near the device') + .option('--with-ability', 'List device ability list') + .option('-q, --quiet', 'Suppress all output', false) + .parse(process.argv); + +const options = program.opts(); + +const { ip, user: userId, key } = options; +const { quiet } = options; + +try { + const transport = new HTTPTransport({ url: `http://${ip}/config`, credentials: { userId, key } }); + const device = new Device(); + + device.setTransport(transport); + + const deviceInformation = await device.fetchDeviceInfo(); + + const devicePassword = computeDevicePassword( + deviceInformation.system.hardware.macAddress, + key, + deviceInformation.system.firmware.userId + ); + + const { withAbility = false } = options; + let deviceAbility = await device.fetchDeviceAbilities(); + if (!quiet) { + await printDeviceTable(deviceInformation, withAbility ? deviceAbility : undefined, devicePassword); + } + + // check if we neet to exchange public keys + if (device.hasAbility(Namespace.ENCRYPT_ECDHE) && !device.encryptionKeys.sharedKey) { + let { privateKey } = options; + + if (privateKey === true) { + const { privateKey: generatedPrivateKey } = await generateKeyPair(); + privateKey = generatedPrivateKey.toString('base64'); + } + + if (!privateKey) { + // use precomputed private key + privateKey = computePresharedPrivateKey( + device.id, + key, + device.hardware.macAddress + ); + } + + await device.setPrivateKey(Buffer.from(privateKey, 'base64')); + + const exchangeKeys = () => device.exchangeKeys(); + await (quiet ? exchangeKeys() : progressFunctionWithMessage(exchangeKeys, 'Exchanging public keys')); + } + + const { withWifi = false } = options; + if (withWifi) { + const fetchNearbyWifi = () => device.fetchNearbyWifi(); + const wifiList = await (quiet ? fetchNearbyWifi() : progressFunctionWithMessage(() => fetchNearbyWifi(), 'Getting WIFI list')); + + if (!quiet && wifiList) { + await printWifiListTable(wifiList); + } + } +} catch (error: any) { + terminal.red(`${error.message}\n`); + if (process.env.LOG_LEVEL) { + terminal.red('Error stack:\n'); + terminal.red(error.stack); + } + process.exit(1); +} diff --git a/packages/cli/src/meross-setup.ts b/packages/cli/src/meross-setup.ts new file mode 100755 index 0000000..13cc015 --- /dev/null +++ b/packages/cli/src/meross-setup.ts @@ -0,0 +1,204 @@ +#!/usr/bin/env node + +'use strict'; + +import pkg from '../package.json' with { type: 'json' }; +import { program, InvalidOptionArgumentError } from 'commander'; +import TerminalKit from 'terminal-kit'; +const { terminal } = TerminalKit; + +import { HTTPTransport, Device, WifiAccessPoint, CloudCredentials, Namespace } from '@meross/lib';; +import { progressFunctionWithMessage } from './cli.js'; +import { generateTimestamp, computePresharedPrivateKey} from '@meross/lib/utils'; +import { generateKeyPair } from '@meross/lib/encryption'; + +type Options = { + ip: string; + wifiSsid?: string; + wifiPass?: string; + wifiEncryption?: number; + wifiCipher?: number; + wifiBssid?: string; + wifiChannel?: number; + mqtt?: string[]; + user: number; + key: string; + privateKey: string | boolean; + setTime: boolean; + verbose: boolean; + quiet: boolean; +}; + +const collection = (value: string, store: string[] = []) => { + store.push(value); + return store; +}; + +const numberInRange = (min: number, max: number) => (value: string) => { + if (Number(value) < min || Number(value) > max) { + throw new InvalidOptionArgumentError( + `Value is out of range (${min}-${max})` + ); + } + return parseInt(value); +}; + +const parseIntWithValidation = (value: string) => { + const i = parseInt(value); + if (isNaN(i)) { + throw new InvalidOptionArgumentError(`Value should be an integer`); + } + + return i; +}; + +program + .version(pkg.version) + .arguments('[options]') + .option( + '-a, --ip ', + 'Send command to device with this IP address', + '10.10.10.1' + ) + .option('--wifi-ssid ', 'WIFI Access Point name') + .option('--wifi-pass ', 'WIFI Access Point password') + .option( + '--wifi-encryption ', + 'WIFI Access Point encryption (this can be found using meross info --include-wifi)', + parseIntWithValidation + ) + .option( + '--wifi-cipher ', + 'WIFI Access Point cipher (this can be found using meross info --include-wifi)', + parseIntWithValidation + ) + .option( + '--wifi-bssid ', + 'WIFI Access Point BSSID (each octet seperated by a colon `:`)' + ) + .option( + '--wifi-channel ', + 'WIFI Access Point 2.4GHz channel number [1-13] (this can be found using meross info --include-wifi)', + numberInRange(1, 13) + ) + .option('--mqtt ', 'MQTT server address', collection) + .option( + '-u, --user ', + 'Integer id. Used by devices connected to the Meross Cloud', + parseIntWithValidation, + 0 + ) + .option( + '-k, --key ', + 'Shared key for generating signatures', + 'meross' + ) + .option('--private-key [private-key]', `Private key for ECDH key exchange. If not provided a new one will be generated`) + .option('-t, --set-time', 'Configure device time with time and timezone of current host') + .option('-q, --quiet', 'Suppress all output', false) + + .parse(process.argv); + +export const options = program.opts(); + +const { ip, user: userId, key } = options; +const { quiet, verbose } = options; + +const { wifiSsid: ssid, wifiBssid: bssid, wifiPass: password, wifiChannel: channel, wifiEncryption: encryption, wifiCipher: cipher } = options; +if (ssid !== undefined && (ssid?.length < 1 || ssid?.length > 32)) { + terminal.red(`WIFI SSID length must be between 1 and 32 characters\n`); + process.exit(1); +} + +if (bssid && (bssid.length < 1 || bssid.length > 17)) { + terminal.red(`WIFI BSSID length must be between 1 and 17 characters\n`); + process.exit(1); +} + +if (password !== undefined && (password?.length < 8 || password?.length > 64)) { + terminal.red(`WIFI password length must be between 8 and 64 characters\n`); + process.exit(1); +} + +try { + const credentials = new CloudCredentials(userId, key); + + const transport = new HTTPTransport({ url: `http://${ip}/config`, credentials }); + const device = new Device(); + + device.setTransport(transport); + + // fetch device information + const fetchDeviceInfo = async () => { + const { system: { hardware, firmware } } = await device.fetchDeviceInfo(); + terminal.green(`${hardware.type} (hardware: ${hardware.version}, firmware: ${firmware.version})`); + }; + await (quiet ? device.fetchDeviceInfo() : progressFunctionWithMessage(fetchDeviceInfo, 'Fetching device information')); + + // fetch device abilities + const fetchDeviceAbilities = () => device.fetchDeviceAbilities(); + await (quiet ? fetchDeviceAbilities() : progressFunctionWithMessage(fetchDeviceAbilities, 'Fetching device abilities')); + + // check if we neet to exchange public keys + if (device.hasAbility(Namespace.ENCRYPT_ECDHE) && !device.encryptionKeys.sharedKey) { + let { privateKey } = options; + + if (privateKey === true) { + const { privateKey: generatedPrivateKey } = await generateKeyPair(); + privateKey = generatedPrivateKey.toString('base64'); + } + + if (!privateKey) { + // use precomputed private key + privateKey = computePresharedPrivateKey( + device.id, + key, + device.hardware.macAddress + ); + } + + await device.setPrivateKey(Buffer.from(privateKey, 'base64')); + + const exchangeKeys = () => device.exchangeKeys(); + await (quiet ? exchangeKeys() : progressFunctionWithMessage(exchangeKeys, 'Exchanging public keys')); + } + + const { setTime = false } = options; + if (setTime) { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const time = generateTimestamp(); + + const configureDeviceTime = () => device.configureDeviceTime(time, timezone); + await (quiet ? configureDeviceTime() : progressFunctionWithMessage(configureDeviceTime, 'Configuring device time')); + } + + const { mqtt = [] } = options; + if (mqtt.length) { + const configureMQTT = () => device.configureMQTTBrokersAndCredentials(mqtt, credentials); + await (quiet ? configureMQTT() : progressFunctionWithMessage(configureMQTT, 'Configuring MQTT brokers')); + } + + if (ssid || bssid) { + const wifiAccessPoint = new WifiAccessPoint({ + ssid, + password, + channel, + encryption, + cipher, + bssid, + }); + const configureWifi = () => device.configureWifi(wifiAccessPoint); + const success = await (quiet ? configureWifi() : progressFunctionWithMessage(configureWifi, 'Configuring WIFI')); + + if (success && !quiet) { + terminal.yellow(`Device will now reboot…\n`); + } + } +} catch (error: any) { + terminal.red(`${error.message}\n`); + if (process.env.LOG_LEVEL) { + terminal.red('Error stack:\n'); + terminal.red(error.stack); + } + process.exit(1); +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..ec4b784 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "strict": true, + "target": "ESNext", + "module": "Node18", + "sourceMap": false, + "esModuleInterop": true, + "moduleResolution": "nodenext", + "resolveJsonModule": true + } +} diff --git a/packages/lib/.npmignore b/packages/lib/.npmignore new file mode 100644 index 0000000..12ee65f --- /dev/null +++ b/packages/lib/.npmignore @@ -0,0 +1,6 @@ +# Directories +src/ + +# Files +*.log +*.test.* \ No newline at end of file diff --git a/packages/lib/LICENSE.md b/packages/lib/LICENSE.md new file mode 100644 index 0000000..f6f16a9 --- /dev/null +++ b/packages/lib/LICENSE.md @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2025 Rob Griffiths + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/packages/lib/README.md b/packages/lib/README.md new file mode 100644 index 0000000..1839eab --- /dev/null +++ b/packages/lib/README.md @@ -0,0 +1,340 @@ +# Meross Library + +A TypeScript/JavaScript library for interacting with Meross smart home devices. + +## Installation + +```bash +npm install @meross/lib +``` + +## Basic Usage + +```typescript +import { HTTPTransport, Device, CloudCredentials } from '@meross/lib'; + +async function main() { + // Setup credentials (use userId: 0 and key: 'meross' for local devices) + const credentials = new CloudCredentials(0, 'meross'); + + // Create HTTP transport + const transport = new HTTPTransport({ + url: 'http://192.168.1.100/config', + credentials, + }); + + // Initialize device + const device = new Device(); + device.setTransport(transport); + + // Get device information + const deviceInfo = await device.fetchDeviceInfo(); + console.log('Device Info:', deviceInfo); + + // Get device abilities + const abilities = await device.fetchDeviceAbilities(); + console.log('Device Abilities:', abilities); +} + +main().catch(console.error); +``` + +## Core Components + +### Device + +The `Device` class is the primary interface for communicating with Meross devices: + +```typescript +import { Device, WifiAccessPoint, CloudCredentials } from '@meross/lib'; + +// Create device instance +const device = new Device(); + +// Connect to device +device.setTransport(transport); + +// Fetch device information +const info = await device.fetchDeviceInfo(); + +// Check device abilities +const abilities = await device.fetchDeviceAbilities(); + +// Check if device has a specific ability +const hasEncryption = device.hasAbility(Namespace.ENCRYPT_ECDHE); + +// Configure WiFi +const wifiAP = new WifiAccessPoint({ + ssid: 'MyNetwork', + password: 'MyPassword', + encryption: 3, + cipher: 1, +}); +await device.configureWifi(wifiAP); + +// Configure MQTT brokers +const credentials = new CloudCredentials(123, 'sharedKey'); +await device.configureMQTTBrokersAndCredentials( + ['mqtt://broker.example.com'], + credentials +); + +// Configure device time +await device.configureDeviceTime( + Date.now() / 1000, + Intl.DateTimeFormat().resolvedOptions().timeZone +); + +// Get nearby WiFi networks +const nearbyNetworks = await device.fetchNearbyWifi(); +``` + +### Transport + +The library includes an HTTP transport for device communication: + +```typescript +import { HTTPTransport, CloudCredentials } from '@meross/lib'; + +// Create credentials +const credentials = new CloudCredentials(0, 'meross'); + +// Create transport with device URL +const transport = new HTTPTransport({ + url: 'http://192.168.1.100/config', + credentials, + timeout: 15000, // Optional custom timeout (default: 10000ms) +}); +``` + +### Device Manager + +For managing multiple devices: + +```typescript +import { DeviceManager, HTTPTransport, Device } from '@meross/lib'; + +// Create shared transport +const transport = new HTTPTransport({ + url: 'http://192.168.1.100/config', + credentials: { userId: 0, key: 'meross' }, +}); + +// Create device manager +const deviceManager = new DeviceManager({ transport }); + +// Add devices +const device1 = new Device(); +deviceManager.addDevice(device1); + +// Get all devices +const devices = deviceManager.getDevices(); + +// Get specific device +const device = deviceManager.getDeviceById('device-uuid'); + +// Send message to device +const message = new Message(); +await deviceManager.sendMessageToDevice(device, message); +``` + +## Encryption + +The library supports ECDH key exchange for encrypted communication: + +```typescript +import { + generateKeyPair, + createKeyPair, + computePresharedPrivateKey, +} from '@meross/lib'; + +// Method 1: Generate new key pair +const { privateKey, publicKey } = await generateKeyPair(); + +// Method 2: Create key pair from existing private key +const keyPair = await createKeyPair(privateKey); + +// Method 3: Use precomputed key based on device info +const precomputedKey = computePresharedPrivateKey( + deviceId, + sharedKey, + macAddress +); + +// Configure device with private key +await device.setPrivateKey(Buffer.from(privateKeyBase64, 'base64')); + +// Exchange keys with the device +await device.exchangeKeys(); +``` + +## WiFi Configuration + +Configure a device's WiFi connection: + +```typescript +import { WifiAccessPoint } from '@meross/lib'; + +// Create WiFi access point configuration +const wifiConfig = new WifiAccessPoint({ + ssid: 'MyNetworkName', + password: 'MySecurePassword', + encryption: 3, // WPA2 PSK + cipher: 1, // CCMP (AES) + channel: 6, // 2.4GHz channel + bssid: '00:11:22:33:44:55', // Optional +}); + +// Configure device +await device.configureWifi(wifiConfig); +``` + +## MQTT Configuration + +Configure a device to connect to MQTT brokers: + +```typescript +import { CloudCredentials } from '@meross/lib'; + +// Create credentials +const credentials = new CloudCredentials(userId, sharedKey); + +// Configure MQTT brokers (supports up to 2 brokers) +const mqttServers = [ + 'mqtt://primary-broker.example.com:1883', + 'mqtts://backup-broker.example.com:8883', +]; + +await device.configureMQTTBrokersAndCredentials(mqttServers, credentials); +``` + +## Error Handling + +```typescript +try { + await device.fetchDeviceInfo(); +} catch (error) { + console.error('Error communicating with device:', error.message); + + // For detailed logs + if (process.env.LOG_LEVEL) { + console.error('Error stack:', error.stack); + } +} +``` + +## Advanced Example: Complete Device Setup + +```typescript +import { + HTTPTransport, + Device, + WifiAccessPoint, + CloudCredentials, + Namespace, + generateTimestamp, + computePresharedPrivateKey, +} from '@meross/lib'; + +async function setupDevice(ip, wifiSettings, mqttServers) { + // Create credentials and transport + const credentials = new CloudCredentials(0, 'meross'); + const transport = new HTTPTransport({ + url: `http://${ip}/config`, + credentials, + }); + + // Initialize device + const device = new Device(); + device.setTransport(transport); + + // Get device info + const deviceInfo = await device.fetchDeviceInfo(); + console.log(`Connected to ${deviceInfo.system.hardware.type}`); + + // Get abilities + await device.fetchDeviceAbilities(); + + // Set up encryption if supported + if (device.hasAbility(Namespace.ENCRYPT_ECDHE)) { + // Use pre-computed key based on device information + const privateKey = computePresharedPrivateKey( + device.id, + credentials.key, + device.hardware.macAddress + ); + + await device.setPrivateKey(Buffer.from(privateKey, 'base64')); + await device.exchangeKeys(); + console.log('Encryption keys exchanged'); + } + + // Configure time + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const time = generateTimestamp(); + await device.configureDeviceTime(time, timezone); + console.log('Device time configured'); + + // Configure MQTT (if provided) + if (mqttServers && mqttServers.length) { + await device.configureMQTTBrokersAndCredentials(mqttServers, credentials); + console.log('MQTT servers configured'); + } + + // Configure WiFi (if provided) + if (wifiSettings) { + const wifiAccessPoint = new WifiAccessPoint(wifiSettings); + const success = await device.configureWifi(wifiAccessPoint); + + if (success) { + console.log('WiFi configured successfully, device will reboot'); + } + } + + return device; +} + +// Usage example +setupDevice( + '10.10.10.1', + { + ssid: 'HomeNetwork', + password: 'SecurePassword', + encryption: 3, + cipher: 1, + channel: 6, + }, + ['mqtts://broker.example.com:8883'] +).catch(console.error); +``` + +## API Reference + +See the TypeScript definitions for complete API details. + +### Main Classes + +- `Device` - Core class for interacting with Meross devices +- `DeviceManager` - Manages multiple devices with a shared transport +- `HTTPTransport` - HTTP communication transport +- `CloudCredentials` - Authentication credentials +- `WifiAccessPoint` - WiFi configuration + +### Namespaces + +The library defines standard Meross namespace constants in `Namespace`: + +```typescript +import { Namespace } from '@meross/lib'; + +// Examples: +Namespace.SYSTEM_ALL; +Namespace.SYSTEM_ABILITY; +Namespace.ENCRYPT_ECDHE; +Namespace.CONFIG_WIFI; +``` + +## License + +ISC diff --git a/packages/lib/debug.log b/packages/lib/debug.log new file mode 100644 index 0000000..e69de29 diff --git a/packages/lib/package.json b/packages/lib/package.json new file mode 100644 index 0000000..8dc3147 --- /dev/null +++ b/packages/lib/package.json @@ -0,0 +1,69 @@ +{ + "name": "@meross/lib", + "version": "2.0.0", + "exports": { + ".": { + "default": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./utils": { + "default": "./dist/utils/index.js", + "types": "./dist/utils/index.d.ts" + }, + "./utils/*": { + "default": "./dist/utils/*.js", + "types": "./dist/utils/*.d.ts" + }, + "./message": { + "default": "./dist/message/index.js", + "types": "./dist/message/index.d.ts" + }, + "./message/*": { + "default": "./dist/message/*.js", + "types": "./dist/message/*.d.ts" + }, + "./transport": { + "default": "./dist/transport/index.js", + "types": "./dist/transport/index.d.ts" + }, + "./transport/*": { + "default": "./dist/transport/*.js", + "types": "./dist/transport/*.d.ts" + }, + "./encryption": { + "default": "./dist/encryption.js", + "types": "./dist/encryption.d.ts" + }, + "./messages": { + "default": "./dist/message/messages.js", + "types": "./dist/message/messages.d.ts" + } + }, + "scripts": { + "test": "tsx --test", + "compile": "tsc", + "build": "npm run build:clean && npm run compile", + "build:clean": "rm -rf ./dist", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "meross", + "automation", + "smarthome" + ], + "author": "Rob Griffiths ", + "license": "ISC", + "description": "Library for interacting with Meross devices", + "repository": { + "type": "git", + "url": "git+https://github.com/bytespider/meross.git" + }, + "dependencies": { + "winston": "^3.17.0" + }, + "devDependencies": { + "@types/node": "^22.13.16", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + } +} \ No newline at end of file diff --git a/packages/lib/src/cloudCredentials.ts b/packages/lib/src/cloudCredentials.ts new file mode 100644 index 0000000..f58a1b3 --- /dev/null +++ b/packages/lib/src/cloudCredentials.ts @@ -0,0 +1,36 @@ +export class CloudCredentials { + userId: number; + key: string; + + constructor(userId: number = 0, key: string = '') { + this.userId = userId; + this.key = key; + } +} + +let instance: CloudCredentials | null = null; + +export function createCloudCredentials( + userId: number, + key: string +): CloudCredentials { + if (!instance) { + instance = new CloudCredentials(userId, key); + } + return instance; +} + +export function getCloudCredentials(): CloudCredentials { + if (!instance) { + throw new Error('Cloud credentials have not been initialized.'); + } + return instance; +} + +export function hasCloudCredentials(): boolean { + return instance !== null; +} + +export function clearCloudCredentials(): void { + instance = null; +} diff --git a/packages/lib/src/device.ts b/packages/lib/src/device.ts new file mode 100644 index 0000000..bec78de --- /dev/null +++ b/packages/lib/src/device.ts @@ -0,0 +1,635 @@ +import { CloudCredentials } from './cloudCredentials.js'; +import { + createKeyPair, + deriveSharedKey, + generateKeyPair, + type EncryptionKeyPair, +} from './encryption.js'; +import { + ConfigureDeviceTimeMessage, + ConfigureECDHMessage, + ConfigureMQTTBrokersAndCredentialsMessage, + ConfigureWifiMessage, + ConfigureWifiXMessage, + QueryDeviceAbilitiesMessage, + QueryDeviceInformationMessage, + QueryDeviceTimeMessage, + QueryWifiListMessage, +} from './message/messages.js'; +import { encryptPassword, WifiAccessPoint } from './wifi.js'; +import { Namespace } from './message/header.js'; +import { Transport } from './transport/transport.js'; +import base64 from './utils/base64.js'; +import logger from './utils/logger.js'; +import md5 from './utils/md5.js'; +import { + protocolFromPort, + portFromProtocol, +} from './utils/protocolFromPort.js'; + +const deviceLogger = logger.child({ + name: 'device', +}); + +export type MacAddress = + `${string}:${string}:${string}:${string}:${string}:${string}`; +export type UUID = string; + +export type DeviceFirmware = { + version: string; + compileTime: Date; +}; + +const FirmwareDefaults: DeviceFirmware = { + version: '0.0.0', + compileTime: new Date(), +}; + +export type DeviceHardware = { + version?: string; + uuid: UUID; + macAddress: MacAddress; +}; + +const HardwareDefaults: DeviceHardware = { + version: '0.0.0', + uuid: '00000000000000000000000000000000', + macAddress: '00:00:00:00:00:00', +}; + +export type EncryptionKeys = { + localKeys: EncryptionKeyPair | undefined; + remotePublicKey: Buffer | undefined; + sharedKey: Buffer | undefined; +}; + +export type DeviceOptions = { + firmware?: DeviceFirmware; + hardware?: DeviceHardware; + model?: string; +}; + +export class Device implements Device { + firmware: DeviceFirmware; + hardware: DeviceHardware; + model?: string; + + ability: Record = {}; + + encryptionKeys: EncryptionKeys = { + localKeys: undefined, + remotePublicKey: undefined, + sharedKey: undefined, + }; + + protected transport: Transport; + + constructor(options: DeviceOptions = {}) { + const { firmware, hardware, model } = options; + this.firmware = firmware || FirmwareDefaults; + this.hardware = hardware || HardwareDefaults; + this.model = model; + } + + get id(): UUID { + return this.hardware.uuid; + } + + setTransport(transport: Transport) { + deviceLogger.debug( + `Setting transport for device ${this.id} to ${transport.constructor.name}`, + { transport } + ); + this.transport = transport; + } + + async setPrivateKey(privateKey: Buffer) { + deviceLogger.debug(`Setting private key for device ${this.id}`); + + const keyPair = await createKeyPair(privateKey); + + this.encryptionKeys.localKeys = keyPair; + } + + hasAbility(ability: Namespace) { + deviceLogger.debug(`Checking if device ${this.id} has ability ${ability}`, { + ability, + }); + return Object.keys(this.ability).includes(ability); + } + + private sendMessage(message: any): Promise> { + return this.transport.send({ + message, + encryptionKey: this.encryptionKeys.sharedKey, + }); + } + + async fetchDeviceInfo() { + deviceLogger.info(`Fetching device information for ${this.id}`); + const message = new QueryDeviceInformationMessage(); + const { + payload: { all }, + } = await this.sendMessage(message); + + const { + system: { firmware = FirmwareDefaults, hardware = HardwareDefaults }, + } = all; + + this.model = hardware?.type; + deviceLogger.info( + `Device Info - Model: ${this.model}, Firmware: ${firmware?.version}, Hardware: ${hardware?.version}, UUID: ${hardware?.uuid}, MAC Address: ${hardware?.macAddress}` + ); + + this.firmware = { + version: firmware?.version, + compileTime: firmware?.compileTime + ? new Date(firmware?.compileTime) + : undefined, + }; + + this.hardware = { + version: hardware?.version, + uuid: hardware?.uuid, + macAddress: hardware?.macAddress, + }; + + return all; + } + + async fetchDeviceAbilities() { + deviceLogger.info(`Fetching device abilities for ${this.id}`); + + const message = new QueryDeviceAbilitiesMessage(); + const { + payload: { ability }, + } = await this.sendMessage(message); + + this.ability = ability; + + deviceLogger.info(`Device Abilities: ${JSON.stringify(this.ability)}`); + + return ability; + } + + async fetchDeviceTime() { + const message = new QueryDeviceTimeMessage(); + const { + payload: { time }, + } = await this.sendMessage(message); + return time; + } + + async exchangeKeys() { + deviceLogger.info(`Exchanging keys for device ${this.id}`); + + if (!this.encryptionKeys.localKeys) { + deviceLogger.debug(`Generating local keys for device ${this.id}`); + this.encryptionKeys.localKeys = await generateKeyPair(); + } + + const { publicKey, privateKey } = this.encryptionKeys.localKeys; + + const message = new ConfigureECDHMessage({ publicKey }); + + const { + payload: { + ecdhe: { pubkey }, + }, + } = await this.sendMessage(message); + + const remotePublicKey = Buffer.from(pubkey, 'base64'); + this.encryptionKeys.remotePublicKey = remotePublicKey; + + // derive the shared key + const sharedKey = await deriveSharedKey(privateKey, remotePublicKey); + + // ...and now for the dumb part + // Meross take the shared key and MD5 it + const sharedKeyMd5 = await md5(sharedKey, 'hex'); + + // then use the 32 hex characters as the shared key + this.encryptionKeys.sharedKey = Buffer.from(sharedKeyMd5, 'utf8'); + + return; + } + + async configureDeviceTime( + timestamp: number, + timezone: string | undefined = undefined + ) { + deviceLogger.info( + `Configuring system time for device ${this.id} with timestamp ${timestamp} and timezone ${timezone}` + ); + + const message = new ConfigureDeviceTimeMessage({ + timestamp, + timezone, + }); + + await this.sendMessage(message); + return; + } + + async configureMQTTBrokersAndCredentials( + mqtt: string[], + credentials: CloudCredentials + ) { + deviceLogger.info( + `Configuring MQTT brokers and credentials for device ${this.id}` + ); + + const brokers = mqtt + .map((broker) => { + if (!URL.canParse(broker)) { + // do we have a port? + const port = broker.split(':')[1]; + if (port) { + const protocol = protocolFromPort(Number(port)); + broker = `${protocol}://${broker}`; + } + } + + let { protocol, hostname, port } = new URL(broker); + if (!port) { + port = `${portFromProtocol(protocol.replace(':', ''))}`; + } + + return { + host: hostname, + port: Number(port), + }; + }) + .slice(0, 2); // Limit to 2 brokers + + const message = new ConfigureMQTTBrokersAndCredentialsMessage({ + mqtt: brokers, + credentials: credentials, + }); + + await this.sendMessage(message); + return; + } + + async fetchNearbyWifi(): Promise { + deviceLogger.info(`Fetching nearby WiFi for device ${this.id}`); + + const message = new QueryWifiListMessage(); + const { + payload: { wifiList }, + } = await this.sendMessage(message); + + return wifiList.map( + (item) => + new WifiAccessPoint({ + ...item, + ssid: item.ssid + ? base64.decode(item.ssid).toString('utf-8') + : undefined, + }) + ); + } + + async configureWifi(wifiAccessPoint: WifiAccessPoint): Promise { + deviceLogger.info( + `Configuring WiFi for device ${this.id} with SSID ${wifiAccessPoint.ssid}` + ); + + let message = new ConfigureWifiMessage({ wifiAccessPoint }); + if (this.hasAbility(Namespace.CONFIG_WIFIX)) { + deviceLogger.debug( + `Device ${this.id} has CONFIG_WIFIX ability, using ConfigureWifiXMessage` + ); + + wifiAccessPoint.password = await encryptPassword({ + password: wifiAccessPoint.password, + hardware: { type: this.model, ...this.hardware }, + }); + + message = new ConfigureWifiXMessage({ + wifiAccessPoint, + }); + } + + await this.sendMessage(message); + return true; + } + + // /** + // * + // * @param {Namespace} namespace + // * @param {object} [payload] + // * @returns {Promise} + // */ + // async queryCustom(namespace, payload = {}) { + // const message = new Message(); + // message.header.method = Method.GET; + // message.header.namespace = namespace; + // message.payload = payload; + + // return this.#transport.send({ + // message, + // signatureKey: this.credentials.key, + // }); + // } + + // /** + // * + // * @param {Namespace} namespace + // * @param {object} [payload] + // * @returns {Promise} + // */ + // async configureCustom(namespace, payload = {}) { + // const message = new Message(); + // message.header.method = Method.SET; + // message.header.namespace = namespace; + // message.payload = payload; + + // return this.#transport.send({ + // message, + // signatureKey: this.credentials.key, + // }); + // } + + // /** + // * @typedef QuerySystemInformationResponse + // * @property {object} system + // * @property {QuerySystemFirmwareResponse} system.firmware + // * @property {QuerySystemHardwareResponse} system.hardware + // */ + // /** + // * + // * @param {boolean} [updateDevice] + // * @returns {Promise} + // */ + // async querySystemInformation(updateDevice = true) { + // const message = new QuerySystemInformationMessage(); + // message.sign(this.credentials.key); + + // const { payload } = await this.#transport.send({ + // message, + // signatureKey: this.credentials.key, + // }); + + // const { all } = payload; + + // if (updateDevice) { + // const { + // system: { firmware = FirmwareDefaults, hardware = HardwareDefaults }, + // } = all; + + // this.model = hardware?.type; + // this.firmware = { + // version: firmware?.version, + // compileTime: firmware?.compileTime + // ? new Date(firmware?.compileTime) + // : undefined, + // }; + // this.hardware = { + // version: hardware?.version, + // macAddress: hardware?.macAddress, + // }; + // } + + // return all; + // } + + // /** + // * @typedef QuerySystemFirmwareResponse + // * @property {string} version + // * @property {number} compileTime + // */ + // /** + // * + // * @param {boolean} [updateDevice] + // * @returns {Promise} + // */ + // async querySystemFirmware(updateDevice = true) { + // const message = new QuerySystemFirmwareMessage(); + + // const { payload } = await this.#transport.send({ + // message, + // signatureKey: this.credentials.key, + // }); + + // const { firmware = FirmwareDefaults } = payload; + + // if (updateDevice) { + // this.firmware = { + // version: firmware?.version, + // compileTime: firmware?.compileTime + // ? new Date(firmware?.compileTime) + // : undefined, + // }; + // } + + // return firmware; + // } + + // /** + // * @typedef QuerySystemHardwareResponse + // * @property {string} version + // * @property {string} macAddress + // */ + // /** + // * + // * @param {boolean} [updateDevice] + // * @returns {Promise} + // */ + // async querySystemHardware(updateDevice = true) { + // const message = new QuerySystemHardwareMessage(); + + // const { payload } = await this.#transport.send({ + // message, + // signatureKey: this.credentials.key, + // }); + + // const { hardware = HardwareDefaults } = payload; + + // if (updateDevice) { + // this.hardware = { + // version: hardware?.version, + // macAddress: hardware?.macAddress, + // }; + // } + + // return hardware; + // } + + // /** + // * + // * @param {Namespace} ability + // * @param {boolean} [updateDevice] + // * @returns {Promise} + // */ + // async hasSystemAbility(ability, updateDevice = true) { + // if (Object.keys(this.ability).length == 0 && updateDevice) { + // this.querySystemAbility(updateDevice); + // } + + // return ability in this.ability; + // } + + // /** + // * @typedef QuerySystemAbilityResponse + // */ + // /** + // * + // * @param {boolean} [updateDevice] + // * @returns {Promise} + // */ + // async querySystemAbility(updateDevice = true) { + // const message = new QuerySystemAbilityMessage(); + + // const { payload } = await this.#transport.send({ + // message, + // signatureKey: this.credentials.key, + // }); + + // const { ability } = payload; + // if (updateDevice) { + // this.ability = ability; + // } + + // return ability; + // } + + // /** + // * @typedef QuerySystemTimeResponse + // * @property {number} timestamp + // * @property {string} timezone + // */ + // /** + // * + // * @param {boolean} [updateDevice] + // * @returns {Promise} + // */ + // async querySystemTime(updateDevice = true) { + // const message = new QuerySystemTimeMessage(); + + // const { payload } = await this.#transport.send({ + // message, + // signatureKey: this.credentials.key, + // }); + + // const { time } = payload; + // if (updateDevice) { + // } + + // return time; + // } + + // /** + // * + // * @param {object} [opts] + // * @param {number} [opts.timestamp] + // * @param {string} [opts.timezone] + // * @param {boolean} [updateDevice] + // * @returns {Promise} + // */ + // async configureSystemTime({ timestamp, timezone } = {}, updateDevice = true) { + // const message = new ConfigureSystemTimeMessage({ timestamp, timezone }); + + // await this.#transport.send({ message, signatureKey: this.credentials.key }); + + // return true; + // } + + // /** + // * @typedef QuerySystemGeolocationResponse + // */ + // /** + // * + // * @param {boolean} [updateDevice] + // * @returns {Promise} + // */ + // async querySystemGeolocation(updateDevice = true) { + // const message = new QuerySystemTimeMessage(); + + // const { payload } = await this.#transport.send({ + // message, + // signatureKey: this.credentials.key, + // }); + + // const { position } = payload; + // if (updateDevice) { + // } + + // return position; + // } + + // /** + // * @param {object} [opts] + // * @param {} [opts.position] + // * @param {boolean} [updateDevice] + // * @returns {Promise} + // */ + // async configureSystemGeolocation({ position } = {}, updateDevice = true) { + // const message = new ConfigureSystemPositionMessage({ position }); + + // await this.#transport.send({ message, signatureKey: this.credentials.key }); + + // return true; + // } + + // /** + // * + // * @returns {Promise} + // */ + // async queryNearbyWifi() { + // const message = new QueryNearbyWifiMessage(); + + // const { payload } = await this.#transport.send({ + // message, + // signatureKey: this.credentials.key, + // }); + + // const { wifiList } = payload; + + // return wifiList.map((item) => new WifiAccessPoint(item)); + // } + + // /** + // * @param { object } [opts] + // * @param { string[] } [opts.mqtt] + // * @returns { Promise } + // */ + // async configureMQTTBrokers({ mqtt = [] } = {}) { + // const message = new ConfigureMQTTMessage({ + // mqtt, + // credentials: this.credentials, + // }); + + // await this.#transport.send({ + // message, + // signatureKey: this.credentials.key, + // }); + + // return true; + // } + + // /** + // * @param {object} opts + // * @param {WifiAccessPoint[]} opts.wifiAccessPoint + // * @returns { Promise } + // */ + // async configureWifi({ wifiAccessPoint }) { + // let message; + // if (await this.hasSystemAbility(Namespace.CONFIG_WIFIX)) { + // const hardware = await this.querySystemHardware(); + // message = new ConfigureWifiXMessage({ + // wifiAccessPoint, + // hardware, + // }); + // } else { + // message = new ConfigureWifiMessage({ wifiAccessPoint }); + // } + + // await this.#transport.send({ + // message, + // signatureKey: this.credentials.key, + // }); + + // return true; + // } +} diff --git a/packages/lib/src/deviceManager.test.ts b/packages/lib/src/deviceManager.test.ts new file mode 100644 index 0000000..f4b0e65 --- /dev/null +++ b/packages/lib/src/deviceManager.test.ts @@ -0,0 +1,156 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { DeviceManager } from './deviceManager.js'; +import { Device } from './device.js'; +import { Namespace } from './message/header.js'; +import { TransportSendOptions, Transport } from './transport/transport.js'; +import { Message } from './message/message.js'; + +class MockTransport extends Transport { + id: string = ''; + timeout: number = 10_000; + + protected _send(options: TransportSendOptions): Promise { + throw new Error('Method not implemented.'); + } + + send(data: any): Promise { + return Promise.resolve(data); + } +} + +class MockDevice extends Device { + constructor(id: string, sharedKey?: string) { + super(); + + this.hardware.uuid = id; + + if (sharedKey) { + this.encryptionKeys = { + publicKey: undefined, + remotePublicKey: undefined, + sharedKey: Buffer.from(sharedKey), + }; + } + } + + hasAbility(namespace: Namespace): boolean { + return namespace === Namespace.ENCRYPT_ECDHE; + } +} + +test('DeviceManager should add and retrieve devices', () => { + const transport = new MockTransport(); + const deviceManager = new DeviceManager({ transport }); + + const device = new MockDevice('device-1'); + deviceManager.addDevice(device); + + const retrievedDevice = deviceManager.getDeviceById('device-1'); + assert.strictEqual(retrievedDevice, device); +}); + +test('DeviceManager should remove devices by instance', () => { + const transport = new MockTransport(); + const deviceManager = new DeviceManager({ transport }); + + const device = new MockDevice('device-1'); + deviceManager.addDevice(device); + deviceManager.removeDevice(device); + + const retrievedDevice = deviceManager.getDeviceById('device-1'); + assert.strictEqual(retrievedDevice, undefined); +}); + +test('DeviceManager should remove devices by ID', () => { + const transport = new MockTransport(); + const deviceManager = new DeviceManager({ transport }); + + const device = new MockDevice('device-1'); + deviceManager.addDevice(device); + deviceManager.removeDeviceById('device-1'); + + const retrievedDevice = deviceManager.getDeviceById('device-1'); + assert.strictEqual(retrievedDevice, undefined); +}); + +test('DeviceManager should send messages to devices', async () => { + const transport = new MockTransport({ + credentials: { userId: 123, key: 'secretKey' }, + }); + const deviceManager = new DeviceManager({ + transport, + }); + + const device = new MockDevice('device-1', 'sharedKey'); + deviceManager.addDevice(device); + + const message = new Message(); + const response = await deviceManager.sendMessageToDevice(device, message); + + assert.deepStrictEqual(response, { + message, + encryptionKey: Buffer.from('sharedKey', 'utf-8'), + }); +}); + +test('DeviceManager should throw an error if device is not found', async () => { + const transport = new MockTransport(); + const deviceManager = new DeviceManager({ transport }); + + await assert.rejects( + async () => + deviceManager.sendMessageToDevice('non-existent-device', new Message()), + new Error('Device with ID non-existent-device not found') + ); +}); + +test('DeviceManager shouldEncryptMessage returns true for devices requiring encryption', () => { + const transport = new MockTransport(); + const deviceManager = new DeviceManager({ transport }); + + const device = new MockDevice('device-1'); + device.hasAbility = (namespace: Namespace) => + namespace === Namespace.ENCRYPT_ECDHE; + + const message = { namespace: 'custom' }; + + const result = (deviceManager as any).shouldEncryptMessage(device, message); + assert.strictEqual(result, true); +}); + +test('DeviceManager shouldEncryptMessage returns false for devices not requiring encryption', () => { + const transport = new MockTransport(); + const deviceManager = new DeviceManager({ transport }); + + const device = new MockDevice('device-1'); + device.hasAbility = () => false; + + const message = { namespace: 'custom' }; + + const result = (deviceManager as any).shouldEncryptMessage(device, message); + assert.strictEqual(result, false); +}); + +test('DeviceManager shouldEncryptMessage returns false for excluded namespaces', () => { + const transport = new MockTransport(); + const deviceManager = new DeviceManager({ transport }); + + const device = new MockDevice('device-1'); + device.hasAbility = (namespace: Namespace) => + namespace === Namespace.ENCRYPT_ECDHE; + + const excludedNamespaces = [ + Namespace.SYSTEM_ALL, + Namespace.SYSTEM_FIRMWARE, + Namespace.SYSTEM_ABILITY, + Namespace.ENCRYPT_ECDHE, + Namespace.ENCRYPT_SUITE, + ]; + + for (const namespace of excludedNamespaces) { + const message = { namespace }; + const result = (deviceManager as any).shouldEncryptMessage(device, message); + assert.strictEqual(result, false, `Failed for namespace: ${namespace}`); + } +}); diff --git a/packages/lib/src/deviceManager.ts b/packages/lib/src/deviceManager.ts new file mode 100644 index 0000000..0c3669c --- /dev/null +++ b/packages/lib/src/deviceManager.ts @@ -0,0 +1,71 @@ +import type { UUID, Device } from './device.js'; +import { type Transport } from './transport/transport.js'; +import { Namespace } from './message/header.js'; +import { Message } from './message/message.js'; + +export type DeviceManagerOptions = { + transport: Transport; +}; + +export class DeviceManager { + private transport: Transport; + private devices: Map = new Map(); + + constructor(options: DeviceManagerOptions) { + this.transport = options.transport; + } + + addDevice(device: Device): void { + this.devices.set(device.id as UUID, device); + } + + removeDevice(device: Device): void { + this.devices.delete(device.id as UUID); + } + + removeDeviceById(deviceId: string): void { + this.devices.delete(deviceId as UUID); + } + + getDevices(): Map { + return this.devices; + } + + getDeviceById(deviceId: string): Device | undefined { + return this.devices.get(deviceId as UUID); + } + + async sendMessageToDevice( + deviceOrId: UUID | Device, + message: Message + ): Promise> { + let device = deviceOrId as Device; + if (typeof deviceOrId === 'string') { + device = this.getDeviceById(deviceOrId) as Device; + if (!device) { + throw new Error(`Device with ID ${deviceOrId} not found`); + } + } + + const shouldEncrypt = this.shouldEncryptMessage(device, message); + + return this.transport.send({ + message, + encryptionKey: shouldEncrypt + ? device.encryptionKeys?.sharedKey + : undefined, + }); + } + + private shouldEncryptMessage(device: Device, message: Message): boolean { + const hasAbility = device.hasAbility(Namespace.ENCRYPT_ECDHE); + const excludedNamespaces = [ + Namespace.SYSTEM_ALL, + Namespace.SYSTEM_FIRMWARE, + Namespace.SYSTEM_ABILITY, + Namespace.ENCRYPT_ECDHE, + Namespace.ENCRYPT_SUITE, + ]; + return hasAbility && !excludedNamespaces.includes(message.header.namespace); + } +} diff --git a/packages/lib/src/encryption.test.ts b/packages/lib/src/encryption.test.ts new file mode 100644 index 0000000..cfeeef9 --- /dev/null +++ b/packages/lib/src/encryption.test.ts @@ -0,0 +1,56 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { randomBytes } from 'node:crypto'; +import Encryption from './encryption.js'; + +test('encrypt should return a buffer of encrypted data', async () => { + const data = Buffer.from('Hello, World!', 'utf-8'); + const encryptionKey = randomBytes(32); // AES-256 requires a 32-byte key + + const encryptedData = await Encryption.encrypt(data, encryptionKey); + + assert.ok(encryptedData); + assert.notStrictEqual( + encryptedData.toString('utf-8'), + data.toString('utf-8') + ); +}); + +test('encrypt should use the provided IV', async () => { + const data = Buffer.from('Hello, World!', 'utf-8'); + const encryptionKey = randomBytes(32); + const customIV = randomBytes(16); // AES-CBC requires a 16-byte IV + + const encryptedData = await Encryption.encrypt(data, encryptionKey, customIV); + + assert.ok(encryptedData); + assert.notStrictEqual( + encryptedData.toString('utf-8'), + data.toString('utf-8') + ); +}); + +test('encrypt should use the default IV if none is provided', async () => { + const data = Buffer.from('Hello, World!', 'utf-8'); + const encryptionKey = randomBytes(32); + + const encryptedData = await Encryption.encrypt(data, encryptionKey); + + assert.ok(encryptedData); + assert.notStrictEqual( + encryptedData.toString('utf-8'), + data.toString('utf-8') + ); +}); + +test('encrypt should throw an error if the encryption key is invalid', async () => { + const data = Buffer.from('Hello, World!', 'utf-8'); + const invalidKey = randomBytes(16); // Invalid key length for AES-256 + + await assert.rejects( + async () => { + await Encryption.encrypt(data, invalidKey); + }, + { name: 'RangeError', message: /Invalid key length/ } + ); +}); diff --git a/packages/lib/src/encryption.ts b/packages/lib/src/encryption.ts new file mode 100644 index 0000000..b2d9588 --- /dev/null +++ b/packages/lib/src/encryption.ts @@ -0,0 +1,124 @@ +import { createCipheriv, createDecipheriv, createECDH } from 'node:crypto'; +import { Buffer } from 'node:buffer'; +import { + calculatePaddingForBlockSize, + pad, + trimPadding, +} from './utils/buffer.js'; +import logger from './utils/logger.js'; + +const encryptionLogger = logger.child({ + name: 'encryption', +}); + +export const DEFAULT_IV = Buffer.from('0000000000000000', 'utf-8'); + +export type EncryptionKeyPair = { + privateKey: Buffer; + publicKey: Buffer; +}; + +export async function encrypt( + data: Buffer, + encryptionKey: Buffer, + iv: Buffer = DEFAULT_IV +): Promise { + encryptionLogger.debug( + `Encrypting: data: ${data.toString('utf-8')}, key: ${encryptionKey.toString( + 'base64' + )}, iv: ${iv.toString('base64')}` + ); + + const cipher = createCipheriv('aes-256-cbc', encryptionKey, iv); + + // Disable auto padding to handle custom padding + cipher.setAutoPadding(false); + + // Ensure the data length is a multiple of 16 by padding with null characters. + const length = calculatePaddingForBlockSize(data, 16); + const paddedData = pad(data, length, 0x0); + + // Encrypt the data + return Buffer.concat([cipher.update(paddedData), cipher.final()]); +} + +export async function decrypt( + data: Buffer, + encryptionKey: Buffer, + iv: Buffer = DEFAULT_IV +): Promise { + encryptionLogger.debug( + `Decrypting: data: ${data.toString( + 'base64' + )}, key: ${encryptionKey.toString('base64')}, iv: ${iv.toString('base64')}` + ); + const decipher = createDecipheriv('aes-256-cbc', encryptionKey, iv); + + // Disable auto padding to handle custom padding + decipher.setAutoPadding(false); + + // Decrypt the data + const decryptedData = Buffer.concat([ + decipher.update(data), + decipher.final(), + ]); + + // Remove padding + const trimmedData = trimPadding(decryptedData, 0x0); + encryptionLogger.debug(`Decrypted data: ${trimmedData.toString('utf-8')}`); + + return trimmedData; +} + +export async function createKeyPair( + privateKey: Buffer +): Promise { + const ecdh = createECDH('prime256v1'); + ecdh.setPrivateKey(privateKey); + + const publicKey = ecdh.getPublicKey(); + + encryptionLogger.debug(`Created key pair`, { publicKey }); + + return { + privateKey, + publicKey, + }; +} + +export async function generateKeyPair(): Promise { + const ecdh = createECDH('prime256v1'); + ecdh.generateKeys(); + + const publicKey = ecdh.getPublicKey(); + const privateKey = ecdh.getPrivateKey(); + + encryptionLogger.debug(`Generated key pair`, { publicKey, privateKey }); + + return { + privateKey, + publicKey, + }; +} + +export async function deriveSharedKey( + privateKey: Buffer, + publicKey: Buffer +): Promise { + const ecdh = createECDH('prime256v1'); + ecdh.setPrivateKey(privateKey); + + const sharedKey = ecdh.computeSecret(publicKey); + + encryptionLogger.debug(`Derived shared key: ${sharedKey.toString('base64')}`); + + return sharedKey; +} + +export default { + encrypt, + decrypt, + generateKeyPair, + deriveSharedKey, + DEFAULT_IV, +}; diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts new file mode 100644 index 0000000..41b8d60 --- /dev/null +++ b/packages/lib/src/index.ts @@ -0,0 +1,8 @@ +export * from './device.js'; +export * from './deviceManager.js'; +export * from './encryption.js'; +export * from './message/index.js'; +export * from './transport/index.js'; +export * from './utils/index.js'; +export * from './wifi.js'; +export * from './cloudCredentials.js'; diff --git a/packages/lib/src/message/configureDeviceTime.ts b/packages/lib/src/message/configureDeviceTime.ts new file mode 100644 index 0000000..7f03405 --- /dev/null +++ b/packages/lib/src/message/configureDeviceTime.ts @@ -0,0 +1,31 @@ +import { generateTimestamp } from '../utils/generateTimestamp.js'; +import { Method, Namespace } from './header.js'; +import { Message, type MessageOptions } from './message.js'; + +export class ConfigureDeviceTimeMessage extends Message { + constructor( + options: MessageOptions & { timestamp: number; timezone: string } = { + timestamp: generateTimestamp(), + timezone: 'Etc/UTC', + } + ) { + const { header, payload, timestamp, timezone } = options; + + super({ + header: { + method: Method.SET, + namespace: Namespace.SYSTEM_TIME, + ...header, + }, + payload: { + time: { + timezone, + timestamp, + }, + ...payload, + }, + }); + } +} + +export default ConfigureDeviceTimeMessage; diff --git a/packages/lib/src/message/configureECDH.ts b/packages/lib/src/message/configureECDH.ts new file mode 100644 index 0000000..d9a965c --- /dev/null +++ b/packages/lib/src/message/configureECDH.ts @@ -0,0 +1,29 @@ +import { Method, Namespace } from './header.js'; +import { Message, MessageOptions } from './message.js'; + +export class ConfigureECDHMessage extends Message { + constructor( + options: MessageOptions & { + publicKey: Buffer; + } + ) { + const { payload = {}, header = {}, publicKey } = options; + + super({ + payload: { + ecdhe: { + step: 1, + pubkey: publicKey.toString('base64'), + }, + ...payload, + }, + header: { + method: Method.SET, + namespace: Namespace.ENCRYPT_ECDHE, + ...header, + }, + }); + } +} + +export default ConfigureECDHMessage; diff --git a/packages/lib/src/message/configureMQTTBrokersAndCredentials.ts b/packages/lib/src/message/configureMQTTBrokersAndCredentials.ts new file mode 100644 index 0000000..8e50c1c --- /dev/null +++ b/packages/lib/src/message/configureMQTTBrokersAndCredentials.ts @@ -0,0 +1,47 @@ +import { CloudCredentials } from '../cloudCredentials.js'; +import { Method, Namespace } from './header.js'; +import { Message, MessageOptions } from './message.js'; + +export type MQTTBroker = { + host: string; + port: number; +}; + +export class ConfigureMQTTBrokersAndCredentialsMessage extends Message { + constructor( + options: MessageOptions & { + mqtt: MQTTBroker[]; + credentials: CloudCredentials; + } + ) { + const { payload = {}, header = {}, mqtt, credentials } = options; + + const primaryBroker = mqtt[0]; + const falloverBroker = mqtt[1] ?? mqtt[0]; + + super({ + payload: { + key: { + userId: `${credentials.userId}`, + key: `${credentials.key}`, + gateway: { + host: primaryBroker.host, + port: primaryBroker.port, + secondHost: falloverBroker.host, + secondPort: falloverBroker.port, + redirect: 1, + }, + }, + ...payload, + }, + header: { + method: Method.SET, + namespace: Namespace.CONFIG_KEY, + payloadVersion: 1, + ...header, + }, + }); + } +} + +export default ConfigureMQTTBrokersAndCredentialsMessage; diff --git a/packages/lib/src/message/configureWifiMessage.ts b/packages/lib/src/message/configureWifiMessage.ts new file mode 100644 index 0000000..a6baa3f --- /dev/null +++ b/packages/lib/src/message/configureWifiMessage.ts @@ -0,0 +1,38 @@ +import { filterUndefined } from '../utils/filterUndefined.js'; +import base64 from '../utils/base64.js'; +import { WifiAccessPoint } from '../wifi.js'; +import { Method, Namespace } from './header.js'; +import { Message, MessageOptions } from './message.js'; + +export class ConfigureWifiMessage extends Message { + constructor( + options: MessageOptions & { + wifiAccessPoint: WifiAccessPoint; + } + ) { + const { payload = {}, header = {}, wifiAccessPoint } = options; + + const wifi = filterUndefined(wifiAccessPoint); + + if (wifi.ssid) { + wifi.ssid = base64.encode(wifi.ssid); + } + if (wifi.password) { + wifi.password = base64.encode(wifi.password); + } + + super({ + payload: { + wifi, + ...payload, + }, + header: { + method: Method.SET, + namespace: Namespace.CONFIG_WIFI, + ...header, + }, + }); + } +} + +export default ConfigureWifiMessage; diff --git a/packages/lib/src/message/configureWifiXMessage.ts b/packages/lib/src/message/configureWifiXMessage.ts new file mode 100644 index 0000000..9baa672 --- /dev/null +++ b/packages/lib/src/message/configureWifiXMessage.ts @@ -0,0 +1,25 @@ +import { WifiAccessPoint } from '../wifi.js'; +import { ConfigureWifiMessage } from './configureWifiMessage.js'; +import { Namespace } from './header.js'; +import { MessageOptions } from './message.js'; + +export class ConfigureWifiXMessage extends ConfigureWifiMessage { + constructor( + options: MessageOptions & { + wifiAccessPoint: WifiAccessPoint; + } + ) { + const { wifiAccessPoint, payload, header } = options; + + super({ + wifiAccessPoint, + header: { + namespace: Namespace.CONFIG_WIFIX, + ...header, + }, + payload, + }); + } +} + +export default ConfigureWifiXMessage; diff --git a/packages/lib/src/message/header.test.ts b/packages/lib/src/message/header.test.ts new file mode 100644 index 0000000..d0fbefc --- /dev/null +++ b/packages/lib/src/message/header.test.ts @@ -0,0 +1,42 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { Header, Method, Namespace } from './header.js'; + +test('should create a Header instance with valid options', (t) => { + const options = { + from: 'device1', + messageId: '12345', + timestamp: 1672531200000, + sign: 'abc123', + method: Method.GET, + namespace: Namespace.SYSTEM_ALL, + }; + + const header = new Header(options); + + assert.strictEqual(header.from, options.from); + assert.strictEqual(header.messageId, options.messageId); + assert.strictEqual(header.timestamp, options.timestamp); + assert.strictEqual(header.sign, options.sign); + assert.strictEqual(header.method, options.method); + assert.strictEqual(header.namespace, options.namespace); + assert.strictEqual(header.payloadVersion, 1); +}); + +test('should use default values for optional fields', (t) => { + const options = { + method: Method.SET, + namespace: Namespace.SYSTEM_TIME, + }; + + const header = new Header(options); + + assert.strictEqual(header.from, ''); + assert.strictEqual(typeof header.messageId, 'string'); + assert.notStrictEqual(header.messageId, ''); + assert.strictEqual(typeof header.timestamp, 'number'); + assert.strictEqual(header.sign, ''); + assert.strictEqual(header.method, options.method); + assert.strictEqual(header.namespace, options.namespace); + assert.strictEqual(header.payloadVersion, 1); +}); diff --git a/packages/lib/src/message/header.ts b/packages/lib/src/message/header.ts new file mode 100644 index 0000000..2863d74 --- /dev/null +++ b/packages/lib/src/message/header.ts @@ -0,0 +1,132 @@ +import randomId from '../utils/randomId.js'; + +export enum Method { + GET = 'GET', + SET = 'SET', +} + +export enum ResponseMethod { + GETACK = 'GETACK', + SETACK = 'SETACK', +} + +export const ResponseMethodLookup = { + [Method.GET]: ResponseMethod.GETACK, + [Method.SET]: ResponseMethod.SETACK, +}; + +export enum Namespace { + // Common abilities + SYSTEM_ALL = 'Appliance.System.All', + SYSTEM_FIRMWARE = 'Appliance.System.Firmware', + SYSTEM_HARDWARE = 'Appliance.System.Hardware', + SYSTEM_ABILITY = 'Appliance.System.Ability', + SYSTEM_ONLINE = 'Appliance.System.Online', + SYSTEM_REPORT = 'Appliance.System.Report', + SYSTEM_DEBUG = 'Appliance.System.Debug', + SYSTEM_CLOCK = 'Appliance.System.Clock', + SYSTEM_TIME = 'Appliance.System.Time', + SYSTEM_GEOLOCATION = 'Appliance.System.Position', + + // Encryption abilities + ENCRYPT_ECDHE = 'Appliance.Encrypt.ECDHE', + ENCRYPT_SUITE = 'Appliance.Encrypt.Suite', + + CONTROL_BIND = 'Appliance.Control.Bind', + CONTROL_UNBIND = 'Appliance.Control.Unbind', + CONTROL_TRIGGER = 'Appliance.Control.Trigger', + CONTROL_TRIGGERX = 'Appliance.Control.TriggerX', + + // Setup abilities + CONFIG_WIFI = 'Appliance.Config.Wifi', + CONFIG_WIFIX = 'Appliance.Config.WifiX', + CONFIG_WIFI_LIST = 'Appliance.Config.WifiList', + CONFIG_TRACE = 'Appliance.Config.Trace', + CONFIG_KEY = 'Appliance.Config.Key', + + // Power plug / bulbs abilities + CONTROL_TOGGLE = 'Appliance.Control.Toggle', + CONTROL_TOGGLEX = 'Appliance.Control.ToggleX', + CONTROL_ELECTRICITY = 'Appliance.Control.Electricity', + CONTROL_CONSUMPTION = 'Appliance.Control.Consumption', + CONTROL_CONSUMPTIONX = 'Appliance.Control.ConsumptionX', + + // Bulbs - only abilities + CONTROL_LIGHT = 'Appliance.Control.Light', + + // Garage opener abilities + GARAGE_DOOR_STATE = 'Appliance.GarageDoor.State', + + // Roller shutter timer + ROLLER_SHUTTER_STATE = 'Appliance.RollerShutter.State', + ROLLER_SHUTTER_POSITION = 'Appliance.RollerShutter.Position', + ROLLER_SHUTTER_CONFIG = 'Appliance.RollerShutter.Config', + + // Humidifier + CONTROL_SPRAY = 'Appliance.Control.Spray', + + SYSTEM_DIGEST_HUB = 'Appliance.Digest.Hub', + + // HUB + HUB_EXCEPTION = 'Appliance.Hub.Exception', + HUB_BATTERY = 'Appliance.Hub.Battery', + HUB_TOGGLEX = 'Appliance.Hub.ToggleX', + HUB_ONLINE = 'Appliance.Hub.Online', + + // SENSORS + HUB_SENSOR_ALL = 'Appliance.Hub.Sensor.All', + HUB_SENSOR_TEMPHUM = 'Appliance.Hub.Sensor.TempHum', + HUB_SENSOR_ALERT = 'Appliance.Hub.Sensor.Alert', + + // MTS100 + HUB_MTS100_ALL = 'Appliance.Hub.Mts100.All', + HUB_MTS100_TEMPERATURE = 'Appliance.Hub.Mts100.Temperature', + HUB_MTS100_MODE = 'Appliance.Hub.Mts100.Mode', + HUB_MTS100_ADJUST = 'Appliance.Hub.Mts100.Adjust', +} + +export type HeaderOptions = { + from?: string; + messageId?: string; + timestamp?: number; + sign?: string; + method?: Method; + namespace?: Namespace; +}; + +export class Header { + method: Method; + namespace: Namespace; + from?: string; + messageId?: string; + timestamp?: number; + payloadVersion?: number = 1; + sign?: string; + + /** + * @param {Object} [opts] + * @param {string} [opts.from] + * @param {string} [opts.messageId] + * @param {number} [opts.timestamp] + * @param {string} [opts.sign] + * @param {Method} [opts.method] + * @param {Namespace} [opts.namespace] + */ + constructor(options: HeaderOptions = {}) { + const { + from = '', + messageId = randomId(), + method = Method.GET, + namespace = Namespace.SYSTEM_ALL, + sign = '', + timestamp = Date.now(), + } = options; + + this.from = from; + this.messageId = messageId; + this.method = method; + this.namespace = namespace; + this.sign = sign; + this.timestamp = timestamp; + } +} diff --git a/packages/lib/src/message/index.ts b/packages/lib/src/message/index.ts new file mode 100644 index 0000000..768d9e2 --- /dev/null +++ b/packages/lib/src/message/index.ts @@ -0,0 +1,2 @@ +export * from './message.js'; +export * from './header.js'; diff --git a/packages/lib/src/message/message.ts b/packages/lib/src/message/message.ts new file mode 100644 index 0000000..aa3be28 --- /dev/null +++ b/packages/lib/src/message/message.ts @@ -0,0 +1,26 @@ +import { Header } from './header.js'; +import { md5 } from '../utils/md5.js'; + +export type MessageOptions = { + header?: Header; + payload?: Record; +}; + +export class Message { + header; + payload; + + constructor(options: MessageOptions = {}) { + this.header = options.header || new Header(); + this.payload = options.payload || {}; + } + + /** + * + * @param {string} key + */ + async sign(key = '') { + const { messageId, timestamp } = this.header; + this.header.sign = md5(`${messageId}${key}${timestamp}`, 'hex'); + } +} diff --git a/packages/lib/src/message/messages.ts b/packages/lib/src/message/messages.ts new file mode 100644 index 0000000..05e6c0c --- /dev/null +++ b/packages/lib/src/message/messages.ts @@ -0,0 +1,9 @@ +export * from './configureDeviceTime.js'; +export * from './configureECDH.js'; +export * from './configureMQTTBrokersAndCredentials.js'; +export * from './configureWifiMessage.js'; +export * from './configureWifiXMessage.js'; +export * from './queryDeviceAbilities.js'; +export * from './queryDeviceInformation.js'; +export * from './queryWifiList.js'; +export * from './queryDeviceTime.js'; diff --git a/packages/lib/src/message/queryDeviceAbilities.ts b/packages/lib/src/message/queryDeviceAbilities.ts new file mode 100644 index 0000000..da9e389 --- /dev/null +++ b/packages/lib/src/message/queryDeviceAbilities.ts @@ -0,0 +1,18 @@ +import { Method, Namespace } from './header.js'; +import { Message, MessageOptions } from './message.js'; + +export class QueryDeviceAbilitiesMessage extends Message { + constructor(options: MessageOptions = {}) { + const { payload = {}, header = {} } = options; + super({ + payload, + header: { + method: Method.GET, + namespace: Namespace.SYSTEM_ABILITY, + ...header, + }, + }); + } +} + +export default QueryDeviceAbilitiesMessage; diff --git a/packages/lib/src/message/queryDeviceInformation.ts b/packages/lib/src/message/queryDeviceInformation.ts new file mode 100644 index 0000000..c1bfbab --- /dev/null +++ b/packages/lib/src/message/queryDeviceInformation.ts @@ -0,0 +1,18 @@ +import { Method, Namespace } from './header.js'; +import { Message, MessageOptions } from './message.js'; + +export class QueryDeviceInformationMessage extends Message { + constructor(options: MessageOptions = {}) { + const { payload = {}, header = {} } = options; + super({ + payload, + header: { + method: Method.GET, + namespace: Namespace.SYSTEM_ALL, + ...header, + }, + }); + } +} + +export default QueryDeviceInformationMessage; diff --git a/packages/lib/src/message/queryDeviceTime.ts b/packages/lib/src/message/queryDeviceTime.ts new file mode 100644 index 0000000..4cb4e10 --- /dev/null +++ b/packages/lib/src/message/queryDeviceTime.ts @@ -0,0 +1,18 @@ +import { Method, Namespace } from './header.js'; +import { Message, type MessageOptions } from './message.js'; + +export class QueryDeviceTimeMessage extends Message { + constructor(options: MessageOptions = {}) { + const { payload = {}, header = {} } = options; + super({ + payload, + header: { + method: Method.GET, + namespace: Namespace.SYSTEM_TIME, + ...header, + }, + }); + } +} + +export default QueryDeviceTimeMessage; diff --git a/packages/lib/src/message/queryWifiList.ts b/packages/lib/src/message/queryWifiList.ts new file mode 100644 index 0000000..c688ad2 --- /dev/null +++ b/packages/lib/src/message/queryWifiList.ts @@ -0,0 +1,22 @@ +import { Method, Namespace } from './header.js'; +import { Message, MessageOptions } from './message.js'; + +export class QueryWifiListMessage extends Message { + constructor(options: MessageOptions = {}) { + const { header, payload } = options; + + super({ + header: { + method: Method.GET, + namespace: Namespace.CONFIG_WIFI_LIST, + ...header, + }, + payload: { + trace: {}, + ...payload, + }, + }); + } +} + +export default QueryWifiListMessage; diff --git a/packages/lib/src/transport/http.test.ts b/packages/lib/src/transport/http.test.ts new file mode 100644 index 0000000..814409e --- /dev/null +++ b/packages/lib/src/transport/http.test.ts @@ -0,0 +1,89 @@ +import { test, before } from 'node:test'; +import assert from 'node:assert'; +import { HTTPTransport } from './http.js'; + +test('HTTPTransport should send a message without encryption', async () => { + before(() => { + global.fetch = async (request) => { + const { url, method, headers } = request; + const body = await request.text(); + + assert.strictEqual(url, 'https://example.com/'); + assert.strictEqual(method, 'POST'); + assert.strictEqual( + headers.get('Content-Type'), + 'application/json; charset=utf-8' + ); + assert.strictEqual(headers.get('Accept'), 'application/json'); + assert.strictEqual(body, JSON.stringify({ test: 'message' })); + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + }); + + const transport = new HTTPTransport({ url: 'https://example.com' }); + const response = await transport['_send']({ + message: { + test: 'message', + }, + }); + assert.deepStrictEqual(response, { success: true }); +}); + +test('HTTPTransport should handle an HTTP error response', async () => { + before(() => { + global.fetch = async () => + new Response(null, { + status: 500, + statusText: 'Internal Server Error', + }); + }); + + const transport = new HTTPTransport({ url: 'https://example.com' }); + await assert.rejects( + async () => { + await transport['_send']({ message: { test: 'message' } }); + }, + { message: 'HTTP error! status: 500' } + ); +}); + +test('HTTPTransport should handle an empty response body', async () => { + before(() => { + global.fetch = async () => + new Response(null, { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + + const transport = new HTTPTransport({ url: 'https://example.com' }); + await assert.rejects( + async () => { + await transport['_send']({ message: { test: 'message' } }); + }, + { message: 'Empty response body' } + ); +}); + +test('HTTPTransport should throw an error for server error messages', async () => { + before(() => { + global.fetch = async () => + new Response(JSON.stringify({ error: 'Server error' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + + const transport = new HTTPTransport({ url: 'https://example.com' }); + await assert.rejects( + async () => { + await transport['_send']({ + message: { test: 'message' }, + }); + }, + { message: 'Error from server: Server error' } + ); +}); diff --git a/packages/lib/src/transport/http.ts b/packages/lib/src/transport/http.ts new file mode 100644 index 0000000..ef24022 --- /dev/null +++ b/packages/lib/src/transport/http.ts @@ -0,0 +1,113 @@ +import Encryption from '../encryption.js'; +import { + type TransportOptions, + Transport, + TransportSendOptions, +} from './transport.js'; +import base64 from '../utils/base64.js'; +import logger from '../utils/logger.js'; + +export type HTTPTransportOptions = TransportOptions & { + url: string; +}; + +const httpLogger = logger.child({ + name: 'http', +}); + +export class HTTPTransport extends Transport { + private url: string; + + constructor(options: HTTPTransportOptions) { + super(options); + this.url = options.url; + this.id = `${this.url}`; + + httpLogger.debug(`HTTPTransport initialized with URL: ${this.url}`); + } + + protected async _send( + options: TransportSendOptions + ): Promise> { + const { message, encryptionKey } = options; + + const requestLogger = logger.child({ + name: 'request', + requestId: message.header?.messageId, + }); + + let body = JSON.stringify(message); + + let request = new Request(this.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + Accept: 'application/json', + }, + body, + }); + + // Encrypt the message if encryptionKey is provided + if (encryptionKey) { + const data = Buffer.from(body, 'utf-8'); + + const encryptedData = await Encryption.encrypt(data, encryptionKey); + body = await base64.encode(encryptedData); + + request = new Request(this.url, { + method: 'POST', + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + Accept: 'text/plain', + }, + body, + }); + } + + requestLogger.http( + `${request.method} ${request.url} ${JSON.stringify( + request.headers + )} ${await request.clone().text()}`, + { + request, + } + ); + + const response = await fetch(request); + + requestLogger.http( + `${response.status} ${response.statusText} ${JSON.stringify( + response.headers + )} ${await response.clone().text()}`, + { + response, + } + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + let responseBody: string | undefined; + + // Decrypt the response if encryptionKey is provided + if (encryptionKey) { + responseBody = await response.text(); + const data = base64.decode(responseBody); + const decryptedData = await Encryption.decrypt(data, encryptionKey); + responseBody = decryptedData.toString('utf-8'); + } else { + responseBody = await response.text(); + } + + if (!responseBody) { + throw new Error('Empty response body'); + } + + const responseMessage = JSON.parse(responseBody); + if (responseMessage.error) { + throw new Error(`Error from server: ${responseMessage.error}`); + } + return responseMessage; + } +} diff --git a/packages/lib/src/transport/index.ts b/packages/lib/src/transport/index.ts new file mode 100644 index 0000000..29e5499 --- /dev/null +++ b/packages/lib/src/transport/index.ts @@ -0,0 +1,2 @@ +export * from './transport.js'; +export * from './http.js'; diff --git a/packages/lib/src/transport/transport.test.ts b/packages/lib/src/transport/transport.test.ts new file mode 100644 index 0000000..e56c24e --- /dev/null +++ b/packages/lib/src/transport/transport.test.ts @@ -0,0 +1,104 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert'; +import { Transport, MessageSendOptions } from './transport.js'; +import { Message } from '../message/message.js'; +import { ResponseMethod } from '../message/header.js'; + +class MockTransport extends Transport { + async _send(options: any) { + const { message } = options; + return { + header: { + method: ResponseMethod[message.header.method], + }, + }; + } +} + +test('Transport should initialize with default timeout', () => { + const transport = new MockTransport(); + assert.strictEqual(transport.timeout, 10000); +}); + +test('Transport should initialize with custom timeout', () => { + const transport = new MockTransport({ timeout: 5000 }); + assert.strictEqual(transport.timeout, 5000); +}); + +test('Transport should throw error if message is not provided', async () => { + const transport = new MockTransport(); + const options: MessageSendOptions = { + message: null as unknown as Message, + }; + + await assert.rejects(async () => transport.send(options), { + message: 'Message is required', + }); +}); + +test('Transport should set default messageId and timestamp if not provided', async () => { + const transport = new MockTransport(); + const message = new Message(); + message.header.method = 'SomeMethod'; + + assert.ok(message.header.messageId); + assert.ok(message.header.timestamp); +}); + +test('Transport should use provided messageId and timestamp if available', async () => { + const transport = new MockTransport(); + const message = new Message(); + message.header.method = 'SomeMethod'; + message.header.messageId = 'custom-id'; + message.header.timestamp = 'custom-timestamp'; + + await transport.send({ message }); + + assert.strictEqual(message.header.messageId, 'custom-id'); + assert.strictEqual(message.header.timestamp, 'custom-timestamp'); +}); + +test('Transport should set the "from" field in the message header', async () => { + const transport = new MockTransport(); + transport.id = 'transport-id'; + const message = new Message(); + message.header.method = 'SomeMethod'; + + await transport.send({ message }); + + assert.strictEqual(message.header.from, 'transport-id'); +}); + +test('Transport should throw error if response method does not match expected method', async () => { + class InvalidResponseTransport extends Transport { + async _send(options: any) { + return { + header: { + method: 'InvalidMethod', + }, + }; + } + } + + const transport = new InvalidResponseTransport(); + const message = new Message(); + message.header.method = 'SomeMethod'; + + await assert.rejects(async () => transport.send({ message }), { + message: 'Response was not undefined', + }); +}); + +test('Transport should return the response if everything is valid', async () => { + const transport = new MockTransport(); + const message = new Message(); + message.header.method = 'SomeMethod'; + + const response = await transport.send({ message }); + + assert.ok(response); + assert.strictEqual( + response.header.method, + ResponseMethod[message.header.method] + ); +}); diff --git a/packages/lib/src/transport/transport.ts b/packages/lib/src/transport/transport.ts new file mode 100644 index 0000000..9c88b57 --- /dev/null +++ b/packages/lib/src/transport/transport.ts @@ -0,0 +1,79 @@ +import { Message } from '../message/message.js'; +import { ResponseMethodLookup } from '../message/header.js'; +import { generateTimestamp, randomId } from '../utils/index.js'; +import { CloudCredentials } from '../cloudCredentials.js'; +import logger from '../utils/logger.js'; + +const transportLogger = logger.child({ + name: 'transport', +}); + +export const DEFAULT_TIMEOUT = 10_000; + +export type TransportOptions = { + timeout?: number; + credentials?: CloudCredentials; +}; + +export type MessageSendOptions = { + message: Message; + encryptionKey?: Buffer; +}; + +export class TransportSendOptions { + message: Record = {}; + encryptionKey?: Buffer; +} + +export abstract class Transport { + id: string = `transport/${randomId()}`; + timeout; + + credentials: CloudCredentials | undefined; + + constructor(options: TransportOptions = {}) { + this.timeout = options.timeout || DEFAULT_TIMEOUT; + this.credentials = options.credentials; + + transportLogger.debug( + `Transport initialized. Credentials: ${JSON.stringify(this.credentials)}` + ); + } + + async send(options: MessageSendOptions) { + const { message, encryptionKey } = options; + + if (!message) { + throw new Error('Message is required'); + } + + message.header.from = this.id; + + if (!message.header.messageId) { + message.header.messageId = randomId(); + } + + if (!message.header.timestamp) { + message.header.timestamp = generateTimestamp(); + } + + logger.debug(`Signing message ${message.header.messageId}`); + + message.sign(this.credentials?.key); + + const response = await this._send({ + message, + encryptionKey, + }); + const { header } = response; + + const expectedResponseMethod = ResponseMethodLookup[message.header.method]; + if (header.method !== expectedResponseMethod) { + throw new Error(`Response was not ${expectedResponseMethod}`); + } + + return response; + } + + protected abstract _send(options: TransportSendOptions): Promise; +} diff --git a/packages/lib/src/utils/base64.test.ts b/packages/lib/src/utils/base64.test.ts new file mode 100644 index 0000000..f8a479f --- /dev/null +++ b/packages/lib/src/utils/base64.test.ts @@ -0,0 +1,23 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +import { encode, decode } from './base64.js'; + +test('encode should convert a Buffer to a base64 string', () => { + const buffer = Buffer.from('hello world'); + const result = encode(buffer); + assert.strictEqual(result, 'aGVsbG8gd29ybGQ='); +}); + +test('decode should convert a base64 string to a Buffer', () => { + const base64String = 'aGVsbG8gd29ybGQ='; + const result = decode(base64String); + assert.strictEqual(result.toString(), 'hello world'); +}); + +test('encode and decode should be inverses of each other', () => { + const originalBuffer = Buffer.from('test data'); + const encoded = encode(originalBuffer); + const decoded = decode(encoded); + assert.deepStrictEqual(decoded, originalBuffer); +}); diff --git a/packages/lib/src/utils/base64.ts b/packages/lib/src/utils/base64.ts new file mode 100644 index 0000000..b3d1f9e --- /dev/null +++ b/packages/lib/src/utils/base64.ts @@ -0,0 +1,17 @@ +import { Buffer } from 'node:buffer'; + +export function encode(data: string | Buffer): string { + if (typeof data === 'string') { + data = Buffer.from(data, 'utf-8'); + } + return data.toString('base64'); +} + +export function decode(data: string): Buffer { + return Buffer.from(data, 'base64'); +} + +export default { + encode, + decode, +}; diff --git a/packages/lib/src/utils/buffer.test.ts b/packages/lib/src/utils/buffer.test.ts new file mode 100644 index 0000000..44cedf8 --- /dev/null +++ b/packages/lib/src/utils/buffer.test.ts @@ -0,0 +1,53 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { calculatePaddingForBlockSize, pad, trimPadding } from './buffer.js'; + +test('calculatePaddingForBlockSize should calculate correct padding', () => { + const data = Buffer.from('12345'); + const blockSize = 8; + const padding = calculatePaddingForBlockSize(data, blockSize); + assert.strictEqual(padding, 3); +}); + +test('calculatePaddingForBlockSize should return blockSize when data length is a multiple of blockSize', () => { + const data = Buffer.from('12345678'); + const blockSize = 8; + const padding = calculatePaddingForBlockSize(data, blockSize); + assert.strictEqual(padding, 8); +}); + +test('pad should append the correct padding to the buffer', () => { + const data = Buffer.from('12345'); + const padded = pad(data, 3, 0); + assert.strictEqual(padded.toString(), '12345\0\0\0'); +}); + +test('pad should handle custom fill values', () => { + const data = Buffer.from('12345'); + const padded = pad(data, 3, 65); // ASCII for 'A' + assert.strictEqual(padded.toString(), '12345AAA'); +}); + +test('trimPadding should remove the correct padding from the buffer', () => { + const data = Buffer.from('12345\0\0\0'); + const trimmed = trimPadding(data, 0); + assert.strictEqual(trimmed.toString(), '12345'); +}); + +test('trimPadding should handle buffers with no padding', () => { + const data = Buffer.from('12345'); + const trimmed = trimPadding(data, 0); + assert.strictEqual(trimmed.toString(), '12345'); +}); + +test('trimPadding should handle empty buffers', () => { + const data = Buffer.from(''); + const trimmed = trimPadding(data, 0); + assert.strictEqual(trimmed.toString(), ''); +}); + +test('trimPadding should handle custom fill values', () => { + const data = Buffer.from('12345AAA'); + const trimmed = trimPadding(data, 65); // ASCII for 'A' + assert.strictEqual(trimmed.toString(), '12345'); +}); diff --git a/packages/lib/src/utils/buffer.ts b/packages/lib/src/utils/buffer.ts new file mode 100644 index 0000000..d408b88 --- /dev/null +++ b/packages/lib/src/utils/buffer.ts @@ -0,0 +1,52 @@ +import { Buffer } from 'node:buffer'; + +export function calculatePaddingForBlockSize(data: Buffer, blockSize: number) { + return blockSize - (data.length % blockSize); +} + +export function pad( + data: Buffer, + length: number, + fill?: string | Uint8Array | number +) { + return Buffer.concat([data, Buffer.alloc(length, fill)]); +} + +export function trimPadding(data: Buffer, fill?: string | Uint8Array | number) { + if (data.length === 0) { + return data; + } + + fill = getFillByte(fill); + + let length = data.length; + // starting from the end iterate backwards and check if the byte is equal to the fill + while (length > 0 && data[length - 1] === fill) { + length--; + } + + return data.subarray(0, length); +} + +function getFillByte(fill: string | number | Uint8Array) { + if (typeof fill === 'string') { + fill = Buffer.from(fill, 'utf-8'); + } else if (fill instanceof Uint8Array) { + fill = Buffer.from(fill); + } else if (fill === undefined) { + fill = 0; + } + // check if the fill is a buffer + if (Buffer.isBuffer(fill)) { + fill = fill[0]; + } else if (typeof fill === 'number') { + fill = fill; + } + return fill; +} + +export default { + calculatePaddingForBlockSize, + pad, + trimPadding, +}; diff --git a/packages/lib/src/utils/computeDevicePassword.test.ts b/packages/lib/src/utils/computeDevicePassword.test.ts new file mode 100644 index 0000000..bb5887a --- /dev/null +++ b/packages/lib/src/utils/computeDevicePassword.test.ts @@ -0,0 +1,59 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { computeDevicePassword } from './computeDevicePassword.js'; + +test('computeDevicePassword should generate a consistent password for the same inputs', () => { + const macAddress = '00:1A:2B:3C:4D:5E'; + const key = 'secretKey'; + const userId = 123; + + const password1 = computeDevicePassword(macAddress, key, userId); + const password2 = computeDevicePassword(macAddress, key, userId); + + assert.strictEqual(password1, password2); +}); + +test('computeDevicePassword should generate different passwords for different MAC addresses', () => { + const macAddress1 = '00:1A:2B:3C:4D:5E'; + const macAddress2 = '11:22:33:44:55:66'; + const key = 'secretKey'; + const userId = 123; + + const password1 = computeDevicePassword(macAddress1, key, userId); + const password2 = computeDevicePassword(macAddress2, key, userId); + + assert.notStrictEqual(password1, password2); +}); + +test('computeDevicePassword should generate different passwords for different keys', () => { + const macAddress = '00:1A:2B:3C:4D:5E'; + const key1 = 'secretKey1'; + const key2 = 'secretKey2'; + const userId = 123; + + const password1 = computeDevicePassword(macAddress, key1, userId); + const password2 = computeDevicePassword(macAddress, key2, userId); + + assert.notStrictEqual(password1, password2); +}); + +test('computeDevicePassword should generate different passwords for different userIds', () => { + const macAddress = '00:1A:2B:3C:4D:5E'; + const key = 'secretKey'; + const userId1 = 123; + const userId2 = 456; + + const password1 = computeDevicePassword(macAddress, key, userId1); + const password2 = computeDevicePassword(macAddress, key, userId2); + + assert.notStrictEqual(password1, password2); +}); + +test('computeDevicePassword should handle default values for key and userId', () => { + const macAddress = '00:1A:2B:3C:4D:5E'; + + const password = computeDevicePassword(macAddress); + + assert.ok(password); + assert.match(password, /^0_[a-f0-9]{32}$/); // Default userId is 0, and MD5 hash is 32 hex characters +}); diff --git a/packages/lib/src/utils/computeDevicePassword.ts b/packages/lib/src/utils/computeDevicePassword.ts new file mode 100644 index 0000000..f18c3ad --- /dev/null +++ b/packages/lib/src/utils/computeDevicePassword.ts @@ -0,0 +1,13 @@ +import { type MacAddress } from '../device.js'; +import { md5 } from './md5.js'; + +export function computeDevicePassword( + macAddress: MacAddress, + key: string = '', + userId: number = 0 +): string { + const hash = md5(`${macAddress}${key}`, 'hex'); + return `${userId}_${hash}`; +} + +export default computeDevicePassword; diff --git a/packages/lib/src/utils/computePresharedKey.test.ts b/packages/lib/src/utils/computePresharedKey.test.ts new file mode 100644 index 0000000..9001635 --- /dev/null +++ b/packages/lib/src/utils/computePresharedKey.test.ts @@ -0,0 +1,72 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import computePresharedPrivateKey from './computePresharedPrivateKey.js'; +import { MacAddress, UUID } from '../device.js'; + +test('computePresharedPrivateKey should return a valid base64 encoded string', () => { + const uuid: UUID = '123e4567-e89b-12d3-a456-426614174000'; + const key = 'sharedsecretkey1234567890'; + const macAddress: MacAddress = '00:11:22:33:44:55'; + + const result = computePresharedPrivateKey(uuid, key, macAddress); + + assert.strictEqual(typeof result, 'string'); + assert.doesNotThrow(() => Buffer.from(result, 'base64')); +}); + +test('computePresharedPrivateKey should produce consistent output for the same inputs', () => { + const uuid: UUID = '123e4567e89b12d3a456426614174000'; + const key = 'sharedsecretkey1234567890'; + const macAddress: MacAddress = '00:11:22:33:44:55'; + + const result1 = computePresharedPrivateKey(uuid, key, macAddress); + const result2 = computePresharedPrivateKey(uuid, key, macAddress); + + assert.strictEqual(result1, result2); +}); + +test('computePresharedPrivateKey should produce different outputs for different UUIDs', () => { + const key = 'sharedsecretkey1234567890'; + const macAddress: MacAddress = '00:11:22:33:44:55'; + + const result1 = computePresharedPrivateKey( + '123e4567e89b12d3a456426614174000' as UUID, + key, + macAddress + ); + const result2 = computePresharedPrivateKey( + '8ebdc941ae7b4bd99662b838af884822' as UUID, + key, + macAddress + ); + + assert.notStrictEqual(result1, result2); +}); + +test('computePresharedPrivateKey should produce different outputs for different keys', () => { + const uuid: UUID = '123e4567e89b12d3a456426614174000'; + const macAddress: MacAddress = '00:11:22:33:44:55'; + + const result1 = computePresharedPrivateKey(uuid, 'key1', macAddress); + const result2 = computePresharedPrivateKey(uuid, 'key2', macAddress); + + assert.notStrictEqual(result1, result2); +}); + +test('computePresharedPrivateKey should produce different outputs for different MAC addresses', () => { + const uuid: UUID = '123e4567e89b12d3a456426614174000'; + const key = 'sharedsecretkey1234567890'; + + const result1 = computePresharedPrivateKey( + uuid, + key, + '00:11:22:33:44:55' as MacAddress + ); + const result2 = computePresharedPrivateKey( + uuid, + key, + '66:77:88:99:AA:BB' as MacAddress + ); + + assert.notStrictEqual(result1, result2); +}); diff --git a/packages/lib/src/utils/computePresharedPrivateKey.ts b/packages/lib/src/utils/computePresharedPrivateKey.ts new file mode 100644 index 0000000..fcac39c --- /dev/null +++ b/packages/lib/src/utils/computePresharedPrivateKey.ts @@ -0,0 +1,22 @@ +import { MacAddress, UUID } from '../device.js'; +import base64 from './base64.js'; +import md5 from './md5.js'; + +/** + * Computes the preshared private key for a device using its UUID, a shared key, and its MAC address. + * Really shouldn't need this with ECDH key exchange but here we are. + */ +export function computePresharedPrivateKey( + uuid: UUID, + key: string, + macAddress: MacAddress +): string { + return base64.encode( + md5( + `${uuid.slice(3, 22)}${key.slice(1, 9)}${macAddress}${key.slice(10, 28)}`, + 'hex' + ) + ); +} + +export default computePresharedPrivateKey; diff --git a/packages/lib/src/utils/filterUndefined.test.ts b/packages/lib/src/utils/filterUndefined.test.ts new file mode 100644 index 0000000..92873cb --- /dev/null +++ b/packages/lib/src/utils/filterUndefined.test.ts @@ -0,0 +1,48 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { filterUndefined } from './filterUndefined.js'; + +test('filterUndefined should remove keys with undefined values', () => { + const input = { a: 1, b: undefined, c: 'test', d: undefined }; + const expected = { a: 1, c: 'test' }; + + const result = filterUndefined(input); + + assert.deepEqual(result, expected); +}); + +test('filterUndefined should return an empty object if all values are undefined', () => { + const input = { a: undefined, b: undefined }; + const expected = {}; + + const result = filterUndefined(input); + + assert.deepEqual(result, expected); +}); + +test('filterUndefined should return the same object if no values are undefined', () => { + const input = { a: 1, b: 'test', c: true }; + const expected = { a: 1, b: 'test', c: true }; + + const result = filterUndefined(input); + + assert.deepEqual(result, expected); +}); + +test('filterUndefined should handle an empty object', () => { + const input = {}; + const expected = {}; + + const result = filterUndefined(input); + + assert.deepEqual(result, expected); +}); + +test('filterUndefined should not remove keys with null or falsy values other than undefined', () => { + const input = { a: null, b: 0, c: false, d: '', e: undefined }; + const expected = { a: null, b: 0, c: false, d: '' }; + + const result = filterUndefined(input); + + assert.deepEqual(result, expected); +}); diff --git a/packages/lib/src/utils/filterUndefined.ts b/packages/lib/src/utils/filterUndefined.ts new file mode 100644 index 0000000..d872125 --- /dev/null +++ b/packages/lib/src/utils/filterUndefined.ts @@ -0,0 +1,5 @@ +export function filterUndefined(obj: Record) { + return Object.fromEntries( + Object.entries(obj).filter(([_, value]) => value !== undefined) + ); +} diff --git a/packages/lib/src/utils/generateTimestamp.ts b/packages/lib/src/utils/generateTimestamp.ts new file mode 100644 index 0000000..050b401 --- /dev/null +++ b/packages/lib/src/utils/generateTimestamp.ts @@ -0,0 +1,3 @@ +export function generateTimestamp() { + return Math.round(Date.now() / 1000); +} diff --git a/packages/lib/src/utils/index.ts b/packages/lib/src/utils/index.ts new file mode 100644 index 0000000..d443ad0 --- /dev/null +++ b/packages/lib/src/utils/index.ts @@ -0,0 +1,7 @@ +export * as base64 from './base64.js'; +export * from './computeDevicePassword.js'; +export * from './computePresharedPrivateKey.js'; +export * from './filterUndefined.js'; +export * from './generateTimestamp.js'; +export * from './md5.js'; +export * from './randomId.js'; diff --git a/packages/lib/src/utils/logger.ts b/packages/lib/src/utils/logger.ts new file mode 100644 index 0000000..d59687b --- /dev/null +++ b/packages/lib/src/utils/logger.ts @@ -0,0 +1,40 @@ +import winston from 'winston'; + +const { combine, timestamp, printf, metadata } = winston.format; + +const capitalizeLevel = winston.format((info) => { + info.level = info.level.toUpperCase(); + return info; +})(); + +const customFormat = printf((info) => + `${info.timestamp} ${info.level}: ${info.message} ${JSON.stringify( + info.metadata + )}`.trim() +); + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + silent: !process.env.LOG_LEVEL, + format: combine( + capitalizeLevel, + timestamp({ + format: 'YYYY-MM-DD HH:mm:ss', + }), + customFormat, + metadata({ fillExcept: ['message', 'level', 'timestamp'] }) + ), + transports: [ + new winston.transports.Console({ + handleExceptions: true, + format: combine(winston.format.colorize(), customFormat), + }), + new winston.transports.File({ + level: 'debug', + filename: 'debug.log', + format: combine(winston.format.json()), + }), + ], +}); + +export default logger; diff --git a/packages/lib/src/utils/md5.test.ts b/packages/lib/src/utils/md5.test.ts new file mode 100644 index 0000000..2a1b0f8 --- /dev/null +++ b/packages/lib/src/utils/md5.test.ts @@ -0,0 +1,58 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { md5 } from './md5.js'; + +test('md5 should correctly hash a Buffer to an MD5 hash string', () => { + const hash = md5('Hello, World!', 'hex'); + + assert.strictEqual(hash, '65a8e27d8879283831b664bd8b7f0ad4'); +}); + +test('md5 should produce consistent hashes for the same input', () => { + const hash1 = md5('Consistent Hash Test', 'hex'); + const hash2 = md5('Consistent Hash Test', 'hex'); + + assert.strictEqual(hash1, hash2); +}); + +test('md5 should produce different hashes for different inputs', () => { + const hash1 = md5('Input One', 'hex'); + const hash2 = md5('Input Two', 'hex'); + + assert.notStrictEqual(hash1, hash2); +}); + +test('md5 should correctly hash a Buffer input', () => { + const bufferInput = Buffer.from('Buffer Input Test', 'utf-8'); + const hash = md5(bufferInput, 'hex'); + + assert.strictEqual(hash, '25d7f032e75c374d64ae492a861306ad'); +}); + +test('md5 should return a Buffer when no encoding is provided', () => { + const result = md5('No Encoding Test'); + + assert.ok(Buffer.isBuffer(result)); + assert.strictEqual( + result.toString('hex'), + '6e946a024f48e761768914ef6437d1eb' + ); +}); + +test('md5 should handle empty string input', () => { + const hash = md5('', 'hex'); + + assert.strictEqual(hash, 'd41d8cd98f00b204e9800998ecf8427e'); // MD5 hash of an empty string +}); + +test('md5 should handle empty Buffer input', () => { + const hash = md5(Buffer.alloc(0), 'hex'); + + assert.strictEqual(hash, 'd41d8cd98f00b204e9800998ecf8427e'); // MD5 hash of an empty buffer +}); + +test('md5 should throw an error for invalid input types', () => { + assert.throws(() => { + md5(123 as unknown as string); + }, /The "data" argument must be of type string or an instance of Buffer/); +}); diff --git a/packages/lib/src/utils/md5.ts b/packages/lib/src/utils/md5.ts new file mode 100644 index 0000000..7f5b94c --- /dev/null +++ b/packages/lib/src/utils/md5.ts @@ -0,0 +1,25 @@ +import { Buffer } from 'node:buffer'; +import { BinaryToTextEncoding, createHash } from 'node:crypto'; + +export function md5(data: string | Buffer): Buffer; +export function md5( + data: string | Buffer, + encoding: BinaryToTextEncoding +): string; +export function md5( + data: string | Buffer, + encoding?: BinaryToTextEncoding +): string | Buffer { + if (typeof data === 'string') { + data = Buffer.from(data, 'utf-8'); + } + + const hash = createHash('md5').update(data); + if (encoding === undefined) { + return hash.digest(); + } + + return hash.digest(encoding); +} + +export default md5; diff --git a/packages/lib/src/utils/protocolFromPort.test.ts b/packages/lib/src/utils/protocolFromPort.test.ts new file mode 100644 index 0000000..a47465b --- /dev/null +++ b/packages/lib/src/utils/protocolFromPort.test.ts @@ -0,0 +1,25 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { protocolFromPort } from './protocolFromPort.js'; + +test('protocolFromPort should return "http" for port 80', () => { + assert.strictEqual(protocolFromPort(80), 'http'); +}); + +test('protocolFromPort should return "https" for port 443', () => { + assert.strictEqual(protocolFromPort(443), 'https'); +}); + +test('protocolFromPort should return "mqtts" for port 8883', () => { + assert.strictEqual(protocolFromPort(8883), 'mqtts'); +}); + +test('protocolFromPort should return "mqtt" for port 1883', () => { + assert.strictEqual(protocolFromPort(1883), 'mqtt'); +}); + +test('protocolFromPort should throw an error for unknown ports', () => { + assert.throws(() => { + protocolFromPort(1234); + }, /Unknown port 1234/); +}); diff --git a/packages/lib/src/utils/protocolFromPort.ts b/packages/lib/src/utils/protocolFromPort.ts new file mode 100644 index 0000000..4ef5df3 --- /dev/null +++ b/packages/lib/src/utils/protocolFromPort.ts @@ -0,0 +1,38 @@ +export function protocolFromPort(port: number) { + switch (port) { + case 80: + return 'http'; + case 443: + return 'https'; + case 8883: + return 'mqtts'; + case 1883: + return 'mqtt'; + } + + throw new Error(`Unknown port ${port}`); +} + +export function portFromProtocol(protocol: string) { + switch (protocol) { + case 'http': + return 80; + case 'https': + return 443; + case 'mqtts': + return 8883; + case 'mqtt': + return 1883; + } + throw new Error(`Unknown protocol ${protocol}`); +} + +export function isValidPort(port: number) { + return port === 80 || port === 443 || port === 8883 || port === 1883; +} + +export default { + protocolFromPort, + portFromProtocol, + isValidPort, +}; diff --git a/packages/lib/src/utils/randomId.test.ts b/packages/lib/src/utils/randomId.test.ts new file mode 100644 index 0000000..16f5fac --- /dev/null +++ b/packages/lib/src/utils/randomId.test.ts @@ -0,0 +1,19 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { randomId } from './randomId.js'; + +test('randomId should generate a string of the correct length', () => { + const id = randomId(); + assert.strictEqual(id.length, 32); // UUID without dashes has 32 characters +}); + +test('randomId should generate unique strings', () => { + const id1 = randomId(); + const id2 = randomId(); + assert.notStrictEqual(id1, id2); // Ensure IDs are unique +}); + +test('randomId should only contain alphanumeric characters', () => { + const id = randomId(); + assert.match(id, /^[a-f0-9]{32}$/i); // UUID without dashes is hexadecimal +}); diff --git a/packages/lib/src/utils/randomId.ts b/packages/lib/src/utils/randomId.ts new file mode 100644 index 0000000..a868d72 --- /dev/null +++ b/packages/lib/src/utils/randomId.ts @@ -0,0 +1,7 @@ +import { randomUUID } from 'node:crypto'; + +export function randomId(): string { + return (randomUUID() as string).replaceAll('-', ''); +} + +export default randomId; diff --git a/packages/lib/src/wifi.test.ts b/packages/lib/src/wifi.test.ts new file mode 100644 index 0000000..ed07689 --- /dev/null +++ b/packages/lib/src/wifi.test.ts @@ -0,0 +1,99 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { + WifiAccessPoint, + WifiCipher, + WifiEncryption, + encryptPassword, +} from './wifi.js'; +import { MacAddress, UUID } from './device.js'; + +test('WifiAccessPoint should throw an error for invalid SSID length', () => { + assert.throws(() => { + new WifiAccessPoint({ ssid: 'a'.repeat(33) }); + }, /SSID length exceeds 32 characters/); +}); + +test('WifiAccessPoint should throw an error for invalid BSSID length', () => { + assert.throws(() => { + new WifiAccessPoint({ bssid: 'a'.repeat(18) }); + }, /BSSID length exceeds 17 characters/); +}); + +test('WifiAccessPoint should throw an error for invalid password length', () => { + assert.throws(() => { + new WifiAccessPoint({ password: 'a'.repeat(65) }); + }, /Password length exceeds 64 characters/); +}); + +test('WifiAccessPoint isOpen should return true for open networks', () => { + const accessPoint = new WifiAccessPoint({ + encryption: WifiEncryption.OPEN, + cipher: WifiCipher.NONE, + }); + + assert.strictEqual(accessPoint.isOpen(), true); +}); + +test('WifiAccessPoint isOpen should return false for non-open networks', () => { + const accessPoint = new WifiAccessPoint({ + encryption: WifiEncryption.WPA2, + cipher: WifiCipher.AES, + }); + + assert.strictEqual(accessPoint.isOpen(), false); +}); + +test('WifiAccessPoint isWEP should return true for WEP networks', () => { + const accessPoint = new WifiAccessPoint({ + encryption: WifiEncryption.OPEN, + cipher: WifiCipher.WEP, + }); + + assert.strictEqual(accessPoint.isWEP(), true); +}); + +test('WifiAccessPoint isWEP should return false for non-WEP networks', () => { + const accessPoint = new WifiAccessPoint({ + encryption: WifiEncryption.WPA2, + cipher: WifiCipher.AES, + }); + + assert.strictEqual(accessPoint.isWEP(), false); +}); + +test('encryptPassword should throw an error if password is missing', async () => { + await assert.rejects(async () => { + await encryptPassword({ + password: '', + hardware: { + type: 'router', + uuid: '1234', + macAddress: '00:11:22:33:44:55', + }, + }); + }, /Password is required/); +}); + +test('encryptPassword should throw an error if hardware information is missing', async () => { + await assert.rejects(async () => { + await encryptPassword({ + password: 'password123', + hardware: { type: '', uuid: '' as UUID, macAddress: '' as MacAddress }, + }); + }, /Hardware information is required/); +}); + +test('encryptPassword should return encrypted data', async () => { + const encryptedData = await encryptPassword({ + password: 'password123', + hardware: { + type: 'router', + uuid: '1234' as UUID, + macAddress: '00:11:22:33:44:55' as MacAddress, + }, + }); + + assert.ok(encryptedData instanceof Buffer); + assert.notStrictEqual(encryptedData.toString('utf-8'), 'password123'); +}); diff --git a/packages/lib/src/wifi.ts b/packages/lib/src/wifi.ts new file mode 100644 index 0000000..a0ccd7a --- /dev/null +++ b/packages/lib/src/wifi.ts @@ -0,0 +1,105 @@ +import type { DeviceHardware } from './device.js'; +import Encryption from './encryption.js'; +import md5 from './utils/md5.js'; + +export enum WifiCipher { + NONE, + WEP, + TKIP, + AES, + TIKPAES, +} + +export enum WifiEncryption { + OPEN, + SHARE, + WEPAUTO, + WPA1, + WPA1PSK, + WPA2, + WPA2PSK, + WPA1WPA2, + WPA1PSKWPA2PS, +} + +type EncryptPasswordOptions = { + password: string; + hardware: DeviceHardware & { + type: string; + }; +}; + +export async function encryptPassword( + options: EncryptPasswordOptions +): Promise { + const { password, hardware } = options; + const { type, uuid, macAddress } = hardware; + if (!password) { + throw new Error('Password is required'); + } + if (!type || !uuid || !macAddress) { + throw new Error('Hardware information is required'); + } + + const key = Buffer.from(md5(`${type}${uuid}${macAddress}`, 'hex'), 'utf-8'); + const data = Buffer.from(password, 'utf-8'); + + return Encryption.encrypt(data, key); +} + +export type WifiAccessPointOptions = { + ssid?: string; + bssid?: string; + channel?: number; + cipher?: WifiCipher; + encryption?: WifiEncryption; + password?: string; + signal?: number; +}; + +export class WifiAccessPoint { + ssid; + bssid; + channel; + cipher; + encryption; + password; + signal; + + constructor(options: WifiAccessPointOptions = {}) { + const { ssid, bssid, channel, cipher, encryption, password, signal } = + options; + + if (ssid?.length > 32) { + throw new Error('SSID length exceeds 32 characters'); + } + + if (bssid?.length > 17) { + throw new Error('BSSID length exceeds 17 characters'); + } + + if (password?.length > 64) { + throw new Error('Password length exceeds 64 characters'); + } + + this.ssid = ssid; + this.bssid = bssid; + this.channel = channel; + this.cipher = cipher; + this.encryption = encryption; + this.password = password; + this.signal = signal; + } + + isOpen() { + return ( + this.encryption == WifiEncryption.OPEN && this.cipher == WifiCipher.NONE + ); + } + + isWEP() { + return ( + this.encryption == WifiEncryption.OPEN && this.cipher == WifiCipher.WEP + ); + } +} diff --git a/packages/lib/tsconfig.json b/packages/lib/tsconfig.json new file mode 100644 index 0000000..79d71d2 --- /dev/null +++ b/packages/lib/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022"], + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "declaration": true + }, + "exclude": ["**/*.test.ts", "dist/**/*"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1 @@ +{} From b8a2eb8248054e5b85d5e33f8fe4139b97354fe1 Mon Sep 17 00:00:00 2001 From: Rob Griffiths Date: Sat, 2 Aug 2025 17:09:33 +0100 Subject: [PATCH 16/18] Complete rewrite to make adding features and fixes easier (#98) * rewrite as ESM * WIP * added missing files * componentise the library * keep code clean * format to standard * Remove unneeded imports * Remove debuging * Use `configure` rather then `set` to imply action over the network * rename functions and document * updated deps * Proof read options * Update readme * Update readme * Update readme with requirements * removed old api file * updated github action * updated version * update versions * update to use import attributes rather than import assertions * add docker test environment * remove parameters from shebang * clean up ignore files * remove VERSION file * add license * complete typescript rewrite. Added encrypted communication for devices that require it * removed dist * added dist to ignore * allow strings to be passed to base64 encode * decide the protocol and the port based on the input * use strings not buffers * remove commented reference code * added readme and license * ip flag is not required as we default it * fix license * for build and publishing * for build and publishing * fix packages * make sure node std libraries are imported with node: prefix * 2.0.0 * WIP * update version * Update packages/cli/src/cli.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Added information about factory mode * Bump axios from 0.21.1 to 0.21.2 Bumps [axios](https://github.com/axios/axios) from 0.21.1 to 0.21.2. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/master/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v0.21.1...v0.21.2) --- updated-dependencies: - dependency-name: axios dependency-type: direct:production ... Signed-off-by: dependabot[bot] * add mention of node requirement to docs and enforce via npm * added further error message * Create npm-ghr-publish.yml * Delete .github/workflows/npm-publish.yml Duplicate workflow * Update npm-ghr-publish.yml added manual run option * Update README.md Update status badge * Update npm-ghr-publish.yml * Update npm-ghr-publish.yml * Update and rename npm-ghr-publish.yml to npm-publish.yml Remove GHR * version changes * updated node version * updated dockerfile example * update packages and engines * update to new versions * updated version * Bump axios from 0.21.1 to 0.21.2 Bumps [axios](https://github.com/axios/axios) from 0.21.1 to 0.21.2. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/master/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v0.21.1...v0.21.2) --- updated-dependencies: - dependency-name: axios dependency-type: direct:production ... Signed-off-by: dependabot[bot] * added further error message * Delete .github/workflows/npm-publish.yml Duplicate workflow * 1.0.16 (#94) * version changes * update node version requirement * Bump axios from 0.21.4 to 0.28.0 Bumps [axios](https://github.com/axios/axios) from 0.21.4 to 0.28.0. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v0.28.0/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v0.21.4...v0.28.0) --- updated-dependencies: - dependency-name: axios dependency-type: direct:production ... Signed-off-by: dependabot[bot] * mosquitto example dockerfile * use fixed engine range, remove unused deps * removed old directories from .gitignore * updated node version * add vscode workspace settings * updated dockerfile example * coding style * information about CVE-2022-32214 resolution in node >21 that affects parsing device responses * update packages and engines * update to new versions * 1.0.16 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * rewrite as ESM * WIP * rename functions and document * updated deps * Update readme * updated github action * add docker test environment * remove VERSION file * complete typescript rewrite. Added encrypted communication for devices that require it * removed dist * WIP * updated version * fix merge error * remove old files * add npm-publish.yml. Merge issue * added further error message * Create npm-ghr-publish.yml * Update npm-ghr-publish.yml added manual run option * Update README.md Update status badge * Update npm-ghr-publish.yml * Update npm-ghr-publish.yml * Update and rename npm-ghr-publish.yml to npm-publish.yml Remove GHR * updated dockerfile example * update packages and engines * updated version * updated version * updated stale config * be consistent with imports * be consistent with imports * Namespace property is part of the message header not the message --------- Signed-off-by: dependabot[bot] Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Simon Porter From baece6edeb81aefd8b9f77bcb358cd931d3e5e43 Mon Sep 17 00:00:00 2001 From: Rob Griffiths Date: Sat, 2 Aug 2025 17:39:14 +0100 Subject: [PATCH 17/18] added missing command (#100) --- packages/cli/src/meross.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 packages/cli/src/meross.ts diff --git a/packages/cli/src/meross.ts b/packages/cli/src/meross.ts new file mode 100644 index 0000000..ea7088b --- /dev/null +++ b/packages/cli/src/meross.ts @@ -0,0 +1,12 @@ +#!/usr/bin/env node + +'use strict'; + +import pkg from '../package.json' with { type: 'json' }; +import { program } from 'commander'; + +program + .version(pkg.version) + .command('info [options]', 'get information about compatable Meross smart device') + .command('setup [options]', 'setup compatable Meross smart device') + .parse(process.argv); \ No newline at end of file From c3219473f37d67f9c381efd319738bcd80780d30 Mon Sep 17 00:00:00 2001 From: Rob Griffiths Date: Sat, 2 Aug 2025 17:49:35 +0100 Subject: [PATCH 18/18] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/lib/src/wifi.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/lib/src/wifi.ts b/packages/lib/src/wifi.ts index a0ccd7a..d6c08d1 100644 --- a/packages/lib/src/wifi.ts +++ b/packages/lib/src/wifi.ts @@ -7,7 +7,7 @@ export enum WifiCipher { WEP, TKIP, AES, - TIKPAES, + TKIPAES, } export enum WifiEncryption { @@ -19,7 +19,7 @@ export enum WifiEncryption { WPA2, WPA2PSK, WPA1WPA2, - WPA1PSKWPA2PS, + WPA1PSKWPA2PSK, } type EncryptPasswordOptions = {