diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml
index 43591df..497fcee 100644
--- a/.github/workflows/npm-publish.yml
+++ b/.github/workflows/npm-publish.yml
@@ -6,15 +6,16 @@ name: Node.js Package
on:
release:
types: [created]
+ workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
- - uses: actions/setup-node@v2
+ - uses: actions/checkout@v3
+ - uses: actions/setup-node@v3
with:
- node-version: 12
+ node-version: 18
- run: npm ci
- run: npm test
@@ -22,29 +23,29 @@ jobs:
needs: build
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
- - uses: actions/setup-node@v2
+ - uses: actions/checkout@v3
+ - uses: actions/setup-node@v3
with:
- node-version: 12
+ node-version: 18
registry-url: https://registry.npmjs.org/
- run: npm ci
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{secrets.npm_token}}
- publish-gpr:
- needs: build
- runs-on: ubuntu-latest
- permissions:
- packages: write
- contents: read
- steps:
- - uses: actions/checkout@v2
- - uses: actions/setup-node@v2
- with:
- node-version: 12
- registry-url: https://npm.pkg.github.com/
- - run: npm ci
- - run: npm publish
- env:
- NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}}
+ # publish-gpr:
+ # needs: build
+ # runs-on: ubuntu-latest
+ # permissions:
+ # packages: write
+ # contents: read
+ # steps:
+ # - uses: actions/checkout@v3
+ # - uses: actions/setup-node@v3
+ # with:
+ # node-version: 18
+ # registry-url: https://npm.pkg.github.com/
+ # - run: npm ci
+ # - run: npm publish
+ # env:
+ # NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}}
diff --git a/.gitignore b/.gitignore
index ccce27f..2d822ed 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
-bin/src/node_modules
-cmd/
-dumps/
-old_certs/
+# Directories
+node_modules/
+dist/
+
+# Files
+*.log
\ No newline at end of file
diff --git a/.npmignore b/.npmignore
index 344ca8f..205fac5 100644
--- a/.npmignore
+++ b/.npmignore
@@ -1,6 +1,6 @@
+# Directories
certs/
-cmd/
-dumps/
mosquito/
-old_certs/
teardown/
+
+#Files
\ No newline at end of file
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..334b796
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,7 @@
+{
+ "arrowParens": "always",
+ "semi": true,
+ "singleQuote": true,
+ "tabWidth": 2,
+ "useTabs": false
+}
\ No newline at end of file
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..c83e263
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,3 @@
+{
+ "recommendations": ["esbenp.prettier-vscode"]
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..6ff6621
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,5 @@
+{
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.detectIndentation": false,
+ "editor.tabSize": 2
+}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..a1209fd
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,16 @@
+FROM eclipse-mosquitto:1.6.15-openssl
+
+COPY mosquitto/basic.conf ./mosquitto/config/mosquitto.conf
+RUN apk add --update --no-cache openssl && \
+ 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/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 8f4259b..f6f6cbe 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,58 @@
# Meross utilities
+
[](https://github.com/bytespider/Meross/actions/workflows/npm-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.
+
+## Requirements
-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`.
+NodeJS: ^21.0.0, ^20.10.0, ^18.20.0
+NPM: ^10.0.0
-## Home Assistant
-It's possible to get these devices to work with Home Assistant (HASSIO).
-Setup Home Assistant MQTT
+## Setup
-Once paired and linked to your broker, you can use the Meross Lan integration to control the devices.
+TODO:
+[Devices with WIFI pairing]()
+
+[Devices with Bluetooth pairing]()
## Tools
+
### Info
-`npx meross info [--inclide-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 seperated 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/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/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 d2a61f0..0000000
--- a/bin/meross-setup
+++ /dev/null
@@ -1,103 +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('--wifi-ssid ', 'WIFI AP name')
- .option('--wifi-pass ', 'WIFI AP 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.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();
-
- await api.configureWifiCredentials({
- ssid: options.wifiSsid,
- password: options.wifiPass,
- channel: options.wifiChannel,
- encryption: options.wifiEncryption,
- cipher: options.wifiCipher,
- bssid: options.wifiBssid,
- })
- console.log(`Device will reboot...`)
-})()
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/lib/api.js b/lib/api.js
deleted file mode 100644
index b7925a1..0000000
--- a/lib/api.js
+++ /dev/null
@@ -1,378 +0,0 @@
-'use strict'
-
-if (typeof(URL) === 'undefined') {
- var URL = class URL {
- constructor(url) {
- return require('url').parse(url)
- }
- }
-}
-
-const util = require('util')
-const uuid4 = require('uuid4')
-const md5 = require('md5')
-const term = require( 'terminal-kit' ).terminal
-const axios = require('axios')
-
-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 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 tableOptions = {
- 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 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) {
- 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)
- )
- ));
- 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 (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
-
- axios.interceptors.request.use(request => {
- if (verbose) {
- logRequest(request)
- }
- return request
- })
-
- axios.interceptors.response.use(response => {
- if (verbose) {
- logResponse(response)
- }
- return response
- })
- }
-
- 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 packet = this.signPacket({
- 'header': {
- 'method': 'GET',
- 'namespace': 'Appliance.System.All'
- },
- 'payload': {}
- })
-
- try {
- const response = await axios.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
- }
-
- const system = data.payload.all.system
- const digest = data.payload.all.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
- )
- } catch (error) {
- handleRequestError(error, this.verbose)
- }
- }
-
- async deviceWifiList() {
- const packet = this.signPacket({
- 'header': {
- 'method': 'GET',
- 'namespace': 'Appliance.Config.WifiList'
- },
- 'payload': {}
- })
-
- try {
- let spinner = await term.spinner({animation:'dotSpinner', rightPadding: ' '})
- term('Getting WIFI list…\n')
-
- const response = await axios.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': {
- '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 axios.post(
- `http://${this.host}/config`,
- packet,
- {
- headers: {
- 'Content-Type': 'application/json'
- },
- }
- )
- } catch (error) {
- handleRequestError(error, this.verbose)
- }
- }
-
- async configureWifiCredentials(credentials) {
- const ssid = base64Encode(credentials.ssid)
- const password = base64Encode(credentials.password)
-
- const packet = this.signPacket({
- 'header': {
- 'method': 'SET',
- 'namespace': 'Appliance.Config.Wifi'
- },
- 'payload': {
- 'wifi': {
- ...filterUndefined(credentials),
- ssid,
- password,
- }
- }
- })
-
- try {
- const response = await axios.post(
- `http://${this.host}/config`,
- packet,
- {
- headers: {
- 'Content-Type': 'application/json'
- },
- }
- )
- } catch (error) {
- handleRequestError(error, this.verbose)
- }
- }
-
- calculateDevicePassword(macAddress, user = null) {
- return `${user}_${md5(macAddress + '' + this.key)}`
- }
-}
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/mosquitto/basic.conf b/mosquitto/basic.conf
index 19c4d30..60b7f93 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
diff --git a/package-lock.json b/package-lock.json
index 3c21890..1084856 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,230 +1,1082 @@
{
"name": "meross",
- "version": "1.0.7",
- "lockfileVersion": 1,
+ "version": "2.0.0-beta-5",
+ "lockfileVersion": 3,
"requires": true,
- "dependencies": {
- "@cronvel/get-pixels": {
- "version": "3.4.0",
- "resolved": "https://registry.npmjs.org/@cronvel/get-pixels/-/get-pixels-3.4.0.tgz",
- "integrity": "sha512-do5jDoX9oCR/dGHE4POVQ3PYDCmQ2Fow4CA72UL4WoE8zUImA/0lChczjfl+ucNjE4sXFWUnzoO6j4WzrUvLnw==",
- "requires": {
- "jpeg-js": "^0.4.1",
+ "packages": {
+ "": {
+ "name": "meross",
+ "version": "2.0.0-beta-5",
+ "license": "ISC",
+ "workspaces": [
+ "packages/lib",
+ "packages/cli",
+ "packages/*"
+ ],
+ "bin": {
+ "meross": "packages/cli/bin/meross.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@colors/colors": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
+ "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==",
+ "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==",
+ "dependencies": {
+ "jpeg-js": "^0.4.4",
"ndarray": "^1.0.19",
"ndarray-pack": "^1.1.1",
"node-bitmap": "0.0.1",
"omggif": "^1.0.10",
- "pngjs": "^5.0.0"
+ "pngjs": "^6.0.0"
+ }
+ },
+ "node_modules/@dabh/diagnostics": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz",
+ "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==",
+ "dependencies": {
+ "colorspace": "1.1.x",
+ "enabled": "2.0.x",
+ "kuler": "^2.0.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz",
+ "integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz",
+ "integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz",
+ "integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz",
+ "integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz",
+ "integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz",
+ "integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz",
+ "integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz",
+ "integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz",
+ "integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz",
+ "integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz",
+ "integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz",
+ "integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz",
+ "integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
}
},
- "axios": {
- "version": "0.21.1",
- "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
- "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
- "requires": {
- "follow-redirects": "^1.10.0"
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz",
+ "integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
}
},
- "charenc": {
- "version": "0.0.2",
- "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
- "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc="
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz",
+ "integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz",
+ "integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz",
+ "integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz",
+ "integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz",
+ "integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz",
+ "integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz",
+ "integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz",
+ "integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz",
+ "integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz",
+ "integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz",
+ "integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@meross/lib": {
+ "resolved": "packages/lib",
+ "link": true
},
- "chroma-js": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.1.0.tgz",
- "integrity": "sha512-uiRdh4ZZy+UTPSrAdp8hqEdVb1EllLtTHOt5TMaOjJUvi+O54/83Fc5K2ld1P+TJX+dw5B+8/sCgzI6eaur/lg==",
- "requires": {
- "cross-env": "^6.0.3"
+ "node_modules/@types/nextgen-events": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@types/nextgen-events/-/nextgen-events-1.1.4.tgz",
+ "integrity": "sha512-YczHp+887i3MpHUOCOztk7y10SklNZ3aQlToKnu0LON0ZdFpgwq8POtnATAoFz8V1IxyR6d8pp8ZyYkUIy26Cw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/terminal-kit": {
+ "version": "2.5.7",
+ "resolved": "https://registry.npmjs.org/@types/terminal-kit/-/terminal-kit-2.5.7.tgz",
+ "integrity": "sha512-IpbCBFSb3OqCEZBZlk368tGftqss88eNQaJdD9msEShRbksEiVahEqroONi60ppUt9/arLM6IDrHMx9jpzzCOw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/nextgen-events": "*"
}
},
- "commander": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
- "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="
+ "node_modules/@types/triple-beam": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz",
+ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
+ "license": "MIT"
+ },
+ "node_modules/async": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz",
+ "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ=="
},
- "cross-env": {
- "version": "6.0.3",
- "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-6.0.3.tgz",
- "integrity": "sha512-+KqxF6LCvfhWvADcDPqo64yVIB31gv/jQulX2NGzKS/g3GEVz6/pt4wjHFtFWsHMddebWD/sDthJemzM4MaAag==",
- "requires": {
- "cross-spawn": "^7.0.0"
+ "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=="
+ },
+ "node_modules/color": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
+ "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==",
+ "dependencies": {
+ "color-convert": "^1.9.3",
+ "color-string": "^1.6.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+ "dependencies": {
+ "color-name": "1.1.3"
}
},
- "cross-spawn": {
- "version": "7.0.3",
- "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
- "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
- "requires": {
- "path-key": "^3.1.0",
- "shebang-command": "^2.0.0",
- "which": "^2.0.1"
+ "node_modules/color-name": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
+ },
+ "node_modules/color-string": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
+ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
+ "dependencies": {
+ "color-name": "^1.0.0",
+ "simple-swizzle": "^0.2.2"
}
},
- "crypt": {
- "version": "0.0.2",
- "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
- "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs="
+ "node_modules/colorspace": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz",
+ "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==",
+ "dependencies": {
+ "color": "^3.1.3",
+ "text-hex": "1.0.x"
+ }
},
- "cwise-compiler": {
+ "node_modules/cwise-compiler": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/cwise-compiler/-/cwise-compiler-1.1.3.tgz",
- "integrity": "sha1-9NZnQQ6FDToxOn0tt7HlBbsDTMU=",
- "requires": {
+ "integrity": "sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ==",
+ "dependencies": {
"uniq": "^1.0.0"
}
},
- "follow-redirects": {
- "version": "1.13.2",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.2.tgz",
- "integrity": "sha512-6mPTgLxYm3r6Bkkg0vNM0HTjfGrOEtsfbhagQvbxDEsEkpNhw582upBaoRZylzen6krEmxXJgt9Ju6HiI4O7BA=="
+ "node_modules/enabled": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz",
+ "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="
},
- "iota-array": {
+ "node_modules/esbuild": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz",
+ "integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "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/fecha": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
+ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
+ "license": "MIT"
+ },
+ "node_modules/fn.name": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
+ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "4.10.0",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.0.tgz",
+ "integrity": "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+ },
+ "node_modules/iota-array": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz",
- "integrity": "sha1-ge9X/l0FgUzVjCSDYyqZwwoOgIc="
+ "integrity": "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA=="
},
- "is-buffer": {
+ "node_modules/is-arrayish": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
+ "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="
+ },
+ "node_modules/is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
},
- "isexe": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
- "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA="
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "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=="
},
- "jpeg-js": {
- "version": "0.4.3",
- "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.3.tgz",
- "integrity": "sha512-ru1HWKek8octvUHFHvE5ZzQ1yAsJmIvRdGWvSoKV52XKyuyYA437QWDttXT8eZXDSbuMpHlLzPDZUPd6idIz+Q=="
+ "node_modules/kuler": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
+ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="
},
- "lazyness": {
+ "node_modules/lazyness": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/lazyness/-/lazyness-1.2.0.tgz",
- "integrity": "sha512-KenL6EFbwxBwRxG93t0gcUyi0Nw0Ub31FJKN1laA4UscdkL1K1AxUd0gYZdcLU3v+x+wcFi4uQKS5hL+fk500g=="
+ "integrity": "sha512-KenL6EFbwxBwRxG93t0gcUyi0Nw0Ub31FJKN1laA4UscdkL1K1AxUd0gYZdcLU3v+x+wcFi4uQKS5hL+fk500g==",
+ "engines": {
+ "node": ">=6.0.0"
+ }
},
- "md5": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
- "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
- "requires": {
- "charenc": "0.0.2",
- "crypt": "0.0.2",
- "is-buffer": "~1.1.6"
+ "node_modules/logform": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
+ "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@colors/colors": "1.6.0",
+ "@types/triple-beam": "^1.3.2",
+ "fecha": "^4.2.0",
+ "ms": "^2.1.1",
+ "safe-stable-stringify": "^2.3.1",
+ "triple-beam": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
}
},
- "ndarray": {
+ "node_modules/meross": {
+ "resolved": "packages/cli",
+ "link": true
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
+ },
+ "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==",
- "requires": {
+ "dependencies": {
"iota-array": "^1.0.0",
"is-buffer": "^1.0.2"
}
},
- "ndarray-pack": {
+ "node_modules/ndarray-pack": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ndarray-pack/-/ndarray-pack-1.2.1.tgz",
- "integrity": "sha1-jK6+qqJNXs9w/4YCBjeXfajuWFo=",
- "requires": {
+ "integrity": "sha512-51cECUJMT0rUZNQa09EoKsnFeDL4x2dHRT0VR5U2H5ZgEcm95ZDWcMA5JShroXjHOejmAD/fg8+H+OvUnVXz2g==",
+ "dependencies": {
"cwise-compiler": "^1.1.2",
"ndarray": "^1.0.13"
}
},
- "nextgen-events": {
- "version": "1.3.4",
- "resolved": "https://registry.npmjs.org/nextgen-events/-/nextgen-events-1.3.4.tgz",
- "integrity": "sha512-umMRD9VOvQ7+AeCvMETA7tekqrzG0xOX2HLrpyZRuW+4NlXR5baZwY/CP7Sq3x1BkKCIa1KnI1m2+Fs+fJpOiQ=="
+ "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==",
+ "engines": {
+ "node": ">=6.0.0"
+ }
},
- "node-bitmap": {
+ "node_modules/node-bitmap": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/node-bitmap/-/node-bitmap-0.0.1.tgz",
- "integrity": "sha1-GA6scAPgxwdhjvMTaPYvhLKmkJE="
+ "integrity": "sha512-Jx5lPaaLdIaOsj2mVLWMWulXF6GQVdyLvNSxmiYCvZ8Ma2hfKX0POoR2kgKOqz+oFsRreq0yYZjQ2wjE9VNzCA==",
+ "engines": {
+ "node": ">=v0.6.5"
+ }
},
- "omggif": {
+ "node_modules/omggif": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
"integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="
},
- "path-key": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
- "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="
+ "node_modules/one-time": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
+ "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==",
+ "dependencies": {
+ "fn.name": "1.x.x"
+ }
+ },
+ "node_modules/pngjs": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
+ "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
+ "engines": {
+ "node": ">=12.13.0"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "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",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
},
- "pngjs": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
- "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="
+ "node_modules/safe-stable-stringify": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
+ "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
},
- "setimmediate": {
+ "node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
- "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU="
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
},
- "seventh": {
- "version": "0.7.40",
- "resolved": "https://registry.npmjs.org/seventh/-/seventh-0.7.40.tgz",
- "integrity": "sha512-7sxUydQx4iEh17uJUFjZDAwbffJirldZaNIJvVB/hk9mPEL3J4GpLGSL+mHFH2ydkye46DAsLGqzFJ+/Qj5foQ==",
- "requires": {
+ "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==",
+ "dependencies": {
"setimmediate": "^1.0.5"
+ },
+ "engines": {
+ "node": ">=16.13.0"
}
},
- "shebang-command": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
- "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "requires": {
- "shebang-regex": "^3.0.0"
- }
- },
- "shebang-regex": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
- "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="
- },
- "string-kit": {
- "version": "0.11.10",
- "resolved": "https://registry.npmjs.org/string-kit/-/string-kit-0.11.10.tgz",
- "integrity": "sha512-ATVmIpMrqxPFNiNQTnmEeXzt3743O6DubJWh2MiAQV1ifKd4PcCjBcybCdb0ENnPO1T6asORK9nOpygn1BATag=="
- },
- "terminal-kit": {
- "version": "1.47.0",
- "resolved": "https://registry.npmjs.org/terminal-kit/-/terminal-kit-1.47.0.tgz",
- "integrity": "sha512-wyfslVEOJrB2kmZyUEkTKtd4jlNTVR9G1ogkrs7a1VMk55t83+m8sMDJTNuEqfwYXvWbRyGD8pyhv9vyl9xc5w==",
- "requires": {
- "@cronvel/get-pixels": "^3.4.0",
- "chroma-js": "^2.1.0",
+ "node_modules/simple-swizzle": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
+ "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
+ "dependencies": {
+ "is-arrayish": "^0.3.1"
+ }
+ },
+ "node_modules/stack-trace": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
+ "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/string-kit": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/string-kit/-/string-kit-0.19.2.tgz",
+ "integrity": "sha512-o5rhsZy4WS76+uMc4fkcQYM7dcdxe8wKCoLeLqCcGZxbUmtawkBE8G0JS6ooBnBOy+j1MpZ1IgWIuojIr71vPw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.15.0"
+ }
+ },
+ "node_modules/terminal-kit": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/terminal-kit/-/terminal-kit-3.1.2.tgz",
+ "integrity": "sha512-ro2FyU4A+NwA74DLTYTnoCFYuFpgV1aM07IS6MPrJeajoI2hwF44EdUqjoTmKEl6srYDWtbVkc/b1C16iUnxFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@cronvel/get-pixels": "^3.4.1",
+ "chroma-js": "^2.4.2",
"lazyness": "^1.2.0",
"ndarray": "^1.0.19",
- "nextgen-events": "^1.3.4",
- "seventh": "^0.7.40",
- "string-kit": "^0.11.9",
- "tree-kit": "^0.6.2"
+ "nextgen-events": "^1.5.3",
+ "seventh": "^0.9.2",
+ "string-kit": "^0.19.0",
+ "tree-kit": "^0.8.7"
+ },
+ "engines": {
+ "node": ">=16.13.0"
+ }
+ },
+ "node_modules/text-hex": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
+ "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="
+ },
+ "node_modules/tree-kit": {
+ "version": "0.8.8",
+ "resolved": "https://registry.npmjs.org/tree-kit/-/tree-kit-0.8.8.tgz",
+ "integrity": "sha512-L7zwpXp0/Nha6mljVcVOnhhxuCkFRWmt26wza3TKnyMBewid4F2vyiVdcSsw41ZoG1Wj+3lM48Er9lhttbxfLA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.13.0"
+ }
+ },
+ "node_modules/triple-beam": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
+ "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.0.0"
}
},
- "tree-kit": {
- "version": "0.6.2",
- "resolved": "https://registry.npmjs.org/tree-kit/-/tree-kit-0.6.2.tgz",
- "integrity": "sha512-95UzJA0EMbFfu5sGUUOoXixQMUGkwu82nGM4lmqLyQl+R4H3FK+lS0nT8TZJ5x7JhSHy+saVn7/AOqh6d+tmOg=="
+ "node_modules/tsx": {
+ "version": "4.19.3",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz",
+ "integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==",
+ "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",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
+ "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
},
- "uniq": {
+ "node_modules/uniq": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz",
- "integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8="
+ "integrity": "sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA=="
},
- "uuid4": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/uuid4/-/uuid4-2.0.2.tgz",
- "integrity": "sha512-TzsQS8sN1B2m9WojyNp0X/3JL8J2RScnrAJnooNPL6lq3lA02/XdoWysyUgI6rAif0DzkkWk51N6OggujPy2RA=="
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
},
- "which": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
- "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "requires": {
- "isexe": "^2.0.0"
+ "node_modules/winston": {
+ "version": "3.17.0",
+ "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz",
+ "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
+ "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",
+ "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",
+ "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==",
+ "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": {
+ "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",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.16.tgz",
+ "integrity": "sha512-15tM+qA4Ypml/N7kyRdvfRjBQT2RL461uF1Bldn06K0Nzn1lY3nAPgHlsVrJxdZ9WhZiW0Fmc1lOYMtDsAuB3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.20.0"
+ }
+ },
+ "packages/cli/node_modules/commander": {
+ "version": "13.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
+ "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "packages/cli/node_modules/undici-types": {
+ "version": "6.20.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
+ "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
+ "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",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.16.tgz",
+ "integrity": "sha512-15tM+qA4Ypml/N7kyRdvfRjBQT2RL461uF1Bldn06K0Nzn1lY3nAPgHlsVrJxdZ9WhZiW0Fmc1lOYMtDsAuB3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.20.0"
+ }
+ },
+ "packages/lib/node_modules/undici-types": {
+ "version": "6.20.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
+ "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
+ "dev": true,
+ "license": "MIT"
}
}
}
diff --git a/package.json b/package.json
index a30a073..9b1dd1c 100644
--- a/package.json
+++ b/package.json
@@ -1,27 +1,33 @@
{
"name": "meross",
- "version": "1.0.12",
+ "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",
+ "contributors": [],
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/bytespider/meross.git"
+ },
"license": "ISC",
- "dependencies": {
- "axios": "^0.21.1",
- "commander": "^7.2",
- "md5": "^2.2.1",
- "terminal-kit": "^1.47.0",
- "uuid4": "^2.0.2"
- }
-}
+ "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..635af13
--- /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"
+ }
+}
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
new file mode 100644
index 0000000..b00af1f
--- /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 failire
+ */
+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-device-ability.js b/packages/cli/src/meross-device-ability.js
new file mode 100644
index 0000000..76a8f98
--- /dev/null
+++ b/packages/cli/src/meross-device-ability.js
@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+
+'use strict'
+
+import pkg from '../../../package.json' with { type: 'json' };
+import TerminalKit from 'terminal-kit';
+import { program } from 'commander';
+import { Device } from '../src/device.js';
+import { HTTPTransport } from '../src/transport/http.js';
+import { logger } from '../src/util.js';
+import { parseIntWithValidation } from '../src/cli.js';
+
+const { terminal } = TerminalKit;
+
+program
+ .version(pkg.version)
+ .arguments('')
+ .requiredOption(
+ '-a, --addr, --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',
+ parseIntWithValidation,
+ 0
+ )
+ .option('-k, --key ', 'Shared key for generating signatures', '')
+ .option('-v, --verbose', 'Show debugging messages', '')
+ .parse(process.argv);
+
+const options = program.opts();
+
+const ip = options.ip;
+const key = options.key;
+const userId = options.user;
+const verbose = options.verbose;
+
+if (verbose) {
+ logger.transports[0].level = 'debug';
+}
+
+try {
+ const transport = new HTTPTransport({ ip });
+ const device = new Device({
+ transport, credentials: {
+ userId,
+ key
+ }
+ });
+
+ const abilities = await device.querySystemAbility();
+ terminal.table([
+ ['Ability', 'Parameters'],
+ ...Object.entries(abilities).map(([ability, params]) => [ability, JSON.stringify(params)])
+ ], {
+ firstRowTextAttr: { color: 'white', bold: true, underline: true },
+ hasBorder: false,
+ width: 80
+ });
+} catch (error) {
+ logger.error(error);
+}
\ No newline at end of file
diff --git a/packages/cli/src/meross-device-time.js b/packages/cli/src/meross-device-time.js
new file mode 100644
index 0000000..f013191
--- /dev/null
+++ b/packages/cli/src/meross-device-time.js
@@ -0,0 +1,63 @@
+#!/usr/bin/env node
+
+'use strict'
+
+import pkg from '../package.json' with { type: 'json' };
+import TerminalKit from 'terminal-kit';
+import { program } from 'commander';
+import { Device } from '../src/device.js';
+import { HTTPTransport } from '../src/transport/http.js';
+import { base64, logger } from '../src/util.js';
+import { bar, parseIntWithValidation } from '../src/cli.js';
+
+const { terminal } = TerminalKit;
+
+program
+ .version(pkg.version)
+ .arguments('')
+ .requiredOption(
+ '-a, --addr, --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',
+ parseIntWithValidation,
+ 0
+ )
+ .option('-k, --key ', 'Shared key for generating signatures', '')
+ .option('-v, --verbose', 'Show debugging messages')
+ .parse(process.argv);
+
+const options = program.opts();
+
+const ip = options.ip;
+const key = options.key;
+const userId = options.user;
+const verbose = options.verbose;
+
+if (verbose) {
+ logger.transports[0].level = 'debug';
+}
+
+try {
+ const transport = new HTTPTransport({ ip });
+ const device = new Device({
+ transport, credentials: {
+ userId,
+ key
+ }
+ });
+
+ const time = await device.querySystemTime();
+ const datetime = Intl.DateTimeFormat(navigator.language, {
+ timeStyle: 'long',
+ dateStyle: 'long',
+ timeZone: time.timezone ? time.timezone : 'UTC',
+ }).format(time.timestamp);
+
+ console.log(datetime)
+} catch (error) {
+ logger.error(error);
+}
\ No newline at end of file
diff --git a/packages/cli/src/meross-device-wifi.js b/packages/cli/src/meross-device-wifi.js
new file mode 100644
index 0000000..1b3ba43
--- /dev/null
+++ b/packages/cli/src/meross-device-wifi.js
@@ -0,0 +1,85 @@
+#!/usr/bin/env node
+
+'use strict'
+
+import pkg from '../package.json' with { type: 'json' };
+import TerminalKit from 'terminal-kit';
+import { program } from 'commander';
+import { Device } from '../src/device.js';
+import { HTTPTransport } from '../src/transport/http.js';
+import { base64, logger } from '../src/util.js';
+import { bar, parseIntWithValidation } from '../src/cli.js';
+
+const { terminal } = TerminalKit;
+
+program
+ .version(pkg.version)
+ .arguments('')
+ .requiredOption(
+ '-a, --addr, --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',
+ parseIntWithValidation,
+ 0
+ )
+ .option('-k, --key ', 'Shared key for generating signatures', '')
+ .option('--expanded', 'Display all gathered WIFI information', false)
+ .option('-v, --verbose', 'Show debugging messages')
+ .parse(process.argv);
+
+const options = program.opts();
+
+const ip = options.ip;
+const key = options.key;
+const userId = options.user;
+const expanded = options.expanded;
+const verbose = options.verbose;
+
+if (verbose) {
+ logger.transports[0].level = 'debug';
+}
+
+try {
+ const transport = new HTTPTransport({ ip });
+ const device = new Device({
+ transport, credentials: {
+ userId,
+ key
+ }
+ });
+
+ const wifiListPromise = device.queryNearbyWifi();
+
+ const spinner = await terminal.spinner({
+ animation: 'dotSpinner',
+ rightPadding: ' ',
+ attr: { color: 'cyan' },
+ });
+
+ const wifiList = await wifiListPromise;
+ spinner.animate(false);
+
+ terminal.column(0); // overwrite spinner
+
+ terminal.table([
+ ['WIFI', ...(expanded ? ['Detail'] : []), 'Signal strength'],
+ ...wifiList.map(({ ssid, bssid, channel, encryption, cipher, signal }) => {
+ const decodedSsid = base64.decode(ssid);
+
+ return [decodedSsid ? decodedSsid : '', ...(expanded ? [
+ `BSSID: ${bssid}\nChannel: ${channel}\nEncryption: ${encryption}\nCipher: ${cipher}`
+ ] : []), bar(signal / 100, 20)]
+ })
+ ], {
+ firstRowTextAttr: { color: 'white', bold: true, underline: true },
+ hasBorder: false,
+ width: 80,
+ contentHasMarkup: true,
+ });
+} catch (error) {
+ logger.error(error);
+}
\ No newline at end of file
diff --git a/packages/cli/src/meross-device.js b/packages/cli/src/meross-device.js
new file mode 100644
index 0000000..f8dfd56
--- /dev/null
+++ b/packages/cli/src/meross-device.js
@@ -0,0 +1,14 @@
+#!/usr/bin/env node
+
+'use strict'
+
+import pkg from '../package.json' with { type: 'json' };
+import { program } from 'commander';
+
+program
+ .version(pkg.version)
+ .command('ability [options]', 'display a list of abilities for a device')
+ .command('wifi [options]', 'display wifi access points discovered by a device')
+ .command('time [options]', 'display the time on a device')
+
+program.parse(process.argv);
\ No newline at end of file
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/bin/meross b/packages/cli/src/meross.ts
similarity index 62%
rename from bin/meross
rename to packages/cli/src/meross.ts
index 4676163..46ee511 100755
--- a/bin/meross
+++ b/packages/cli/src/meross.ts
@@ -1,12 +1,12 @@
#!/usr/bin/env node
-'use strict'
+'use strict';
-const {version} = require('../package.json')
-const program = require('commander')
+import pkg from '../package.json' with { type: 'json' };
+import { program } from 'commander';
program
- .version(version)
+ .version(pkg.version)
program
.command('info [options]', 'get information about compatable Meross smart device')
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/package.json b/packages/lib/package.json
new file mode 100644
index 0000000..d8b96d0
--- /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"
+ }
+}
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..e020ab7
--- /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';
+import { DeviceFirmware, DeviceHardware, Device } from './device';
+import { Namespace } from './message/header';
+import { TransportSendOptions, Transport } from './transport/transport';
+import { Message } from './message';
+
+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..2c61d17
--- /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: any): 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.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..257a5ab
--- /dev/null
+++ b/packages/lib/src/encryption.ts
@@ -0,0 +1,120 @@
+import { createCipheriv, createDecipheriv, createECDH } from 'node:crypto';
+import { Buffer } from 'node:buffer';
+import { calculatePaddingForBlockSize, pad, trimPadding } from './utils/buffer';
+import logger from './utils/logger';
+
+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..2c0ce2a
--- /dev/null
+++ b/packages/lib/src/message/configureMQTTBrokersAndCredentials.ts
@@ -0,0 +1,47 @@
+import { CloudCredentials } from '../cloudCredentials';
+import { Method, Namespace } from './header';
+import { Message, MessageOptions } from './message';
+
+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..f3a4626
--- /dev/null
+++ b/packages/lib/src/message/configureWifiMessage.ts
@@ -0,0 +1,38 @@
+import { filterUndefined } from '../utils';
+import base64 from '../utils/base64';
+import { WifiAccessPoint } from '../wifi';
+import { Method, Namespace } from './header';
+import { Message, MessageOptions } from './message';
+
+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..f66c9d3
--- /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';
+
+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..22d8006
--- /dev/null
+++ b/packages/lib/src/message/index.ts
@@ -0,0 +1,2 @@
+export * from './message';
+export * from './header';
diff --git a/packages/lib/src/message/message.ts b/packages/lib/src/message/message.ts
new file mode 100644
index 0000000..56992a8
--- /dev/null
+++ b/packages/lib/src/message/message.ts
@@ -0,0 +1,28 @@
+import { Header, Method, Namespace } from './header.js';
+import { encryptPassword } from '../wifi.js';
+import { md5 } from '../utils/md5.js';
+import { generateTimestamp } from '../utils/generateTimestamp.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..8947fc8
--- /dev/null
+++ b/packages/lib/src/message/queryDeviceTime.ts
@@ -0,0 +1,18 @@
+import { Method, Namespace } from './header';
+import { Message, type MessageOptions } from './message';
+
+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..4be89c3
--- /dev/null
+++ b/packages/lib/src/message/queryWifiList.ts
@@ -0,0 +1,22 @@
+import { Method, Namespace } from './header';
+import { Message, MessageOptions } from './message';
+
+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..352a8a3
--- /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';
+
+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..eea6ca9
--- /dev/null
+++ b/packages/lib/src/transport/index.ts
@@ -0,0 +1,2 @@
+export * from './transport';
+export * from './http';
diff --git a/packages/lib/src/transport/transport.test.ts b/packages/lib/src/transport/transport.test.ts
new file mode 100644
index 0000000..a733ae9
--- /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';
+import { Message } from '../message/message';
+import { ResponseMethod } from '../message/header';
+
+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..d24f4d2
--- /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';
+
+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..c8c5203
--- /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';
+
+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..310bf91
--- /dev/null
+++ b/packages/lib/src/utils/computeDevicePassword.ts
@@ -0,0 +1,13 @@
+import { type MacAddress } from '../device';
+import { md5 } from './md5';
+
+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..bd8b163
--- /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';
+
+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..686af9f
--- /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';
+
+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..cdb53cc
--- /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';
+
+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..1a77477
--- /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';
+
+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 @@
+{}