diff options
| author | Li Zhineng <[email protected]> | 2025-07-19 15:35:02 +0800 |
|---|---|---|
| committer | Li Zhineng <[email protected]> | 2025-07-19 15:35:02 +0800 |
| commit | e6279ae486eef2d549a4b5dd15e4e446d2180dc7 (patch) | |
| tree | b25897278de3685d79df0a0381ceba9ca23d1ef9 /packages | |
| parent | 3cbee218d16e2c7c86d3c1433fde100246da0332 (diff) | |
| download | airmx-e6279ae486eef2d549a4b5dd15e4e446d2180dc7.tar.gz airmx-e6279ae486eef2d549a4b5dd15e4e446d2180dc7.zip | |
give up monorepo
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/airmx/LICENSE | 21 | ||||
| -rw-r--r-- | packages/airmx/README.md | 161 | ||||
| -rw-r--r-- | packages/airmx/jest.config.js | 11 | ||||
| -rw-r--r-- | packages/airmx/package.json | 38 | ||||
| -rw-r--r-- | packages/airmx/src/airmx.test.ts | 150 | ||||
| -rw-r--r-- | packages/airmx/src/airmx.ts | 188 | ||||
| -rw-r--r-- | packages/airmx/src/eagle.test.ts | 103 | ||||
| -rw-r--r-- | packages/airmx/src/eagle.ts | 207 | ||||
| -rw-r--r-- | packages/airmx/src/index.ts | 4 | ||||
| -rw-r--r-- | packages/airmx/src/messages.ts | 67 | ||||
| -rw-r--r-- | packages/airmx/src/snow.ts | 169 | ||||
| -rw-r--r-- | packages/airmx/src/types.ts | 117 | ||||
| -rw-r--r-- | packages/airmx/src/util.ts | 43 | ||||
| -rw-r--r-- | packages/airmx/tsconfig.json | 15 |
14 files changed, 0 insertions, 1294 deletions
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://<YOUR-MQTT-SERVER>'), - devices: [ - { id: <YOUR-DEVICE-ID>, key: '<YOUR-DEVICE-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: ['<rootDir>/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 <[email protected]>", - "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<MqttClient> - - beforeEach(() => { - mockMqttClient = { - on: jest.fn(), - subscribe: jest.fn(), - } as unknown as jest.Mocked<MqttClient> - }) - - 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<number, EagleStatus>() - } - - 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<unknown>) { - 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<unknown>) { - 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<EagleStatusData> = {}, -): Message<EagleStatusData> => ({ - 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<EagleStatusData>, - ) {} - - static commandId() { - return 210 - } - - static from(deviceId: number, message: Message<EagleStatusData>) { - 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<EagleControlData>) { - 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<T> { - 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<InstantPushData> { - 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<EagleControlData> { - 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<SnowStatusData>, - ) { - // - } - - static commandId() { - return 200 - } - - static from(deviceId: number, message: Message<SnowStatusData>) { - 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<T> { - 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<unknown>, 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"] -} |
