summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLi Zhineng <[email protected]>2024-11-20 21:36:35 +0800
committerLi Zhineng <[email protected]>2024-11-20 21:36:35 +0800
commitda9a75299b5505be80039712c57274cee54d0b8b (patch)
tree4157db4e3a0d6a99c6c78ce1cd888963e268283f
parentf94cabd919d58935a5edec1f8a01b6c0e5a1d997 (diff)
downloadairmx-da9a75299b5505be80039712c57274cee54d0b8b.tar.gz
airmx-da9a75299b5505be80039712c57274cee54d0b8b.zip
skeleton
-rw-r--r--.editorconfig12
-rw-r--r--.gitignore3
-rw-r--r--packages/airmx/jest.config.js7
-rw-r--r--packages/airmx/package.json30
-rw-r--r--packages/airmx/src/airmx.test.ts88
-rw-r--r--packages/airmx/src/airmx.ts144
-rw-r--r--packages/airmx/src/eagle.test.ts95
-rw-r--r--packages/airmx/src/eagle.ts130
-rw-r--r--packages/airmx/src/index.ts3
-rw-r--r--packages/airmx/src/snow.ts202
-rw-r--r--packages/airmx/tsconfig.json11
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
+ }
+}