summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authorLi Zhineng <[email protected]>2025-07-19 15:35:02 +0800
committerLi Zhineng <[email protected]>2025-07-19 15:35:02 +0800
commite6279ae486eef2d549a4b5dd15e4e446d2180dc7 (patch)
treeb25897278de3685d79df0a0381ceba9ca23d1ef9 /packages
parent3cbee218d16e2c7c86d3c1433fde100246da0332 (diff)
downloadairmx-e6279ae486eef2d549a4b5dd15e4e446d2180dc7.tar.gz
airmx-e6279ae486eef2d549a4b5dd15e4e446d2180dc7.zip
give up monorepo
Diffstat (limited to 'packages')
-rw-r--r--packages/airmx/LICENSE21
-rw-r--r--packages/airmx/README.md161
-rw-r--r--packages/airmx/jest.config.js11
-rw-r--r--packages/airmx/package.json38
-rw-r--r--packages/airmx/src/airmx.test.ts150
-rw-r--r--packages/airmx/src/airmx.ts188
-rw-r--r--packages/airmx/src/eagle.test.ts103
-rw-r--r--packages/airmx/src/eagle.ts207
-rw-r--r--packages/airmx/src/index.ts4
-rw-r--r--packages/airmx/src/messages.ts67
-rw-r--r--packages/airmx/src/snow.ts169
-rw-r--r--packages/airmx/src/types.ts117
-rw-r--r--packages/airmx/src/util.ts43
-rw-r--r--packages/airmx/tsconfig.json15
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"]
-}