diff options
| author | Li Zhineng <[email protected]> | 2024-11-20 21:36:35 +0800 |
|---|---|---|
| committer | Li Zhineng <[email protected]> | 2024-11-20 21:36:35 +0800 |
| commit | da9a75299b5505be80039712c57274cee54d0b8b (patch) | |
| tree | 4157db4e3a0d6a99c6c78ce1cd888963e268283f | |
| parent | f94cabd919d58935a5edec1f8a01b6c0e5a1d997 (diff) | |
| download | airmx-da9a75299b5505be80039712c57274cee54d0b8b.tar.gz airmx-da9a75299b5505be80039712c57274cee54d0b8b.zip | |
skeleton
| -rw-r--r-- | .editorconfig | 12 | ||||
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | packages/airmx/jest.config.js | 7 | ||||
| -rw-r--r-- | packages/airmx/package.json | 30 | ||||
| -rw-r--r-- | packages/airmx/src/airmx.test.ts | 88 | ||||
| -rw-r--r-- | packages/airmx/src/airmx.ts | 144 | ||||
| -rw-r--r-- | packages/airmx/src/eagle.test.ts | 95 | ||||
| -rw-r--r-- | packages/airmx/src/eagle.ts | 130 | ||||
| -rw-r--r-- | packages/airmx/src/index.ts | 3 | ||||
| -rw-r--r-- | packages/airmx/src/snow.ts | 202 | ||||
| -rw-r--r-- | packages/airmx/tsconfig.json | 11 |
11 files changed, 725 insertions, 0 deletions
diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e66a4a8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://editorconfig.org + +root = true + +[*] +end_of_line = lf +insert_final_newline = true + +[*.ts] +charset = utf-8 +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..320c107 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +package-lock.json diff --git a/packages/airmx/jest.config.js b/packages/airmx/jest.config.js new file mode 100644 index 0000000..11a87cb --- /dev/null +++ b/packages/airmx/jest.config.js @@ -0,0 +1,7 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} **/ +module.exports = { + testEnvironment: "node", + transform: { + "^.+.tsx?$": ["ts-jest",{}], + }, +}; diff --git a/packages/airmx/package.json b/packages/airmx/package.json new file mode 100644 index 0000000..2defcdd --- /dev/null +++ b/packages/airmx/package.json @@ -0,0 +1,30 @@ +{ + "name": "@lizhineng/airmx", + "version": "0.0.1", + "description": "Control AIRMX Pro 1S with Javascript.", + "main": "dist/index.js", + "scripts": { + "test": "jest" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/lizhineng/airmx.git" + }, + "keywords": [ + "airmx", + "mqtt" + ], + "author": "Li Zhineng <[email protected]>", + "license": "MIT", + "bugs": { + "url": "https://github.com/lizhineng/airmx/issues" + }, + "homepage": "https://github.com/lizhineng/airmx#readme", + "dependencies": { + "mqtt": "^5.10.2" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "ts-jest": "^29.2.5" + } +} diff --git a/packages/airmx/src/airmx.test.ts b/packages/airmx/src/airmx.test.ts new file mode 100644 index 0000000..e0c7048 --- /dev/null +++ b/packages/airmx/src/airmx.test.ts @@ -0,0 +1,88 @@ +import { MqttClient } from 'mqtt/*' +import { Airmx, Topic, TOPIC_STATUS } from './airmx' + +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 }) + const connectHandler = mockMqttClient.on.mock.calls.find( + ([event]) => event === 'connect' + )?.[1] as (() => void) | undefined; + connectHandler?.() + expect(mockMqttClient.subscribe).toHaveBeenCalledWith(TOPIC_STATUS); + }) +}) diff --git a/packages/airmx/src/airmx.ts b/packages/airmx/src/airmx.ts new file mode 100644 index 0000000..6ece32e --- /dev/null +++ b/packages/airmx/src/airmx.ts @@ -0,0 +1,144 @@ +import { MqttClient } from 'mqtt' +import { EagleStatus } from './eagle' +import { SnowStatus } from './snow' + +type TSnowListener = (status: SnowStatus) => void +type TEagleListener = (status: EagleStatus) => void + +export interface TConfig { + mqtt: MqttClient +} + +export enum Command { + SnowStatus = 200, + EagleStatus = 210 +} + +export enum MessageSource { + Snow = 1, + Eagle = 2, + App_iOS = 3, + App_Android = 4 +} + +export interface TMessage<TMessageData> { + cmdId: number + name: string + time: number + from: MessageSource + data: TMessageData + sig: string +} + +export const TOPIC_STATUS = 'airmx/01/+/+/1/1/+' + +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 + ) + } +} + +export class Airmx { + #listeners: { + eagle: TEagleListener[], + snow: TSnowListener[] + } = { + eagle: [], + snow: [] + } + + #client: MqttClient + + constructor( + private readonly config: TConfig + ) { + this.#client = this.config.mqtt + this.#client.on('connect', this.#handleConnect.bind(this)) + this.#client.on('message', this.#handleMessage.bind(this)) + } + + onSnowUpdate(callback: TSnowListener) { + this.#listeners.snow.push(callback) + return this + } + + onEagleUpdate(callback: TEagleListener) { + this.#listeners.eagle.push(callback) + return this + } + + #handleConnect() { + this.#client.subscribe(TOPIC_STATUS) + } + + #handleMessage(topic: string, message: Buffer): void { + let t: Topic + + try { + t = Topic.parse(topic) + } catch (Error) { + return + } + + const data = JSON.parse(message.toString()) + + switch (data.cmdId) { + case Command.SnowStatus: + this.#notifySnow(SnowStatus.from(t.deviceId, data)) + break + case Command.EagleStatus: + this.#notifyEagle(EagleStatus.from(t.deviceId, data)) + break + } + } + + #notifySnow(status: SnowStatus) { + this.#listeners.snow.forEach((listener) => listener(status)) + } + + #notifyEagle(status: EagleStatus) { + this.#listeners.eagle.forEach((listener) => listener(status)) + } +} diff --git a/packages/airmx/src/eagle.test.ts b/packages/airmx/src/eagle.test.ts new file mode 100644 index 0000000..f430197 --- /dev/null +++ b/packages/airmx/src/eagle.test.ts @@ -0,0 +1,95 @@ +import { EagleMode, EagleStatus, TEagleStatusData } from './eagle' + +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<TEagleStatusData> = {}) => ({ + 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 new file mode 100644 index 0000000..fab922a --- /dev/null +++ b/packages/airmx/src/eagle.ts @@ -0,0 +1,130 @@ +import { TMessage } from './airmx' + +export interface TEagleStatusData { + version: string + power: number + mode: number + status: number + denoise: number + heatStatus: number + cadr: number + prm: number + diffPressure1: number, + diffPressure2: number, + t0: number, + g4Id: string + g4Percent: number + carbonId: string + carbonPercent: number + hepaId: string + hepaPercent: number +} + +export enum EagleMode { + Silent = 2 +} + +export class EagleStatus { + constructor( + public readonly deviceId: number, + public readonly message: TMessage<TEagleStatusData> + ) { + // + } + + static from(deviceId: number, message: TMessage<TEagleStatusData>) { + if (message.cmdId !== 210) { + throw new Error('Eagle status expects a message with command ID 210.') + } + + return new this(deviceId, message) + } + + get power() { + return this.message.data.power + } + + isOn() { + return this.power === 1 + } + + 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 === 1 + } + + isDenoiseOff() { + return ! this.isDenoiseOn() + } + + get heatStatus() { + return this.message.data.heatStatus + } + + isHeaterOn() { + return this.heatStatus === 1 + } + + 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 + } +} diff --git a/packages/airmx/src/index.ts b/packages/airmx/src/index.ts new file mode 100644 index 0000000..d2056ec --- /dev/null +++ b/packages/airmx/src/index.ts @@ -0,0 +1,3 @@ +export * from './airmx' +export * from './snow' +export * from './eagle' diff --git a/packages/airmx/src/snow.ts b/packages/airmx/src/snow.ts new file mode 100644 index 0000000..c8390d7 --- /dev/null +++ b/packages/airmx/src/snow.ts @@ -0,0 +1,202 @@ +import { Command, TMessage } from './airmx' + +export enum SensorState { + Sampling = 'sampling' +} + +export enum BatteryState { + Charging = 'charging', + Discharge = 'discharge' +} + +export interface TSnowStatusData { + 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 class SnowStatus { + constructor( + public readonly deviceId: number, + public readonly message: TMessage<TSnowStatusData> + ) { + // + } + + static from(deviceId: number, message: TMessage<TSnowStatusData>) { + if (message.cmdId !== Command.SnowStatus) { + throw new Error(`Snow status expects a message with command ID "${Command.SnowStatus}".`) + } + + 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/tsconfig.json b/packages/airmx/tsconfig.json new file mode 100644 index 0000000..90a783e --- /dev/null +++ b/packages/airmx/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es2016", + "module": "commonjs", + "rootDir": "./src", + "outDir": "dist", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true + } +} |
