├── packages ├── lib │ ├── debug.log │ ├── .npmignore │ ├── src │ │ ├── message │ │ │ ├── index.ts │ │ │ ├── messages.ts │ │ │ ├── queryDeviceTime.ts │ │ │ ├── queryDeviceAbilities.ts │ │ │ ├── queryDeviceInformation.ts │ │ │ ├── queryWifiList.ts │ │ │ ├── message.ts │ │ │ ├── configureWifiXMessage.ts │ │ │ ├── configureECDH.ts │ │ │ ├── configureDeviceTime.ts │ │ │ ├── configureWifiMessage.ts │ │ │ ├── configureMQTTBrokersAndCredentials.ts │ │ │ ├── header.test.ts │ │ │ └── header.ts │ │ ├── transport │ │ │ ├── index.ts │ │ │ ├── transport.ts │ │ │ ├── http.test.ts │ │ │ ├── http.ts │ │ │ └── transport.test.ts │ │ ├── utils │ │ │ ├── generateTimestamp.ts │ │ │ ├── filterUndefined.ts │ │ │ ├── randomId.ts │ │ │ ├── index.ts │ │ │ ├── computeDevicePassword.ts │ │ │ ├── base64.ts │ │ │ ├── computePresharedPrivateKey.ts │ │ │ ├── md5.ts │ │ │ ├── randomId.test.ts │ │ │ ├── base64.test.ts │ │ │ ├── protocolFromPort.ts │ │ │ ├── protocolFromPort.test.ts │ │ │ ├── logger.ts │ │ │ ├── buffer.ts │ │ │ ├── filterUndefined.test.ts │ │ │ ├── md5.test.ts │ │ │ ├── buffer.test.ts │ │ │ ├── computeDevicePassword.test.ts │ │ │ └── computePresharedKey.test.ts │ │ ├── index.ts │ │ ├── cloudCredentials.ts │ │ ├── encryption.test.ts │ │ ├── deviceManager.ts │ │ ├── wifi.ts │ │ ├── wifi.test.ts │ │ ├── encryption.ts │ │ ├── deviceManager.test.ts │ │ └── device.ts │ ├── tsconfig.json │ ├── LICENSE.md │ ├── package.json │ └── README.md └── cli │ ├── .npmignore │ ├── tsconfig.json │ ├── src │ ├── meross.ts │ ├── meross-info.ts │ ├── cli.ts │ └── meross-setup.ts │ ├── LICENSE.md │ ├── package.json │ └── README.md ├── tsconfig.json ├── .npmrc ├── .gitignore ├── .npmignore ├── .vscode ├── extensions.json └── settings.json ├── teardown ├── IMG_6869.JPG ├── IMG_6870.JPG ├── IMG_6871.JPG ├── IMG_6872.JPG ├── IMG_6873.JPG └── README.md ├── .prettierrc ├── mosquitto ├── basic.conf └── authenticated.conf ├── .github └── workflows │ ├── stale.yml │ └── npm-publish.yml ├── Dockerfile ├── LICENSE.md ├── package.json └── README.md /packages/lib/debug.log: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Directories 2 | node_modules/ 3 | dist/ 4 | 5 | # Files 6 | *.log -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Directories 2 | certs/ 3 | mosquito/ 4 | teardown/ 5 | 6 | #Files -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/cli/.npmignore: -------------------------------------------------------------------------------- 1 | # Directories 2 | src/ 3 | 4 | # Files 5 | *.log 6 | *.test.* -------------------------------------------------------------------------------- /packages/lib/.npmignore: -------------------------------------------------------------------------------- 1 | # Directories 2 | src/ 3 | 4 | # Files 5 | *.log 6 | *.test.* -------------------------------------------------------------------------------- /teardown/IMG_6869.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytespider/Meross/HEAD/teardown/IMG_6869.JPG -------------------------------------------------------------------------------- /teardown/IMG_6870.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytespider/Meross/HEAD/teardown/IMG_6870.JPG -------------------------------------------------------------------------------- /teardown/IMG_6871.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytespider/Meross/HEAD/teardown/IMG_6871.JPG -------------------------------------------------------------------------------- /teardown/IMG_6872.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytespider/Meross/HEAD/teardown/IMG_6872.JPG -------------------------------------------------------------------------------- /teardown/IMG_6873.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytespider/Meross/HEAD/teardown/IMG_6873.JPG -------------------------------------------------------------------------------- /packages/lib/src/message/index.ts: -------------------------------------------------------------------------------- 1 | export * from './message.js'; 2 | export * from './header.js'; 3 | -------------------------------------------------------------------------------- /packages/lib/src/transport/index.ts: -------------------------------------------------------------------------------- 1 | export * from './transport.js'; 2 | export * from './http.js'; 3 | -------------------------------------------------------------------------------- /packages/lib/src/utils/generateTimestamp.ts: -------------------------------------------------------------------------------- 1 | export function generateTimestamp() { 2 | return Math.round(Date.now() / 1000); 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "useTabs": false 7 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.detectIndentation": false, 4 | "editor.tabSize": 2 5 | } 6 | -------------------------------------------------------------------------------- /packages/lib/src/utils/filterUndefined.ts: -------------------------------------------------------------------------------- 1 | export function filterUndefined(obj: Record) { 2 | return Object.fromEntries( 3 | Object.entries(obj).filter(([_, value]) => value !== undefined) 4 | ); 5 | } 6 | -------------------------------------------------------------------------------- /packages/lib/src/utils/randomId.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto'; 2 | 3 | export function randomId(): string { 4 | return (randomUUID() as string).replaceAll('-', ''); 5 | } 6 | 7 | export default randomId; 8 | -------------------------------------------------------------------------------- /packages/lib/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * as base64 from './base64.js'; 2 | export * from './computeDevicePassword.js'; 3 | export * from './computePresharedPrivateKey.js'; 4 | export * from './filterUndefined.js'; 5 | export * from './generateTimestamp.js'; 6 | export * from './md5.js'; 7 | export * from './randomId.js'; 8 | -------------------------------------------------------------------------------- /packages/lib/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './device.js'; 2 | export * from './deviceManager.js'; 3 | export * from './encryption.js'; 4 | export * from './message/index.js'; 5 | export * from './transport/index.js'; 6 | export * from './utils/index.js'; 7 | export * from './wifi.js'; 8 | export * from './cloudCredentials.js'; 9 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "dist", 5 | "strict": true, 6 | "target": "ESNext", 7 | "module": "Node18", 8 | "sourceMap": false, 9 | "esModuleInterop": true, 10 | "moduleResolution": "nodenext", 11 | "resolveJsonModule": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/lib/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "rootDir": "./src", 5 | "lib": ["ES2022"], 6 | "target": "ES2022", 7 | "module": "NodeNext", 8 | "moduleResolution": "NodeNext", 9 | "esModuleInterop": true, 10 | "declaration": true 11 | }, 12 | "exclude": ["**/*.test.ts", "dist/**/*"] 13 | } 14 | -------------------------------------------------------------------------------- /mosquitto/basic.conf: -------------------------------------------------------------------------------- 1 | log_type all 2 | log_dest stdout 3 | 4 | use_username_as_clientid true 5 | 6 | listener 8883 7 | # replace with your CA Root 8 | cafile /mosquitto/config/certs/ca.crt 9 | 10 | # replace with your server certificate and key paths 11 | keyfile /mosquitto/config/certs/server.key 12 | certfile /mosquitto/config/certs/server.crt 13 | 14 | allow_anonymous true 15 | require_certificate false -------------------------------------------------------------------------------- /packages/cli/src/meross.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | import pkg from '../package.json' with { type: 'json' }; 6 | import { program } from 'commander'; 7 | 8 | program 9 | .version(pkg.version) 10 | .command('info [options]', 'get information about compatable Meross smart device') 11 | .command('setup [options]', 'setup compatable Meross smart device') 12 | .parse(process.argv); -------------------------------------------------------------------------------- /packages/lib/src/utils/computeDevicePassword.ts: -------------------------------------------------------------------------------- 1 | import { type MacAddress } from '../device.js'; 2 | import { md5 } from './md5.js'; 3 | 4 | export function computeDevicePassword( 5 | macAddress: MacAddress, 6 | key: string = '', 7 | userId: number = 0 8 | ): string { 9 | const hash = md5(`${macAddress}${key}`, 'hex'); 10 | return `${userId}_${hash}`; 11 | } 12 | 13 | export default computeDevicePassword; 14 | -------------------------------------------------------------------------------- /packages/lib/src/utils/base64.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'node:buffer'; 2 | 3 | export function encode(data: string | Buffer): string { 4 | if (typeof data === 'string') { 5 | data = Buffer.from(data, 'utf-8'); 6 | } 7 | return data.toString('base64'); 8 | } 9 | 10 | export function decode(data: string): Buffer { 11 | return Buffer.from(data, 'base64'); 12 | } 13 | 14 | export default { 15 | encode, 16 | decode, 17 | }; 18 | -------------------------------------------------------------------------------- /packages/lib/src/message/messages.ts: -------------------------------------------------------------------------------- 1 | export * from './configureDeviceTime.js'; 2 | export * from './configureECDH.js'; 3 | export * from './configureMQTTBrokersAndCredentials.js'; 4 | export * from './configureWifiMessage.js'; 5 | export * from './configureWifiXMessage.js'; 6 | export * from './queryDeviceAbilities.js'; 7 | export * from './queryDeviceInformation.js'; 8 | export * from './queryWifiList.js'; 9 | export * from './queryDeviceTime.js'; 10 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues' 2 | on: 3 | schedule: 4 | - cron: '30 1 * * *' 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v9 11 | with: 12 | stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 15 days.' 13 | days-before-stale: 60 14 | days-before-close: 15 15 | only: issues 16 | -------------------------------------------------------------------------------- /packages/lib/src/message/queryDeviceTime.ts: -------------------------------------------------------------------------------- 1 | import { Method, Namespace } from './header.js'; 2 | import { Message, type MessageOptions } from './message.js'; 3 | 4 | export class QueryDeviceTimeMessage extends Message { 5 | constructor(options: MessageOptions = {}) { 6 | const { payload = {}, header = {} } = options; 7 | super({ 8 | payload, 9 | header: { 10 | method: Method.GET, 11 | namespace: Namespace.SYSTEM_TIME, 12 | ...header, 13 | }, 14 | }); 15 | } 16 | } 17 | 18 | export default QueryDeviceTimeMessage; 19 | -------------------------------------------------------------------------------- /packages/lib/src/message/queryDeviceAbilities.ts: -------------------------------------------------------------------------------- 1 | import { Method, Namespace } from './header.js'; 2 | import { Message, MessageOptions } from './message.js'; 3 | 4 | export class QueryDeviceAbilitiesMessage extends Message { 5 | constructor(options: MessageOptions = {}) { 6 | const { payload = {}, header = {} } = options; 7 | super({ 8 | payload, 9 | header: { 10 | method: Method.GET, 11 | namespace: Namespace.SYSTEM_ABILITY, 12 | ...header, 13 | }, 14 | }); 15 | } 16 | } 17 | 18 | export default QueryDeviceAbilitiesMessage; 19 | -------------------------------------------------------------------------------- /packages/lib/src/message/queryDeviceInformation.ts: -------------------------------------------------------------------------------- 1 | import { Method, Namespace } from './header.js'; 2 | import { Message, MessageOptions } from './message.js'; 3 | 4 | export class QueryDeviceInformationMessage extends Message { 5 | constructor(options: MessageOptions = {}) { 6 | const { payload = {}, header = {} } = options; 7 | super({ 8 | payload, 9 | header: { 10 | method: Method.GET, 11 | namespace: Namespace.SYSTEM_ALL, 12 | ...header, 13 | }, 14 | }); 15 | } 16 | } 17 | 18 | export default QueryDeviceInformationMessage; 19 | -------------------------------------------------------------------------------- /packages/lib/src/message/queryWifiList.ts: -------------------------------------------------------------------------------- 1 | import { Method, Namespace } from './header.js'; 2 | import { Message, MessageOptions } from './message.js'; 3 | 4 | export class QueryWifiListMessage extends Message { 5 | constructor(options: MessageOptions = {}) { 6 | const { header, payload } = options; 7 | 8 | super({ 9 | header: { 10 | method: Method.GET, 11 | namespace: Namespace.CONFIG_WIFI_LIST, 12 | ...header, 13 | }, 14 | payload: { 15 | trace: {}, 16 | ...payload, 17 | }, 18 | }); 19 | } 20 | } 21 | 22 | export default QueryWifiListMessage; 23 | -------------------------------------------------------------------------------- /packages/lib/src/message/message.ts: -------------------------------------------------------------------------------- 1 | import { Header } from './header.js'; 2 | import { md5 } from '../utils/md5.js'; 3 | 4 | export type MessageOptions = { 5 | header?: Header; 6 | payload?: Record; 7 | }; 8 | 9 | export class Message { 10 | header; 11 | payload; 12 | 13 | constructor(options: MessageOptions = {}) { 14 | this.header = options.header || new Header(); 15 | this.payload = options.payload || {}; 16 | } 17 | 18 | /** 19 | * 20 | * @param {string} key 21 | */ 22 | async sign(key = '') { 23 | const { messageId, timestamp } = this.header; 24 | this.header.sign = md5(`${messageId}${key}${timestamp}`, 'hex'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/lib/src/utils/computePresharedPrivateKey.ts: -------------------------------------------------------------------------------- 1 | import { MacAddress, UUID } from '../device.js'; 2 | import base64 from './base64.js'; 3 | import md5 from './md5.js'; 4 | 5 | /** 6 | * Computes the preshared private key for a device using its UUID, a shared key, and its MAC address. 7 | * Really shouldn't need this with ECDH key exchange but here we are. 8 | */ 9 | export function computePresharedPrivateKey( 10 | uuid: UUID, 11 | key: string, 12 | macAddress: MacAddress 13 | ): string { 14 | return base64.encode( 15 | md5( 16 | `${uuid.slice(3, 22)}${key.slice(1, 9)}${macAddress}${key.slice(10, 28)}`, 17 | 'hex' 18 | ) 19 | ); 20 | } 21 | 22 | export default computePresharedPrivateKey; 23 | -------------------------------------------------------------------------------- /packages/lib/src/utils/md5.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'node:buffer'; 2 | import { BinaryToTextEncoding, createHash } from 'node:crypto'; 3 | 4 | export function md5(data: string | Buffer): Buffer; 5 | export function md5( 6 | data: string | Buffer, 7 | encoding: BinaryToTextEncoding 8 | ): string; 9 | export function md5( 10 | data: string | Buffer, 11 | encoding?: BinaryToTextEncoding 12 | ): string | Buffer { 13 | if (typeof data === 'string') { 14 | data = Buffer.from(data, 'utf-8'); 15 | } 16 | 17 | const hash = createHash('md5').update(data); 18 | if (encoding === undefined) { 19 | return hash.digest(); 20 | } 21 | 22 | return hash.digest(encoding); 23 | } 24 | 25 | export default md5; 26 | -------------------------------------------------------------------------------- /packages/lib/src/utils/randomId.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test'; 2 | import { strict as assert } from 'node:assert'; 3 | import { randomId } from './randomId.js'; 4 | 5 | test('randomId should generate a string of the correct length', () => { 6 | const id = randomId(); 7 | assert.strictEqual(id.length, 32); // UUID without dashes has 32 characters 8 | }); 9 | 10 | test('randomId should generate unique strings', () => { 11 | const id1 = randomId(); 12 | const id2 = randomId(); 13 | assert.notStrictEqual(id1, id2); // Ensure IDs are unique 14 | }); 15 | 16 | test('randomId should only contain alphanumeric characters', () => { 17 | const id = randomId(); 18 | assert.match(id, /^[a-f0-9]{32}$/i); // UUID without dashes is hexadecimal 19 | }); 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM eclipse-mosquitto:1.6.15-openssl 2 | 3 | COPY mosquitto/basic.conf ./mosquitto/config/mosquitto.conf 4 | RUN apk add --update --no-cache openssl && \ 5 | mkdir /mosquitto/config/certs && \ 6 | cd /mosquitto/config/certs && \ 7 | openssl genrsa -out ca.key 2048 && \ 8 | openssl req -x509 -new -nodes -key ca.key -days 3650 -out ca.crt -subj '/CN=My Root' && \ 9 | openssl req -new -nodes -out server.csr -newkey rsa:2048 -keyout server.key -subj '/CN=Mosquitto' && \ 10 | openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 3650 && \ 11 | c_rehash . && \ 12 | chown -R mosquitto:mosquitto /mosquitto && \ 13 | chmod 600 /mosquitto/config/certs/* 14 | 15 | EXPOSE 1883 16 | EXPOSE 8883 -------------------------------------------------------------------------------- /packages/lib/src/message/configureWifiXMessage.ts: -------------------------------------------------------------------------------- 1 | import { WifiAccessPoint } from '../wifi.js'; 2 | import { ConfigureWifiMessage } from './configureWifiMessage.js'; 3 | import { Namespace } from './header.js'; 4 | import { MessageOptions } from './message.js'; 5 | 6 | export class ConfigureWifiXMessage extends ConfigureWifiMessage { 7 | constructor( 8 | options: MessageOptions & { 9 | wifiAccessPoint: WifiAccessPoint; 10 | } 11 | ) { 12 | const { wifiAccessPoint, payload, header } = options; 13 | 14 | super({ 15 | wifiAccessPoint, 16 | header: { 17 | namespace: Namespace.CONFIG_WIFIX, 18 | ...header, 19 | }, 20 | payload, 21 | }); 22 | } 23 | } 24 | 25 | export default ConfigureWifiXMessage; 26 | -------------------------------------------------------------------------------- /packages/lib/src/message/configureECDH.ts: -------------------------------------------------------------------------------- 1 | import { Method, Namespace } from './header.js'; 2 | import { Message, MessageOptions } from './message.js'; 3 | 4 | export class ConfigureECDHMessage extends Message { 5 | constructor( 6 | options: MessageOptions & { 7 | publicKey: Buffer; 8 | } 9 | ) { 10 | const { payload = {}, header = {}, publicKey } = options; 11 | 12 | super({ 13 | payload: { 14 | ecdhe: { 15 | step: 1, 16 | pubkey: publicKey.toString('base64'), 17 | }, 18 | ...payload, 19 | }, 20 | header: { 21 | method: Method.SET, 22 | namespace: Namespace.ENCRYPT_ECDHE, 23 | ...header, 24 | }, 25 | }); 26 | } 27 | } 28 | 29 | export default ConfigureECDHMessage; 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2025 Rob Griffiths 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /packages/cli/LICENSE.md: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2025 Rob Griffiths 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /packages/lib/LICENSE.md: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2025 Rob Griffiths 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /mosquitto/authenticated.conf: -------------------------------------------------------------------------------- 1 | log_type all 2 | log_dest stdout 3 | 4 | listener 1883 5 | 6 | listener 8883 7 | 8 | allow_anonymous false 9 | 10 | # replace with your CA Root 11 | cafile ../certs/ca.crt 12 | 13 | # replace with your server certificate and key paths 14 | certfile ../certs/server.crt 15 | keyfile ../certs/server.key 16 | 17 | auth_plugin /usr/local/opt/mosquitto/share/auth-plug.so 18 | tls_version tlsv1.2 19 | 20 | auth_opt_backends mysql 21 | auth_opt_host 127.0.0.1 22 | auth_opt_port 3306 23 | auth_opt_dbname mosquitto 24 | auth_opt_user mosquitto 25 | auth_opt_pass mosquitto 26 | auth_opt_userquery SELECT password_hash FROM users WHERE username = '%s' 27 | auth_opt_aclquery SELECT topic FROM acls WHERE (username = '%s') AND (rw >= %d) 28 | auth_opt_superquery SELECT IFNULL(COUNT(*), 0) FROM users WHERE username = '%s' AND is_super = 1 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meross", 3 | "version": "2.0.1", 4 | "description": "Utility to configure Meross devices for local MQTT", 5 | "keywords": [ 6 | "smarthome", 7 | "mqtt", 8 | "meross", 9 | "refoss", 10 | "cli" 11 | ], 12 | "type": "module", 13 | "engines": { 14 | "node": ">=18" 15 | }, 16 | "scripts": { 17 | "test": "npm run test --workspaces --if-present", 18 | "build": "npm run build --workspaces --if-present" 19 | }, 20 | "author": "Rob Griffiths ", 21 | "contributors": [], 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/bytespider/meross.git" 25 | }, 26 | "license": "ISC", 27 | "workspaces": [ 28 | "packages/lib", 29 | "packages/cli", 30 | "packages/*" 31 | ], 32 | "bin": "packages/cli/bin/meross.js" 33 | } 34 | -------------------------------------------------------------------------------- /packages/lib/src/utils/base64.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test'; 2 | import { strict as assert } from 'node:assert'; 3 | 4 | import { encode, decode } from './base64.js'; 5 | 6 | test('encode should convert a Buffer to a base64 string', () => { 7 | const buffer = Buffer.from('hello world'); 8 | const result = encode(buffer); 9 | assert.strictEqual(result, 'aGVsbG8gd29ybGQ='); 10 | }); 11 | 12 | test('decode should convert a base64 string to a Buffer', () => { 13 | const base64String = 'aGVsbG8gd29ybGQ='; 14 | const result = decode(base64String); 15 | assert.strictEqual(result.toString(), 'hello world'); 16 | }); 17 | 18 | test('encode and decode should be inverses of each other', () => { 19 | const originalBuffer = Buffer.from('test data'); 20 | const encoded = encode(originalBuffer); 21 | const decoded = decode(encoded); 22 | assert.deepStrictEqual(decoded, originalBuffer); 23 | }); 24 | -------------------------------------------------------------------------------- /packages/lib/src/cloudCredentials.ts: -------------------------------------------------------------------------------- 1 | export class CloudCredentials { 2 | userId: number; 3 | key: string; 4 | 5 | constructor(userId: number = 0, key: string = '') { 6 | this.userId = userId; 7 | this.key = key; 8 | } 9 | } 10 | 11 | let instance: CloudCredentials | null = null; 12 | 13 | export function createCloudCredentials( 14 | userId: number, 15 | key: string 16 | ): CloudCredentials { 17 | if (!instance) { 18 | instance = new CloudCredentials(userId, key); 19 | } 20 | return instance; 21 | } 22 | 23 | export function getCloudCredentials(): CloudCredentials { 24 | if (!instance) { 25 | throw new Error('Cloud credentials have not been initialized.'); 26 | } 27 | return instance; 28 | } 29 | 30 | export function hasCloudCredentials(): boolean { 31 | return instance !== null; 32 | } 33 | 34 | export function clearCloudCredentials(): void { 35 | instance = null; 36 | } 37 | -------------------------------------------------------------------------------- /packages/lib/src/message/configureDeviceTime.ts: -------------------------------------------------------------------------------- 1 | import { generateTimestamp } from '../utils/generateTimestamp.js'; 2 | import { Method, Namespace } from './header.js'; 3 | import { Message, type MessageOptions } from './message.js'; 4 | 5 | export class ConfigureDeviceTimeMessage extends Message { 6 | constructor( 7 | options: MessageOptions & { timestamp: number; timezone: string } = { 8 | timestamp: generateTimestamp(), 9 | timezone: 'Etc/UTC', 10 | } 11 | ) { 12 | const { header, payload, timestamp, timezone } = options; 13 | 14 | super({ 15 | header: { 16 | method: Method.SET, 17 | namespace: Namespace.SYSTEM_TIME, 18 | ...header, 19 | }, 20 | payload: { 21 | time: { 22 | timezone, 23 | timestamp, 24 | }, 25 | ...payload, 26 | }, 27 | }); 28 | } 29 | } 30 | 31 | export default ConfigureDeviceTimeMessage; 32 | -------------------------------------------------------------------------------- /packages/lib/src/utils/protocolFromPort.ts: -------------------------------------------------------------------------------- 1 | export function protocolFromPort(port: number) { 2 | switch (port) { 3 | case 80: 4 | return 'http'; 5 | case 443: 6 | return 'https'; 7 | case 8883: 8 | return 'mqtts'; 9 | case 1883: 10 | return 'mqtt'; 11 | } 12 | 13 | throw new Error(`Unknown port ${port}`); 14 | } 15 | 16 | export function portFromProtocol(protocol: string) { 17 | switch (protocol) { 18 | case 'http': 19 | return 80; 20 | case 'https': 21 | return 443; 22 | case 'mqtts': 23 | return 8883; 24 | case 'mqtt': 25 | return 1883; 26 | } 27 | throw new Error(`Unknown protocol ${protocol}`); 28 | } 29 | 30 | export function isValidPort(port: number) { 31 | return port === 80 || port === 443 || port === 8883 || port === 1883; 32 | } 33 | 34 | export default { 35 | protocolFromPort, 36 | portFromProtocol, 37 | isValidPort, 38 | }; 39 | -------------------------------------------------------------------------------- /packages/lib/src/utils/protocolFromPort.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test'; 2 | import { strict as assert } from 'node:assert'; 3 | import { protocolFromPort } from './protocolFromPort.js'; 4 | 5 | test('protocolFromPort should return "http" for port 80', () => { 6 | assert.strictEqual(protocolFromPort(80), 'http'); 7 | }); 8 | 9 | test('protocolFromPort should return "https" for port 443', () => { 10 | assert.strictEqual(protocolFromPort(443), 'https'); 11 | }); 12 | 13 | test('protocolFromPort should return "mqtts" for port 8883', () => { 14 | assert.strictEqual(protocolFromPort(8883), 'mqtts'); 15 | }); 16 | 17 | test('protocolFromPort should return "mqtt" for port 1883', () => { 18 | assert.strictEqual(protocolFromPort(1883), 'mqtt'); 19 | }); 20 | 21 | test('protocolFromPort should throw an error for unknown ports', () => { 22 | assert.throws(() => { 23 | protocolFromPort(1234); 24 | }, /Unknown port 1234/); 25 | }); 26 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meross", 3 | "version": "2.0.1", 4 | "main": "index.js", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "tsx src/meross.ts", 8 | "test": "tsx --test", 9 | "build": "tsc", 10 | "prepublishOnly": "npm run build" 11 | }, 12 | "bin": { 13 | "meross": "dist/meross.js" 14 | }, 15 | "keywords": [ 16 | "meross", 17 | "automation", 18 | "smarthome" 19 | ], 20 | "author": "Rob Griffiths ", 21 | "license": "ISC", 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/bytespider/meross.git" 25 | }, 26 | "dependencies": { 27 | "@meross/lib": "*", 28 | "commander": "^13.1.0", 29 | "terminal-kit": "^3.1.2" 30 | }, 31 | "description": "", 32 | "devDependencies": { 33 | "@types/node": "^22.13.16", 34 | "@types/terminal-kit": "^2.5.7", 35 | "tsx": "^4.19.3", 36 | "typescript": "^5.8.2" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/lib/src/message/configureWifiMessage.ts: -------------------------------------------------------------------------------- 1 | import { filterUndefined } from '../utils/filterUndefined.js'; 2 | import base64 from '../utils/base64.js'; 3 | import { WifiAccessPoint } from '../wifi.js'; 4 | import { Method, Namespace } from './header.js'; 5 | import { Message, MessageOptions } from './message.js'; 6 | 7 | export class ConfigureWifiMessage extends Message { 8 | constructor( 9 | options: MessageOptions & { 10 | wifiAccessPoint: WifiAccessPoint; 11 | } 12 | ) { 13 | const { payload = {}, header = {}, wifiAccessPoint } = options; 14 | 15 | const wifi = filterUndefined(wifiAccessPoint); 16 | 17 | if (wifi.ssid) { 18 | wifi.ssid = base64.encode(wifi.ssid); 19 | } 20 | if (wifi.password) { 21 | wifi.password = base64.encode(wifi.password); 22 | } 23 | 24 | super({ 25 | payload: { 26 | wifi, 27 | ...payload, 28 | }, 29 | header: { 30 | method: Method.SET, 31 | namespace: Namespace.CONFIG_WIFI, 32 | ...header, 33 | }, 34 | }); 35 | } 36 | } 37 | 38 | export default ConfigureWifiMessage; 39 | -------------------------------------------------------------------------------- /packages/lib/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | 3 | const { combine, timestamp, printf, metadata } = winston.format; 4 | 5 | const capitalizeLevel = winston.format((info) => { 6 | info.level = info.level.toUpperCase(); 7 | return info; 8 | })(); 9 | 10 | const customFormat = printf((info) => 11 | `${info.timestamp} ${info.level}: ${info.message} ${JSON.stringify( 12 | info.metadata 13 | )}`.trim() 14 | ); 15 | 16 | const logger = winston.createLogger({ 17 | level: process.env.LOG_LEVEL || 'info', 18 | silent: !process.env.LOG_LEVEL, 19 | format: combine( 20 | capitalizeLevel, 21 | timestamp({ 22 | format: 'YYYY-MM-DD HH:mm:ss', 23 | }), 24 | customFormat, 25 | metadata({ fillExcept: ['message', 'level', 'timestamp'] }) 26 | ), 27 | transports: [ 28 | new winston.transports.Console({ 29 | handleExceptions: true, 30 | format: combine(winston.format.colorize(), customFormat), 31 | }), 32 | new winston.transports.File({ 33 | level: 'debug', 34 | filename: 'debug.log', 35 | format: combine(winston.format.json()), 36 | }), 37 | ], 38 | }); 39 | 40 | export default logger; 41 | -------------------------------------------------------------------------------- /packages/lib/src/message/configureMQTTBrokersAndCredentials.ts: -------------------------------------------------------------------------------- 1 | import { CloudCredentials } from '../cloudCredentials.js'; 2 | import { Method, Namespace } from './header.js'; 3 | import { Message, MessageOptions } from './message.js'; 4 | 5 | export type MQTTBroker = { 6 | host: string; 7 | port: number; 8 | }; 9 | 10 | export class ConfigureMQTTBrokersAndCredentialsMessage extends Message { 11 | constructor( 12 | options: MessageOptions & { 13 | mqtt: MQTTBroker[]; 14 | credentials: CloudCredentials; 15 | } 16 | ) { 17 | const { payload = {}, header = {}, mqtt, credentials } = options; 18 | 19 | const primaryBroker = mqtt[0]; 20 | const falloverBroker = mqtt[1] ?? mqtt[0]; 21 | 22 | super({ 23 | payload: { 24 | key: { 25 | userId: `${credentials.userId}`, 26 | key: `${credentials.key}`, 27 | gateway: { 28 | host: primaryBroker.host, 29 | port: primaryBroker.port, 30 | secondHost: falloverBroker.host, 31 | secondPort: falloverBroker.port, 32 | redirect: 1, 33 | }, 34 | }, 35 | ...payload, 36 | }, 37 | header: { 38 | method: Method.SET, 39 | namespace: Namespace.CONFIG_KEY, 40 | payloadVersion: 1, 41 | ...header, 42 | }, 43 | }); 44 | } 45 | } 46 | 47 | export default ConfigureMQTTBrokersAndCredentialsMessage; 48 | -------------------------------------------------------------------------------- /packages/lib/src/utils/buffer.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'node:buffer'; 2 | 3 | export function calculatePaddingForBlockSize(data: Buffer, blockSize: number) { 4 | return blockSize - (data.length % blockSize); 5 | } 6 | 7 | export function pad( 8 | data: Buffer, 9 | length: number, 10 | fill?: string | Uint8Array | number 11 | ) { 12 | return Buffer.concat([data, Buffer.alloc(length, fill)]); 13 | } 14 | 15 | export function trimPadding(data: Buffer, fill?: string | Uint8Array | number) { 16 | if (data.length === 0) { 17 | return data; 18 | } 19 | 20 | fill = getFillByte(fill); 21 | 22 | let length = data.length; 23 | // starting from the end iterate backwards and check if the byte is equal to the fill 24 | while (length > 0 && data[length - 1] === fill) { 25 | length--; 26 | } 27 | 28 | return data.subarray(0, length); 29 | } 30 | 31 | function getFillByte(fill: string | number | Uint8Array) { 32 | if (typeof fill === 'string') { 33 | fill = Buffer.from(fill, 'utf-8'); 34 | } else if (fill instanceof Uint8Array) { 35 | fill = Buffer.from(fill); 36 | } else if (fill === undefined) { 37 | fill = 0; 38 | } 39 | // check if the fill is a buffer 40 | if (Buffer.isBuffer(fill)) { 41 | fill = fill[0]; 42 | } else if (typeof fill === 'number') { 43 | fill = fill; 44 | } 45 | return fill; 46 | } 47 | 48 | export default { 49 | calculatePaddingForBlockSize, 50 | pad, 51 | trimPadding, 52 | }; 53 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Publish Package to npmjs 5 | 6 | on: 7 | release: 8 | types: [published] 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build-lib: 13 | runs-on: ubuntu-latest 14 | defaults: 15 | run: 16 | working-directory: packages/lib 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: '20.x' 22 | registry-url: https://registry.npmjs.org 23 | - run: npm ci 24 | - run: npm test 25 | - run: npm run build 26 | - run: npm publish --access public 27 | env: 28 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 29 | 30 | build-cli: 31 | runs-on: ubuntu-latest 32 | needs: build-lib 33 | defaults: 34 | run: 35 | working-directory: packages/cli 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: actions/setup-node@v4 39 | with: 40 | node-version: '20.x' 41 | registry-url: https://registry.npmjs.org 42 | - run: npm ci 43 | - run: npm test 44 | - run: npm run build 45 | - run: npm publish --access public 46 | env: 47 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 48 | -------------------------------------------------------------------------------- /packages/lib/src/message/header.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test'; 2 | import { strict as assert } from 'node:assert'; 3 | import { Header, Method, Namespace } from './header.js'; 4 | 5 | test('should create a Header instance with valid options', (t) => { 6 | const options = { 7 | from: 'device1', 8 | messageId: '12345', 9 | timestamp: 1672531200000, 10 | sign: 'abc123', 11 | method: Method.GET, 12 | namespace: Namespace.SYSTEM_ALL, 13 | }; 14 | 15 | const header = new Header(options); 16 | 17 | assert.strictEqual(header.from, options.from); 18 | assert.strictEqual(header.messageId, options.messageId); 19 | assert.strictEqual(header.timestamp, options.timestamp); 20 | assert.strictEqual(header.sign, options.sign); 21 | assert.strictEqual(header.method, options.method); 22 | assert.strictEqual(header.namespace, options.namespace); 23 | assert.strictEqual(header.payloadVersion, 1); 24 | }); 25 | 26 | test('should use default values for optional fields', (t) => { 27 | const options = { 28 | method: Method.SET, 29 | namespace: Namespace.SYSTEM_TIME, 30 | }; 31 | 32 | const header = new Header(options); 33 | 34 | assert.strictEqual(header.from, ''); 35 | assert.strictEqual(typeof header.messageId, 'string'); 36 | assert.notStrictEqual(header.messageId, ''); 37 | assert.strictEqual(typeof header.timestamp, 'number'); 38 | assert.strictEqual(header.sign, ''); 39 | assert.strictEqual(header.method, options.method); 40 | assert.strictEqual(header.namespace, options.namespace); 41 | assert.strictEqual(header.payloadVersion, 1); 42 | }); 43 | -------------------------------------------------------------------------------- /packages/lib/src/utils/filterUndefined.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test'; 2 | import { strict as assert } from 'node:assert'; 3 | import { filterUndefined } from './filterUndefined.js'; 4 | 5 | test('filterUndefined should remove keys with undefined values', () => { 6 | const input = { a: 1, b: undefined, c: 'test', d: undefined }; 7 | const expected = { a: 1, c: 'test' }; 8 | 9 | const result = filterUndefined(input); 10 | 11 | assert.deepEqual(result, expected); 12 | }); 13 | 14 | test('filterUndefined should return an empty object if all values are undefined', () => { 15 | const input = { a: undefined, b: undefined }; 16 | const expected = {}; 17 | 18 | const result = filterUndefined(input); 19 | 20 | assert.deepEqual(result, expected); 21 | }); 22 | 23 | test('filterUndefined should return the same object if no values are undefined', () => { 24 | const input = { a: 1, b: 'test', c: true }; 25 | const expected = { a: 1, b: 'test', c: true }; 26 | 27 | const result = filterUndefined(input); 28 | 29 | assert.deepEqual(result, expected); 30 | }); 31 | 32 | test('filterUndefined should handle an empty object', () => { 33 | const input = {}; 34 | const expected = {}; 35 | 36 | const result = filterUndefined(input); 37 | 38 | assert.deepEqual(result, expected); 39 | }); 40 | 41 | test('filterUndefined should not remove keys with null or falsy values other than undefined', () => { 42 | const input = { a: null, b: 0, c: false, d: '', e: undefined }; 43 | const expected = { a: null, b: 0, c: false, d: '' }; 44 | 45 | const result = filterUndefined(input); 46 | 47 | assert.deepEqual(result, expected); 48 | }); 49 | -------------------------------------------------------------------------------- /packages/lib/src/encryption.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test'; 2 | import { strict as assert } from 'node:assert'; 3 | import { randomBytes } from 'node:crypto'; 4 | import Encryption from './encryption.js'; 5 | 6 | test('encrypt should return a buffer of encrypted data', async () => { 7 | const data = Buffer.from('Hello, World!', 'utf-8'); 8 | const encryptionKey = randomBytes(32); // AES-256 requires a 32-byte key 9 | 10 | const encryptedData = await Encryption.encrypt(data, encryptionKey); 11 | 12 | assert.ok(encryptedData); 13 | assert.notStrictEqual( 14 | encryptedData.toString('utf-8'), 15 | data.toString('utf-8'), 16 | ); 17 | }); 18 | 19 | test('encrypt should use the provided IV', async () => { 20 | const data = Buffer.from('Hello, World!', 'utf-8'); 21 | const encryptionKey = randomBytes(32); 22 | const customIV = randomBytes(16); // AES-CBC requires a 16-byte IV 23 | 24 | const encryptedData = await Encryption.encrypt(data, encryptionKey, customIV); 25 | 26 | assert.ok(encryptedData); 27 | assert.notStrictEqual( 28 | encryptedData.toString('utf-8'), 29 | data.toString('utf-8'), 30 | ); 31 | }); 32 | 33 | test('encrypt should use the default IV if none is provided', async () => { 34 | const data = Buffer.from('Hello, World!', 'utf-8'); 35 | const encryptionKey = randomBytes(32); 36 | 37 | const encryptedData = await Encryption.encrypt(data, encryptionKey); 38 | 39 | assert.ok(encryptedData); 40 | assert.notStrictEqual( 41 | encryptedData.toString('utf-8'), 42 | data.toString('utf-8'), 43 | ); 44 | }); 45 | 46 | test('encrypt should throw an error if the encryption key is invalid', async () => { 47 | const data = Buffer.from('Hello, World!', 'utf-8'); 48 | const invalidKey = randomBytes(16); // Invalid key length for AES-256 49 | 50 | await assert.rejects( 51 | async () => { 52 | await Encryption.encrypt(data, invalidKey); 53 | }, 54 | { name: 'RangeError', message: /Invalid key length/ }, 55 | ); 56 | }); 57 | -------------------------------------------------------------------------------- /packages/lib/src/utils/md5.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test'; 2 | import { strict as assert } from 'node:assert'; 3 | import { md5 } from './md5.js'; 4 | 5 | test('md5 should correctly hash a Buffer to an MD5 hash string', () => { 6 | const hash = md5('Hello, World!', 'hex'); 7 | 8 | assert.strictEqual(hash, '65a8e27d8879283831b664bd8b7f0ad4'); 9 | }); 10 | 11 | test('md5 should produce consistent hashes for the same input', () => { 12 | const hash1 = md5('Consistent Hash Test', 'hex'); 13 | const hash2 = md5('Consistent Hash Test', 'hex'); 14 | 15 | assert.strictEqual(hash1, hash2); 16 | }); 17 | 18 | test('md5 should produce different hashes for different inputs', () => { 19 | const hash1 = md5('Input One', 'hex'); 20 | const hash2 = md5('Input Two', 'hex'); 21 | 22 | assert.notStrictEqual(hash1, hash2); 23 | }); 24 | 25 | test('md5 should correctly hash a Buffer input', () => { 26 | const bufferInput = Buffer.from('Buffer Input Test', 'utf-8'); 27 | const hash = md5(bufferInput, 'hex'); 28 | 29 | assert.strictEqual(hash, '25d7f032e75c374d64ae492a861306ad'); 30 | }); 31 | 32 | test('md5 should return a Buffer when no encoding is provided', () => { 33 | const result = md5('No Encoding Test'); 34 | 35 | assert.ok(Buffer.isBuffer(result)); 36 | assert.strictEqual( 37 | result.toString('hex'), 38 | '6e946a024f48e761768914ef6437d1eb', 39 | ); 40 | }); 41 | 42 | test('md5 should handle empty string input', () => { 43 | const hash = md5('', 'hex'); 44 | 45 | assert.strictEqual(hash, 'd41d8cd98f00b204e9800998ecf8427e'); // MD5 hash of an empty string 46 | }); 47 | 48 | test('md5 should handle empty Buffer input', () => { 49 | const hash = md5(Buffer.alloc(0), 'hex'); 50 | 51 | assert.strictEqual(hash, 'd41d8cd98f00b204e9800998ecf8427e'); // MD5 hash of an empty buffer 52 | }); 53 | 54 | test('md5 should throw an error for invalid input types', () => { 55 | assert.throws(() => { 56 | md5(123 as unknown as string); 57 | }, /The "data" argument must be of type string or an instance of Buffer/); 58 | }); 59 | -------------------------------------------------------------------------------- /packages/lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@meross/lib", 3 | "version": "2.0.1", 4 | "type": "module", 5 | "exports": { 6 | ".": { 7 | "default": "./dist/index.js", 8 | "types": "./dist/index.d.ts" 9 | }, 10 | "./utils": { 11 | "default": "./dist/utils/index.js", 12 | "types": "./dist/utils/index.d.ts" 13 | }, 14 | "./utils/*": { 15 | "default": "./dist/utils/*.js", 16 | "types": "./dist/utils/*.d.ts" 17 | }, 18 | "./message": { 19 | "default": "./dist/message/index.js", 20 | "types": "./dist/message/index.d.ts" 21 | }, 22 | "./message/*": { 23 | "default": "./dist/message/*.js", 24 | "types": "./dist/message/*.d.ts" 25 | }, 26 | "./transport": { 27 | "default": "./dist/transport/index.js", 28 | "types": "./dist/transport/index.d.ts" 29 | }, 30 | "./transport/*": { 31 | "default": "./dist/transport/*.js", 32 | "types": "./dist/transport/*.d.ts" 33 | }, 34 | "./encryption": { 35 | "default": "./dist/encryption.js", 36 | "types": "./dist/encryption.d.ts" 37 | }, 38 | "./messages": { 39 | "default": "./dist/message/messages.js", 40 | "types": "./dist/message/messages.d.ts" 41 | } 42 | }, 43 | "scripts": { 44 | "test": "tsx --test", 45 | "compile": "tsc", 46 | "build": "npm run build:clean && npm run compile", 47 | "build:clean": "rm -rf ./dist", 48 | "prepublishOnly": "npm run build" 49 | }, 50 | "keywords": [ 51 | "meross", 52 | "automation", 53 | "smarthome" 54 | ], 55 | "author": "Rob Griffiths ", 56 | "license": "ISC", 57 | "description": "Library for interacting with Meross devices", 58 | "repository": { 59 | "type": "git", 60 | "url": "git+https://github.com/bytespider/meross.git" 61 | }, 62 | "dependencies": { 63 | "node-fetch": "^3.3.2", 64 | "winston": "^3.17.0" 65 | }, 66 | "devDependencies": { 67 | "@types/node": "^22.13.16", 68 | "@types/node-fetch": "^2.6.13", 69 | "tsx": "^4.19.3", 70 | "typescript": "^5.8.2" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/lib/src/utils/buffer.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test'; 2 | import { strict as assert } from 'node:assert'; 3 | import { calculatePaddingForBlockSize, pad, trimPadding } from './buffer.js'; 4 | 5 | test('calculatePaddingForBlockSize should calculate correct padding', () => { 6 | const data = Buffer.from('12345'); 7 | const blockSize = 8; 8 | const padding = calculatePaddingForBlockSize(data, blockSize); 9 | assert.strictEqual(padding, 3); 10 | }); 11 | 12 | test('calculatePaddingForBlockSize should return blockSize when data length is a multiple of blockSize', () => { 13 | const data = Buffer.from('12345678'); 14 | const blockSize = 8; 15 | const padding = calculatePaddingForBlockSize(data, blockSize); 16 | assert.strictEqual(padding, 8); 17 | }); 18 | 19 | test('pad should append the correct padding to the buffer', () => { 20 | const data = Buffer.from('12345'); 21 | const padded = pad(data, 3, 0); 22 | assert.strictEqual(padded.toString(), '12345\0\0\0'); 23 | }); 24 | 25 | test('pad should handle custom fill values', () => { 26 | const data = Buffer.from('12345'); 27 | const padded = pad(data, 3, 65); // ASCII for 'A' 28 | assert.strictEqual(padded.toString(), '12345AAA'); 29 | }); 30 | 31 | test('trimPadding should remove the correct padding from the buffer', () => { 32 | const data = Buffer.from('12345\0\0\0'); 33 | const trimmed = trimPadding(data, 0); 34 | assert.strictEqual(trimmed.toString(), '12345'); 35 | }); 36 | 37 | test('trimPadding should handle buffers with no padding', () => { 38 | const data = Buffer.from('12345'); 39 | const trimmed = trimPadding(data, 0); 40 | assert.strictEqual(trimmed.toString(), '12345'); 41 | }); 42 | 43 | test('trimPadding should handle empty buffers', () => { 44 | const data = Buffer.from(''); 45 | const trimmed = trimPadding(data, 0); 46 | assert.strictEqual(trimmed.toString(), ''); 47 | }); 48 | 49 | test('trimPadding should handle custom fill values', () => { 50 | const data = Buffer.from('12345AAA'); 51 | const trimmed = trimPadding(data, 65); // ASCII for 'A' 52 | assert.strictEqual(trimmed.toString(), '12345'); 53 | }); 54 | -------------------------------------------------------------------------------- /packages/lib/src/deviceManager.ts: -------------------------------------------------------------------------------- 1 | import type { UUID, Device } from './device.js'; 2 | import { type Transport } from './transport/transport.js'; 3 | import { Namespace } from './message/header.js'; 4 | import { Message } from './message/message.js'; 5 | 6 | export type DeviceManagerOptions = { 7 | transport: Transport; 8 | }; 9 | 10 | export class DeviceManager { 11 | private transport: Transport; 12 | private devices: Map = new Map(); 13 | 14 | constructor(options: DeviceManagerOptions) { 15 | this.transport = options.transport; 16 | } 17 | 18 | addDevice(device: Device): void { 19 | this.devices.set(device.id as UUID, device); 20 | } 21 | 22 | removeDevice(device: Device): void { 23 | this.devices.delete(device.id as UUID); 24 | } 25 | 26 | removeDeviceById(deviceId: string): void { 27 | this.devices.delete(deviceId as UUID); 28 | } 29 | 30 | getDevices(): Map { 31 | return this.devices; 32 | } 33 | 34 | getDeviceById(deviceId: string): Device | undefined { 35 | return this.devices.get(deviceId as UUID); 36 | } 37 | 38 | async sendMessageToDevice( 39 | deviceOrId: UUID | Device, 40 | message: Message 41 | ): Promise> { 42 | let device = deviceOrId as Device; 43 | if (typeof deviceOrId === 'string') { 44 | device = this.getDeviceById(deviceOrId) as Device; 45 | if (!device) { 46 | throw new Error(`Device with ID ${deviceOrId} not found`); 47 | } 48 | } 49 | 50 | const shouldEncrypt = this.shouldEncryptMessage(device, message); 51 | 52 | return this.transport.send({ 53 | message, 54 | encryptionKey: shouldEncrypt 55 | ? device.encryptionKeys?.sharedKey 56 | : undefined, 57 | }); 58 | } 59 | 60 | private shouldEncryptMessage(device: Device, message: Message): boolean { 61 | const hasAbility = device.hasAbility(Namespace.ENCRYPT_ECDHE); 62 | const excludedNamespaces = [ 63 | Namespace.SYSTEM_ALL, 64 | Namespace.SYSTEM_FIRMWARE, 65 | Namespace.SYSTEM_ABILITY, 66 | Namespace.ENCRYPT_ECDHE, 67 | Namespace.ENCRYPT_SUITE, 68 | ]; 69 | return hasAbility && !excludedNamespaces.includes(message.header.namespace); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/lib/src/transport/transport.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '../message/message.js'; 2 | import { ResponseMethodLookup } from '../message/header.js'; 3 | import { generateTimestamp, randomId } from '../utils/index.js'; 4 | import { CloudCredentials } from '../cloudCredentials.js'; 5 | import logger from '../utils/logger.js'; 6 | 7 | const transportLogger = logger.child({ 8 | name: 'transport', 9 | }); 10 | 11 | export const DEFAULT_TIMEOUT = 10_000; 12 | 13 | export type TransportOptions = { 14 | timeout?: number; 15 | credentials?: CloudCredentials; 16 | }; 17 | 18 | export type MessageSendOptions = { 19 | message: Message; 20 | encryptionKey?: Buffer; 21 | }; 22 | 23 | export class TransportSendOptions { 24 | message: Record = {}; 25 | encryptionKey?: Buffer; 26 | } 27 | 28 | export abstract class Transport { 29 | id: string = `transport/${randomId()}`; 30 | timeout; 31 | 32 | credentials: CloudCredentials | undefined; 33 | 34 | constructor(options: TransportOptions = {}) { 35 | this.timeout = options.timeout || DEFAULT_TIMEOUT; 36 | this.credentials = options.credentials; 37 | 38 | transportLogger.debug( 39 | `Transport initialized. Credentials: ${JSON.stringify(this.credentials)}`, 40 | ); 41 | } 42 | 43 | async send(options: MessageSendOptions) { 44 | const { message, encryptionKey } = options; 45 | 46 | if (!message) { 47 | throw new Error('Message is required'); 48 | } 49 | 50 | message.header.from = this.id; 51 | 52 | if (!message.header.messageId) { 53 | message.header.messageId = randomId(); 54 | } 55 | 56 | if (!message.header.timestamp) { 57 | message.header.timestamp = generateTimestamp(); 58 | } 59 | 60 | logger.debug(`Signing message ${message.header.messageId}`); 61 | 62 | message.sign(this.credentials?.key); 63 | 64 | const response = await this._send({ 65 | message, 66 | encryptionKey, 67 | }); 68 | const { header } = response; 69 | 70 | const expectedResponseMethod = ResponseMethodLookup[message.header.method]; 71 | if (header.method !== expectedResponseMethod) { 72 | throw new Error(`Response was not ${expectedResponseMethod}`); 73 | } 74 | 75 | return response; 76 | } 77 | 78 | protected abstract _send(options: TransportSendOptions): Promise; 79 | } 80 | -------------------------------------------------------------------------------- /packages/lib/src/utils/computeDevicePassword.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test'; 2 | import { strict as assert } from 'node:assert'; 3 | import { computeDevicePassword } from './computeDevicePassword.js'; 4 | 5 | test('computeDevicePassword should generate a consistent password for the same inputs', () => { 6 | const macAddress = '00:1A:2B:3C:4D:5E'; 7 | const key = 'secretKey'; 8 | const userId = 123; 9 | 10 | const password1 = computeDevicePassword(macAddress, key, userId); 11 | const password2 = computeDevicePassword(macAddress, key, userId); 12 | 13 | assert.strictEqual(password1, password2); 14 | }); 15 | 16 | test('computeDevicePassword should generate different passwords for different MAC addresses', () => { 17 | const macAddress1 = '00:1A:2B:3C:4D:5E'; 18 | const macAddress2 = '11:22:33:44:55:66'; 19 | const key = 'secretKey'; 20 | const userId = 123; 21 | 22 | const password1 = computeDevicePassword(macAddress1, key, userId); 23 | const password2 = computeDevicePassword(macAddress2, key, userId); 24 | 25 | assert.notStrictEqual(password1, password2); 26 | }); 27 | 28 | test('computeDevicePassword should generate different passwords for different keys', () => { 29 | const macAddress = '00:1A:2B:3C:4D:5E'; 30 | const key1 = 'secretKey1'; 31 | const key2 = 'secretKey2'; 32 | const userId = 123; 33 | 34 | const password1 = computeDevicePassword(macAddress, key1, userId); 35 | const password2 = computeDevicePassword(macAddress, key2, userId); 36 | 37 | assert.notStrictEqual(password1, password2); 38 | }); 39 | 40 | test('computeDevicePassword should generate different passwords for different userIds', () => { 41 | const macAddress = '00:1A:2B:3C:4D:5E'; 42 | const key = 'secretKey'; 43 | const userId1 = 123; 44 | const userId2 = 456; 45 | 46 | const password1 = computeDevicePassword(macAddress, key, userId1); 47 | const password2 = computeDevicePassword(macAddress, key, userId2); 48 | 49 | assert.notStrictEqual(password1, password2); 50 | }); 51 | 52 | test('computeDevicePassword should handle default values for key and userId', () => { 53 | const macAddress = '00:1A:2B:3C:4D:5E'; 54 | 55 | const password = computeDevicePassword(macAddress); 56 | 57 | assert.ok(password); 58 | assert.match(password, /^0_[a-f0-9]{32}$/); // Default userId is 0, and MD5 hash is 32 hex characters 59 | }); 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Meross utilities 2 | 3 | [![Node.js Package](https://github.com/bytespider/Meross/actions/workflows/npm-ghr-publish.yml/badge.svg)](https://github.com/bytespider/Meross/actions/workflows/npm-ghr-publish.yml) 4 | 5 | Tools to help configure the Meross devices to use private MQTT servers. 6 | 7 | ## Requirements 8 | 9 | NodeJS: ^21.0.0, ^20.10.0, ^18.20.0 10 | NPM: ^10.0.0 11 | 12 | ## Setup 13 | 14 | TODO: 15 | [Devices with WIFI pairing]() 16 | 17 | [Devices with Bluetooth pairing]() 18 | 19 | ## Tools 20 | 21 | ### Info 22 | 23 | ``` 24 | npx meross-info [options] 25 | 26 | Options: 27 | -V, --version output the version number 28 | -a, --ip Send command to device with this IP address (default: "10.10.10.1") 29 | -u, --user Integer id. Used by devices connected to the Meross Cloud 30 | -k, --key Shared key for generating signatures (default: "") 31 | --include-wifi List WIFI Access Points near the device 32 | --include-ability List device ability list 33 | --include-time List device time 34 | -v, --verbose Show debugging messages 35 | -h, --help display help for command 36 | ``` 37 | 38 | ### Setup 39 | 40 | ``` 41 | npx meross-setup [options] 42 | 43 | Options: 44 | -V, --version output the version number 45 | -a, --ip Send command to device with this IP address (default: "10.10.10.1") 46 | --wifi-ssid WIFI Access Point name 47 | --wifi-pass WIFI Access Point password 48 | --wifi-encryption WIFI Access Point encryption (this can be found using meross info --include-wifi) 49 | --wifi-cipher WIFI Access Point cipher (this can be found using meross info --include-wifi) 50 | --wifi-bssid WIFI Access Point BSSID (each octet separated by a colon `:`) 51 | --wifi-channel WIFI Access Point 2.5GHz channel number [1-13] (this can be found using meross info --include-wifi) 52 | --mqtt MQTT server address 53 | -u, --user Integer id. Used by devices connected to the Meross Cloud (default: 0) 54 | -k, --key Shared key for generating signatures (default: "") 55 | -t, --set-time Configure device time with time and timezone of current host 56 | -v, --verbose Show debugging messages (default: "") 57 | -h, --help display help for command 58 | ``` 59 | -------------------------------------------------------------------------------- /packages/lib/src/wifi.ts: -------------------------------------------------------------------------------- 1 | import type { DeviceHardware } from './device.js'; 2 | import Encryption from './encryption.js'; 3 | import md5 from './utils/md5.js'; 4 | 5 | export enum WifiCipher { 6 | NONE, 7 | WEP, 8 | TKIP, 9 | AES, 10 | TKIPAES, 11 | } 12 | 13 | export enum WifiEncryption { 14 | OPEN, 15 | SHARE, 16 | WEPAUTO, 17 | WPA1, 18 | WPA1PSK, 19 | WPA2, 20 | WPA2PSK, 21 | WPA1WPA2, 22 | WPA1PSKWPA2PSK, 23 | } 24 | 25 | type EncryptPasswordOptions = { 26 | password: string; 27 | hardware: DeviceHardware & { 28 | type: string; 29 | }; 30 | }; 31 | 32 | export async function encryptPassword( 33 | options: EncryptPasswordOptions 34 | ): Promise { 35 | const { password, hardware } = options; 36 | const { type, uuid, macAddress } = hardware; 37 | if (!password) { 38 | throw new Error('Password is required'); 39 | } 40 | if (!type || !uuid || !macAddress) { 41 | throw new Error('Hardware information is required'); 42 | } 43 | 44 | const key = Buffer.from(md5(`${type}${uuid}${macAddress}`, 'hex'), 'utf-8'); 45 | const data = Buffer.from(password, 'utf-8'); 46 | 47 | return Encryption.encrypt(data, key); 48 | } 49 | 50 | export type WifiAccessPointOptions = { 51 | ssid?: string; 52 | bssid?: string; 53 | channel?: number; 54 | cipher?: WifiCipher; 55 | encryption?: WifiEncryption; 56 | password?: string; 57 | signal?: number; 58 | }; 59 | 60 | export class WifiAccessPoint { 61 | ssid; 62 | bssid; 63 | channel; 64 | cipher; 65 | encryption; 66 | password; 67 | signal; 68 | 69 | constructor(options: WifiAccessPointOptions = {}) { 70 | const { ssid, bssid, channel, cipher, encryption, password, signal } = 71 | options; 72 | 73 | if (ssid?.length > 32) { 74 | throw new Error('SSID length exceeds 32 characters'); 75 | } 76 | 77 | if (bssid?.length > 17) { 78 | throw new Error('BSSID length exceeds 17 characters'); 79 | } 80 | 81 | if (password?.length > 64) { 82 | throw new Error('Password length exceeds 64 characters'); 83 | } 84 | 85 | this.ssid = ssid; 86 | this.bssid = bssid; 87 | this.channel = channel; 88 | this.cipher = cipher; 89 | this.encryption = encryption; 90 | this.password = password; 91 | this.signal = signal; 92 | } 93 | 94 | isOpen() { 95 | return ( 96 | this.encryption == WifiEncryption.OPEN && this.cipher == WifiCipher.NONE 97 | ); 98 | } 99 | 100 | isWEP() { 101 | return ( 102 | this.encryption == WifiEncryption.OPEN && this.cipher == WifiCipher.WEP 103 | ); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /packages/lib/src/utils/computePresharedKey.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test'; 2 | import { strict as assert } from 'node:assert'; 3 | import computePresharedPrivateKey from './computePresharedPrivateKey.js'; 4 | import { MacAddress, UUID } from '../device.js'; 5 | 6 | test('computePresharedPrivateKey should return a valid base64 encoded string', () => { 7 | const uuid: UUID = '123e4567-e89b-12d3-a456-426614174000'; 8 | const key = 'sharedsecretkey1234567890'; 9 | const macAddress: MacAddress = '00:11:22:33:44:55'; 10 | 11 | const result = computePresharedPrivateKey(uuid, key, macAddress); 12 | 13 | assert.strictEqual(typeof result, 'string'); 14 | assert.doesNotThrow(() => Buffer.from(result, 'base64')); 15 | }); 16 | 17 | test('computePresharedPrivateKey should produce consistent output for the same inputs', () => { 18 | const uuid: UUID = '123e4567e89b12d3a456426614174000'; 19 | const key = 'sharedsecretkey1234567890'; 20 | const macAddress: MacAddress = '00:11:22:33:44:55'; 21 | 22 | const result1 = computePresharedPrivateKey(uuid, key, macAddress); 23 | const result2 = computePresharedPrivateKey(uuid, key, macAddress); 24 | 25 | assert.strictEqual(result1, result2); 26 | }); 27 | 28 | test('computePresharedPrivateKey should produce different outputs for different UUIDs', () => { 29 | const key = 'sharedsecretkey1234567890'; 30 | const macAddress: MacAddress = '00:11:22:33:44:55'; 31 | 32 | const result1 = computePresharedPrivateKey( 33 | '123e4567e89b12d3a456426614174000' as UUID, 34 | key, 35 | macAddress, 36 | ); 37 | const result2 = computePresharedPrivateKey( 38 | '8ebdc941ae7b4bd99662b838af884822' as UUID, 39 | key, 40 | macAddress, 41 | ); 42 | 43 | assert.notStrictEqual(result1, result2); 44 | }); 45 | 46 | test('computePresharedPrivateKey should produce different outputs for different keys', () => { 47 | const uuid: UUID = '123e4567e89b12d3a456426614174000'; 48 | const macAddress: MacAddress = '00:11:22:33:44:55'; 49 | 50 | const result1 = computePresharedPrivateKey(uuid, 'key1', macAddress); 51 | const result2 = computePresharedPrivateKey(uuid, 'key2', macAddress); 52 | 53 | assert.notStrictEqual(result1, result2); 54 | }); 55 | 56 | test('computePresharedPrivateKey should produce different outputs for different MAC addresses', () => { 57 | const uuid: UUID = '123e4567e89b12d3a456426614174000'; 58 | const key = 'sharedsecretkey1234567890'; 59 | 60 | const result1 = computePresharedPrivateKey( 61 | uuid, 62 | key, 63 | '00:11:22:33:44:55' as MacAddress, 64 | ); 65 | const result2 = computePresharedPrivateKey( 66 | uuid, 67 | key, 68 | '66:77:88:99:AA:BB' as MacAddress, 69 | ); 70 | 71 | assert.notStrictEqual(result1, result2); 72 | }); 73 | -------------------------------------------------------------------------------- /packages/lib/src/transport/http.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test'; 2 | import { strict as assert } from 'node:assert'; 3 | import { Response, RequestInfo, RequestInit, Headers } from 'node-fetch'; 4 | import { HTTPTransport } from './http.js'; 5 | 6 | test('HTTPTransport should send a message without encryption', async () => { 7 | const fetch = async ( 8 | input: RequestInfo | URL, 9 | init?: RequestInit, 10 | ): Promise => { 11 | const url = input.toString(); 12 | const method = init?.method || 'GET'; 13 | const headers = new Headers(init?.headers); 14 | const body = init?.body as string; 15 | 16 | assert.strictEqual(url, 'https://example.com'); 17 | assert.strictEqual(method, 'POST'); 18 | assert.strictEqual( 19 | headers.get('Content-Type'), 20 | 'application/json; charset=utf-8', 21 | ); 22 | assert.strictEqual(headers.get('Accept'), 'application/json'); 23 | assert.strictEqual(body, JSON.stringify({ test: 'message' })); 24 | return new Response(JSON.stringify({ success: true }), { 25 | status: 200, 26 | headers: { 'Content-Type': 'application/json' }, 27 | }); 28 | }; 29 | 30 | const transport = new HTTPTransport({ url: 'https://example.com', fetch }); 31 | const response = await transport['_send']({ 32 | message: { 33 | test: 'message', 34 | }, 35 | }); 36 | assert.deepStrictEqual(response, { success: true }); 37 | }); 38 | 39 | test('HTTPTransport should handle an HTTP error response', async () => { 40 | const fetch = async () => 41 | new Response(null, { 42 | status: 500, 43 | statusText: 'Internal Server Error', 44 | }); 45 | 46 | const transport = new HTTPTransport({ url: 'https://example.com', fetch }); 47 | await assert.rejects( 48 | async () => { 49 | await transport['_send']({ message: { test: 'message' } }); 50 | }, 51 | { message: 'HTTP error! status: 500' }, 52 | ); 53 | }); 54 | 55 | test('HTTPTransport should handle an empty response body', async () => { 56 | const fetch = async () => 57 | new Response(null, { 58 | status: 200, 59 | headers: { 'Content-Type': 'application/json' }, 60 | }); 61 | 62 | const transport = new HTTPTransport({ url: 'https://example.com', fetch }); 63 | await assert.rejects( 64 | async () => { 65 | await transport['_send']({ message: { test: 'message' } }); 66 | }, 67 | { message: 'Empty response body' }, 68 | ); 69 | }); 70 | 71 | test('HTTPTransport should throw an error for server error messages', async () => { 72 | const fetch = async () => 73 | new Response(JSON.stringify({ error: 'Server error' }), { 74 | status: 200, 75 | headers: { 'Content-Type': 'application/json' }, 76 | }); 77 | 78 | const transport = new HTTPTransport({ url: 'https://example.com', fetch }); 79 | await assert.rejects( 80 | async () => { 81 | await transport['_send']({ 82 | message: { test: 'message' }, 83 | }); 84 | }, 85 | { message: 'Error from server: Server error' }, 86 | ); 87 | }); 88 | -------------------------------------------------------------------------------- /packages/lib/src/wifi.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test'; 2 | import { strict as assert } from 'node:assert'; 3 | import { 4 | WifiAccessPoint, 5 | WifiCipher, 6 | WifiEncryption, 7 | encryptPassword, 8 | } from './wifi.js'; 9 | import { MacAddress, UUID } from './device.js'; 10 | 11 | test('WifiAccessPoint should throw an error for invalid SSID length', () => { 12 | assert.throws(() => { 13 | new WifiAccessPoint({ ssid: 'a'.repeat(33) }); 14 | }, /SSID length exceeds 32 characters/); 15 | }); 16 | 17 | test('WifiAccessPoint should throw an error for invalid BSSID length', () => { 18 | assert.throws(() => { 19 | new WifiAccessPoint({ bssid: 'a'.repeat(18) }); 20 | }, /BSSID length exceeds 17 characters/); 21 | }); 22 | 23 | test('WifiAccessPoint should throw an error for invalid password length', () => { 24 | assert.throws(() => { 25 | new WifiAccessPoint({ password: 'a'.repeat(65) }); 26 | }, /Password length exceeds 64 characters/); 27 | }); 28 | 29 | test('WifiAccessPoint isOpen should return true for open networks', () => { 30 | const accessPoint = new WifiAccessPoint({ 31 | encryption: WifiEncryption.OPEN, 32 | cipher: WifiCipher.NONE, 33 | }); 34 | 35 | assert.strictEqual(accessPoint.isOpen(), true); 36 | }); 37 | 38 | test('WifiAccessPoint isOpen should return false for non-open networks', () => { 39 | const accessPoint = new WifiAccessPoint({ 40 | encryption: WifiEncryption.WPA2, 41 | cipher: WifiCipher.AES, 42 | }); 43 | 44 | assert.strictEqual(accessPoint.isOpen(), false); 45 | }); 46 | 47 | test('WifiAccessPoint isWEP should return true for WEP networks', () => { 48 | const accessPoint = new WifiAccessPoint({ 49 | encryption: WifiEncryption.OPEN, 50 | cipher: WifiCipher.WEP, 51 | }); 52 | 53 | assert.strictEqual(accessPoint.isWEP(), true); 54 | }); 55 | 56 | test('WifiAccessPoint isWEP should return false for non-WEP networks', () => { 57 | const accessPoint = new WifiAccessPoint({ 58 | encryption: WifiEncryption.WPA2, 59 | cipher: WifiCipher.AES, 60 | }); 61 | 62 | assert.strictEqual(accessPoint.isWEP(), false); 63 | }); 64 | 65 | test('encryptPassword should throw an error if password is missing', async () => { 66 | await assert.rejects(async () => { 67 | await encryptPassword({ 68 | password: '', 69 | hardware: { 70 | type: 'router', 71 | uuid: '1234', 72 | macAddress: '00:11:22:33:44:55', 73 | }, 74 | }); 75 | }, /Password is required/); 76 | }); 77 | 78 | test('encryptPassword should throw an error if hardware information is missing', async () => { 79 | await assert.rejects(async () => { 80 | await encryptPassword({ 81 | password: 'password123', 82 | hardware: { type: '', uuid: '' as UUID, macAddress: '' as MacAddress }, 83 | }); 84 | }, /Hardware information is required/); 85 | }); 86 | 87 | test('encryptPassword should return encrypted data', async () => { 88 | const encryptedData = await encryptPassword({ 89 | password: 'password123', 90 | hardware: { 91 | type: 'router', 92 | uuid: '1234' as UUID, 93 | macAddress: '00:11:22:33:44:55' as MacAddress, 94 | }, 95 | }); 96 | 97 | assert.ok(encryptedData instanceof Buffer); 98 | assert.notStrictEqual(encryptedData.toString('utf-8'), 'password123'); 99 | }); 100 | -------------------------------------------------------------------------------- /packages/lib/src/transport/http.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import { Request } from 'node-fetch'; 3 | import Encryption from '../encryption.js'; 4 | import { 5 | type TransportOptions, 6 | Transport, 7 | TransportSendOptions, 8 | } from './transport.js'; 9 | import base64 from '../utils/base64.js'; 10 | import logger from '../utils/logger.js'; 11 | 12 | export type HTTPTransportOptions = TransportOptions & { 13 | url: string; 14 | fetch?: typeof fetch; 15 | }; 16 | 17 | const httpLogger = logger.child({ 18 | name: 'http', 19 | }); 20 | 21 | export class HTTPTransport extends Transport { 22 | private url: string; 23 | private fetch: typeof fetch; 24 | 25 | constructor(options: HTTPTransportOptions) { 26 | super(options); 27 | this.url = options.url; 28 | this.id = `${this.url}`; 29 | this.fetch = options.fetch ?? fetch; 30 | 31 | httpLogger.debug(`HTTPTransport initialized with URL: ${this.url}`); 32 | } 33 | 34 | protected async _send( 35 | options: TransportSendOptions, 36 | ): Promise> { 37 | const { message, encryptionKey } = options; 38 | 39 | const requestLogger = logger.child({ 40 | name: 'request', 41 | requestId: message.header?.messageId, 42 | }); 43 | 44 | let body = JSON.stringify(message); 45 | 46 | let requestInit = { 47 | method: 'POST', 48 | headers: { 49 | 'Content-Type': 'application/json; charset=utf-8', 50 | Accept: 'application/json', 51 | }, 52 | body, 53 | }; 54 | 55 | // Encrypt the message if encryptionKey is provided 56 | if (encryptionKey) { 57 | const data = Buffer.from(body, 'utf-8'); 58 | 59 | const encryptedData = await Encryption.encrypt(data, encryptionKey); 60 | body = await base64.encode(encryptedData); 61 | 62 | requestInit = { 63 | method: 'POST', 64 | headers: { 65 | 'Content-Type': 'text/plain; charset=utf-8', 66 | Accept: 'text/plain', 67 | }, 68 | body, 69 | }; 70 | } 71 | 72 | requestLogger.http( 73 | `${requestInit.method} ${this.url} ${JSON.stringify( 74 | requestInit.headers, 75 | )} ${requestInit.body}`, 76 | { 77 | request: requestInit, 78 | }, 79 | ); 80 | 81 | const response = await this.fetch(this.url, requestInit); 82 | 83 | requestLogger.http( 84 | `${response.status} ${response.statusText} ${JSON.stringify( 85 | response.headers, 86 | )} ${await response.clone().text()}`, 87 | { 88 | response, 89 | }, 90 | ); 91 | 92 | if (!response.ok) { 93 | throw new Error(`HTTP error! status: ${response.status}`); 94 | } 95 | 96 | let responseBody: string | undefined; 97 | 98 | // Decrypt the response if encryptionKey is provided 99 | if (encryptionKey) { 100 | responseBody = await response.text(); 101 | const data = base64.decode(responseBody); 102 | const decryptedData = await Encryption.decrypt(data, encryptionKey); 103 | responseBody = decryptedData.toString('utf-8'); 104 | } else { 105 | responseBody = await response.text(); 106 | } 107 | 108 | if (!responseBody) { 109 | throw new Error('Empty response body'); 110 | } 111 | 112 | const responseMessage = JSON.parse(responseBody); 113 | if (responseMessage.error) { 114 | throw new Error(`Error from server: ${responseMessage.error}`); 115 | } 116 | return responseMessage; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /packages/lib/src/transport/transport.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test'; 2 | import * as assert from 'node:assert'; 3 | import { Transport, MessageSendOptions } from './transport.js'; 4 | import { Message } from '../message/message.js'; 5 | import { ResponseMethod } from '../message/header.js'; 6 | 7 | class MockTransport extends Transport { 8 | async _send(options: any) { 9 | const { message } = options; 10 | return { 11 | header: { 12 | method: ResponseMethod[message.header.method], 13 | }, 14 | }; 15 | } 16 | } 17 | 18 | test('Transport should initialize with default timeout', () => { 19 | const transport = new MockTransport(); 20 | assert.strictEqual(transport.timeout, 10000); 21 | }); 22 | 23 | test('Transport should initialize with custom timeout', () => { 24 | const transport = new MockTransport({ timeout: 5000 }); 25 | assert.strictEqual(transport.timeout, 5000); 26 | }); 27 | 28 | test('Transport should throw error if message is not provided', async () => { 29 | const transport = new MockTransport(); 30 | const options: MessageSendOptions = { 31 | message: null as unknown as Message, 32 | }; 33 | 34 | await assert.rejects(async () => transport.send(options), { 35 | message: 'Message is required', 36 | }); 37 | }); 38 | 39 | test('Transport should set default messageId and timestamp if not provided', async () => { 40 | const transport = new MockTransport(); 41 | const message = new Message(); 42 | message.header.method = 'SomeMethod'; 43 | 44 | assert.ok(message.header.messageId); 45 | assert.ok(message.header.timestamp); 46 | }); 47 | 48 | test('Transport should use provided messageId and timestamp if available', async () => { 49 | const transport = new MockTransport(); 50 | const message = new Message(); 51 | message.header.method = 'SomeMethod'; 52 | message.header.messageId = 'custom-id'; 53 | message.header.timestamp = 'custom-timestamp'; 54 | 55 | await transport.send({ message }); 56 | 57 | assert.strictEqual(message.header.messageId, 'custom-id'); 58 | assert.strictEqual(message.header.timestamp, 'custom-timestamp'); 59 | }); 60 | 61 | test('Transport should set the "from" field in the message header', async () => { 62 | const transport = new MockTransport(); 63 | transport.id = 'transport-id'; 64 | const message = new Message(); 65 | message.header.method = 'SomeMethod'; 66 | 67 | await transport.send({ message }); 68 | 69 | assert.strictEqual(message.header.from, 'transport-id'); 70 | }); 71 | 72 | test('Transport should throw error if response method does not match expected method', async () => { 73 | class InvalidResponseTransport extends Transport { 74 | async _send(options: any) { 75 | return { 76 | header: { 77 | method: 'InvalidMethod', 78 | }, 79 | }; 80 | } 81 | } 82 | 83 | const transport = new InvalidResponseTransport(); 84 | const message = new Message(); 85 | message.header.method = 'SomeMethod'; 86 | 87 | await assert.rejects(async () => transport.send({ message }), { 88 | message: 'Response was not undefined', 89 | }); 90 | }); 91 | 92 | test('Transport should return the response if everything is valid', async () => { 93 | const transport = new MockTransport(); 94 | const message = new Message(); 95 | message.header.method = 'SomeMethod'; 96 | 97 | const response = await transport.send({ message }); 98 | 99 | assert.ok(response); 100 | assert.strictEqual( 101 | response.header.method, 102 | ResponseMethod[message.header.method] 103 | ); 104 | }); 105 | -------------------------------------------------------------------------------- /packages/lib/src/encryption.ts: -------------------------------------------------------------------------------- 1 | import { createCipheriv, createDecipheriv, createECDH } from 'node:crypto'; 2 | import { Buffer } from 'node:buffer'; 3 | import { 4 | calculatePaddingForBlockSize, 5 | pad, 6 | trimPadding, 7 | } from './utils/buffer.js'; 8 | import logger from './utils/logger.js'; 9 | 10 | const encryptionLogger = logger.child({ 11 | name: 'encryption', 12 | }); 13 | 14 | export const DEFAULT_IV = Buffer.from('0000000000000000', 'utf-8'); 15 | 16 | export type EncryptionKeyPair = { 17 | privateKey: Buffer; 18 | publicKey: Buffer; 19 | }; 20 | 21 | export async function encrypt( 22 | data: Buffer, 23 | encryptionKey: Buffer, 24 | iv: Buffer = DEFAULT_IV 25 | ): Promise { 26 | encryptionLogger.debug( 27 | `Encrypting: data: ${data.toString('utf-8')}, key: ${encryptionKey.toString( 28 | 'base64' 29 | )}, iv: ${iv.toString('base64')}` 30 | ); 31 | 32 | const cipher = createCipheriv('aes-256-cbc', encryptionKey, iv); 33 | 34 | // Disable auto padding to handle custom padding 35 | cipher.setAutoPadding(false); 36 | 37 | // Ensure the data length is a multiple of 16 by padding with null characters. 38 | const length = calculatePaddingForBlockSize(data, 16); 39 | const paddedData = pad(data, length, 0x0); 40 | 41 | // Encrypt the data 42 | return Buffer.concat([cipher.update(paddedData), cipher.final()]); 43 | } 44 | 45 | export async function decrypt( 46 | data: Buffer, 47 | encryptionKey: Buffer, 48 | iv: Buffer = DEFAULT_IV 49 | ): Promise { 50 | encryptionLogger.debug( 51 | `Decrypting: data: ${data.toString( 52 | 'base64' 53 | )}, key: ${encryptionKey.toString('base64')}, iv: ${iv.toString('base64')}` 54 | ); 55 | const decipher = createDecipheriv('aes-256-cbc', encryptionKey, iv); 56 | 57 | // Disable auto padding to handle custom padding 58 | decipher.setAutoPadding(false); 59 | 60 | // Decrypt the data 61 | const decryptedData = Buffer.concat([ 62 | decipher.update(data), 63 | decipher.final(), 64 | ]); 65 | 66 | // Remove padding 67 | const trimmedData = trimPadding(decryptedData, 0x0); 68 | encryptionLogger.debug(`Decrypted data: ${trimmedData.toString('utf-8')}`); 69 | 70 | return trimmedData; 71 | } 72 | 73 | export async function createKeyPair( 74 | privateKey: Buffer 75 | ): Promise { 76 | const ecdh = createECDH('prime256v1'); 77 | ecdh.setPrivateKey(privateKey); 78 | 79 | const publicKey = ecdh.getPublicKey(); 80 | 81 | encryptionLogger.debug(`Created key pair`, { publicKey }); 82 | 83 | return { 84 | privateKey, 85 | publicKey, 86 | }; 87 | } 88 | 89 | export async function generateKeyPair(): Promise { 90 | const ecdh = createECDH('prime256v1'); 91 | ecdh.generateKeys(); 92 | 93 | const publicKey = ecdh.getPublicKey(); 94 | const privateKey = ecdh.getPrivateKey(); 95 | 96 | encryptionLogger.debug(`Generated key pair`, { publicKey, privateKey }); 97 | 98 | return { 99 | privateKey, 100 | publicKey, 101 | }; 102 | } 103 | 104 | export async function deriveSharedKey( 105 | privateKey: Buffer, 106 | publicKey: Buffer 107 | ): Promise { 108 | const ecdh = createECDH('prime256v1'); 109 | ecdh.setPrivateKey(privateKey); 110 | 111 | const sharedKey = ecdh.computeSecret(publicKey); 112 | 113 | encryptionLogger.debug(`Derived shared key: ${sharedKey.toString('base64')}`); 114 | 115 | return sharedKey; 116 | } 117 | 118 | export default { 119 | encrypt, 120 | decrypt, 121 | generateKeyPair, 122 | deriveSharedKey, 123 | DEFAULT_IV, 124 | }; 125 | -------------------------------------------------------------------------------- /packages/cli/src/meross-info.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | import pkg from '../package.json' with { type: 'json' }; 6 | import { program } from 'commander'; 7 | import TerminalKit from 'terminal-kit'; 8 | const { terminal } = TerminalKit; 9 | 10 | import { printDeviceTable, printWifiListTable, progressFunctionWithMessage } from './cli.js'; 11 | 12 | import { HTTPTransport, Device, computeDevicePassword, Namespace, computePresharedPrivateKey, generateKeyPair } from '@meross/lib'; 13 | 14 | type Options = { 15 | ip: string; 16 | user: number; 17 | key: string; 18 | privateKey: string | boolean; 19 | withWifi: boolean; 20 | withAbility: boolean; 21 | includeTime: boolean; 22 | quiet: boolean; 23 | }; 24 | 25 | program 26 | .version(pkg.version) 27 | .arguments('[options]') 28 | .option( 29 | '-a, --ip ', 30 | 'Send command to device with this IP address', 31 | '10.10.10.1' 32 | ) 33 | .option( 34 | '-u, --user ', 35 | 'Integer id. Used by devices connected to the Meross Cloud', 36 | parseInt, 37 | 0 38 | ) 39 | .option( 40 | '-k, --key ', 41 | 'Shared key for generating signatures', 42 | 'meross' 43 | ) 44 | .option('--private-key [private-key]', `Private key for ECDH key exchange. If not provided a new one will be generated`) 45 | .option('--with-wifi', 'List WIFI Access Points near the device') 46 | .option('--with-ability', 'List device ability list') 47 | .option('-q, --quiet', 'Suppress all output', false) 48 | .parse(process.argv); 49 | 50 | const options = program.opts(); 51 | 52 | const { ip, user: userId, key } = options; 53 | const { quiet } = options; 54 | 55 | try { 56 | const transport = new HTTPTransport({ url: `http://${ip}/config`, credentials: { userId, key } }); 57 | const device = new Device(); 58 | 59 | device.setTransport(transport); 60 | 61 | const deviceInformation = await device.fetchDeviceInfo(); 62 | 63 | const devicePassword = computeDevicePassword( 64 | deviceInformation.system.hardware.macAddress, 65 | key, 66 | deviceInformation.system.firmware.userId 67 | ); 68 | 69 | const { withAbility = false } = options; 70 | let deviceAbility = await device.fetchDeviceAbilities(); 71 | if (!quiet) { 72 | await printDeviceTable(deviceInformation, withAbility ? deviceAbility : undefined, devicePassword); 73 | } 74 | 75 | // check if we neet to exchange public keys 76 | if (device.hasAbility(Namespace.ENCRYPT_ECDHE) && !device.encryptionKeys.sharedKey) { 77 | let { privateKey } = options; 78 | 79 | if (privateKey === true) { 80 | const { privateKey: generatedPrivateKey } = await generateKeyPair(); 81 | privateKey = generatedPrivateKey.toString('base64'); 82 | } 83 | 84 | if (!privateKey) { 85 | // use precomputed private key 86 | privateKey = computePresharedPrivateKey( 87 | device.id, 88 | key, 89 | device.hardware.macAddress 90 | ); 91 | } 92 | 93 | await device.setPrivateKey(Buffer.from(privateKey, 'base64')); 94 | 95 | const exchangeKeys = () => device.exchangeKeys(); 96 | await (quiet ? exchangeKeys() : progressFunctionWithMessage(exchangeKeys, 'Exchanging public keys')); 97 | } 98 | 99 | const { withWifi = false } = options; 100 | if (withWifi) { 101 | const fetchNearbyWifi = () => device.fetchNearbyWifi(); 102 | const wifiList = await (quiet ? fetchNearbyWifi() : progressFunctionWithMessage(() => fetchNearbyWifi(), 'Getting WIFI list')); 103 | 104 | if (!quiet && wifiList) { 105 | await printWifiListTable(wifiList); 106 | } 107 | } 108 | } catch (error: any) { 109 | terminal.red(`${error.message}\n`); 110 | if (process.env.LOG_LEVEL) { 111 | terminal.red('Error stack:\n'); 112 | terminal.red(error.stack); 113 | } 114 | process.exit(1); 115 | } 116 | -------------------------------------------------------------------------------- /packages/cli/src/cli.ts: -------------------------------------------------------------------------------- 1 | import TerminalKit from 'terminal-kit'; 2 | import { WifiAccessPoint } from '@meross/lib'; 3 | import { TextTableOptions } from 'terminal-kit/Terminal.js'; 4 | 5 | const { terminal } = TerminalKit; 6 | 7 | const tableOptions: TextTableOptions = { 8 | hasBorder: true, 9 | borderChars: 'light', 10 | contentHasMarkup: true, 11 | fit: true, 12 | width: 80, 13 | firstColumnTextAttr: { color: 'yellow' }, 14 | }; 15 | 16 | /** 17 | * Converts a decimal between zero and one to TerminalKit color code 18 | */ 19 | export const percentToColor = (percent: number): string => 20 | percent > 0.7 ? '^G' : percent > 0.5 ? '^Y' : percent > 0.3 ? '^y' : '^r'; 21 | 22 | /** 23 | * Draws a coloured bar of specified width 24 | */ 25 | export const bar = (percent: number, width: number): string => { 26 | const partials = ['▏', '▎', '▍', '▌', '▋', '▊', '▉']; 27 | let ticks = percent * width; 28 | if (ticks < 0) { 29 | ticks = 0; 30 | } 31 | let filled = Math.floor(ticks); 32 | let open = width - filled; 33 | 34 | return ( 35 | (percentToColor(percent) + '▉').repeat(filled) + 36 | partials[Math.floor((ticks - filled) * partials.length)] + 37 | ' '.repeat(open) 38 | ); 39 | }; 40 | 41 | /** 42 | * Draws a spinner and a message that is updated on success or failure 43 | */ 44 | export async function progressFunctionWithMessage( 45 | callback: () => Promise, 46 | message: string 47 | ): Promise { 48 | let spinner = await terminal.spinner({ 49 | animation: 'dotSpinner', 50 | attr: { color: 'cyan' }, 51 | }); 52 | terminal(`${message}…`); 53 | 54 | try { 55 | const response = await callback(); 56 | spinner.animate(false); 57 | terminal.saveCursor().column(0).green('✓').restoreCursor(); 58 | terminal('\n'); 59 | return response; 60 | } catch (e) { 61 | terminal.saveCursor().column(0).red('✗').restoreCursor(); 62 | terminal('\n'); 63 | throw e; 64 | } finally { 65 | spinner.animate(false); 66 | } 67 | } 68 | 69 | export async function printDeviceTable( 70 | deviceInformation: Record, 71 | deviceAbility?: Record, 72 | devicePassword?: string 73 | ): Promise { 74 | const { 75 | system: { hardware: hw, firmware: fw }, 76 | } = deviceInformation; 77 | 78 | const rows = [ 79 | [ 80 | 'Device', 81 | `${hw.type} ${hw.subType} ${hw.chipType} (hardware:${hw.version} firmware:${fw.version})`, 82 | ], 83 | ['UUID', hw.uuid], 84 | ['Mac address', hw.macAddress], 85 | ['IP address', fw.innerIp], 86 | ]; 87 | 88 | if (fw.server) { 89 | rows.push(['Current MQTT broker', `${fw.server}:${fw.port}`]); 90 | } 91 | 92 | rows.push( 93 | ['Credentials', `User: ^C${hw.macAddress}\nPassword: ^C${devicePassword}`], 94 | [ 95 | 'MQTT topics', 96 | `Publishes to: ^C/appliance/${hw.uuid}/publish\nSubscribes to: ^C/appliance/${hw.uuid}/subscribe`, 97 | ] 98 | ); 99 | 100 | if (deviceAbility) { 101 | const abilityRows = []; 102 | for (const [ability, params] of Object.entries(deviceAbility)) { 103 | abilityRows.push(`${ability.padEnd(38)}\t${JSON.stringify(params)}`); 104 | } 105 | 106 | rows.push(['Ability', abilityRows.join('\n')]); 107 | } 108 | 109 | terminal.table(rows, tableOptions); 110 | } 111 | 112 | /** 113 | * Displays a list of WIFI Access Points 114 | * @param {object[]} wifiList 115 | */ 116 | export async function printWifiListTable( 117 | wifiList: WifiAccessPoint[] 118 | ): Promise { 119 | const rows = [['WIFI', 'Signal strength']]; 120 | 121 | for (const { ssid, bssid, channel, encryption, cipher, signal } of wifiList) { 122 | rows.push([ 123 | `${ 124 | ssid ? ssid : '' 125 | }\n^B${bssid}^ ^+^YCh:^ ${channel} ^+^YEncryption:^ ${encryption} ^+^YCipher:^ ${cipher}`, 126 | bar(signal / 100, 20), 127 | ]); 128 | } 129 | 130 | const thisTableOptions = tableOptions; 131 | thisTableOptions.firstColumnVoidAttr = { contentWidth: 55 }; 132 | thisTableOptions.firstColumnTextAttr = { color: 'cyan' }; 133 | thisTableOptions.firstRowTextAttr = { color: 'yellow' }; 134 | 135 | terminal.table(rows, thisTableOptions); 136 | } 137 | -------------------------------------------------------------------------------- /packages/lib/src/message/header.ts: -------------------------------------------------------------------------------- 1 | import randomId from '../utils/randomId.js'; 2 | 3 | export enum Method { 4 | GET = 'GET', 5 | SET = 'SET', 6 | } 7 | 8 | export enum ResponseMethod { 9 | GETACK = 'GETACK', 10 | SETACK = 'SETACK', 11 | } 12 | 13 | export const ResponseMethodLookup = { 14 | [Method.GET]: ResponseMethod.GETACK, 15 | [Method.SET]: ResponseMethod.SETACK, 16 | }; 17 | 18 | export enum Namespace { 19 | // Common abilities 20 | SYSTEM_ALL = 'Appliance.System.All', 21 | SYSTEM_FIRMWARE = 'Appliance.System.Firmware', 22 | SYSTEM_HARDWARE = 'Appliance.System.Hardware', 23 | SYSTEM_ABILITY = 'Appliance.System.Ability', 24 | SYSTEM_ONLINE = 'Appliance.System.Online', 25 | SYSTEM_REPORT = 'Appliance.System.Report', 26 | SYSTEM_DEBUG = 'Appliance.System.Debug', 27 | SYSTEM_CLOCK = 'Appliance.System.Clock', 28 | SYSTEM_TIME = 'Appliance.System.Time', 29 | SYSTEM_GEOLOCATION = 'Appliance.System.Position', 30 | 31 | // Encryption abilities 32 | ENCRYPT_ECDHE = 'Appliance.Encrypt.ECDHE', 33 | ENCRYPT_SUITE = 'Appliance.Encrypt.Suite', 34 | 35 | CONTROL_BIND = 'Appliance.Control.Bind', 36 | CONTROL_UNBIND = 'Appliance.Control.Unbind', 37 | CONTROL_TRIGGER = 'Appliance.Control.Trigger', 38 | CONTROL_TRIGGERX = 'Appliance.Control.TriggerX', 39 | 40 | // Setup abilities 41 | CONFIG_WIFI = 'Appliance.Config.Wifi', 42 | CONFIG_WIFIX = 'Appliance.Config.WifiX', 43 | CONFIG_WIFI_LIST = 'Appliance.Config.WifiList', 44 | CONFIG_TRACE = 'Appliance.Config.Trace', 45 | CONFIG_KEY = 'Appliance.Config.Key', 46 | 47 | // Power plug / bulbs abilities 48 | CONTROL_TOGGLE = 'Appliance.Control.Toggle', 49 | CONTROL_TOGGLEX = 'Appliance.Control.ToggleX', 50 | CONTROL_ELECTRICITY = 'Appliance.Control.Electricity', 51 | CONTROL_CONSUMPTION = 'Appliance.Control.Consumption', 52 | CONTROL_CONSUMPTIONX = 'Appliance.Control.ConsumptionX', 53 | 54 | // Bulbs - only abilities 55 | CONTROL_LIGHT = 'Appliance.Control.Light', 56 | 57 | // Garage opener abilities 58 | GARAGE_DOOR_STATE = 'Appliance.GarageDoor.State', 59 | 60 | // Roller shutter timer 61 | ROLLER_SHUTTER_STATE = 'Appliance.RollerShutter.State', 62 | ROLLER_SHUTTER_POSITION = 'Appliance.RollerShutter.Position', 63 | ROLLER_SHUTTER_CONFIG = 'Appliance.RollerShutter.Config', 64 | 65 | // Humidifier 66 | CONTROL_SPRAY = 'Appliance.Control.Spray', 67 | 68 | SYSTEM_DIGEST_HUB = 'Appliance.Digest.Hub', 69 | 70 | // HUB 71 | HUB_EXCEPTION = 'Appliance.Hub.Exception', 72 | HUB_BATTERY = 'Appliance.Hub.Battery', 73 | HUB_TOGGLEX = 'Appliance.Hub.ToggleX', 74 | HUB_ONLINE = 'Appliance.Hub.Online', 75 | 76 | // SENSORS 77 | HUB_SENSOR_ALL = 'Appliance.Hub.Sensor.All', 78 | HUB_SENSOR_TEMPHUM = 'Appliance.Hub.Sensor.TempHum', 79 | HUB_SENSOR_ALERT = 'Appliance.Hub.Sensor.Alert', 80 | 81 | // MTS100 82 | HUB_MTS100_ALL = 'Appliance.Hub.Mts100.All', 83 | HUB_MTS100_TEMPERATURE = 'Appliance.Hub.Mts100.Temperature', 84 | HUB_MTS100_MODE = 'Appliance.Hub.Mts100.Mode', 85 | HUB_MTS100_ADJUST = 'Appliance.Hub.Mts100.Adjust', 86 | } 87 | 88 | export type HeaderOptions = { 89 | from?: string; 90 | messageId?: string; 91 | timestamp?: number; 92 | sign?: string; 93 | method?: Method; 94 | namespace?: Namespace; 95 | }; 96 | 97 | export class Header { 98 | method: Method; 99 | namespace: Namespace; 100 | from?: string; 101 | messageId?: string; 102 | timestamp?: number; 103 | payloadVersion?: number = 1; 104 | sign?: string; 105 | 106 | /** 107 | * @param {Object} [opts] 108 | * @param {string} [opts.from] 109 | * @param {string} [opts.messageId] 110 | * @param {number} [opts.timestamp] 111 | * @param {string} [opts.sign] 112 | * @param {Method} [opts.method] 113 | * @param {Namespace} [opts.namespace] 114 | */ 115 | constructor(options: HeaderOptions = {}) { 116 | const { 117 | from = '', 118 | messageId = randomId(), 119 | method = Method.GET, 120 | namespace = Namespace.SYSTEM_ALL, 121 | sign = '', 122 | timestamp = Date.now(), 123 | } = options; 124 | 125 | this.from = from; 126 | this.messageId = messageId; 127 | this.method = method; 128 | this.namespace = namespace; 129 | this.sign = sign; 130 | this.timestamp = timestamp; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /packages/cli/README.md: -------------------------------------------------------------------------------- 1 | # Meross CLI 2 | 3 | A command-line tool for configuring and managing Meross smart home devices. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | npm install -g meross 9 | ``` 10 | 11 | You can also run the commands without installing the package globally by using `npx`. For example: 12 | 13 | ```bash 14 | npx meross info -a 192.168.1.100 15 | ``` 16 | 17 | ## Commands 18 | 19 | ### Info 20 | 21 | Get information about compatible Meross smart devices. 22 | 23 | ```bash 24 | meross info [options] 25 | ``` 26 | 27 | Options: 28 | 29 | - `-a, --ip ` - Send command to device with this IP address (default: 10.10.10.1) 30 | - `-u, --user ` - Integer ID used by devices connected to Meross Cloud (default: 0) 31 | - `-k, --key ` - Shared key for generating signatures (default: meross) 32 | - `--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. 33 | - `--with-wifi` - List WIFI Access Points near the device 34 | - `--with-ability` - List device ability list 35 | - `-q, --quiet` - Suppress standard output 36 | 37 | Example: 38 | 39 | ```bash 40 | # Get basic information about a device 41 | meross info -a 192.168.1.100 42 | 43 | # Get device info and nearby WiFi networks 44 | meross info -a 192.168.1.100 --with-wifi 45 | ``` 46 | 47 | ### Setup 48 | 49 | Setup and configure compatible Meross smart devices. 50 | 51 | ```bash 52 | meross setup [options] 53 | ``` 54 | 55 | Options: 56 | 57 | - `-a, --ip ` - Send command to device with this IP address (default: 10.10.10.1) 58 | - `--wifi-ssid ` - WIFI Access Point name 59 | - `--wifi-pass ` - WIFI Access Point password 60 | - `--wifi-encryption ` - WIFI Access Point encryption 61 | - `--wifi-cipher ` - WIFI Access Point cipher 62 | - `--wifi-bssid ` - WIFI Access Point BSSID 63 | - `--wifi-channel ` - WIFI Access Point 2.4GHz channel number [1-13] 64 | - `--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. 65 | - `-u, --user ` - Integer ID for devices connected to Meross Cloud (default: 0) 66 | - `-k, --key ` - Shared key for generating signatures (default: meross) 67 | - `--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. 68 | - `-t, --set-time` - Configure device time with current host time and timezone 69 | - `-q, --quiet` - Suppress standard output 70 | 71 | Example: 72 | 73 | ```bash 74 | # Configure device WiFi settings 75 | meross setup -a 10.10.10.1 --wifi-ssid 'MyHomeNetwork' --wifi-pass 'MySecurePassword' --wifi-encryption 3 --wifi-cipher 1 --wifi-channel 6 76 | 77 | # Configure device MQTT and time settings 78 | meross setup -a 192.168.1.100 --mqtt 'mqtt://broker.example.com' -t 79 | ``` 80 | 81 | ## Workflow Examples 82 | 83 | ### Initial Device Setup 84 | 85 | 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. 86 | 87 | 1. Connect to the device's AP mode: 88 | 89 | ```bash 90 | # Connect to the device's WiFi network (typically Meross_XXXXXX) 91 | ``` 92 | 93 | 2. Get device information: 94 | 95 | ```bash 96 | meross info -a 10.10.10.1 --with-wifi 97 | ``` 98 | 99 | 3. Configure the device with your home WiFi: 100 | 101 | ```bash 102 | meross setup -a 10.10.10.1 --wifi-ssid 'YourHomeWifi' --wifi-pass 'YourPassword' --mqtt 'mqtts://192.168.1.2' 103 | ``` 104 | 105 | ### Managing Existing Devices 106 | 107 | 1. Get device information: 108 | 109 | ```bash 110 | meross info -a 192.168.1.100 111 | ``` 112 | 113 | 2. Update MQTT server configuration: 114 | 115 | ```bash 116 | meross setup -a 192.168.1.100 --mqtt 'mqtt://192.168.1.10' --mqtt 'mqtt://backup.example.com' 117 | ``` 118 | 119 | ## Troubleshooting 120 | 121 | - If you're having trouble connecting to a device, make sure you're using the correct IP address 122 | - 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. 123 | - Set the `LOG_LEVEL` environment variable, in combination with `--quiet` for more detailed error messages 124 | 125 | ## Reporting Issues 126 | 127 | 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: 128 | 129 | - A clear description of the problem or feature request 130 | - Steps to reproduce the issue (if applicable) 131 | - The version of the CLI you are using 132 | - Any relevant logs or error messages (use the `LOG_LEVEL` environment variable for detailed logs). 133 | 134 | We appreciate your feedback and contributions! 135 | 136 | > **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. 137 | > We appreciate your feedback and contributions! 138 | 139 | ## License 140 | 141 | ISC 142 | -------------------------------------------------------------------------------- /packages/lib/src/deviceManager.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'node:test'; 2 | import { strict as assert } from 'node:assert'; 3 | import { DeviceManager } from './deviceManager.js'; 4 | import { Device } from './device.js'; 5 | import { Namespace } from './message/header.js'; 6 | import { TransportSendOptions, Transport } from './transport/transport.js'; 7 | import { Message } from './message/message.js'; 8 | 9 | class MockTransport extends Transport { 10 | id: string = ''; 11 | timeout: number = 10_000; 12 | 13 | protected _send(options: TransportSendOptions): Promise { 14 | throw new Error('Method not implemented.'); 15 | } 16 | 17 | send(data: any): Promise { 18 | return Promise.resolve(data); 19 | } 20 | } 21 | 22 | class MockDevice extends Device { 23 | constructor(id: string, sharedKey?: string) { 24 | super(); 25 | 26 | this.hardware.uuid = id; 27 | 28 | if (sharedKey) { 29 | this.encryptionKeys = { 30 | publicKey: undefined, 31 | remotePublicKey: undefined, 32 | sharedKey: Buffer.from(sharedKey), 33 | }; 34 | } 35 | } 36 | 37 | hasAbility(namespace: Namespace): boolean { 38 | return namespace === Namespace.ENCRYPT_ECDHE; 39 | } 40 | } 41 | 42 | test('DeviceManager should add and retrieve devices', () => { 43 | const transport = new MockTransport(); 44 | const deviceManager = new DeviceManager({ transport }); 45 | 46 | const device = new MockDevice('device-1'); 47 | deviceManager.addDevice(device); 48 | 49 | const retrievedDevice = deviceManager.getDeviceById('device-1'); 50 | assert.strictEqual(retrievedDevice, device); 51 | }); 52 | 53 | test('DeviceManager should remove devices by instance', () => { 54 | const transport = new MockTransport(); 55 | const deviceManager = new DeviceManager({ transport }); 56 | 57 | const device = new MockDevice('device-1'); 58 | deviceManager.addDevice(device); 59 | deviceManager.removeDevice(device); 60 | 61 | const retrievedDevice = deviceManager.getDeviceById('device-1'); 62 | assert.strictEqual(retrievedDevice, undefined); 63 | }); 64 | 65 | test('DeviceManager should remove devices by ID', () => { 66 | const transport = new MockTransport(); 67 | const deviceManager = new DeviceManager({ transport }); 68 | 69 | const device = new MockDevice('device-1'); 70 | deviceManager.addDevice(device); 71 | deviceManager.removeDeviceById('device-1'); 72 | 73 | const retrievedDevice = deviceManager.getDeviceById('device-1'); 74 | assert.strictEqual(retrievedDevice, undefined); 75 | }); 76 | 77 | test('DeviceManager should send messages to devices', async () => { 78 | const transport = new MockTransport({ 79 | credentials: { userId: 123, key: 'secretKey' }, 80 | }); 81 | const deviceManager = new DeviceManager({ 82 | transport, 83 | }); 84 | 85 | const device = new MockDevice('device-1', 'sharedKey'); 86 | deviceManager.addDevice(device); 87 | 88 | const message = new Message(); 89 | const response = await deviceManager.sendMessageToDevice(device, message); 90 | 91 | assert.deepStrictEqual(response, { 92 | message, 93 | encryptionKey: undefined, 94 | }); 95 | }); 96 | 97 | test('DeviceManager should throw an error if device is not found', async () => { 98 | const transport = new MockTransport(); 99 | const deviceManager = new DeviceManager({ transport }); 100 | 101 | await assert.rejects( 102 | async () => 103 | deviceManager.sendMessageToDevice('non-existent-device', new Message()), 104 | new Error('Device with ID non-existent-device not found'), 105 | ); 106 | }); 107 | 108 | test('DeviceManager shouldEncryptMessage returns true for devices requiring encryption', () => { 109 | const transport = new MockTransport(); 110 | const deviceManager = new DeviceManager({ transport }); 111 | 112 | const device = new MockDevice('device-1'); 113 | device.hasAbility = (namespace: Namespace) => 114 | namespace === Namespace.ENCRYPT_ECDHE; 115 | 116 | const message = { header: { namespace: 'custom' } }; 117 | 118 | const result = (deviceManager as any).shouldEncryptMessage(device, message); 119 | assert.strictEqual(result, true); 120 | }); 121 | 122 | test('DeviceManager shouldEncryptMessage returns false for devices not requiring encryption', () => { 123 | const transport = new MockTransport(); 124 | const deviceManager = new DeviceManager({ transport }); 125 | 126 | const device = new MockDevice('device-1'); 127 | device.hasAbility = () => false; 128 | 129 | const message = { heaader: { namespace: 'custom' } }; 130 | 131 | const result = (deviceManager as any).shouldEncryptMessage(device, message); 132 | assert.strictEqual(result, false); 133 | }); 134 | 135 | test('DeviceManager shouldEncryptMessage returns false for excluded namespaces', () => { 136 | const transport = new MockTransport(); 137 | const deviceManager = new DeviceManager({ transport }); 138 | 139 | const device = new MockDevice('device-1'); 140 | device.hasAbility = (namespace: Namespace) => 141 | namespace === Namespace.ENCRYPT_ECDHE; 142 | 143 | const excludedNamespaces = [ 144 | Namespace.SYSTEM_ALL, 145 | Namespace.SYSTEM_FIRMWARE, 146 | Namespace.SYSTEM_ABILITY, 147 | Namespace.ENCRYPT_ECDHE, 148 | Namespace.ENCRYPT_SUITE, 149 | ]; 150 | 151 | for (const namespace of excludedNamespaces) { 152 | const message = { header: { namespace } }; 153 | const result = (deviceManager as any).shouldEncryptMessage(device, message); 154 | assert.strictEqual(result, false, `Failed for namespace: ${namespace}`); 155 | } 156 | }); 157 | -------------------------------------------------------------------------------- /packages/cli/src/meross-setup.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | import pkg from '../package.json' with { type: 'json' }; 6 | import { program, InvalidOptionArgumentError } from 'commander'; 7 | import TerminalKit from 'terminal-kit'; 8 | const { terminal } = TerminalKit; 9 | 10 | import { HTTPTransport, Device, WifiAccessPoint, CloudCredentials, Namespace } from '@meross/lib';; 11 | import { progressFunctionWithMessage } from './cli.js'; 12 | import { generateTimestamp, computePresharedPrivateKey} from '@meross/lib/utils'; 13 | import { generateKeyPair } from '@meross/lib/encryption'; 14 | 15 | type Options = { 16 | ip: string; 17 | wifiSsid?: string; 18 | wifiPass?: string; 19 | wifiEncryption?: number; 20 | wifiCipher?: number; 21 | wifiBssid?: string; 22 | wifiChannel?: number; 23 | mqtt?: string[]; 24 | user: number; 25 | key: string; 26 | privateKey: string | boolean; 27 | setTime: boolean; 28 | verbose: boolean; 29 | quiet: boolean; 30 | }; 31 | 32 | const collection = (value: string, store: string[] = []) => { 33 | store.push(value); 34 | return store; 35 | }; 36 | 37 | const numberInRange = (min: number, max: number) => (value: string) => { 38 | if (Number(value) < min || Number(value) > max) { 39 | throw new InvalidOptionArgumentError( 40 | `Value is out of range (${min}-${max})` 41 | ); 42 | } 43 | return parseInt(value); 44 | }; 45 | 46 | const parseIntWithValidation = (value: string) => { 47 | const i = parseInt(value); 48 | if (isNaN(i)) { 49 | throw new InvalidOptionArgumentError(`Value should be an integer`); 50 | } 51 | 52 | return i; 53 | }; 54 | 55 | program 56 | .version(pkg.version) 57 | .arguments('[options]') 58 | .option( 59 | '-a, --ip ', 60 | 'Send command to device with this IP address', 61 | '10.10.10.1' 62 | ) 63 | .option('--wifi-ssid ', 'WIFI Access Point name') 64 | .option('--wifi-pass ', 'WIFI Access Point password') 65 | .option( 66 | '--wifi-encryption ', 67 | 'WIFI Access Point encryption (this can be found using meross info --include-wifi)', 68 | parseIntWithValidation 69 | ) 70 | .option( 71 | '--wifi-cipher ', 72 | 'WIFI Access Point cipher (this can be found using meross info --include-wifi)', 73 | parseIntWithValidation 74 | ) 75 | .option( 76 | '--wifi-bssid ', 77 | 'WIFI Access Point BSSID (each octet seperated by a colon `:`)' 78 | ) 79 | .option( 80 | '--wifi-channel ', 81 | 'WIFI Access Point 2.4GHz channel number [1-13] (this can be found using meross info --include-wifi)', 82 | numberInRange(1, 13) 83 | ) 84 | .option('--mqtt ', 'MQTT server address', collection) 85 | .option( 86 | '-u, --user ', 87 | 'Integer id. Used by devices connected to the Meross Cloud', 88 | parseIntWithValidation, 89 | 0 90 | ) 91 | .option( 92 | '-k, --key ', 93 | 'Shared key for generating signatures', 94 | 'meross' 95 | ) 96 | .option('--private-key [private-key]', `Private key for ECDH key exchange. If not provided a new one will be generated`) 97 | .option('-t, --set-time', 'Configure device time with time and timezone of current host') 98 | .option('-q, --quiet', 'Suppress all output', false) 99 | 100 | .parse(process.argv); 101 | 102 | export const options = program.opts(); 103 | 104 | const { ip, user: userId, key } = options; 105 | const { quiet, verbose } = options; 106 | 107 | const { wifiSsid: ssid, wifiBssid: bssid, wifiPass: password, wifiChannel: channel, wifiEncryption: encryption, wifiCipher: cipher } = options; 108 | if (ssid !== undefined && (ssid?.length < 1 || ssid?.length > 32)) { 109 | terminal.red(`WIFI SSID length must be between 1 and 32 characters\n`); 110 | process.exit(1); 111 | } 112 | 113 | if (bssid && (bssid.length < 1 || bssid.length > 17)) { 114 | terminal.red(`WIFI BSSID length must be between 1 and 17 characters\n`); 115 | process.exit(1); 116 | } 117 | 118 | if (password !== undefined && (password?.length < 8 || password?.length > 64)) { 119 | terminal.red(`WIFI password length must be between 8 and 64 characters\n`); 120 | process.exit(1); 121 | } 122 | 123 | try { 124 | const credentials = new CloudCredentials(userId, key); 125 | 126 | const transport = new HTTPTransport({ url: `http://${ip}/config`, credentials }); 127 | const device = new Device(); 128 | 129 | device.setTransport(transport); 130 | 131 | // fetch device information 132 | const fetchDeviceInfo = async () => { 133 | const { system: { hardware, firmware } } = await device.fetchDeviceInfo(); 134 | terminal.green(`${hardware.type} (hardware: ${hardware.version}, firmware: ${firmware.version})`); 135 | }; 136 | await (quiet ? device.fetchDeviceInfo() : progressFunctionWithMessage(fetchDeviceInfo, 'Fetching device information')); 137 | 138 | // fetch device abilities 139 | const fetchDeviceAbilities = () => device.fetchDeviceAbilities(); 140 | await (quiet ? fetchDeviceAbilities() : progressFunctionWithMessage(fetchDeviceAbilities, 'Fetching device abilities')); 141 | 142 | // check if we neet to exchange public keys 143 | if (device.hasAbility(Namespace.ENCRYPT_ECDHE) && !device.encryptionKeys.sharedKey) { 144 | let { privateKey } = options; 145 | 146 | if (privateKey === true) { 147 | const { privateKey: generatedPrivateKey } = await generateKeyPair(); 148 | privateKey = generatedPrivateKey.toString('base64'); 149 | } 150 | 151 | if (!privateKey) { 152 | // use precomputed private key 153 | privateKey = computePresharedPrivateKey( 154 | device.id, 155 | key, 156 | device.hardware.macAddress 157 | ); 158 | } 159 | 160 | await device.setPrivateKey(Buffer.from(privateKey, 'base64')); 161 | 162 | const exchangeKeys = () => device.exchangeKeys(); 163 | await (quiet ? exchangeKeys() : progressFunctionWithMessage(exchangeKeys, 'Exchanging public keys')); 164 | } 165 | 166 | const { setTime = false } = options; 167 | if (setTime) { 168 | const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 169 | const time = generateTimestamp(); 170 | 171 | const configureDeviceTime = () => device.configureDeviceTime(time, timezone); 172 | await (quiet ? configureDeviceTime() : progressFunctionWithMessage(configureDeviceTime, 'Configuring device time')); 173 | } 174 | 175 | const { mqtt = [] } = options; 176 | if (mqtt.length) { 177 | const configureMQTT = () => device.configureMQTTBrokersAndCredentials(mqtt, credentials); 178 | await (quiet ? configureMQTT() : progressFunctionWithMessage(configureMQTT, 'Configuring MQTT brokers')); 179 | } 180 | 181 | if (ssid || bssid) { 182 | const wifiAccessPoint = new WifiAccessPoint({ 183 | ssid, 184 | password, 185 | channel, 186 | encryption, 187 | cipher, 188 | bssid, 189 | }); 190 | const configureWifi = () => device.configureWifi(wifiAccessPoint); 191 | const success = await (quiet ? configureWifi() : progressFunctionWithMessage(configureWifi, 'Configuring WIFI')); 192 | 193 | if (success && !quiet) { 194 | terminal.yellow(`Device will now reboot…\n`); 195 | } 196 | } 197 | } catch (error: any) { 198 | terminal.red(`${error.message}\n`); 199 | if (process.env.LOG_LEVEL) { 200 | terminal.red('Error stack:\n'); 201 | terminal.red(error.stack); 202 | } 203 | process.exit(1); 204 | } 205 | -------------------------------------------------------------------------------- /packages/lib/README.md: -------------------------------------------------------------------------------- 1 | # Meross Library 2 | 3 | A TypeScript/JavaScript library for interacting with Meross smart home devices. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | npm install @meross/lib 9 | ``` 10 | 11 | ## Basic Usage 12 | 13 | ```typescript 14 | import { HTTPTransport, Device, CloudCredentials } from '@meross/lib'; 15 | 16 | async function main() { 17 | // Setup credentials (use userId: 0 and key: 'meross' for local devices) 18 | const credentials = new CloudCredentials(0, 'meross'); 19 | 20 | // Create HTTP transport 21 | const transport = new HTTPTransport({ 22 | url: 'http://192.168.1.100/config', 23 | credentials, 24 | }); 25 | 26 | // Initialize device 27 | const device = new Device(); 28 | device.setTransport(transport); 29 | 30 | // Get device information 31 | const deviceInfo = await device.fetchDeviceInfo(); 32 | console.log('Device Info:', deviceInfo); 33 | 34 | // Get device abilities 35 | const abilities = await device.fetchDeviceAbilities(); 36 | console.log('Device Abilities:', abilities); 37 | } 38 | 39 | main().catch(console.error); 40 | ``` 41 | 42 | ## Core Components 43 | 44 | ### Device 45 | 46 | The `Device` class is the primary interface for communicating with Meross devices: 47 | 48 | ```typescript 49 | import { Device, WifiAccessPoint, CloudCredentials } from '@meross/lib'; 50 | 51 | // Create device instance 52 | const device = new Device(); 53 | 54 | // Connect to device 55 | device.setTransport(transport); 56 | 57 | // Fetch device information 58 | const info = await device.fetchDeviceInfo(); 59 | 60 | // Check device abilities 61 | const abilities = await device.fetchDeviceAbilities(); 62 | 63 | // Check if device has a specific ability 64 | const hasEncryption = device.hasAbility(Namespace.ENCRYPT_ECDHE); 65 | 66 | // Configure WiFi 67 | const wifiAP = new WifiAccessPoint({ 68 | ssid: 'MyNetwork', 69 | password: 'MyPassword', 70 | encryption: 3, 71 | cipher: 1, 72 | }); 73 | await device.configureWifi(wifiAP); 74 | 75 | // Configure MQTT brokers 76 | const credentials = new CloudCredentials(123, 'sharedKey'); 77 | await device.configureMQTTBrokersAndCredentials( 78 | ['mqtt://broker.example.com'], 79 | credentials 80 | ); 81 | 82 | // Configure device time 83 | await device.configureDeviceTime( 84 | Date.now() / 1000, 85 | Intl.DateTimeFormat().resolvedOptions().timeZone 86 | ); 87 | 88 | // Get nearby WiFi networks 89 | const nearbyNetworks = await device.fetchNearbyWifi(); 90 | ``` 91 | 92 | ### Transport 93 | 94 | The library includes an HTTP transport for device communication: 95 | 96 | ```typescript 97 | import { HTTPTransport, CloudCredentials } from '@meross/lib'; 98 | 99 | // Create credentials 100 | const credentials = new CloudCredentials(0, 'meross'); 101 | 102 | // Create transport with device URL 103 | const transport = new HTTPTransport({ 104 | url: 'http://192.168.1.100/config', 105 | credentials, 106 | timeout: 15000, // Optional custom timeout (default: 10000ms) 107 | }); 108 | ``` 109 | 110 | ### Device Manager 111 | 112 | For managing multiple devices: 113 | 114 | ```typescript 115 | import { DeviceManager, HTTPTransport, Device } from '@meross/lib'; 116 | 117 | // Create shared transport 118 | const transport = new HTTPTransport({ 119 | url: 'http://192.168.1.100/config', 120 | credentials: { userId: 0, key: 'meross' }, 121 | }); 122 | 123 | // Create device manager 124 | const deviceManager = new DeviceManager({ transport }); 125 | 126 | // Add devices 127 | const device1 = new Device(); 128 | deviceManager.addDevice(device1); 129 | 130 | // Get all devices 131 | const devices = deviceManager.getDevices(); 132 | 133 | // Get specific device 134 | const device = deviceManager.getDeviceById('device-uuid'); 135 | 136 | // Send message to device 137 | const message = new Message(); 138 | await deviceManager.sendMessageToDevice(device, message); 139 | ``` 140 | 141 | ## Encryption 142 | 143 | The library supports ECDH key exchange for encrypted communication: 144 | 145 | ```typescript 146 | import { 147 | generateKeyPair, 148 | createKeyPair, 149 | computePresharedPrivateKey, 150 | } from '@meross/lib'; 151 | 152 | // Method 1: Generate new key pair 153 | const { privateKey, publicKey } = await generateKeyPair(); 154 | 155 | // Method 2: Create key pair from existing private key 156 | const keyPair = await createKeyPair(privateKey); 157 | 158 | // Method 3: Use precomputed key based on device info 159 | const precomputedKey = computePresharedPrivateKey( 160 | deviceId, 161 | sharedKey, 162 | macAddress 163 | ); 164 | 165 | // Configure device with private key 166 | await device.setPrivateKey(Buffer.from(privateKeyBase64, 'base64')); 167 | 168 | // Exchange keys with the device 169 | await device.exchangeKeys(); 170 | ``` 171 | 172 | ## WiFi Configuration 173 | 174 | Configure a device's WiFi connection: 175 | 176 | ```typescript 177 | import { WifiAccessPoint } from '@meross/lib'; 178 | 179 | // Create WiFi access point configuration 180 | const wifiConfig = new WifiAccessPoint({ 181 | ssid: 'MyNetworkName', 182 | password: 'MySecurePassword', 183 | encryption: 3, // WPA2 PSK 184 | cipher: 1, // CCMP (AES) 185 | channel: 6, // 2.4GHz channel 186 | bssid: '00:11:22:33:44:55', // Optional 187 | }); 188 | 189 | // Configure device 190 | await device.configureWifi(wifiConfig); 191 | ``` 192 | 193 | ## MQTT Configuration 194 | 195 | Configure a device to connect to MQTT brokers: 196 | 197 | ```typescript 198 | import { CloudCredentials } from '@meross/lib'; 199 | 200 | // Create credentials 201 | const credentials = new CloudCredentials(userId, sharedKey); 202 | 203 | // Configure MQTT brokers (supports up to 2 brokers) 204 | const mqttServers = [ 205 | 'mqtt://primary-broker.example.com:1883', 206 | 'mqtts://backup-broker.example.com:8883', 207 | ]; 208 | 209 | await device.configureMQTTBrokersAndCredentials(mqttServers, credentials); 210 | ``` 211 | 212 | ## Error Handling 213 | 214 | ```typescript 215 | try { 216 | await device.fetchDeviceInfo(); 217 | } catch (error) { 218 | console.error('Error communicating with device:', error.message); 219 | 220 | // For detailed logs 221 | if (process.env.LOG_LEVEL) { 222 | console.error('Error stack:', error.stack); 223 | } 224 | } 225 | ``` 226 | 227 | ## Advanced Example: Complete Device Setup 228 | 229 | ```typescript 230 | import { 231 | HTTPTransport, 232 | Device, 233 | WifiAccessPoint, 234 | CloudCredentials, 235 | Namespace, 236 | generateTimestamp, 237 | computePresharedPrivateKey, 238 | } from '@meross/lib'; 239 | 240 | async function setupDevice(ip, wifiSettings, mqttServers) { 241 | // Create credentials and transport 242 | const credentials = new CloudCredentials(0, 'meross'); 243 | const transport = new HTTPTransport({ 244 | url: `http://${ip}/config`, 245 | credentials, 246 | }); 247 | 248 | // Initialize device 249 | const device = new Device(); 250 | device.setTransport(transport); 251 | 252 | // Get device info 253 | const deviceInfo = await device.fetchDeviceInfo(); 254 | console.log(`Connected to ${deviceInfo.system.hardware.type}`); 255 | 256 | // Get abilities 257 | await device.fetchDeviceAbilities(); 258 | 259 | // Set up encryption if supported 260 | if (device.hasAbility(Namespace.ENCRYPT_ECDHE)) { 261 | // Use pre-computed key based on device information 262 | const privateKey = computePresharedPrivateKey( 263 | device.id, 264 | credentials.key, 265 | device.hardware.macAddress 266 | ); 267 | 268 | await device.setPrivateKey(Buffer.from(privateKey, 'base64')); 269 | await device.exchangeKeys(); 270 | console.log('Encryption keys exchanged'); 271 | } 272 | 273 | // Configure time 274 | const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 275 | const time = generateTimestamp(); 276 | await device.configureDeviceTime(time, timezone); 277 | console.log('Device time configured'); 278 | 279 | // Configure MQTT (if provided) 280 | if (mqttServers && mqttServers.length) { 281 | await device.configureMQTTBrokersAndCredentials(mqttServers, credentials); 282 | console.log('MQTT servers configured'); 283 | } 284 | 285 | // Configure WiFi (if provided) 286 | if (wifiSettings) { 287 | const wifiAccessPoint = new WifiAccessPoint(wifiSettings); 288 | const success = await device.configureWifi(wifiAccessPoint); 289 | 290 | if (success) { 291 | console.log('WiFi configured successfully, device will reboot'); 292 | } 293 | } 294 | 295 | return device; 296 | } 297 | 298 | // Usage example 299 | setupDevice( 300 | '10.10.10.1', 301 | { 302 | ssid: 'HomeNetwork', 303 | password: 'SecurePassword', 304 | encryption: 3, 305 | cipher: 1, 306 | channel: 6, 307 | }, 308 | ['mqtts://broker.example.com:8883'] 309 | ).catch(console.error); 310 | ``` 311 | 312 | ## API Reference 313 | 314 | See the TypeScript definitions for complete API details. 315 | 316 | ### Main Classes 317 | 318 | - `Device` - Core class for interacting with Meross devices 319 | - `DeviceManager` - Manages multiple devices with a shared transport 320 | - `HTTPTransport` - HTTP communication transport 321 | - `CloudCredentials` - Authentication credentials 322 | - `WifiAccessPoint` - WiFi configuration 323 | 324 | ### Namespaces 325 | 326 | The library defines standard Meross namespace constants in `Namespace`: 327 | 328 | ```typescript 329 | import { Namespace } from '@meross/lib'; 330 | 331 | // Examples: 332 | Namespace.SYSTEM_ALL; 333 | Namespace.SYSTEM_ABILITY; 334 | Namespace.ENCRYPT_ECDHE; 335 | Namespace.CONFIG_WIFI; 336 | ``` 337 | 338 | ## License 339 | 340 | ISC 341 | -------------------------------------------------------------------------------- /packages/lib/src/device.ts: -------------------------------------------------------------------------------- 1 | import { CloudCredentials } from './cloudCredentials.js'; 2 | import { 3 | createKeyPair, 4 | deriveSharedKey, 5 | generateKeyPair, 6 | type EncryptionKeyPair, 7 | } from './encryption.js'; 8 | import { 9 | ConfigureDeviceTimeMessage, 10 | ConfigureECDHMessage, 11 | ConfigureMQTTBrokersAndCredentialsMessage, 12 | ConfigureWifiMessage, 13 | ConfigureWifiXMessage, 14 | QueryDeviceAbilitiesMessage, 15 | QueryDeviceInformationMessage, 16 | QueryDeviceTimeMessage, 17 | QueryWifiListMessage, 18 | } from './message/messages.js'; 19 | import { encryptPassword, WifiAccessPoint } from './wifi.js'; 20 | import { Namespace } from './message/header.js'; 21 | import { Transport } from './transport/transport.js'; 22 | import base64 from './utils/base64.js'; 23 | import logger from './utils/logger.js'; 24 | import md5 from './utils/md5.js'; 25 | import { 26 | protocolFromPort, 27 | portFromProtocol, 28 | } from './utils/protocolFromPort.js'; 29 | 30 | const deviceLogger = logger.child({ 31 | name: 'device', 32 | }); 33 | 34 | export type MacAddress = 35 | `${string}:${string}:${string}:${string}:${string}:${string}`; 36 | export type UUID = string; 37 | 38 | export type DeviceFirmware = { 39 | version: string; 40 | compileTime: Date; 41 | }; 42 | 43 | const FirmwareDefaults: DeviceFirmware = { 44 | version: '0.0.0', 45 | compileTime: new Date(), 46 | }; 47 | 48 | export type DeviceHardware = { 49 | version?: string; 50 | uuid: UUID; 51 | macAddress: MacAddress; 52 | }; 53 | 54 | const HardwareDefaults: DeviceHardware = { 55 | version: '0.0.0', 56 | uuid: '00000000000000000000000000000000', 57 | macAddress: '00:00:00:00:00:00', 58 | }; 59 | 60 | export type EncryptionKeys = { 61 | localKeys: EncryptionKeyPair | undefined; 62 | remotePublicKey: Buffer | undefined; 63 | sharedKey: Buffer | undefined; 64 | }; 65 | 66 | export type DeviceOptions = { 67 | firmware?: DeviceFirmware; 68 | hardware?: DeviceHardware; 69 | model?: string; 70 | }; 71 | 72 | export class Device implements Device { 73 | firmware: DeviceFirmware; 74 | hardware: DeviceHardware; 75 | model?: string; 76 | 77 | ability: Record = {}; 78 | 79 | encryptionKeys: EncryptionKeys = { 80 | localKeys: undefined, 81 | remotePublicKey: undefined, 82 | sharedKey: undefined, 83 | }; 84 | 85 | protected transport: Transport; 86 | 87 | constructor(options: DeviceOptions = {}) { 88 | const { firmware, hardware, model } = options; 89 | this.firmware = firmware || FirmwareDefaults; 90 | this.hardware = hardware || HardwareDefaults; 91 | this.model = model; 92 | } 93 | 94 | get id(): UUID { 95 | return this.hardware.uuid; 96 | } 97 | 98 | setTransport(transport: Transport) { 99 | deviceLogger.debug( 100 | `Setting transport for device ${this.id} to ${transport.constructor.name}`, 101 | { transport } 102 | ); 103 | this.transport = transport; 104 | } 105 | 106 | async setPrivateKey(privateKey: Buffer) { 107 | deviceLogger.debug(`Setting private key for device ${this.id}`); 108 | 109 | const keyPair = await createKeyPair(privateKey); 110 | 111 | this.encryptionKeys.localKeys = keyPair; 112 | } 113 | 114 | hasAbility(ability: Namespace) { 115 | deviceLogger.debug(`Checking if device ${this.id} has ability ${ability}`, { 116 | ability, 117 | }); 118 | return Object.keys(this.ability).includes(ability); 119 | } 120 | 121 | private sendMessage(message: any): Promise> { 122 | return this.transport.send({ 123 | message, 124 | encryptionKey: this.encryptionKeys.sharedKey, 125 | }); 126 | } 127 | 128 | async fetchDeviceInfo() { 129 | deviceLogger.info(`Fetching device information for ${this.id}`); 130 | const message = new QueryDeviceInformationMessage(); 131 | const { 132 | payload: { all }, 133 | } = await this.sendMessage(message); 134 | 135 | const { 136 | system: { firmware = FirmwareDefaults, hardware = HardwareDefaults }, 137 | } = all; 138 | 139 | this.model = hardware?.type; 140 | deviceLogger.info( 141 | `Device Info - Model: ${this.model}, Firmware: ${firmware?.version}, Hardware: ${hardware?.version}, UUID: ${hardware?.uuid}, MAC Address: ${hardware?.macAddress}` 142 | ); 143 | 144 | this.firmware = { 145 | version: firmware?.version, 146 | compileTime: firmware?.compileTime 147 | ? new Date(firmware?.compileTime) 148 | : undefined, 149 | }; 150 | 151 | this.hardware = { 152 | version: hardware?.version, 153 | uuid: hardware?.uuid, 154 | macAddress: hardware?.macAddress, 155 | }; 156 | 157 | return all; 158 | } 159 | 160 | async fetchDeviceAbilities() { 161 | deviceLogger.info(`Fetching device abilities for ${this.id}`); 162 | 163 | const message = new QueryDeviceAbilitiesMessage(); 164 | const { 165 | payload: { ability }, 166 | } = await this.sendMessage(message); 167 | 168 | this.ability = ability; 169 | 170 | deviceLogger.info(`Device Abilities: ${JSON.stringify(this.ability)}`); 171 | 172 | return ability; 173 | } 174 | 175 | async fetchDeviceTime() { 176 | const message = new QueryDeviceTimeMessage(); 177 | const { 178 | payload: { time }, 179 | } = await this.sendMessage(message); 180 | return time; 181 | } 182 | 183 | async exchangeKeys() { 184 | deviceLogger.info(`Exchanging keys for device ${this.id}`); 185 | 186 | if (!this.encryptionKeys.localKeys) { 187 | deviceLogger.debug(`Generating local keys for device ${this.id}`); 188 | this.encryptionKeys.localKeys = await generateKeyPair(); 189 | } 190 | 191 | const { publicKey, privateKey } = this.encryptionKeys.localKeys; 192 | 193 | const message = new ConfigureECDHMessage({ publicKey }); 194 | 195 | const { 196 | payload: { 197 | ecdhe: { pubkey }, 198 | }, 199 | } = await this.sendMessage(message); 200 | 201 | const remotePublicKey = Buffer.from(pubkey, 'base64'); 202 | this.encryptionKeys.remotePublicKey = remotePublicKey; 203 | 204 | // derive the shared key 205 | const sharedKey = await deriveSharedKey(privateKey, remotePublicKey); 206 | 207 | // ...and now for the dumb part 208 | // Meross take the shared key and MD5 it 209 | const sharedKeyMd5 = await md5(sharedKey, 'hex'); 210 | 211 | // then use the 32 hex characters as the shared key 212 | this.encryptionKeys.sharedKey = Buffer.from(sharedKeyMd5, 'utf8'); 213 | 214 | return; 215 | } 216 | 217 | async configureDeviceTime( 218 | timestamp: number, 219 | timezone: string | undefined = undefined 220 | ) { 221 | deviceLogger.info( 222 | `Configuring system time for device ${this.id} with timestamp ${timestamp} and timezone ${timezone}` 223 | ); 224 | 225 | const message = new ConfigureDeviceTimeMessage({ 226 | timestamp, 227 | timezone, 228 | }); 229 | 230 | await this.sendMessage(message); 231 | return; 232 | } 233 | 234 | async configureMQTTBrokersAndCredentials( 235 | mqtt: string[], 236 | credentials: CloudCredentials 237 | ) { 238 | deviceLogger.info( 239 | `Configuring MQTT brokers and credentials for device ${this.id}` 240 | ); 241 | 242 | const brokers = mqtt 243 | .map((broker) => { 244 | if (!URL.canParse(broker)) { 245 | // do we have a port? 246 | const port = broker.split(':')[1]; 247 | if (port) { 248 | const protocol = protocolFromPort(Number(port)); 249 | broker = `${protocol}://${broker}`; 250 | } 251 | } 252 | 253 | let { protocol, hostname, port } = new URL(broker); 254 | if (!port) { 255 | port = `${portFromProtocol(protocol.replace(':', ''))}`; 256 | } 257 | 258 | return { 259 | host: hostname, 260 | port: Number(port), 261 | }; 262 | }) 263 | .slice(0, 2); // Limit to 2 brokers 264 | 265 | const message = new ConfigureMQTTBrokersAndCredentialsMessage({ 266 | mqtt: brokers, 267 | credentials: credentials, 268 | }); 269 | 270 | await this.sendMessage(message); 271 | return; 272 | } 273 | 274 | async fetchNearbyWifi(): Promise { 275 | deviceLogger.info(`Fetching nearby WiFi for device ${this.id}`); 276 | 277 | const message = new QueryWifiListMessage(); 278 | const { 279 | payload: { wifiList }, 280 | } = await this.sendMessage(message); 281 | 282 | return wifiList.map( 283 | (item) => 284 | new WifiAccessPoint({ 285 | ...item, 286 | ssid: item.ssid 287 | ? base64.decode(item.ssid).toString('utf-8') 288 | : undefined, 289 | }) 290 | ); 291 | } 292 | 293 | async configureWifi(wifiAccessPoint: WifiAccessPoint): Promise { 294 | deviceLogger.info( 295 | `Configuring WiFi for device ${this.id} with SSID ${wifiAccessPoint.ssid}` 296 | ); 297 | 298 | let message = new ConfigureWifiMessage({ wifiAccessPoint }); 299 | if (this.hasAbility(Namespace.CONFIG_WIFIX)) { 300 | deviceLogger.debug( 301 | `Device ${this.id} has CONFIG_WIFIX ability, using ConfigureWifiXMessage` 302 | ); 303 | 304 | wifiAccessPoint.password = await encryptPassword({ 305 | password: wifiAccessPoint.password, 306 | hardware: { type: this.model, ...this.hardware }, 307 | }); 308 | 309 | message = new ConfigureWifiXMessage({ 310 | wifiAccessPoint, 311 | }); 312 | } 313 | 314 | await this.sendMessage(message); 315 | return true; 316 | } 317 | 318 | // /** 319 | // * 320 | // * @param {Namespace} namespace 321 | // * @param {object} [payload] 322 | // * @returns {Promise} 323 | // */ 324 | // async queryCustom(namespace, payload = {}) { 325 | // const message = new Message(); 326 | // message.header.method = Method.GET; 327 | // message.header.namespace = namespace; 328 | // message.payload = payload; 329 | 330 | // return this.#transport.send({ 331 | // message, 332 | // signatureKey: this.credentials.key, 333 | // }); 334 | // } 335 | 336 | // /** 337 | // * 338 | // * @param {Namespace} namespace 339 | // * @param {object} [payload] 340 | // * @returns {Promise} 341 | // */ 342 | // async configureCustom(namespace, payload = {}) { 343 | // const message = new Message(); 344 | // message.header.method = Method.SET; 345 | // message.header.namespace = namespace; 346 | // message.payload = payload; 347 | 348 | // return this.#transport.send({ 349 | // message, 350 | // signatureKey: this.credentials.key, 351 | // }); 352 | // } 353 | 354 | // /** 355 | // * @typedef QuerySystemInformationResponse 356 | // * @property {object} system 357 | // * @property {QuerySystemFirmwareResponse} system.firmware 358 | // * @property {QuerySystemHardwareResponse} system.hardware 359 | // */ 360 | // /** 361 | // * 362 | // * @param {boolean} [updateDevice] 363 | // * @returns {Promise} 364 | // */ 365 | // async querySystemInformation(updateDevice = true) { 366 | // const message = new QuerySystemInformationMessage(); 367 | // message.sign(this.credentials.key); 368 | 369 | // const { payload } = await this.#transport.send({ 370 | // message, 371 | // signatureKey: this.credentials.key, 372 | // }); 373 | 374 | // const { all } = payload; 375 | 376 | // if (updateDevice) { 377 | // const { 378 | // system: { firmware = FirmwareDefaults, hardware = HardwareDefaults }, 379 | // } = all; 380 | 381 | // this.model = hardware?.type; 382 | // this.firmware = { 383 | // version: firmware?.version, 384 | // compileTime: firmware?.compileTime 385 | // ? new Date(firmware?.compileTime) 386 | // : undefined, 387 | // }; 388 | // this.hardware = { 389 | // version: hardware?.version, 390 | // macAddress: hardware?.macAddress, 391 | // }; 392 | // } 393 | 394 | // return all; 395 | // } 396 | 397 | // /** 398 | // * @typedef QuerySystemFirmwareResponse 399 | // * @property {string} version 400 | // * @property {number} compileTime 401 | // */ 402 | // /** 403 | // * 404 | // * @param {boolean} [updateDevice] 405 | // * @returns {Promise} 406 | // */ 407 | // async querySystemFirmware(updateDevice = true) { 408 | // const message = new QuerySystemFirmwareMessage(); 409 | 410 | // const { payload } = await this.#transport.send({ 411 | // message, 412 | // signatureKey: this.credentials.key, 413 | // }); 414 | 415 | // const { firmware = FirmwareDefaults } = payload; 416 | 417 | // if (updateDevice) { 418 | // this.firmware = { 419 | // version: firmware?.version, 420 | // compileTime: firmware?.compileTime 421 | // ? new Date(firmware?.compileTime) 422 | // : undefined, 423 | // }; 424 | // } 425 | 426 | // return firmware; 427 | // } 428 | 429 | // /** 430 | // * @typedef QuerySystemHardwareResponse 431 | // * @property {string} version 432 | // * @property {string} macAddress 433 | // */ 434 | // /** 435 | // * 436 | // * @param {boolean} [updateDevice] 437 | // * @returns {Promise} 438 | // */ 439 | // async querySystemHardware(updateDevice = true) { 440 | // const message = new QuerySystemHardwareMessage(); 441 | 442 | // const { payload } = await this.#transport.send({ 443 | // message, 444 | // signatureKey: this.credentials.key, 445 | // }); 446 | 447 | // const { hardware = HardwareDefaults } = payload; 448 | 449 | // if (updateDevice) { 450 | // this.hardware = { 451 | // version: hardware?.version, 452 | // macAddress: hardware?.macAddress, 453 | // }; 454 | // } 455 | 456 | // return hardware; 457 | // } 458 | 459 | // /** 460 | // * 461 | // * @param {Namespace} ability 462 | // * @param {boolean} [updateDevice] 463 | // * @returns {Promise} 464 | // */ 465 | // async hasSystemAbility(ability, updateDevice = true) { 466 | // if (Object.keys(this.ability).length == 0 && updateDevice) { 467 | // this.querySystemAbility(updateDevice); 468 | // } 469 | 470 | // return ability in this.ability; 471 | // } 472 | 473 | // /** 474 | // * @typedef QuerySystemAbilityResponse 475 | // */ 476 | // /** 477 | // * 478 | // * @param {boolean} [updateDevice] 479 | // * @returns {Promise} 480 | // */ 481 | // async querySystemAbility(updateDevice = true) { 482 | // const message = new QuerySystemAbilityMessage(); 483 | 484 | // const { payload } = await this.#transport.send({ 485 | // message, 486 | // signatureKey: this.credentials.key, 487 | // }); 488 | 489 | // const { ability } = payload; 490 | // if (updateDevice) { 491 | // this.ability = ability; 492 | // } 493 | 494 | // return ability; 495 | // } 496 | 497 | // /** 498 | // * @typedef QuerySystemTimeResponse 499 | // * @property {number} timestamp 500 | // * @property {string} timezone 501 | // */ 502 | // /** 503 | // * 504 | // * @param {boolean} [updateDevice] 505 | // * @returns {Promise} 506 | // */ 507 | // async querySystemTime(updateDevice = true) { 508 | // const message = new QuerySystemTimeMessage(); 509 | 510 | // const { payload } = await this.#transport.send({ 511 | // message, 512 | // signatureKey: this.credentials.key, 513 | // }); 514 | 515 | // const { time } = payload; 516 | // if (updateDevice) { 517 | // } 518 | 519 | // return time; 520 | // } 521 | 522 | // /** 523 | // * 524 | // * @param {object} [opts] 525 | // * @param {number} [opts.timestamp] 526 | // * @param {string} [opts.timezone] 527 | // * @param {boolean} [updateDevice] 528 | // * @returns {Promise} 529 | // */ 530 | // async configureSystemTime({ timestamp, timezone } = {}, updateDevice = true) { 531 | // const message = new ConfigureSystemTimeMessage({ timestamp, timezone }); 532 | 533 | // await this.#transport.send({ message, signatureKey: this.credentials.key }); 534 | 535 | // return true; 536 | // } 537 | 538 | // /** 539 | // * @typedef QuerySystemGeolocationResponse 540 | // */ 541 | // /** 542 | // * 543 | // * @param {boolean} [updateDevice] 544 | // * @returns {Promise} 545 | // */ 546 | // async querySystemGeolocation(updateDevice = true) { 547 | // const message = new QuerySystemTimeMessage(); 548 | 549 | // const { payload } = await this.#transport.send({ 550 | // message, 551 | // signatureKey: this.credentials.key, 552 | // }); 553 | 554 | // const { position } = payload; 555 | // if (updateDevice) { 556 | // } 557 | 558 | // return position; 559 | // } 560 | 561 | // /** 562 | // * @param {object} [opts] 563 | // * @param {} [opts.position] 564 | // * @param {boolean} [updateDevice] 565 | // * @returns {Promise} 566 | // */ 567 | // async configureSystemGeolocation({ position } = {}, updateDevice = true) { 568 | // const message = new ConfigureSystemPositionMessage({ position }); 569 | 570 | // await this.#transport.send({ message, signatureKey: this.credentials.key }); 571 | 572 | // return true; 573 | // } 574 | 575 | // /** 576 | // * 577 | // * @returns {Promise} 578 | // */ 579 | // async queryNearbyWifi() { 580 | // const message = new QueryNearbyWifiMessage(); 581 | 582 | // const { payload } = await this.#transport.send({ 583 | // message, 584 | // signatureKey: this.credentials.key, 585 | // }); 586 | 587 | // const { wifiList } = payload; 588 | 589 | // return wifiList.map((item) => new WifiAccessPoint(item)); 590 | // } 591 | 592 | // /** 593 | // * @param { object } [opts] 594 | // * @param { string[] } [opts.mqtt] 595 | // * @returns { Promise } 596 | // */ 597 | // async configureMQTTBrokers({ mqtt = [] } = {}) { 598 | // const message = new ConfigureMQTTMessage({ 599 | // mqtt, 600 | // credentials: this.credentials, 601 | // }); 602 | 603 | // await this.#transport.send({ 604 | // message, 605 | // signatureKey: this.credentials.key, 606 | // }); 607 | 608 | // return true; 609 | // } 610 | 611 | // /** 612 | // * @param {object} opts 613 | // * @param {WifiAccessPoint[]} opts.wifiAccessPoint 614 | // * @returns { Promise } 615 | // */ 616 | // async configureWifi({ wifiAccessPoint }) { 617 | // let message; 618 | // if (await this.hasSystemAbility(Namespace.CONFIG_WIFIX)) { 619 | // const hardware = await this.querySystemHardware(); 620 | // message = new ConfigureWifiXMessage({ 621 | // wifiAccessPoint, 622 | // hardware, 623 | // }); 624 | // } else { 625 | // message = new ConfigureWifiMessage({ wifiAccessPoint }); 626 | // } 627 | 628 | // await this.#transport.send({ 629 | // message, 630 | // signatureKey: this.credentials.key, 631 | // }); 632 | 633 | // return true; 634 | // } 635 | } 636 | -------------------------------------------------------------------------------- /teardown/README.md: -------------------------------------------------------------------------------- 1 | # Meross 2 | Investigating the Meross MSS310 Smart Plug for purpose of utilising our own MQTT servers. 3 | 4 | 5 | 6 | ## Teardown 7 | ![alt text](https://raw.githubusercontent.com/bytespider/Meross/master/teardown/IMG_6869.JPG) 8 | ![alt text](https://raw.githubusercontent.com/bytespider/Meross/master/teardown/IMG_6870.JPG) 9 | ![alt text](https://raw.githubusercontent.com/bytespider/Meross/master/teardown/IMG_6871.JPG) 10 | ![alt text](https://raw.githubusercontent.com/bytespider/Meross/master/teardown/IMG_6872.JPG) 11 | ![alt text](https://raw.githubusercontent.com/bytespider/Meross/master/teardown/IMG_6873.JPG) 12 | 13 | ## UART / Serial interface 14 | The board has a serial connection @ baud 115200. 15 | It appears to be running a custom shell on top of a unix like OS. Other than the provided commands, there looks to be no way to get further info about the OS. 16 | 17 | ``` 18 | R⸮ 19 | F1: 0000 0000 20 | V0: 0000 0000 [0001] 21 | 00: 0006 000C 22 | 01: 0000 0000 23 | U0: 0000 0001 [0000] 24 | T0: 0000 001B 25 | Leaving the BROM 26 | 27 | set CP10 an CP11 Full Access 28 | bl_uart_init 29 | hal_emi_configure 30 | hal_clock_set_pll_dcm_init 31 | hf_fsys_ck freq=191997 32 | hal_emi_configure_advanced 33 | custom_setSFIExt 34 | NOR_init 35 | hal_flash_init 36 | read update info from 0xeee00 37 | read update info from 0xeee00 38 | exit: bl_fota_is_triggered 627 39 | Jump to addr 0x8012000 40 | sleep locked. 41 | 42 | normal bootup 43 | 44 | No 32k crystal 45 | 46 | [T: 128 M: common C: info F: system_init L: 353]: platform:MT7682-E3. 47 | [T: 128 M: common C: info F: system_init L: 354]: system initialize done. 48 | 49 | system up:0 50 | [T: 128 M: mrs_main C: info F: mrs_main_task_init L: 224]: main task init ok 51 | Last reset reason:HAL_WDT_SOFTWARE_RESET 52 | meross watch dog start 53 | cmd id 0xdd -- 0x0 seq 0x0 54 | pAd2 55 | ===> rt2860_close 56 | ############################### 57 | #### Meross IOT running... #### 58 | ############################### 59 | 60 | cmd id 0xdf -- 0x12 seq 0x1 61 | __original_rt2860_init 62 | EntryLifeCheck=1024 63 | PhyMode=14, BW=0, BssCoex=0, 40MHzIntolerant=0, CH adjustment NOT required! 64 | ap upxx:xx:xx:xx:xx:xx 65 | [T: 140 M: mrs_main C: info F: mrs_main_proc L: 89]: event system up 66 | [T: 141 M: mrs_reset C: info F: mrs_reset_start L: 83]: button reset enable,max count[60] 67 | [T: 141 M: mrs_timer C: info F: mrs_timer_task_WTBL1 for cli[255] fail 68 | NORMAL MODE 69 | RMAC_RFCR = 0x1DE70A 70 | NORMAL MODE 71 | RMAC_RFCR = 0x1DE70A 72 | Response MAC=xx:xx:xx:xx:xx:xx 73 | Event 0x30 not handled 74 | TxPower = 160 75 | auth:0, encrypt:1 76 | 77 | [T: 144 M: mrs_atf C: info F: mrs_autooff_task_init L: 820]: autooff task init ok 78 | [T: 144 M: mrs_http C: info F: mrs_http_handle_event L: 29]: meross httpd handle start 79 | [T: 152 M: mrs_timer C: info F: mrs_timer_nvdm_load_rules L: 556]: load 0 rules 80 | [T: 153 M: mrs_http C: info F: mrs_http_handle_event L: 33]: httpd status = 1 81 | [T: 153 M: mrs_http C: info F: mrs_http_server_start L: 159]: meross http server init success. 82 | [T: 156 M: mrs_atf C: info F: mrs_autooff_nvdm_load_rules L: 712]: load 0 rules 83 | [T: 198 M: mrs_main C: info F: mrs_main_wifi_init_done_handler L: 34]: WiFi Init Done: port = 1 84 | ``` 85 | 86 | ### Commands 87 | ``` 88 | ? - show the commands available 89 | ip - show current IP config 90 | stat - show statistics 91 | meross - meross module tools 92 | config - user config read/write/reset/show 93 | reboot - reboot 94 | ver - f/w ver 95 | ``` 96 | 97 | ``` 98 | $ ip 99 | 100 | interface: lo0 101 | mode: static 102 | static: 103 | ip 127.0.0.1 104 | netmask 255.0.0.0 105 | gateway 127.0.0.1 106 | 107 | interface: ap1 108 | mode: static 109 | static: 110 | ip 10.10.10.1 111 | netmask 255.255.255.0 112 | gateway 10.10.10.1 113 | 114 | interface: st2 115 | mode: static 116 | static: 117 | ip 10.10.10.1 118 | netmask 255.255.255.0 119 | gateway 10.10.10.1 120 | ``` 121 | 122 | ``` 123 | $ meross 124 | incomplete command, more options: 125 | 126 | hw - hardware info 127 | fw - firmware info 128 | net - network info 129 | task - task info 130 | mem - heap info 131 | time - time info 132 | sun - suncal info 133 | e2p - e2p info 134 | ping - ping tool 135 | power - energy info 136 | scan - scan wifi 137 | switch - switch 138 | led - status led 139 | crash - crash test 140 | fac - fac info 141 | test - hw test 142 | iot - iot command 143 | ``` 144 | 145 | ``` 146 | $ meross hw 147 | hardware info 148 | model :mss310 149 | local :us 150 | hw version:2.0.0 151 | chipset :mt7682 152 | uuid :xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 153 | mac :xx:xx:xx:xx:xx:xx 154 | ``` 155 | UUID seems to be the same one used in communications with the Meross Cloud. 156 | 157 | 158 | ``` 159 | $ meross fw 160 | firmware info 161 | fw version:2.1.7 162 | buildDate :2018/11/08 09:58:45 GMT +08:00 163 | ``` 164 | 165 | 166 | ``` 167 | $ meross net 168 | network info: 169 | ip: : 170 | cmd id 0xd5 -- 0x0 seq 0x5 171 | cmd id 0xd2 -- 0x0 seq 0x6 172 | router mac:xx:x:xx:xx:xx:xx 173 | ``` 174 | In an unpared state, the route mac address is set to the device's mac address. 175 | 176 | 177 | ``` 178 | $ meross task 179 | task info: 180 | name | status | prio | stack | id 181 | cli R 7 748 10 182 | IDLE R 0 235 11 183 | Tmr Svc B 19 468 12 184 | mrs_main B 5 848 2 185 | mrs_atf B 4 808 14 186 | mrs_timer B 4 800 13 187 | httpd_proc B 4 931 15 188 | lwIP B 6 457 8 189 | inband B 6 956 3 190 | fw_agent B 6 987 5 191 | SYSLOG B 1 89 1 192 | net B 6 942 4 193 | wfw B 6 238 6 194 | dhcpd B 4 147 9 195 | ``` 196 | Is a list of currently running processes. Not certain if this is useful, perhaps for debugging during firmware testing. 197 | 198 | 199 | ``` 200 | $ meross mem 201 | heap info: 202 | total:184320 203 | free: 83536 204 | low: 80496 205 | ``` 206 | Amount of memory used from the 180K available. Again probably most useful in debugging new firmware. 207 | 208 | 209 | ``` 210 | $ meross time 211 | time info: 212 | local time :Thu Jan 1 00:08:57 1970 ,local offset:0 213 | utc timestamp:537 214 | system uptime:0h8m57s 215 | ``` 216 | Shows the currently set time information. 217 | I assume this is set during device paring, on on first connection to the MQTT server. I think this information is used for schedules when the device is unable to connect to the cloud. 218 | 219 | 220 | ``` 221 | $ meross sun 222 | latitude:0,longitude:0,tz:0/3600.0 223 | sunrise 05:59 224 | sunset 18:07 225 | ``` 226 | Current location of the device in the world and the sunset/rise times of that location. 227 | I think this information is used for schedules when the device is unable to connect to the cloud. 228 | 229 | 230 | ``` 231 | $ meross e2p 232 | meross e2p uuid - read uuid 233 | meross e2p uuid {uuid} - write uuid 234 | meross e2p mac - read mac 235 | meross e2p mac {mac} - write mac 236 | ``` 237 | Get and Set the UUID and MAC of the device. Probably used during manufacture of the device, though I'm usure why the option to write this information is available out side of Factory Mode. 238 | 239 | 240 | ``` 241 | $ meross ping 127.0.0.1 242 | $ [T: 1117837 M: ping C: info F: ping_send L: 194]: [ping]: ping: send seq(0x0001) 127.0.0.1 243 | [T: 1117837 M: ping C: info F: ping_recv L: 256]: [ping]: ping: recv seq(0x0001) 127.0.0.1, 1 ms 244 | ``` 245 | Pings the specified host. Useful for testing connectivity to the outside world. 246 | 247 | 248 | ``` 249 | $ meross power 250 | --------------runtime---------------- 251 | energy:cur:0,last:0,now:0 252 | p:0 253 | v:0 (ratio:188) 254 | i:0 (ratio:100) 255 | pulse unit:0 256 | max current:0 257 | report status:todo 258 | report time[0] 00:00 259 | report time[1] 00:00 260 | report time[2] 00:00 261 | --------------history data---------------- 262 | no data 263 | ``` 264 | Current energy data from the HLW8032 chip. It seems that some histoy is stored on board. 265 | 266 | 267 | ``` 268 | $ meross scan 269 | Ch SSID BSSID Auth Cipher RSSI WPS_EN CM DPID SR 270 | 6 VM0000000 xx:xx:xx:xx:xx:xx 9 8 -21 1 0 0 0 271 | [MRS Scan Event Callback]: Scan Done! 272 | cmd id 0xd8 -- 0x0 seq 0x3b 273 | ``` 274 | Performs a WIFI scan. Interesting that this can be done from the device. This tells the app what AP's are available near by to that it could connect to. This makes sense as the location of the device may not be the same as the App. 275 | 276 | 277 | ``` 278 | meross switch on/off 279 | ``` 280 | External power is required to run the relay, which is not recommended while serial is connected. 281 | My assumtion is that it controls the relay, but why is the device UUID needed? Can we control other devices from this one? 282 | 283 | 284 | ``` 285 | $ meross led 286 | meross led red/green on/off/quick/slow 287 | ``` 288 | Change the LED states. However the LED states seem tied to internal conditions as the LED colours as a result of the command do not match. 289 | you can however trigger disco mode with `$ meross led green quick` ;) 290 | 291 | 292 | ``` 293 | $ meross crash 294 | system crash test.... 295 | ``` 296 | Runs some form of crash test. 297 | 298 | 299 | ``` 300 | $meross fac 301 | In fac mode:NO 302 | ``` 303 | Get and Set if the device is on factory mode. `meross fac 1` sets factory mode and `meross fac 0` disables it. 304 | 305 | 306 | ``` 307 | $ meross test 308 | meross test relay count interval 309 | $ meross test relay 1 1 310 | ``` 311 | Tests the the relay is activated number of times every . External power is required to run the relay, which is not recommended while serial is connected. 312 | 313 | 314 | ``` 315 | $ meross iot 316 | incomplete command, more options: 317 | status - show status 318 | unbind - unbind 319 | upgrade - upgrade 320 | rule - show rules 321 | log - log on|off 322 | dbg - show debug 323 | ``` 324 | 325 | ``` 326 | $ meross iot status 327 | wifi status:disconnect 328 | iot status:offline 329 | iot info: 330 | primary host: :0 331 | secondary host: :0 332 | user id : 333 | user key : 334 | bind id : 335 | ``` 336 | Shows the status of the device and the MQTT details. Will update this section when paired. 337 | 338 | 339 | ``` 340 | $ meross iot unbind 341 | device unbind via cli [Offline] 342 | [T: 274103 M: mrs_main C: info F: mrs_main_proc L: 172]: event factory reset 343 | [T: 274103 M: mrs_event C: warning F: mrs_iot_event_send_primary L: 116]: event not init 344 | ``` 345 | Unpairs the device then reboots. 346 | 347 | 348 | ``` 349 | meross iot upgrade 350 | ``` 351 | Downloads and installs new firmware. Need to find the URL of a new firmware image to test further. 352 | 353 | 354 | ``` 355 | $ meross iot rule 356 | timer rules info: 357 | autooff rules info: 358 | ``` 359 | Displays information about currently set schedules. 360 | 361 | 362 | ``` 363 | $ meross iot log on 364 | mrs_protocol_set_log:protocol package dump 0 365 | ``` 366 | Need to test further. 367 | 368 | 369 | ``` 370 | $ meross iot dbg 371 | cmd id 0xd5 -- 0x0 seq 0x5 372 | cmd id 0xd2 -- 0x0 seq 0x6 373 | 374 | { 375 | "system": { 376 | "version": "2.1.7", 377 | "sysUpTime": "0h6m59s", 378 | "localTimeOffset": 0, 379 | "localTime": "Thu Jan 1 00:06:59 1970", 380 | "suncalc": "5:59;18:7" 381 | }, 382 | "network": { 383 | "linkStatus": "disconnect", 384 | "ssid": "", 385 | "gatewayMac": "xx:xx:xx:xx:xx:xx", 386 | "innerIp": "", 387 | "wifiDisconnectCount": 0 388 | }, 389 | "cloud": { 390 | "activeServer": "", 391 | "mainServer": "", 392 | "mainPort": 0, 393 | "secondServer": "", 394 | "secondPort": 0, 395 | "userId": 0, 396 | "sysConnectTime": "N/A", 397 | "sysOnlineT[T wifi C: erro 398 | "sysDisconnectCount": 0, 399 | "pingTrace": [] 400 | } 401 | } 402 | ``` 403 | Dumps a bunch of debug information about the current state of the device. Will update when device is paired. 404 | 405 | ``` 406 | $ config 407 | incomplete command, more options: 408 | read - config read 409 | write - config write 410 | reset - config reset 411 | show - config show 412 | ``` 413 | 414 | ``` 415 | $ config show 416 | show all group 417 | [common]OpMode: 1 418 | [common]CountryCode: CN 419 | [common]CountryRegion: 5 420 | [common]CountryRegionABand: 3 421 | [common]RadioOff: 0 422 | [common]DbgLevel: 3 423 | [common]RTSThreshold: 2347 424 | [common]FragThreshold: 2346 425 | [common]BGChannelTable: 1,14,0| 426 | [common]AChannelTable: 36,8,0|100,11,0|149,4,0| 427 | [common]syslog_filters: 428 | [common]WiFiPrivilegeEnable: 0 429 | [common]StaFastLink: 0 430 | [STA]LocalAdminMAC: 1 431 | [STA]MacAddr: xx:xx:xx:xx:xx:xx 432 | [STA]Ssid: MTK_SOFT_AP 433 | [STA]SsidLen: 11 434 | [STA]BssType: 1 435 | [STA]Channel: 1 436 | [STA]BW: 0 437 | [STA]WirelessMode: 9 438 | [STA]BADecline: 0 439 | [STA]AutoBA: 1 440 | [STA]HT_MCS: 33 441 | [STA]HT_BAWinSize: 4 442 | [STA]HT_GI: 1 443 | [STA]HT_PROTECT: 1 444 | [STA]HT_EXTCHA: 1 445 | [STA]WmmCapable: 1 446 | [STA]ListenInterval: 1 447 | [STA]AuthMode: 0 448 | [STA]EncrypType: 1 449 | [STA]WpaPsk: 12345678 450 | [STA]WpaPskLen: 8 451 | [STA]PMK_INFO: 0 452 | [STA]PairCipher: 0 453 | [STA]GroupCipher: 0 454 | [STA]DefaultKeyId: 0 455 | [STA]SharedKey: 12345,12345,12345,12345 456 | [STA]SharedKeyLen: 5,5,5,5 457 | [STA]PSMode: 0 458 | [STA]KeepAlivePeriod: 55 459 | [STA]BeaconLostTime: 20 460 | [STA]ApcliBWAutoUpBelow: 1 461 | [STA]StaKeepAlivePacket: 1 462 | [AP]LocalAdminMAC: 1 463 | [AP]MacAddr: xx:xx:xx:xx:xx:xx 464 | [AP]Ssid: Meross_SW_XXXX 465 | [AP]SsidLen: 14 466 | [AP]Channel: 1 467 | [AP]BW: 0 468 | [AP]WirelessMode: 9 469 | [AP]AutoBA: 1 470 | [AP]HT_MCS: 33 471 | [AP]HT_BAWinSize: 4 472 | [AP]HT_GI: 1 473 | [AP]HT_PROTECT: 1 474 | [AP]HT_EXTCHA: 1 475 | [AP]WmmCapable: 1 476 | [AP]DtimPeriod: 1 477 | [AP]AuthMode: 0 478 | [AP]EncrypType: 1 479 | [AP]WpaPsk: 12345678 480 | [AP]WpaPskLen: 8 481 | [AP]PairCipher: 0 482 | [AP]GroupCipher: 0 483 | [AP]DefaultKeyId: 0 484 | [AP]SharedKey: 11111,22222,33333,44444 485 | [AP]SharedKeyLen: 5,5,5,5 486 | [AP]HideSSID: 0 487 | [AP]RekeyInterval: 3600 488 | [AP]AutoChannelSelect: 0 489 | [AP]BcnDisEn: 0 490 | [network]IpAddr: 10.10.10.1 491 | [network]IpNetmask: 255.255.255.0 492 | [network]IpGateway: 10.10.10.1 493 | [network]IpMode: dhcp 494 | [meross]dev.cfg.ResetCount: 0 495 | [meross]dev.cfg.State: 0 496 | [meross]dev.cfg.Bind: 0 497 | [meross]dev.cfg.Key: 498 | [meross]dev.cfg.bindTime: 0 499 | [meross]dev.cfg.bindId: 500 | [meross]dev.cfg.Server: 501 | [meross]dev.cfg.Port: 502 | [meross]dev.cfg.BackServer: 503 | [meross]dev.cfg.BackPort: 504 | [meross]dev.cfg.userId: 505 | [meross]dev.cfg.upgradeFlag: 0 506 | [meross]dev.cfg.DNDMode: 0 507 | [meross]dev.cfg.networkTrace00: 508 | [meross]dev.cfg.networkTrace01: 509 | [meross]dev.cfg.networkTrace02: 510 | [meross]dev.cfg.networkTrace03: 511 | [meross]dev.cfg.networkTrace04: 512 | [meross]dev.ctl.timeZone: 513 | [meross]dev.ctl.timeStamp: 0 514 | [meross]dev.ctl.latitude: 0 515 | [meross]dev.ctl.longitude: 0 516 | [meross]dev.ctl.switchStatus.00: 1 517 | [meross]dev.ctl.SwitchLmTime.00: 0 518 | [meross]dev.ctl.switchStatus.01: 1 519 | [meross]dev.ctl.SwitchLmTime.01: 0 520 | [meross]dev.ctl.switchStatus.02: 1 521 | [meross]dev.ctl.SwitchLmTime.02: 0 522 | [meross]dev.ctl.switchStatus.03: 1 523 | [meross]dev.ctl.SwitchLmTime.03: 0 524 | [meross]dev.ctl.switchStatus.04: 1 525 | [meross]dev.ctl.SwitchLmTime.04: 0 526 | [meross]dev.ctl.switchStatus.05: 1 527 | [meross]dev.ctl.SwitchLmTime.05: 0 528 | [meross]dev.tim.Rule.00: 529 | [meross]dev.tim.Rule.01: 530 | [meross]dev.tim.Rule.02: 531 | [meross]dev.tim.Rule.03: 532 | [meross]dev.tim.Rule.04: 533 | [meross]dev.tim.Rule.05: 534 | [meross]dev.tim.Rule.06: 535 | [meross]dev.tim.Rule.07: 536 | [meross]dev.tim.Rule.08: 537 | [meross]dev.tim.Rule.09: 538 | [meross]dev.tim.Rule.10: 539 | [meross]dev.tim.Rule.11: 540 | [meross]dev.tim.Rule.12: 541 | [meross]dev.tim.Rule.13: 542 | [meross]dev.tim.Rule.14: 543 | [meross]dev.tim.Rule.15: 544 | [meross]dev.tim.Rule.16: 545 | [meross]dev.tim.Rule.17: 546 | [meross]dev.tim.Rule.18: 547 | [meross]dev.tim.Rule.19: 548 | [meross]dev.tmr.Rule.00: 549 | [meross]dev.tmr.Rule.01: 550 | [meross]dev.tmr.Rule.02: 551 | [meross]dev.tmr.Rule.03: 552 | [meross]dev.tmr.Rule.04: 553 | [meross]dev.tmr.Rule.05: 554 | [meross]dev.tmr.Rule.06: 555 | [meross]dev.tmr.Rule.07: 556 | [meross]dev.tmr.Rule.08: 557 | [meross]dev.tmr.Rule.09: 558 | [meross]dev.tmr.Rule.10: 559 | [meross]dev.tmr.Rule.11: 560 | [meross]dev.tmr.Rule.12: 561 | [meross]dev.tmr.Rule.13: 562 | [meross]dev.tmr.Rule.14: 563 | [meross]dev.tmr.Rule.15: 564 | [meross]dev.tmr.Rule.16: 565 | [meross]dev.tmr.Rule.17: 566 | [meross]dev.tmr.Rule.18: 567 | [meross]dev.tmr.Rule.19: 568 | [meross]dev.tmr.Rule.20: 569 | [meross]dev.tmr.Rule.21: 570 | [meross]dev.tmr.Rule.22: 571 | [meross]dev.tmr.Rule.23: 572 | [meross]dev.tmr.Rule.24: 573 | [meross]dev.tmr.Rule.25: 574 | [meross]dev.tmr.Rule.26: 575 | [meross]dev.tmr.Rule.27: 576 | [meross]dev.tmr.Rule.28: 577 | [meross]dev.tmr.Rule.29: 578 | [meross]dev.tmr.Rule.30: 579 | [meross]dev.tmr.Rule.31: 580 | [meross]dev.atf.Rule.00: 581 | [meross]dev.atf.Rule.01: 582 | [meross]dev.atf.Rule.02: 583 | [meross]dev.atf.Rule.03: 584 | [meross]dev.atf.Rule.04: 585 | [meross]dev.atf.Rule.05: 586 | [meross]dev.atf.Rule.06: 587 | [meross]dev.atf.Rule.07: 588 | [meross]dev.atf.Rule.08: 589 | [meross]dev.atf.Rule.09: 590 | [meross]dev.atf.Rule.10: 591 | [meross]dev.atf.Rule.11: 592 | [meross]dev.atf.Rule.12: 593 | [meross]dev.atf.Rule.13: 594 | [meross]dev.atf.Rule.14: 595 | [meross]dev.atf.Rule.15: 596 | [meross]dev.atf.Rule.16: 597 | [meross]dev.atf.Rule.17: 598 | [meross]dev.atf.Rule.18: 599 | [meross]dev.atf.Rule.19: 600 | [meross]dev.atf.Rule.20: 601 | [meross]dev.atf.Rule.21: 602 | [meross]dev.atf.Rule.22: 603 | [meross]dev.atf.Rule.23: 604 | [meross]dev.atf.Rule.24: 605 | [meross]dev.atf.Rule.25: 606 | [meross]dev.atf.Rule.26: 607 | [meross]dev.atf.Rule.27: 608 | [meross]dev.atf.Rule.28: 609 | [meross]dev.atf.Rule.29: 610 | [meross]dev.atf.Rule.30: 611 | [meross]dev.atf.Rule.31: 612 | [meross]dev.enr.data01: 613 | [meross]dev.enr.data02: 614 | [meross]dev.enr.data03: 615 | [meross]dev.enr.data04: 616 | [meross]dev.enr.data05: 617 | [meross]dev.enr.data06: 618 | [meross]dev.enr.data07: 619 | [meross]dev.enr.data08: 620 | [meross]dev.enr.data09: 621 | [meross]dev.enr.data10: 622 | [meross]dev.enr.data11: 623 | [meross]dev.enr.data12: 624 | [meross]dev.enr.data13: 625 | [meross]dev.enr.data14: 626 | [meross]dev.enr.data15: 627 | [meross]dev.enr.data16: 628 | [meross]dev.enr.data17: 629 | [meross]dev.enr.data18: 630 | [meross]dev.enr.data19: 631 | [meross]dev.enr.data20: 632 | [meross]dev.enr.data21: 633 | [meross]dev.enr.data22: 634 | [meross]dev.enr.data23: 635 | [meross]dev.enr.data24: 636 | [meross]dev.enr.data25: 637 | [meross]dev.enr.data26: 638 | [meross]dev.enr.data27: 639 | [meross]dev.enr.data28: 640 | [meross]dev.enr.data29: 641 | [meross]dev.enr.data30: 642 | [meross]dev.enr.VRatio: 188 643 | [meross]dev.enr.IRatio: 100 644 | [meross]dev.enr.ratioFlag: 0 645 | [factory]dev.fac.locale: us 646 | [factory]dev.fac.uuid: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 647 | [factory]dev.fac.mac: xx:xx:xx:xx:xx:xx 648 | [factory]dev.fac.mode: 0 649 | [factory]dev.fac.testStep: 0 650 | [factory]dev.cfg.traceInfo: 651 | ``` 652 | Sections in brackets are the group names used for other commands. 653 | 654 | 655 | ``` 656 | $ ver 657 | CM4 Image Ver: SDK_V4.6.2 658 | N9 Image Ver: 20170823172833 659 | ``` 660 | Not sure what this information relates to. Perhaps the build environment for the MediaTek chip? 661 | 662 | ### LED status 663 | 664 | #### Red flashing 665 | Device is in factory mode. You can get back via serial and running the commands 666 | ``` 667 | $ meross fac 0 668 | $ reboot 669 | ``` 670 | or using the following steps provided by Meross Support (thanks to Alex Brosi for this information for a MSS425e): 671 | 1. Launch a wifi hotspot using an Android phone or your router; 672 | 2. Rename it exactly as `Meross_factory_mss425e`; 673 | 3. Don't set any password for it. Leave it an open wifi so that the switch can connect to it. 674 | 4. Put your phone or wifi router close to the switch/plug and it will auto connect. 675 | 5. Wait for 1-3min and the switch/plug should reboot itself, and the LED should be blinking amber and green. 676 | 677 | I *assume* that you need to change the AP name to match your device for example `Meross_factory_mss310` 678 | 679 | #### Green Orange Alternating 680 | Device is in pairing mode 681 | 682 | #### Green 683 | Device is paired and relay is active 684 | 685 | #### Green flashing 686 | Device is trying to connect to the WIFI and/or MQTT broker. If it fails to then the device will reset and flash Green Orange alternating. 687 | --------------------------------------------------------------------------------