summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/airmx.test.ts150
-rw-r--r--src/airmx.ts188
-rw-r--r--src/eagle.test.ts103
-rw-r--r--src/eagle.ts207
-rw-r--r--src/index.ts4
-rw-r--r--src/messages.ts67
-rw-r--r--src/snow.ts169
-rw-r--r--src/types.ts117
-rw-r--r--src/util.ts43
9 files changed, 1048 insertions, 0 deletions
diff --git a/src/airmx.test.ts b/src/airmx.test.ts
new file mode 100644
index 0000000..aee5bbe
--- /dev/null
+++ b/src/airmx.test.ts
@@ -0,0 +1,150 @@
+import { MqttClient } from 'mqtt'
+import { Airmx, Topic } from './airmx.js'
+import { EagleStatus } from './eagle.js'
+
+describe('topic', () => {
+ test('parse parses topic from string', () => {
+ const topic = Topic.parse('airmx/01/0/1/1/1/12345')
+ expect(topic.deviceId).toBe(12345)
+ expect(topic.unknown1).toBe(false)
+ expect(topic.unknown2).toBe(true)
+ expect(topic.unknown3).toBe(true)
+ expect(topic.unknown4).toBe(true)
+ })
+
+ test('the topic format is expected to have 7 parts', () => {
+ expect(() => Topic.parse('foo')).toThrow(
+ 'The topic format is expected to be airmx/+/+/+/+/+/+.',
+ )
+ })
+
+ test('the 1st part is expected to be airmx', () => {
+ expect(() => Topic.parse('foo/01/0/1/1/1/12345')).toThrow(
+ 'The 1st part of the topic must be "airmx".',
+ )
+ })
+
+ test('the 2nd part is expected to be "01"', () => {
+ expect(() => Topic.parse('airmx/00/0/1/1/1/12345')).toThrow(
+ 'The 2nd part of the topic must be "01".',
+ )
+ })
+
+ test('the 3rd part is expected to be either "1" or "0"', () => {
+ expect(Topic.parse('airmx/01/0/1/1/1/12345')).toBeInstanceOf(Topic)
+ expect(Topic.parse('airmx/01/1/1/1/1/12345')).toBeInstanceOf(Topic)
+
+ expect(() => Topic.parse('airmx/01/2/1/1/1/12345')).toThrow(
+ 'The 3rd part of the topic must be either "1" or "0".',
+ )
+ })
+
+ test('the 4th part is expected to be either "1" or "0"', () => {
+ expect(Topic.parse('airmx/01/0/0/1/1/12345')).toBeInstanceOf(Topic)
+ expect(Topic.parse('airmx/01/0/1/1/1/12345')).toBeInstanceOf(Topic)
+
+ expect(() => Topic.parse('airmx/01/0/2/1/1/12345')).toThrow(
+ 'The 4th part of the topic must be either "1" or "0".',
+ )
+ })
+
+ test('the 5th part is expected to be either "1" or "0"', () => {
+ expect(Topic.parse('airmx/01/0/1/0/1/12345')).toBeInstanceOf(Topic)
+ expect(Topic.parse('airmx/01/0/1/1/1/12345')).toBeInstanceOf(Topic)
+
+ expect(() => Topic.parse('airmx/01/0/1/2/1/12345')).toThrow(
+ 'The 5th part of the topic must be either "1" or "0".',
+ )
+ })
+
+ test('the 6th part is expected to be either "1" or "0"', () => {
+ expect(Topic.parse('airmx/01/0/1/1/0/12345')).toBeInstanceOf(Topic)
+ expect(Topic.parse('airmx/01/0/1/1/1/12345')).toBeInstanceOf(Topic)
+
+ expect(() => Topic.parse('airmx/01/0/1/1/2/12345')).toThrow(
+ 'The 6th part of the topic must be either "1" or "0".',
+ )
+ })
+
+ test('the 7th part is expected to be the device id', () => {
+ expect(() => Topic.parse('airmx/01/0/1/1/1/')).toThrow(
+ 'The 7th part of the topic must be a device ID.',
+ )
+
+ expect(() => Topic.parse('airmx/01/0/1/1/1/foo')).toThrow(
+ 'The 7th part of the topic must be a device ID.',
+ )
+ })
+})
+
+describe('airmx', () => {
+ let mockMqttClient: jest.Mocked<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/src/airmx.ts b/src/airmx.ts
new file mode 100644
index 0000000..a507260
--- /dev/null
+++ b/src/airmx.ts
@@ -0,0 +1,188 @@
+import { MqttClient } from 'mqtt'
+
+import type {
+ Config,
+ SnowListener,
+ EagleListener,
+ EagleControlData,
+} from './types.js'
+import type { CommandMessage } from './messages.js'
+
+import { EagleControlMesasge, InstantPushMessage } from './messages.js'
+import { EagleController, EagleStatus } from './eagle.js'
+import { Signer } from './util.js'
+import { SnowStatus } from './snow.js'
+
+export class Topic {
+ constructor(
+ public readonly unknown1: boolean,
+ public readonly unknown2: boolean,
+ public readonly unknown3: boolean,
+ public readonly unknown4: boolean,
+ public readonly deviceId: number,
+ ) {
+ //
+ }
+
+ static parse(topic: string) {
+ const components = topic.split('/')
+
+ if (components.length !== 7) {
+ throw new Error('The topic format is expected to be airmx/+/+/+/+/+/+.')
+ }
+
+ if (components[0] !== 'airmx') {
+ throw new Error('The 1st part of the topic must be "airmx".')
+ }
+
+ if (components[1] !== '01') {
+ throw new Error('The 2nd part of the topic must be "01".')
+ }
+
+ for (let i = 2; i < 6; i++) {
+ if (components[i] !== '0' && components[i] !== '1') {
+ const ordinal = `${i + 1}${i + 1 === 3 ? 'rd' : 'th'}`
+ throw new Error(
+ `The ${ordinal} part of the topic must be either "1" or "0".`,
+ )
+ }
+ }
+
+ const deviceId = components[6]
+ if (deviceId === '' || !/^\d+$/.test(deviceId)) {
+ throw new Error('The 7th part of the topic must be a device ID.')
+ }
+
+ return new this(
+ components[2] === '1',
+ components[3] === '1',
+ components[4] === '1',
+ components[5] === '1',
+ +deviceId,
+ )
+ }
+}
+
+interface AirmxListeners {
+ eagle: EagleListener[]
+ snow: SnowListener[]
+}
+
+export class Airmx {
+ #listeners: AirmxListeners = {
+ eagle: [],
+ snow: [],
+ }
+
+ #client: MqttClient
+
+ #signer
+
+ /** Pairs of device ID and its latest running status. */
+ #eagles
+
+ constructor(private readonly config: Config) {
+ this.#client = this.config.mqtt
+ this.#client.on('connect', this.#handleConnect.bind(this))
+ this.#client.on('message', this.#handleMessage.bind(this))
+ this.#signer = new Signer()
+ this.#eagles = new Map<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/src/eagle.test.ts b/src/eagle.test.ts
new file mode 100644
index 0000000..2af1bd4
--- /dev/null
+++ b/src/eagle.test.ts
@@ -0,0 +1,103 @@
+import type { Message, EagleStatusData } from './types.js'
+
+import { EagleStatus } from './eagle.js'
+import { EagleMode } from './types.js'
+
+test('from parses message to eagle status', () => {
+ const status = EagleStatus.from(12345, createStubStatusData())
+ expect(status).toBeInstanceOf(EagleStatus)
+})
+
+test('data resolution', () => {
+ const status = new EagleStatus(12345, createStubStatusData())
+ expect(status.deviceId).toBe(12345)
+ expect(status.power).toBe(1)
+ expect(status.mode).toBe(2)
+ expect(status.cadr).toBe(17)
+ expect(status.denoise).toBe(0)
+ expect(status.heatStatus).toBe(0)
+ expect(status.status).toBe(0)
+ expect(status.prm).toBe(660)
+ expect(status.temperature).toBe(28)
+ expect(status.g4Id).toBe('0111111')
+ expect(status.g4Percent).toBe(20)
+ expect(status.carbonId).toBe('0222222')
+ expect(status.carbonPercent).toBe(30)
+ expect(status.hepaId).toBe('0333333')
+ expect(status.hepaPercent).toBe(40)
+ expect(status.version).toBe('10.00.17')
+})
+
+test('isOn determines if the power is on', () => {
+ const status = new EagleStatus(12345, createStubStatusData({ power: 1 }))
+ expect(status.isOn()).toBe(true)
+ expect(status.isOff()).toBe(false)
+})
+
+test('isOff determines if the power is off', () => {
+ const status = new EagleStatus(12345, createStubStatusData({ power: 0 }))
+ expect(status.isOff()).toBe(true)
+ expect(status.isOn()).toBe(false)
+})
+
+test('mode 2 is the silent mode', () => {
+ const status = new EagleStatus(
+ 12345,
+ createStubStatusData({ mode: EagleMode.Silent }),
+ )
+ expect(status.isSilentMode()).toBe(true)
+})
+
+test('isDenoiseOn determines if the denoise feature is on', () => {
+ const status = new EagleStatus(12345, createStubStatusData({ denoise: 1 }))
+ expect(status.isDenoiseOn()).toBe(true)
+ expect(status.isDenoiseOff()).toBe(false)
+})
+
+test('isDenoiseOff determines if the denoise feature is off', () => {
+ const status = new EagleStatus(12345, createStubStatusData({ denoise: 0 }))
+ expect(status.isDenoiseOff()).toBe(true)
+ expect(status.isDenoiseOn()).toBe(false)
+})
+
+test('isHeaterOn determines if the heater is on', () => {
+ const status = new EagleStatus(12345, createStubStatusData({ heatStatus: 1 }))
+ expect(status.isHeaterOn()).toBe(true)
+ expect(status.isHeaterOff()).toBe(false)
+})
+
+test('isHeaterOff determines if the heater is off', () => {
+ const status = new EagleStatus(12345, createStubStatusData({ heatStatus: 0 }))
+ expect(status.isHeaterOff()).toBe(true)
+ expect(status.isHeaterOn()).toBe(false)
+})
+
+const createStubStatusData = (
+ data: Partial<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/src/eagle.ts b/src/eagle.ts
new file mode 100644
index 0000000..dbe74cc
--- /dev/null
+++ b/src/eagle.ts
@@ -0,0 +1,207 @@
+import type { Message, EagleStatusData, EagleControlData } from './types.js'
+
+import { Airmx } from './airmx.js'
+import { EagleMode, Switch } from './types.js'
+
+export class EagleStatus {
+ constructor(
+ public readonly deviceId: number,
+ public readonly message: Message<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/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..1002c35
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,4 @@
+export { Airmx } from './airmx.js'
+export type { SnowStatus } from './snow.js'
+export type { EagleStatus, EagleController } from './eagle.js'
+export type * from './types.js'
diff --git a/src/messages.ts b/src/messages.ts
new file mode 100644
index 0000000..66c63d1
--- /dev/null
+++ b/src/messages.ts
@@ -0,0 +1,67 @@
+import type { EagleControlData, InstantPushData } from './types.js'
+import { MessageSource } from './types.js'
+
+export class CommandMessage<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/src/snow.ts b/src/snow.ts
new file mode 100644
index 0000000..a5a0c05
--- /dev/null
+++ b/src/snow.ts
@@ -0,0 +1,169 @@
+import type { Message, SnowStatusData } from './types.js'
+import { BatteryState, SensorState } from './types.js'
+
+export class SnowStatus {
+ constructor(
+ public readonly deviceId: number,
+ public readonly message: Message<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/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..32ca95b
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,117 @@
+import type { MqttClient } from 'mqtt'
+import type { EagleStatus } from './eagle.js'
+import type { SnowStatus } from './snow.js'
+
+export interface Config {
+ mqtt: MqttClient
+ devices: Device[]
+}
+
+export interface Device {
+ id: number
+ key: string
+}
+
+export type SnowListener = (status: SnowStatus) => void
+export type EagleListener = (status: EagleStatus) => void
+
+export enum MessageSource {
+ Snow = 1,
+ Eagle = 2,
+ App_iOS = 3,
+ App_Android = 4,
+}
+
+export interface Message<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/src/util.ts b/src/util.ts
new file mode 100644
index 0000000..b5cbd4b
--- /dev/null
+++ b/src/util.ts
@@ -0,0 +1,43 @@
+import crypto from 'crypto'
+import type { CommandMessage } from './messages.js'
+
+export class Signer {
+ /**
+ * Calculate the signature for the message.
+ *
+ * @param message - The command message.
+ * @param key - The device key.
+ * @returns An 8-byte signature for the given message.
+ */
+ sign(message: CommandMessage<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')
+ }
+}