From e6279ae486eef2d549a4b5dd15e4e446d2180dc7 Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Sat, 19 Jul 2025 15:35:02 +0800 Subject: give up monorepo --- README.md | 162 +++++++++++++++++++++++++++++- jest.config.js | 11 +++ package.json | 39 ++++++-- packages/airmx/LICENSE | 21 ---- packages/airmx/README.md | 161 ------------------------------ packages/airmx/jest.config.js | 11 --- packages/airmx/package.json | 38 ------- packages/airmx/src/airmx.test.ts | 150 ---------------------------- packages/airmx/src/airmx.ts | 188 ----------------------------------- packages/airmx/src/eagle.test.ts | 103 ------------------- packages/airmx/src/eagle.ts | 207 --------------------------------------- packages/airmx/src/index.ts | 4 - packages/airmx/src/messages.ts | 67 ------------- packages/airmx/src/snow.ts | 169 -------------------------------- packages/airmx/src/types.ts | 117 ---------------------- packages/airmx/src/util.ts | 43 -------- packages/airmx/tsconfig.json | 15 --- src/airmx.test.ts | 150 ++++++++++++++++++++++++++++ src/airmx.ts | 188 +++++++++++++++++++++++++++++++++++ src/eagle.test.ts | 103 +++++++++++++++++++ src/eagle.ts | 207 +++++++++++++++++++++++++++++++++++++++ src/index.ts | 4 + src/messages.ts | 67 +++++++++++++ src/snow.ts | 169 ++++++++++++++++++++++++++++++++ src/types.ts | 117 ++++++++++++++++++++++ src/util.ts | 43 ++++++++ tsconfig.json | 15 +++ 27 files changed, 1268 insertions(+), 1301 deletions(-) create mode 100644 jest.config.js delete mode 100644 packages/airmx/LICENSE delete mode 100644 packages/airmx/README.md delete mode 100644 packages/airmx/jest.config.js delete mode 100644 packages/airmx/package.json delete mode 100644 packages/airmx/src/airmx.test.ts delete mode 100644 packages/airmx/src/airmx.ts delete mode 100644 packages/airmx/src/eagle.test.ts delete mode 100644 packages/airmx/src/eagle.ts delete mode 100644 packages/airmx/src/index.ts delete mode 100644 packages/airmx/src/messages.ts delete mode 100644 packages/airmx/src/snow.ts delete mode 100644 packages/airmx/src/types.ts delete mode 100644 packages/airmx/src/util.ts delete mode 100644 packages/airmx/tsconfig.json create mode 100644 src/airmx.test.ts create mode 100644 src/airmx.ts create mode 100644 src/eagle.test.ts create mode 100644 src/eagle.ts create mode 100644 src/index.ts create mode 100644 src/messages.ts create mode 100644 src/snow.ts create mode 100644 src/types.ts create mode 100644 src/util.ts create mode 100644 tsconfig.json diff --git a/README.md b/README.md index 8b13ee1..c5cce11 100644 --- a/README.md +++ b/README.md @@ -1 +1,161 @@ -# AIRMX +# Control AIRMX Pro with JavaScript + +The package utilizes the MQTT protocol to communicate with your AIRMX devices. +Once connected to the server, it constantly monitors the status updates from +your machines. Additionally, it provides a set of useful APIs to facilitate +smooth control of your devices. + +## Installation + +The package can be installed via NPM: + +```bash +npm i airmx +``` + +## Usage + +First of all, we need to initialize an AIRMX client before we can monitor or +control our machines: + +```typescript +import mqtt from 'mqtt' + +const airmx = new Airmx({ + mqtt: mqtt.connect('mqtt://'), + devices: [ + { id: , key: '' } + ] +}) +``` + +You can register a handler when an AIRMX Pro sends us its latest status. + +```typescript +airmx.onEagleUpdate((status: EagleStatus) => { + console.log(`🎈 AIRMX: ${status.deviceId} ${status.power ? 'on' : 'off'}`) +}) +``` + +Sending commands directly to your machines is simple with the control API. +Just provide the device ID and control data. + +```typescript +airmx.control(1, { + power: 1, // 1 indicates on + mode: 0, // 0 indicates manual control + cadr: 47, // CADR accepts a number range from 0 - 100 + denoise: 0, // 0 indicates off + heatStatus: 0 // 0 indicates off +}) +``` + +Dealing with the control API's fixed data structure can be tedious. That's +why we built a semantic and fluent API for easier device control. Its method +names are so intuitive, you'll instantly grasp their function. + +```typescript +// Turn on the machine +airmx.device(1).on() + +// Turn off the machine +airmx.device(1).off() + +// Turn on heat mode +airmx.device(1).heatOn() + +// Turn off heat mode +airmx.device(1).heatOff() + +// Turn on noise cancelling mode +airmx.device(1).denoiseOn() + +// Turn off noise cancelling mode +airmx.device(1).denoiseOff() + +// Adjust the CADR value (fan speed) +airmx.device(1).cadr(47) +``` + +The library replicates the built-in modes available in the official mobile +applications: + +- **AI Mode:** Automatically adjusts CADR (Clean Air Delivery Rate) based + on data from a paired air monitor. +- **Silent Mode:** Reduces fan speed to minimize noise output. +- **Turbo Mode:** Maximizes the device's purification capability, operating + at 100% fan speed for rapid air cleaning. + +```typescript +airmx.device(1).ai() +airmx.device(1).slient() +airmx.device(1).turbo() +``` + +Whenever a device sends an update or you request its current state, you'll +get an EagleStatus object with all the latest information. + +```typescript +airmx.device(1).status() +``` + +## AIRMX Pro Status Reference + +```typescript +// Determine the device's power status +// +// Data type: boolean + +status.isOn() +status.isOff() + +// Determine if the machine is set to silent mode +// +// Data type: boolean + +status.isSilentMode() + +// Determine if noise cancelling is enabled +// +// Data type: boolean + +status.isDenoiseOn() +status.isDenoiseOff() + +// Determine if AUX heat is enabled +// +// Data type: boolean + +status.isHeaterOn() +status.isHeaterOff() + +// The current Clean Air Delivery Rate +// +// Data type: number (0 - 100) + +status.cadr + +// The filter identifications +// +// Data type: string + +status.g4Id +status.carbonId +status.hepaId + +// The usage percentage for the filters +// +// Data type: number +// Value range: 0 - 100 +// +// 0 indicates the filter is brand new +// 100 indicates the filter is run out, and should be replaced + +status.g4Percent +status.carbonPercent +status.hepaPercent +``` + +## License + +This package is released under [the MIT license](LICENSE). diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..fbde747 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,11 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} **/ +const config = { + preset: 'ts-jest/presets/default-esm', + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + testEnvironment: 'node', + testMatch: ['/src/**/*.test.ts'], +} + +export default config diff --git a/package.json b/package.json index 4568912..d77e5fd 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,39 @@ { - "private": true, + "name": "airmx", + "version": "0.0.1", + "description": "Control AIRMX Pro with JavaScript.", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], "scripts": { + "build": "tsc", + "test": "jest", + "test:watch": "jest --watch --verbose", "format": "prettier --write --cache ." }, - "devDependencies": { - "prettier": "3.6.2" + "repository": { + "type": "git", + "url": "git+https://github.com/openairmx/airmx.git" + }, + "keywords": [ + "airmx", + "mqtt" + ], + "author": "Li Zhineng ", + "license": "MIT", + "bugs": { + "url": "https://github.com/openairmx/airmx/issues" }, - "workspaces": [ - "packages/airmx" - ] + "homepage": "https://github.com/openairmx/airmx#readme", + "peerDependencies": { + "mqtt": "^5.0.0" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "mqtt": "^5.0.0", + "ts-jest": "^29.2.5" + } } diff --git a/packages/airmx/LICENSE b/packages/airmx/LICENSE deleted file mode 100644 index b72df28..0000000 --- a/packages/airmx/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 Li Zhineng - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/airmx/README.md b/packages/airmx/README.md deleted file mode 100644 index c5cce11..0000000 --- a/packages/airmx/README.md +++ /dev/null @@ -1,161 +0,0 @@ -# Control AIRMX Pro with JavaScript - -The package utilizes the MQTT protocol to communicate with your AIRMX devices. -Once connected to the server, it constantly monitors the status updates from -your machines. Additionally, it provides a set of useful APIs to facilitate -smooth control of your devices. - -## Installation - -The package can be installed via NPM: - -```bash -npm i airmx -``` - -## Usage - -First of all, we need to initialize an AIRMX client before we can monitor or -control our machines: - -```typescript -import mqtt from 'mqtt' - -const airmx = new Airmx({ - mqtt: mqtt.connect('mqtt://'), - devices: [ - { id: , key: '' } - ] -}) -``` - -You can register a handler when an AIRMX Pro sends us its latest status. - -```typescript -airmx.onEagleUpdate((status: EagleStatus) => { - console.log(`🎈 AIRMX: ${status.deviceId} ${status.power ? 'on' : 'off'}`) -}) -``` - -Sending commands directly to your machines is simple with the control API. -Just provide the device ID and control data. - -```typescript -airmx.control(1, { - power: 1, // 1 indicates on - mode: 0, // 0 indicates manual control - cadr: 47, // CADR accepts a number range from 0 - 100 - denoise: 0, // 0 indicates off - heatStatus: 0 // 0 indicates off -}) -``` - -Dealing with the control API's fixed data structure can be tedious. That's -why we built a semantic and fluent API for easier device control. Its method -names are so intuitive, you'll instantly grasp their function. - -```typescript -// Turn on the machine -airmx.device(1).on() - -// Turn off the machine -airmx.device(1).off() - -// Turn on heat mode -airmx.device(1).heatOn() - -// Turn off heat mode -airmx.device(1).heatOff() - -// Turn on noise cancelling mode -airmx.device(1).denoiseOn() - -// Turn off noise cancelling mode -airmx.device(1).denoiseOff() - -// Adjust the CADR value (fan speed) -airmx.device(1).cadr(47) -``` - -The library replicates the built-in modes available in the official mobile -applications: - -- **AI Mode:** Automatically adjusts CADR (Clean Air Delivery Rate) based - on data from a paired air monitor. -- **Silent Mode:** Reduces fan speed to minimize noise output. -- **Turbo Mode:** Maximizes the device's purification capability, operating - at 100% fan speed for rapid air cleaning. - -```typescript -airmx.device(1).ai() -airmx.device(1).slient() -airmx.device(1).turbo() -``` - -Whenever a device sends an update or you request its current state, you'll -get an EagleStatus object with all the latest information. - -```typescript -airmx.device(1).status() -``` - -## AIRMX Pro Status Reference - -```typescript -// Determine the device's power status -// -// Data type: boolean - -status.isOn() -status.isOff() - -// Determine if the machine is set to silent mode -// -// Data type: boolean - -status.isSilentMode() - -// Determine if noise cancelling is enabled -// -// Data type: boolean - -status.isDenoiseOn() -status.isDenoiseOff() - -// Determine if AUX heat is enabled -// -// Data type: boolean - -status.isHeaterOn() -status.isHeaterOff() - -// The current Clean Air Delivery Rate -// -// Data type: number (0 - 100) - -status.cadr - -// The filter identifications -// -// Data type: string - -status.g4Id -status.carbonId -status.hepaId - -// The usage percentage for the filters -// -// Data type: number -// Value range: 0 - 100 -// -// 0 indicates the filter is brand new -// 100 indicates the filter is run out, and should be replaced - -status.g4Percent -status.carbonPercent -status.hepaPercent -``` - -## License - -This package is released under [the MIT license](LICENSE). diff --git a/packages/airmx/jest.config.js b/packages/airmx/jest.config.js deleted file mode 100644 index fbde747..0000000 --- a/packages/airmx/jest.config.js +++ /dev/null @@ -1,11 +0,0 @@ -/** @type {import('ts-jest').JestConfigWithTsJest} **/ -const config = { - preset: 'ts-jest/presets/default-esm', - moduleNameMapper: { - '^(\\.{1,2}/.*)\\.js$': '$1', - }, - testEnvironment: 'node', - testMatch: ['/src/**/*.test.ts'], -} - -export default config diff --git a/packages/airmx/package.json b/packages/airmx/package.json deleted file mode 100644 index 9125dac..0000000 --- a/packages/airmx/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "airmx", - "version": "0.0.1", - "description": "Control AIRMX Pro with JavaScript.", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "jest", - "test:watch": "jest --watch --verbose" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/openairmx/airmx.git" - }, - "keywords": [ - "airmx", - "mqtt" - ], - "author": "Li Zhineng ", - "license": "MIT", - "bugs": { - "url": "https://github.com/openairmx/airmx/issues" - }, - "homepage": "https://github.com/openairmx/airmx#readme", - "peerDependencies": { - "mqtt": "^5.0.0" - }, - "devDependencies": { - "@types/jest": "^29.5.14", - "mqtt": "^5.0.0", - "ts-jest": "^29.2.5" - } -} diff --git a/packages/airmx/src/airmx.test.ts b/packages/airmx/src/airmx.test.ts deleted file mode 100644 index aee5bbe..0000000 --- a/packages/airmx/src/airmx.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { MqttClient } from 'mqtt' -import { Airmx, Topic } from './airmx.js' -import { EagleStatus } from './eagle.js' - -describe('topic', () => { - test('parse parses topic from string', () => { - const topic = Topic.parse('airmx/01/0/1/1/1/12345') - expect(topic.deviceId).toBe(12345) - expect(topic.unknown1).toBe(false) - expect(topic.unknown2).toBe(true) - expect(topic.unknown3).toBe(true) - expect(topic.unknown4).toBe(true) - }) - - test('the topic format is expected to have 7 parts', () => { - expect(() => Topic.parse('foo')).toThrow( - 'The topic format is expected to be airmx/+/+/+/+/+/+.', - ) - }) - - test('the 1st part is expected to be airmx', () => { - expect(() => Topic.parse('foo/01/0/1/1/1/12345')).toThrow( - 'The 1st part of the topic must be "airmx".', - ) - }) - - test('the 2nd part is expected to be "01"', () => { - expect(() => Topic.parse('airmx/00/0/1/1/1/12345')).toThrow( - 'The 2nd part of the topic must be "01".', - ) - }) - - test('the 3rd part is expected to be either "1" or "0"', () => { - expect(Topic.parse('airmx/01/0/1/1/1/12345')).toBeInstanceOf(Topic) - expect(Topic.parse('airmx/01/1/1/1/1/12345')).toBeInstanceOf(Topic) - - expect(() => Topic.parse('airmx/01/2/1/1/1/12345')).toThrow( - 'The 3rd part of the topic must be either "1" or "0".', - ) - }) - - test('the 4th part is expected to be either "1" or "0"', () => { - expect(Topic.parse('airmx/01/0/0/1/1/12345')).toBeInstanceOf(Topic) - expect(Topic.parse('airmx/01/0/1/1/1/12345')).toBeInstanceOf(Topic) - - expect(() => Topic.parse('airmx/01/0/2/1/1/12345')).toThrow( - 'The 4th part of the topic must be either "1" or "0".', - ) - }) - - test('the 5th part is expected to be either "1" or "0"', () => { - expect(Topic.parse('airmx/01/0/1/0/1/12345')).toBeInstanceOf(Topic) - expect(Topic.parse('airmx/01/0/1/1/1/12345')).toBeInstanceOf(Topic) - - expect(() => Topic.parse('airmx/01/0/1/2/1/12345')).toThrow( - 'The 5th part of the topic must be either "1" or "0".', - ) - }) - - test('the 6th part is expected to be either "1" or "0"', () => { - expect(Topic.parse('airmx/01/0/1/1/0/12345')).toBeInstanceOf(Topic) - expect(Topic.parse('airmx/01/0/1/1/1/12345')).toBeInstanceOf(Topic) - - expect(() => Topic.parse('airmx/01/0/1/1/2/12345')).toThrow( - 'The 6th part of the topic must be either "1" or "0".', - ) - }) - - test('the 7th part is expected to be the device id', () => { - expect(() => Topic.parse('airmx/01/0/1/1/1/')).toThrow( - 'The 7th part of the topic must be a device ID.', - ) - - expect(() => Topic.parse('airmx/01/0/1/1/1/foo')).toThrow( - 'The 7th part of the topic must be a device ID.', - ) - }) -}) - -describe('airmx', () => { - let mockMqttClient: jest.Mocked - - beforeEach(() => { - mockMqttClient = { - on: jest.fn(), - subscribe: jest.fn(), - } as unknown as jest.Mocked - }) - - it('should subscribe to the topic when the client connects', () => { - new Airmx({ mqtt: mockMqttClient, devices: [] }) - const connectHandler = mockMqttClient.on.mock.calls.find( - ([event]) => event === 'connect', - )?.[1] as (() => void) | undefined - connectHandler?.() - expect(mockMqttClient.subscribe).toHaveBeenCalledWith('airmx/01/+/+/1/1/+') - }) - - it('should record the latest status for eagles', () => { - const testDevice = { id: 1, key: 'f0eb21fe346c88e1d1ac73546022cd5d' } - const message = - '{"cmdId": 210,"name":"eagleStatus","time":1752675701,"from":2,"data":{"version":"10.00.17","power":1,"heatStatus":0,"mode":0,"cadr":47,"prm":1320,"g4Percent": 100,"hepaPercent":100,"carbonId":"031","g4Id":"041","hepaId":"021","carbonPercent":17,"diffPressure1":99999,"diffPressure2":99999,"t0":35,"status":0,"denoise":1},"sig":"b8796682da77e8c929dddf7e6461afec"}' - - const airmx = new Airmx({ mqtt: mockMqttClient, devices: [testDevice] }) - const messageHandler = mockMqttClient.on.mock.calls.find( - ([event]) => event === 'message', - )?.[1] as ((topic: string, message: Buffer) => void) | undefined - expect(messageHandler).toBeDefined() - - messageHandler!('airmx/01/0/1/1/1/1', Buffer.from(message)) - const status = airmx.getEagleStatus(1) - expect(status).toBeInstanceOf(EagleStatus) - }) - - describe('message validation', () => { - const testDevice = { id: 1, key: 'f0eb21fe346c88e1d1ac73546022cd5d' } - let messageHandler: ((topic: string, message: Buffer) => void) | undefined - - beforeEach(() => { - new Airmx({ mqtt: mockMqttClient, devices: [testDevice] }) - messageHandler = mockMqttClient.on.mock.calls.find( - ([event]) => event === 'message', - )?.[1] as ((topic: string, message: Buffer) => void) | undefined - expect(messageHandler).toBeDefined() - }) - - const validMessage = - '{"cmdId": 210,"name":"eagleStatus","time":1752675701,"from":2,"data":{"version":"10.00.17","power":1,"heatStatus":0,"mode":0,"cadr":47,"prm":1320,"g4Percent": 100,"hepaPercent":100,"carbonId":"031","g4Id":"041","hepaId":"021","carbonPercent":17,"diffPressure1":99999,"diffPressure2":99999,"t0":35,"status":0,"denoise":1},"sig":"b8796682da77e8c929dddf7e6461afec"}' - const invalidMessage = - '{"cmdId": 210,"name":"eagleStatus","time":1752675701,"from":2,"data":{"version":"10.00.17","power":1,"heatStatus":0,"mode":0,"cadr":47,"prm":1320,"g4Percent": 100,"hepaPercent":100,"carbonId":"031","g4Id":"041","hepaId":"021","carbonPercent":17,"diffPressure1":99999,"diffPressure2":99999,"t0":35,"status":0,"denoise":1},"sig":"invalid"}' - - it('should validate message signatures when receiving messages', () => { - expect(() => { - messageHandler?.('airmx/01/0/1/1/1/1', Buffer.from(validMessage)) - }).not.toThrow() - }) - - it('should throw errors when message signatures are invalid', () => { - expect(() => { - messageHandler?.('airmx/01/0/1/1/1/1', Buffer.from(invalidMessage)) - }).toThrow('Failed to validate the message.') - }) - - it('should throw errors when device does not exist', () => { - expect(() => { - messageHandler?.('airmx/01/0/1/1/1/99999', Buffer.from(validMessage)) - }).toThrow('Could not find the device with ID 99999.') - }) - }) -}) diff --git a/packages/airmx/src/airmx.ts b/packages/airmx/src/airmx.ts deleted file mode 100644 index a507260..0000000 --- a/packages/airmx/src/airmx.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { MqttClient } from 'mqtt' - -import type { - Config, - SnowListener, - EagleListener, - EagleControlData, -} from './types.js' -import type { CommandMessage } from './messages.js' - -import { EagleControlMesasge, InstantPushMessage } from './messages.js' -import { EagleController, EagleStatus } from './eagle.js' -import { Signer } from './util.js' -import { SnowStatus } from './snow.js' - -export class Topic { - constructor( - public readonly unknown1: boolean, - public readonly unknown2: boolean, - public readonly unknown3: boolean, - public readonly unknown4: boolean, - public readonly deviceId: number, - ) { - // - } - - static parse(topic: string) { - const components = topic.split('/') - - if (components.length !== 7) { - throw new Error('The topic format is expected to be airmx/+/+/+/+/+/+.') - } - - if (components[0] !== 'airmx') { - throw new Error('The 1st part of the topic must be "airmx".') - } - - if (components[1] !== '01') { - throw new Error('The 2nd part of the topic must be "01".') - } - - for (let i = 2; i < 6; i++) { - if (components[i] !== '0' && components[i] !== '1') { - const ordinal = `${i + 1}${i + 1 === 3 ? 'rd' : 'th'}` - throw new Error( - `The ${ordinal} part of the topic must be either "1" or "0".`, - ) - } - } - - const deviceId = components[6] - if (deviceId === '' || !/^\d+$/.test(deviceId)) { - throw new Error('The 7th part of the topic must be a device ID.') - } - - return new this( - components[2] === '1', - components[3] === '1', - components[4] === '1', - components[5] === '1', - +deviceId, - ) - } -} - -interface AirmxListeners { - eagle: EagleListener[] - snow: SnowListener[] -} - -export class Airmx { - #listeners: AirmxListeners = { - eagle: [], - snow: [], - } - - #client: MqttClient - - #signer - - /** Pairs of device ID and its latest running status. */ - #eagles - - constructor(private readonly config: Config) { - this.#client = this.config.mqtt - this.#client.on('connect', this.#handleConnect.bind(this)) - this.#client.on('message', this.#handleMessage.bind(this)) - this.#signer = new Signer() - this.#eagles = new Map() - } - - onSnowUpdate(callback: SnowListener) { - this.#listeners.snow.push(callback) - return this - } - - onEagleUpdate(callback: EagleListener) { - this.#listeners.eagle.push(callback) - return this - } - - #handleConnect() { - this.#client.subscribe('airmx/01/+/+/1/1/+') - - // After successfully connecting to the MQTT server, we need to retrieve - // the latest statuses for all devices instead of waiting for them to - // notify us. It also enables us to make partial tweaks to devices. - this.#dispatchAll(InstantPushMessage.make(2, 1)) - } - - #handleMessage(topic: string, message: Buffer): void { - const { deviceId } = Topic.parse(topic) - - const str = message.toString() - const data = JSON.parse(str) - this.#validateMessage(deviceId, str, data.sig) - - switch (data.cmdId) { - case SnowStatus.commandId(): - this.#notifySnow(SnowStatus.from(deviceId, data)) - break - case EagleStatus.commandId(): - const status = EagleStatus.from(deviceId, data) - this.#eagles.set(deviceId, status) - this.#notifyEagle(status) - break - } - } - - #notifySnow(status: SnowStatus) { - this.#listeners.snow.forEach((listener) => listener(status)) - } - - #notifyEagle(status: EagleStatus) { - this.#listeners.eagle.forEach((listener) => listener(status)) - } - - #validateMessage(deviceId: number, message: string, sig: string) { - const device = this.#getDevice(deviceId) - const plainText = message.slice(1, message.lastIndexOf(',"sig"')) - const calculated = this.#signer.signText(plainText, device.key) - if (calculated !== sig) { - throw new Error('Failed to validate the message.') - } - } - - control(deviceId: number, data: EagleControlData) { - this.#dispatch(deviceId, EagleControlMesasge.make(data)) - } - - #dispatch(deviceId: number, message: CommandMessage) { - const device = this.#getDevice(deviceId) - const sig = this.#signer.sign(message, device.key) - const payload = { ...message.payload(), sig } - this.#client.publish( - `airmx/01/1/1/0/1/${deviceId}`, - JSON.stringify(payload), - ) - } - - #dispatchAll(message: CommandMessage) { - for (const device of this.config.devices) { - this.#dispatch(device.id, message) - } - } - - #getDevice(deviceId: number) { - const device = this.config.devices.find((device) => device.id === deviceId) - if (device === undefined) { - throw new Error(`Could not find the device with ID ${deviceId}.`) - } - return device - } - - getEagleStatus(deviceId: number) { - return this.#eagles.get(deviceId) - } - - /** - * Specify an AIRMX Pro device. - * - * @param deviceId - The device ID. - * @returns The device controller. - */ - device(deviceId: number) { - return new EagleController(this, deviceId) - } -} diff --git a/packages/airmx/src/eagle.test.ts b/packages/airmx/src/eagle.test.ts deleted file mode 100644 index 2af1bd4..0000000 --- a/packages/airmx/src/eagle.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { Message, EagleStatusData } from './types.js' - -import { EagleStatus } from './eagle.js' -import { EagleMode } from './types.js' - -test('from parses message to eagle status', () => { - const status = EagleStatus.from(12345, createStubStatusData()) - expect(status).toBeInstanceOf(EagleStatus) -}) - -test('data resolution', () => { - const status = new EagleStatus(12345, createStubStatusData()) - expect(status.deviceId).toBe(12345) - expect(status.power).toBe(1) - expect(status.mode).toBe(2) - expect(status.cadr).toBe(17) - expect(status.denoise).toBe(0) - expect(status.heatStatus).toBe(0) - expect(status.status).toBe(0) - expect(status.prm).toBe(660) - expect(status.temperature).toBe(28) - expect(status.g4Id).toBe('0111111') - expect(status.g4Percent).toBe(20) - expect(status.carbonId).toBe('0222222') - expect(status.carbonPercent).toBe(30) - expect(status.hepaId).toBe('0333333') - expect(status.hepaPercent).toBe(40) - expect(status.version).toBe('10.00.17') -}) - -test('isOn determines if the power is on', () => { - const status = new EagleStatus(12345, createStubStatusData({ power: 1 })) - expect(status.isOn()).toBe(true) - expect(status.isOff()).toBe(false) -}) - -test('isOff determines if the power is off', () => { - const status = new EagleStatus(12345, createStubStatusData({ power: 0 })) - expect(status.isOff()).toBe(true) - expect(status.isOn()).toBe(false) -}) - -test('mode 2 is the silent mode', () => { - const status = new EagleStatus( - 12345, - createStubStatusData({ mode: EagleMode.Silent }), - ) - expect(status.isSilentMode()).toBe(true) -}) - -test('isDenoiseOn determines if the denoise feature is on', () => { - const status = new EagleStatus(12345, createStubStatusData({ denoise: 1 })) - expect(status.isDenoiseOn()).toBe(true) - expect(status.isDenoiseOff()).toBe(false) -}) - -test('isDenoiseOff determines if the denoise feature is off', () => { - const status = new EagleStatus(12345, createStubStatusData({ denoise: 0 })) - expect(status.isDenoiseOff()).toBe(true) - expect(status.isDenoiseOn()).toBe(false) -}) - -test('isHeaterOn determines if the heater is on', () => { - const status = new EagleStatus(12345, createStubStatusData({ heatStatus: 1 })) - expect(status.isHeaterOn()).toBe(true) - expect(status.isHeaterOff()).toBe(false) -}) - -test('isHeaterOff determines if the heater is off', () => { - const status = new EagleStatus(12345, createStubStatusData({ heatStatus: 0 })) - expect(status.isHeaterOff()).toBe(true) - expect(status.isHeaterOn()).toBe(false) -}) - -const createStubStatusData = ( - data: Partial = {}, -): Message => ({ - cmdId: 210, - data: { - cadr: 17, - carbonId: '0222222', - carbonPercent: 30, - denoise: 0, - diffPressure1: 99999, - diffPressure2: 99999, - g4Id: '0111111', - g4Percent: 20, - heatStatus: 0, - hepaId: '0333333', - hepaPercent: 40, - mode: 2, - power: 1, - prm: 660, - status: 0, - t0: 28, - version: '10.00.17', - ...data, - }, - from: 2, - name: 'eagleStatus', - sig: 'foo', - time: 1700000000, -}) diff --git a/packages/airmx/src/eagle.ts b/packages/airmx/src/eagle.ts deleted file mode 100644 index dbe74cc..0000000 --- a/packages/airmx/src/eagle.ts +++ /dev/null @@ -1,207 +0,0 @@ -import type { Message, EagleStatusData, EagleControlData } from './types.js' - -import { Airmx } from './airmx.js' -import { EagleMode, Switch } from './types.js' - -export class EagleStatus { - constructor( - public readonly deviceId: number, - public readonly message: Message, - ) {} - - static commandId() { - return 210 - } - - static from(deviceId: number, message: Message) { - if (message.cmdId !== this.commandId()) { - throw new Error( - `Eagle status expects a message with command ID ${this.commandId()}.`, - ) - } - - return new this(deviceId, message) - } - - get power() { - return this.message.data.power - } - - isOn() { - return this.power === Switch.On - } - - isOff() { - return !this.isOn() - } - - get mode() { - return this.message.data.mode - } - - isSilentMode() { - return this.mode === EagleMode.Silent - } - - get status() { - return this.message.data.status - } - - get denoise() { - return this.message.data.denoise - } - - isDenoiseOn() { - return this.denoise === Switch.On - } - - isDenoiseOff() { - return !this.isDenoiseOn() - } - - get heatStatus() { - return this.message.data.heatStatus - } - - isHeaterOn() { - return this.heatStatus === Switch.On - } - - isHeaterOff() { - return !this.isHeaterOn() - } - - get cadr() { - return this.message.data.cadr - } - - get prm() { - return this.message.data.prm - } - - get temperature() { - return this.message.data.t0 - } - - get g4Id() { - return this.message.data.g4Id - } - - get g4Percent() { - return this.message.data.g4Percent - } - - get carbonId() { - return this.message.data.carbonId - } - - get carbonPercent() { - return this.message.data.carbonPercent - } - - get hepaId() { - return this.message.data.hepaId - } - - get hepaPercent() { - return this.message.data.hepaPercent - } - - get version() { - return this.message.data.version - } - - toControlData(): EagleControlData { - const { power, heatStatus, mode, cadr, denoise } = this.message.data - return { power, heatStatus, mode, cadr, denoise } - } -} - -export class EagleController { - constructor( - private readonly airmx: Airmx, - private readonly deviceId: number, - ) {} - - on() { - this.#send({ power: Switch.On }) - } - - off() { - this.#send({ power: Switch.Off }) - } - - heatOn() { - this.#send({ heatStatus: Switch.On }) - } - - heatOff() { - this.#send({ heatStatus: Switch.Off }) - } - - denoiseOn() { - this.#send({ denoise: Switch.On }) - } - - denoiseOff() { - this.#send({ denoise: Switch.Off }) - } - - cadr(cadr: number) { - this.#send({ - power: Switch.On, - mode: EagleMode.Manual, - cadr, - }) - } - - /** - * Automate the fan speed based on the data from the air monitor. - */ - ai() { - this.#send({ - power: Switch.On, - mode: EagleMode.Ai, - }) - } - - /** - * Activate silent mode to minimize fan noise. - */ - silent() { - this.#send({ - power: Switch.On, - mode: EagleMode.Silent, - }) - } - - /** - * Activate turbo mode for optimum air purification. - */ - turbo() { - this.#send({ - power: Switch.On, - mode: EagleMode.Turbo, - cadr: 100, - }) - } - - status() { - const status = this.airmx.getEagleStatus(this.deviceId) - - if (status === undefined) { - throw new Error( - `Could not retrieve the status of the device with ID ${this.deviceId}.`, - ) - } - - return status - } - - #send(data: Partial) { - this.airmx.control(this.deviceId, { - ...this.status().toControlData(), - ...data, - }) - } -} diff --git a/packages/airmx/src/index.ts b/packages/airmx/src/index.ts deleted file mode 100644 index 1002c35..0000000 --- a/packages/airmx/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { Airmx } from './airmx.js' -export type { SnowStatus } from './snow.js' -export type { EagleStatus, EagleController } from './eagle.js' -export type * from './types.js' diff --git a/packages/airmx/src/messages.ts b/packages/airmx/src/messages.ts deleted file mode 100644 index 66c63d1..0000000 --- a/packages/airmx/src/messages.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { EagleControlData, InstantPushData } from './types.js' -import { MessageSource } from './types.js' - -export class CommandMessage { - constructor( - readonly commandId: number, - readonly commandName: string, - readonly data: T, - readonly time: number, - readonly from: number, - ) {} - - payload() { - return { - cmdId: this.commandId, - name: this.commandName, - time: this.time, - from: this.from, - data: this.data, - } - } -} - -const current = () => { - return Math.floor(new Date().getTime() / 1000) -} - -export class InstantPushMessage extends CommandMessage { - static commandId() { - return 40 - } - - /** - * @param frequency - Report frequency in seconds. - * @param duration - Report duration in seconds. - */ - static make(frequency: number, duration: number) { - const data = { - frequencyTime: frequency, - durationTime: duration, - } - return new InstantPushMessage( - this.commandId(), - 'instantPush', - data, - current(), - MessageSource.App_Android, - ) - } -} - -export class EagleControlMesasge extends CommandMessage { - static commandId() { - return 100 - } - - static make(data: EagleControlData) { - const timestamp = Math.floor(new Date().getTime() / 1000) - return new EagleControlMesasge( - this.commandId(), - 'control', - data, - timestamp, - MessageSource.App_Android, - ) - } -} diff --git a/packages/airmx/src/snow.ts b/packages/airmx/src/snow.ts deleted file mode 100644 index a5a0c05..0000000 --- a/packages/airmx/src/snow.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { Message, SnowStatusData } from './types.js' -import { BatteryState, SensorState } from './types.js' - -export class SnowStatus { - constructor( - public readonly deviceId: number, - public readonly message: Message, - ) { - // - } - - static commandId() { - return 200 - } - - static from(deviceId: number, message: Message) { - if (message.cmdId !== this.commandId()) { - throw new Error( - `Snow status expects a message with command ID "${this.commandId()}".`, - ) - } - - return new this(deviceId, message) - } - - get battery() { - return this.message.data.battery - } - - get batteryState() { - return this.message.data.battery_state - } - - isCharging() { - return this.message.data.battery_state === BatteryState.Charging - } - - isDischarge() { - return this.message.data.battery_state === BatteryState.Discharge - } - - get temperature() { - return this.message.data.t / 100 - } - - get temperatureState() { - return this.message.data.temp_state - } - - get isTemperatureSampling() { - return this.message.data.temp_state === SensorState.Sampling - } - - get temperatureUnit() { - return this.message.data.temp_unit - } - - get outdoorTemperature() { - return this.message.data.ot / 100 - } - - get humidity() { - return this.message.data.h / 100 - } - - get humidityState() { - return this.message.data.humi_state - } - - get isHumiditySampling() { - return this.message.data.humi_state === SensorState.Sampling - } - - get outdoorHumidity() { - return this.message.data.oh / 100 - } - - /** - * The PM2.5 measurement. - */ - get pm25() { - return this.message.data.pm25 - } - - /** - * The PM10 measurement. - */ - get pm100() { - return this.message.data.pm100 - } - - /** - * The outdoor PM2.5 measurement. - */ - get outdoorPm25() { - return this.message.data.opm25 - } - - /** - * The outdoor PM10 measurement. - */ - get outdoorPm100() { - return this.message.data.opm100 - } - - get pmState() { - return this.message.data.pm_state - } - - isPmSampling() { - return this.message.data.pm_state === SensorState.Sampling - } - - get pmTime() { - return this.message.data.pm_time - } - - get co2() { - return this.message.data.co2 - } - - get co2State() { - return this.message.data.co2_state - } - - isCo2Sampling() { - return this.message.data.co2_state === SensorState.Sampling - } - - get co2Time() { - return this.message.data.co2_time - } - - get tvoc() { - return this.message.data.tvoc - } - - get tvocDuration() { - return this.message.data.tvoc_duration - } - - get tvocState() { - return this.message.data.tvoc_state - } - - isTvocSampling() { - return this.message.data.tvoc_state === SensorState.Sampling - } - - get tvocTime() { - return this.message.data.tvoc_time - } - - get tvocUnit() { - return this.message.data.tvoc_unit - } - - get time() { - return this.message.data.time - } - - get version() { - return this.message.data.version - } - - get versionType() { - return this.message.data.version_type - } -} diff --git a/packages/airmx/src/types.ts b/packages/airmx/src/types.ts deleted file mode 100644 index 32ca95b..0000000 --- a/packages/airmx/src/types.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { MqttClient } from 'mqtt' -import type { EagleStatus } from './eagle.js' -import type { SnowStatus } from './snow.js' - -export interface Config { - mqtt: MqttClient - devices: Device[] -} - -export interface Device { - id: number - key: string -} - -export type SnowListener = (status: SnowStatus) => void -export type EagleListener = (status: EagleStatus) => void - -export enum MessageSource { - Snow = 1, - Eagle = 2, - App_iOS = 3, - App_Android = 4, -} - -export interface Message { - cmdId: number - name: string - time: number - from: MessageSource - data: T - sig: string -} - -export enum Switch { - Off = 0, - On, -} - -export enum EagleMode { - Manual = 0, - Ai, - Silent, - Turbo, -} - -export interface EagleStatusData { - version: string - power: Switch - mode: EagleMode - status: number - denoise: Switch - heatStatus: Switch - cadr: number - prm: number - diffPressure1: number - diffPressure2: number - t0: number - g4Id: string - g4Percent: number - carbonId: string - carbonPercent: number - hepaId: string - hepaPercent: number -} - -export enum SensorState { - Sampling = 'sampling', -} - -export enum BatteryState { - Charging = 'charging', - Discharge = 'discharge', -} - -export interface SnowStatusData { - battery: number - battery_state: BatteryState - co2: number - co2_state: SensorState - co2_time: number - h: number - humi_state: SensorState - oh: number - opm100: number - opm25: number - ot: number - pm100: number - pm25: number - pm250: number - pm50: number - pm_state: SensorState - pm_time: number - t: number - temp_state: SensorState - temp_unit: 'c' - time: number - tvoc: number - tvoc_duration: number - tvoc_state: SensorState - tvoc_time: number - tvoc_unit: 'ppb' - version: string - version_type: 'release' -} - -export interface EagleControlData { - power: Switch - heatStatus: Switch - mode: EagleMode - cadr: number - denoise: Switch -} - -export interface InstantPushData { - frequencyTime: number - durationTime: number -} diff --git a/packages/airmx/src/util.ts b/packages/airmx/src/util.ts deleted file mode 100644 index b5cbd4b..0000000 --- a/packages/airmx/src/util.ts +++ /dev/null @@ -1,43 +0,0 @@ -import crypto from 'crypto' -import type { CommandMessage } from './messages.js' - -export class Signer { - /** - * Calculate the signature for the message. - * - * @param message - The command message. - * @param key - The device key. - * @returns An 8-byte signature for the given message. - */ - sign(message: CommandMessage, key: string) { - const plainText = JSON.stringify(message.payload()) - return this.#hash(plainText.slice(1, -1), key) - } - - /** - * Calculate the signature for the plain text. - * - * @param message - The plain text. - * @param key - The device key. - * @returns An 8-byte signature for the given text. - */ - signText(message: string, key: string) { - return this.#hash(message, key) - } - - /** - * Hash the data with the MD5 algorithm. - * - * @param data - The plain text. - * @param key - The device key. - * @returns An 8-byte signature for the given data. - */ - #hash(data: string, key: string) { - return crypto - .createHash('md5') - .update(data) - .update(',') - .update(key) - .digest('hex') - } -} diff --git a/packages/airmx/tsconfig.json b/packages/airmx/tsconfig.json deleted file mode 100644 index 74ccc18..0000000 --- a/packages/airmx/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "es2022", - "module": "nodenext", - "moduleResolution": "nodenext", - "rootDir": "./src", - "outDir": "dist", - "esModuleInterop": true, - "strict": true, - "skipLibCheck": true, - "declaration": true - }, - "include": ["src"], - "exclude": ["**/*.test.ts"] -} diff --git a/src/airmx.test.ts b/src/airmx.test.ts new file mode 100644 index 0000000..aee5bbe --- /dev/null +++ b/src/airmx.test.ts @@ -0,0 +1,150 @@ +import { MqttClient } from 'mqtt' +import { Airmx, Topic } from './airmx.js' +import { EagleStatus } from './eagle.js' + +describe('topic', () => { + test('parse parses topic from string', () => { + const topic = Topic.parse('airmx/01/0/1/1/1/12345') + expect(topic.deviceId).toBe(12345) + expect(topic.unknown1).toBe(false) + expect(topic.unknown2).toBe(true) + expect(topic.unknown3).toBe(true) + expect(topic.unknown4).toBe(true) + }) + + test('the topic format is expected to have 7 parts', () => { + expect(() => Topic.parse('foo')).toThrow( + 'The topic format is expected to be airmx/+/+/+/+/+/+.', + ) + }) + + test('the 1st part is expected to be airmx', () => { + expect(() => Topic.parse('foo/01/0/1/1/1/12345')).toThrow( + 'The 1st part of the topic must be "airmx".', + ) + }) + + test('the 2nd part is expected to be "01"', () => { + expect(() => Topic.parse('airmx/00/0/1/1/1/12345')).toThrow( + 'The 2nd part of the topic must be "01".', + ) + }) + + test('the 3rd part is expected to be either "1" or "0"', () => { + expect(Topic.parse('airmx/01/0/1/1/1/12345')).toBeInstanceOf(Topic) + expect(Topic.parse('airmx/01/1/1/1/1/12345')).toBeInstanceOf(Topic) + + expect(() => Topic.parse('airmx/01/2/1/1/1/12345')).toThrow( + 'The 3rd part of the topic must be either "1" or "0".', + ) + }) + + test('the 4th part is expected to be either "1" or "0"', () => { + expect(Topic.parse('airmx/01/0/0/1/1/12345')).toBeInstanceOf(Topic) + expect(Topic.parse('airmx/01/0/1/1/1/12345')).toBeInstanceOf(Topic) + + expect(() => Topic.parse('airmx/01/0/2/1/1/12345')).toThrow( + 'The 4th part of the topic must be either "1" or "0".', + ) + }) + + test('the 5th part is expected to be either "1" or "0"', () => { + expect(Topic.parse('airmx/01/0/1/0/1/12345')).toBeInstanceOf(Topic) + expect(Topic.parse('airmx/01/0/1/1/1/12345')).toBeInstanceOf(Topic) + + expect(() => Topic.parse('airmx/01/0/1/2/1/12345')).toThrow( + 'The 5th part of the topic must be either "1" or "0".', + ) + }) + + test('the 6th part is expected to be either "1" or "0"', () => { + expect(Topic.parse('airmx/01/0/1/1/0/12345')).toBeInstanceOf(Topic) + expect(Topic.parse('airmx/01/0/1/1/1/12345')).toBeInstanceOf(Topic) + + expect(() => Topic.parse('airmx/01/0/1/1/2/12345')).toThrow( + 'The 6th part of the topic must be either "1" or "0".', + ) + }) + + test('the 7th part is expected to be the device id', () => { + expect(() => Topic.parse('airmx/01/0/1/1/1/')).toThrow( + 'The 7th part of the topic must be a device ID.', + ) + + expect(() => Topic.parse('airmx/01/0/1/1/1/foo')).toThrow( + 'The 7th part of the topic must be a device ID.', + ) + }) +}) + +describe('airmx', () => { + let mockMqttClient: jest.Mocked + + beforeEach(() => { + mockMqttClient = { + on: jest.fn(), + subscribe: jest.fn(), + } as unknown as jest.Mocked + }) + + it('should subscribe to the topic when the client connects', () => { + new Airmx({ mqtt: mockMqttClient, devices: [] }) + const connectHandler = mockMqttClient.on.mock.calls.find( + ([event]) => event === 'connect', + )?.[1] as (() => void) | undefined + connectHandler?.() + expect(mockMqttClient.subscribe).toHaveBeenCalledWith('airmx/01/+/+/1/1/+') + }) + + it('should record the latest status for eagles', () => { + const testDevice = { id: 1, key: 'f0eb21fe346c88e1d1ac73546022cd5d' } + const message = + '{"cmdId": 210,"name":"eagleStatus","time":1752675701,"from":2,"data":{"version":"10.00.17","power":1,"heatStatus":0,"mode":0,"cadr":47,"prm":1320,"g4Percent": 100,"hepaPercent":100,"carbonId":"031","g4Id":"041","hepaId":"021","carbonPercent":17,"diffPressure1":99999,"diffPressure2":99999,"t0":35,"status":0,"denoise":1},"sig":"b8796682da77e8c929dddf7e6461afec"}' + + const airmx = new Airmx({ mqtt: mockMqttClient, devices: [testDevice] }) + const messageHandler = mockMqttClient.on.mock.calls.find( + ([event]) => event === 'message', + )?.[1] as ((topic: string, message: Buffer) => void) | undefined + expect(messageHandler).toBeDefined() + + messageHandler!('airmx/01/0/1/1/1/1', Buffer.from(message)) + const status = airmx.getEagleStatus(1) + expect(status).toBeInstanceOf(EagleStatus) + }) + + describe('message validation', () => { + const testDevice = { id: 1, key: 'f0eb21fe346c88e1d1ac73546022cd5d' } + let messageHandler: ((topic: string, message: Buffer) => void) | undefined + + beforeEach(() => { + new Airmx({ mqtt: mockMqttClient, devices: [testDevice] }) + messageHandler = mockMqttClient.on.mock.calls.find( + ([event]) => event === 'message', + )?.[1] as ((topic: string, message: Buffer) => void) | undefined + expect(messageHandler).toBeDefined() + }) + + const validMessage = + '{"cmdId": 210,"name":"eagleStatus","time":1752675701,"from":2,"data":{"version":"10.00.17","power":1,"heatStatus":0,"mode":0,"cadr":47,"prm":1320,"g4Percent": 100,"hepaPercent":100,"carbonId":"031","g4Id":"041","hepaId":"021","carbonPercent":17,"diffPressure1":99999,"diffPressure2":99999,"t0":35,"status":0,"denoise":1},"sig":"b8796682da77e8c929dddf7e6461afec"}' + const invalidMessage = + '{"cmdId": 210,"name":"eagleStatus","time":1752675701,"from":2,"data":{"version":"10.00.17","power":1,"heatStatus":0,"mode":0,"cadr":47,"prm":1320,"g4Percent": 100,"hepaPercent":100,"carbonId":"031","g4Id":"041","hepaId":"021","carbonPercent":17,"diffPressure1":99999,"diffPressure2":99999,"t0":35,"status":0,"denoise":1},"sig":"invalid"}' + + it('should validate message signatures when receiving messages', () => { + expect(() => { + messageHandler?.('airmx/01/0/1/1/1/1', Buffer.from(validMessage)) + }).not.toThrow() + }) + + it('should throw errors when message signatures are invalid', () => { + expect(() => { + messageHandler?.('airmx/01/0/1/1/1/1', Buffer.from(invalidMessage)) + }).toThrow('Failed to validate the message.') + }) + + it('should throw errors when device does not exist', () => { + expect(() => { + messageHandler?.('airmx/01/0/1/1/1/99999', Buffer.from(validMessage)) + }).toThrow('Could not find the device with ID 99999.') + }) + }) +}) diff --git a/src/airmx.ts b/src/airmx.ts new file mode 100644 index 0000000..a507260 --- /dev/null +++ b/src/airmx.ts @@ -0,0 +1,188 @@ +import { MqttClient } from 'mqtt' + +import type { + Config, + SnowListener, + EagleListener, + EagleControlData, +} from './types.js' +import type { CommandMessage } from './messages.js' + +import { EagleControlMesasge, InstantPushMessage } from './messages.js' +import { EagleController, EagleStatus } from './eagle.js' +import { Signer } from './util.js' +import { SnowStatus } from './snow.js' + +export class Topic { + constructor( + public readonly unknown1: boolean, + public readonly unknown2: boolean, + public readonly unknown3: boolean, + public readonly unknown4: boolean, + public readonly deviceId: number, + ) { + // + } + + static parse(topic: string) { + const components = topic.split('/') + + if (components.length !== 7) { + throw new Error('The topic format is expected to be airmx/+/+/+/+/+/+.') + } + + if (components[0] !== 'airmx') { + throw new Error('The 1st part of the topic must be "airmx".') + } + + if (components[1] !== '01') { + throw new Error('The 2nd part of the topic must be "01".') + } + + for (let i = 2; i < 6; i++) { + if (components[i] !== '0' && components[i] !== '1') { + const ordinal = `${i + 1}${i + 1 === 3 ? 'rd' : 'th'}` + throw new Error( + `The ${ordinal} part of the topic must be either "1" or "0".`, + ) + } + } + + const deviceId = components[6] + if (deviceId === '' || !/^\d+$/.test(deviceId)) { + throw new Error('The 7th part of the topic must be a device ID.') + } + + return new this( + components[2] === '1', + components[3] === '1', + components[4] === '1', + components[5] === '1', + +deviceId, + ) + } +} + +interface AirmxListeners { + eagle: EagleListener[] + snow: SnowListener[] +} + +export class Airmx { + #listeners: AirmxListeners = { + eagle: [], + snow: [], + } + + #client: MqttClient + + #signer + + /** Pairs of device ID and its latest running status. */ + #eagles + + constructor(private readonly config: Config) { + this.#client = this.config.mqtt + this.#client.on('connect', this.#handleConnect.bind(this)) + this.#client.on('message', this.#handleMessage.bind(this)) + this.#signer = new Signer() + this.#eagles = new Map() + } + + onSnowUpdate(callback: SnowListener) { + this.#listeners.snow.push(callback) + return this + } + + onEagleUpdate(callback: EagleListener) { + this.#listeners.eagle.push(callback) + return this + } + + #handleConnect() { + this.#client.subscribe('airmx/01/+/+/1/1/+') + + // After successfully connecting to the MQTT server, we need to retrieve + // the latest statuses for all devices instead of waiting for them to + // notify us. It also enables us to make partial tweaks to devices. + this.#dispatchAll(InstantPushMessage.make(2, 1)) + } + + #handleMessage(topic: string, message: Buffer): void { + const { deviceId } = Topic.parse(topic) + + const str = message.toString() + const data = JSON.parse(str) + this.#validateMessage(deviceId, str, data.sig) + + switch (data.cmdId) { + case SnowStatus.commandId(): + this.#notifySnow(SnowStatus.from(deviceId, data)) + break + case EagleStatus.commandId(): + const status = EagleStatus.from(deviceId, data) + this.#eagles.set(deviceId, status) + this.#notifyEagle(status) + break + } + } + + #notifySnow(status: SnowStatus) { + this.#listeners.snow.forEach((listener) => listener(status)) + } + + #notifyEagle(status: EagleStatus) { + this.#listeners.eagle.forEach((listener) => listener(status)) + } + + #validateMessage(deviceId: number, message: string, sig: string) { + const device = this.#getDevice(deviceId) + const plainText = message.slice(1, message.lastIndexOf(',"sig"')) + const calculated = this.#signer.signText(plainText, device.key) + if (calculated !== sig) { + throw new Error('Failed to validate the message.') + } + } + + control(deviceId: number, data: EagleControlData) { + this.#dispatch(deviceId, EagleControlMesasge.make(data)) + } + + #dispatch(deviceId: number, message: CommandMessage) { + const device = this.#getDevice(deviceId) + const sig = this.#signer.sign(message, device.key) + const payload = { ...message.payload(), sig } + this.#client.publish( + `airmx/01/1/1/0/1/${deviceId}`, + JSON.stringify(payload), + ) + } + + #dispatchAll(message: CommandMessage) { + for (const device of this.config.devices) { + this.#dispatch(device.id, message) + } + } + + #getDevice(deviceId: number) { + const device = this.config.devices.find((device) => device.id === deviceId) + if (device === undefined) { + throw new Error(`Could not find the device with ID ${deviceId}.`) + } + return device + } + + getEagleStatus(deviceId: number) { + return this.#eagles.get(deviceId) + } + + /** + * Specify an AIRMX Pro device. + * + * @param deviceId - The device ID. + * @returns The device controller. + */ + device(deviceId: number) { + return new EagleController(this, deviceId) + } +} diff --git a/src/eagle.test.ts b/src/eagle.test.ts new file mode 100644 index 0000000..2af1bd4 --- /dev/null +++ b/src/eagle.test.ts @@ -0,0 +1,103 @@ +import type { Message, EagleStatusData } from './types.js' + +import { EagleStatus } from './eagle.js' +import { EagleMode } from './types.js' + +test('from parses message to eagle status', () => { + const status = EagleStatus.from(12345, createStubStatusData()) + expect(status).toBeInstanceOf(EagleStatus) +}) + +test('data resolution', () => { + const status = new EagleStatus(12345, createStubStatusData()) + expect(status.deviceId).toBe(12345) + expect(status.power).toBe(1) + expect(status.mode).toBe(2) + expect(status.cadr).toBe(17) + expect(status.denoise).toBe(0) + expect(status.heatStatus).toBe(0) + expect(status.status).toBe(0) + expect(status.prm).toBe(660) + expect(status.temperature).toBe(28) + expect(status.g4Id).toBe('0111111') + expect(status.g4Percent).toBe(20) + expect(status.carbonId).toBe('0222222') + expect(status.carbonPercent).toBe(30) + expect(status.hepaId).toBe('0333333') + expect(status.hepaPercent).toBe(40) + expect(status.version).toBe('10.00.17') +}) + +test('isOn determines if the power is on', () => { + const status = new EagleStatus(12345, createStubStatusData({ power: 1 })) + expect(status.isOn()).toBe(true) + expect(status.isOff()).toBe(false) +}) + +test('isOff determines if the power is off', () => { + const status = new EagleStatus(12345, createStubStatusData({ power: 0 })) + expect(status.isOff()).toBe(true) + expect(status.isOn()).toBe(false) +}) + +test('mode 2 is the silent mode', () => { + const status = new EagleStatus( + 12345, + createStubStatusData({ mode: EagleMode.Silent }), + ) + expect(status.isSilentMode()).toBe(true) +}) + +test('isDenoiseOn determines if the denoise feature is on', () => { + const status = new EagleStatus(12345, createStubStatusData({ denoise: 1 })) + expect(status.isDenoiseOn()).toBe(true) + expect(status.isDenoiseOff()).toBe(false) +}) + +test('isDenoiseOff determines if the denoise feature is off', () => { + const status = new EagleStatus(12345, createStubStatusData({ denoise: 0 })) + expect(status.isDenoiseOff()).toBe(true) + expect(status.isDenoiseOn()).toBe(false) +}) + +test('isHeaterOn determines if the heater is on', () => { + const status = new EagleStatus(12345, createStubStatusData({ heatStatus: 1 })) + expect(status.isHeaterOn()).toBe(true) + expect(status.isHeaterOff()).toBe(false) +}) + +test('isHeaterOff determines if the heater is off', () => { + const status = new EagleStatus(12345, createStubStatusData({ heatStatus: 0 })) + expect(status.isHeaterOff()).toBe(true) + expect(status.isHeaterOn()).toBe(false) +}) + +const createStubStatusData = ( + data: Partial = {}, +): Message => ({ + cmdId: 210, + data: { + cadr: 17, + carbonId: '0222222', + carbonPercent: 30, + denoise: 0, + diffPressure1: 99999, + diffPressure2: 99999, + g4Id: '0111111', + g4Percent: 20, + heatStatus: 0, + hepaId: '0333333', + hepaPercent: 40, + mode: 2, + power: 1, + prm: 660, + status: 0, + t0: 28, + version: '10.00.17', + ...data, + }, + from: 2, + name: 'eagleStatus', + sig: 'foo', + time: 1700000000, +}) diff --git a/src/eagle.ts b/src/eagle.ts new file mode 100644 index 0000000..dbe74cc --- /dev/null +++ b/src/eagle.ts @@ -0,0 +1,207 @@ +import type { Message, EagleStatusData, EagleControlData } from './types.js' + +import { Airmx } from './airmx.js' +import { EagleMode, Switch } from './types.js' + +export class EagleStatus { + constructor( + public readonly deviceId: number, + public readonly message: Message, + ) {} + + static commandId() { + return 210 + } + + static from(deviceId: number, message: Message) { + if (message.cmdId !== this.commandId()) { + throw new Error( + `Eagle status expects a message with command ID ${this.commandId()}.`, + ) + } + + return new this(deviceId, message) + } + + get power() { + return this.message.data.power + } + + isOn() { + return this.power === Switch.On + } + + isOff() { + return !this.isOn() + } + + get mode() { + return this.message.data.mode + } + + isSilentMode() { + return this.mode === EagleMode.Silent + } + + get status() { + return this.message.data.status + } + + get denoise() { + return this.message.data.denoise + } + + isDenoiseOn() { + return this.denoise === Switch.On + } + + isDenoiseOff() { + return !this.isDenoiseOn() + } + + get heatStatus() { + return this.message.data.heatStatus + } + + isHeaterOn() { + return this.heatStatus === Switch.On + } + + isHeaterOff() { + return !this.isHeaterOn() + } + + get cadr() { + return this.message.data.cadr + } + + get prm() { + return this.message.data.prm + } + + get temperature() { + return this.message.data.t0 + } + + get g4Id() { + return this.message.data.g4Id + } + + get g4Percent() { + return this.message.data.g4Percent + } + + get carbonId() { + return this.message.data.carbonId + } + + get carbonPercent() { + return this.message.data.carbonPercent + } + + get hepaId() { + return this.message.data.hepaId + } + + get hepaPercent() { + return this.message.data.hepaPercent + } + + get version() { + return this.message.data.version + } + + toControlData(): EagleControlData { + const { power, heatStatus, mode, cadr, denoise } = this.message.data + return { power, heatStatus, mode, cadr, denoise } + } +} + +export class EagleController { + constructor( + private readonly airmx: Airmx, + private readonly deviceId: number, + ) {} + + on() { + this.#send({ power: Switch.On }) + } + + off() { + this.#send({ power: Switch.Off }) + } + + heatOn() { + this.#send({ heatStatus: Switch.On }) + } + + heatOff() { + this.#send({ heatStatus: Switch.Off }) + } + + denoiseOn() { + this.#send({ denoise: Switch.On }) + } + + denoiseOff() { + this.#send({ denoise: Switch.Off }) + } + + cadr(cadr: number) { + this.#send({ + power: Switch.On, + mode: EagleMode.Manual, + cadr, + }) + } + + /** + * Automate the fan speed based on the data from the air monitor. + */ + ai() { + this.#send({ + power: Switch.On, + mode: EagleMode.Ai, + }) + } + + /** + * Activate silent mode to minimize fan noise. + */ + silent() { + this.#send({ + power: Switch.On, + mode: EagleMode.Silent, + }) + } + + /** + * Activate turbo mode for optimum air purification. + */ + turbo() { + this.#send({ + power: Switch.On, + mode: EagleMode.Turbo, + cadr: 100, + }) + } + + status() { + const status = this.airmx.getEagleStatus(this.deviceId) + + if (status === undefined) { + throw new Error( + `Could not retrieve the status of the device with ID ${this.deviceId}.`, + ) + } + + return status + } + + #send(data: Partial) { + this.airmx.control(this.deviceId, { + ...this.status().toControlData(), + ...data, + }) + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..1002c35 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,4 @@ +export { Airmx } from './airmx.js' +export type { SnowStatus } from './snow.js' +export type { EagleStatus, EagleController } from './eagle.js' +export type * from './types.js' diff --git a/src/messages.ts b/src/messages.ts new file mode 100644 index 0000000..66c63d1 --- /dev/null +++ b/src/messages.ts @@ -0,0 +1,67 @@ +import type { EagleControlData, InstantPushData } from './types.js' +import { MessageSource } from './types.js' + +export class CommandMessage { + constructor( + readonly commandId: number, + readonly commandName: string, + readonly data: T, + readonly time: number, + readonly from: number, + ) {} + + payload() { + return { + cmdId: this.commandId, + name: this.commandName, + time: this.time, + from: this.from, + data: this.data, + } + } +} + +const current = () => { + return Math.floor(new Date().getTime() / 1000) +} + +export class InstantPushMessage extends CommandMessage { + static commandId() { + return 40 + } + + /** + * @param frequency - Report frequency in seconds. + * @param duration - Report duration in seconds. + */ + static make(frequency: number, duration: number) { + const data = { + frequencyTime: frequency, + durationTime: duration, + } + return new InstantPushMessage( + this.commandId(), + 'instantPush', + data, + current(), + MessageSource.App_Android, + ) + } +} + +export class EagleControlMesasge extends CommandMessage { + static commandId() { + return 100 + } + + static make(data: EagleControlData) { + const timestamp = Math.floor(new Date().getTime() / 1000) + return new EagleControlMesasge( + this.commandId(), + 'control', + data, + timestamp, + MessageSource.App_Android, + ) + } +} diff --git a/src/snow.ts b/src/snow.ts new file mode 100644 index 0000000..a5a0c05 --- /dev/null +++ b/src/snow.ts @@ -0,0 +1,169 @@ +import type { Message, SnowStatusData } from './types.js' +import { BatteryState, SensorState } from './types.js' + +export class SnowStatus { + constructor( + public readonly deviceId: number, + public readonly message: Message, + ) { + // + } + + static commandId() { + return 200 + } + + static from(deviceId: number, message: Message) { + if (message.cmdId !== this.commandId()) { + throw new Error( + `Snow status expects a message with command ID "${this.commandId()}".`, + ) + } + + return new this(deviceId, message) + } + + get battery() { + return this.message.data.battery + } + + get batteryState() { + return this.message.data.battery_state + } + + isCharging() { + return this.message.data.battery_state === BatteryState.Charging + } + + isDischarge() { + return this.message.data.battery_state === BatteryState.Discharge + } + + get temperature() { + return this.message.data.t / 100 + } + + get temperatureState() { + return this.message.data.temp_state + } + + get isTemperatureSampling() { + return this.message.data.temp_state === SensorState.Sampling + } + + get temperatureUnit() { + return this.message.data.temp_unit + } + + get outdoorTemperature() { + return this.message.data.ot / 100 + } + + get humidity() { + return this.message.data.h / 100 + } + + get humidityState() { + return this.message.data.humi_state + } + + get isHumiditySampling() { + return this.message.data.humi_state === SensorState.Sampling + } + + get outdoorHumidity() { + return this.message.data.oh / 100 + } + + /** + * The PM2.5 measurement. + */ + get pm25() { + return this.message.data.pm25 + } + + /** + * The PM10 measurement. + */ + get pm100() { + return this.message.data.pm100 + } + + /** + * The outdoor PM2.5 measurement. + */ + get outdoorPm25() { + return this.message.data.opm25 + } + + /** + * The outdoor PM10 measurement. + */ + get outdoorPm100() { + return this.message.data.opm100 + } + + get pmState() { + return this.message.data.pm_state + } + + isPmSampling() { + return this.message.data.pm_state === SensorState.Sampling + } + + get pmTime() { + return this.message.data.pm_time + } + + get co2() { + return this.message.data.co2 + } + + get co2State() { + return this.message.data.co2_state + } + + isCo2Sampling() { + return this.message.data.co2_state === SensorState.Sampling + } + + get co2Time() { + return this.message.data.co2_time + } + + get tvoc() { + return this.message.data.tvoc + } + + get tvocDuration() { + return this.message.data.tvoc_duration + } + + get tvocState() { + return this.message.data.tvoc_state + } + + isTvocSampling() { + return this.message.data.tvoc_state === SensorState.Sampling + } + + get tvocTime() { + return this.message.data.tvoc_time + } + + get tvocUnit() { + return this.message.data.tvoc_unit + } + + get time() { + return this.message.data.time + } + + get version() { + return this.message.data.version + } + + get versionType() { + return this.message.data.version_type + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..32ca95b --- /dev/null +++ b/src/types.ts @@ -0,0 +1,117 @@ +import type { MqttClient } from 'mqtt' +import type { EagleStatus } from './eagle.js' +import type { SnowStatus } from './snow.js' + +export interface Config { + mqtt: MqttClient + devices: Device[] +} + +export interface Device { + id: number + key: string +} + +export type SnowListener = (status: SnowStatus) => void +export type EagleListener = (status: EagleStatus) => void + +export enum MessageSource { + Snow = 1, + Eagle = 2, + App_iOS = 3, + App_Android = 4, +} + +export interface Message { + cmdId: number + name: string + time: number + from: MessageSource + data: T + sig: string +} + +export enum Switch { + Off = 0, + On, +} + +export enum EagleMode { + Manual = 0, + Ai, + Silent, + Turbo, +} + +export interface EagleStatusData { + version: string + power: Switch + mode: EagleMode + status: number + denoise: Switch + heatStatus: Switch + cadr: number + prm: number + diffPressure1: number + diffPressure2: number + t0: number + g4Id: string + g4Percent: number + carbonId: string + carbonPercent: number + hepaId: string + hepaPercent: number +} + +export enum SensorState { + Sampling = 'sampling', +} + +export enum BatteryState { + Charging = 'charging', + Discharge = 'discharge', +} + +export interface SnowStatusData { + battery: number + battery_state: BatteryState + co2: number + co2_state: SensorState + co2_time: number + h: number + humi_state: SensorState + oh: number + opm100: number + opm25: number + ot: number + pm100: number + pm25: number + pm250: number + pm50: number + pm_state: SensorState + pm_time: number + t: number + temp_state: SensorState + temp_unit: 'c' + time: number + tvoc: number + tvoc_duration: number + tvoc_state: SensorState + tvoc_time: number + tvoc_unit: 'ppb' + version: string + version_type: 'release' +} + +export interface EagleControlData { + power: Switch + heatStatus: Switch + mode: EagleMode + cadr: number + denoise: Switch +} + +export interface InstantPushData { + frequencyTime: number + durationTime: number +} diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..b5cbd4b --- /dev/null +++ b/src/util.ts @@ -0,0 +1,43 @@ +import crypto from 'crypto' +import type { CommandMessage } from './messages.js' + +export class Signer { + /** + * Calculate the signature for the message. + * + * @param message - The command message. + * @param key - The device key. + * @returns An 8-byte signature for the given message. + */ + sign(message: CommandMessage, key: string) { + const plainText = JSON.stringify(message.payload()) + return this.#hash(plainText.slice(1, -1), key) + } + + /** + * Calculate the signature for the plain text. + * + * @param message - The plain text. + * @param key - The device key. + * @returns An 8-byte signature for the given text. + */ + signText(message: string, key: string) { + return this.#hash(message, key) + } + + /** + * Hash the data with the MD5 algorithm. + * + * @param data - The plain text. + * @param key - The device key. + * @returns An 8-byte signature for the given data. + */ + #hash(data: string, key: string) { + return crypto + .createHash('md5') + .update(data) + .update(',') + .update(key) + .digest('hex') + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..74ccc18 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "rootDir": "./src", + "outDir": "dist", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "declaration": true + }, + "include": ["src"], + "exclude": ["**/*.test.ts"] +} -- cgit v1.2.3