diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/airmx.test.ts | 150 | ||||
| -rw-r--r-- | src/airmx.ts | 188 | ||||
| -rw-r--r-- | src/eagle.test.ts | 103 | ||||
| -rw-r--r-- | src/eagle.ts | 207 | ||||
| -rw-r--r-- | src/index.ts | 4 | ||||
| -rw-r--r-- | src/messages.ts | 67 | ||||
| -rw-r--r-- | src/snow.ts | 169 | ||||
| -rw-r--r-- | src/types.ts | 117 | ||||
| -rw-r--r-- | src/util.ts | 43 |
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') + } +} |
