diff options
| -rw-r--r-- | packages/airmx/src/airmx.test.ts | 61 | ||||
| -rw-r--r-- | packages/airmx/src/airmx.ts | 35 | ||||
| -rw-r--r-- | packages/airmx/src/eagle.test.ts | 21 | ||||
| -rw-r--r-- | packages/airmx/src/eagle.ts | 8 | ||||
| -rw-r--r-- | packages/airmx/src/messages.ts | 16 | ||||
| -rw-r--r-- | packages/airmx/src/snow.ts | 14 | ||||
| -rw-r--r-- | packages/airmx/src/types.ts | 16 | ||||
| -rw-r--r-- | packages/airmx/src/util.ts | 3 |
8 files changed, 107 insertions, 67 deletions
diff --git a/packages/airmx/src/airmx.test.ts b/packages/airmx/src/airmx.test.ts index b184b80..9f726be 100644 --- a/packages/airmx/src/airmx.test.ts +++ b/packages/airmx/src/airmx.test.ts @@ -12,58 +12,67 @@ describe('topic', () => { }) test('the topic format is expected to have 7 parts', () => { - expect(() => Topic.parse('foo')) - .toThrow('The topic format is expected to be airmx/+/+/+/+/+/+.') + 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".') + 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".') + 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".') + 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".') + 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".') + 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".') + 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/')).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.') + expect(() => Topic.parse('airmx/01/0/1/1/1/foo')).toThrow( + 'The 7th part of the topic must be a device ID.', + ) }) }) @@ -73,17 +82,17 @@ describe('airmx', () => { beforeEach(() => { mockMqttClient = { on: jest.fn(), - subscribe: 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; + ([event]) => event === 'connect', + )?.[1] as (() => void) | undefined connectHandler?.() - expect(mockMqttClient.subscribe).toHaveBeenCalledWith('airmx/01/+/+/1/1/+'); + expect(mockMqttClient.subscribe).toHaveBeenCalledWith('airmx/01/+/+/1/1/+') }) describe('message validation', () => { @@ -93,13 +102,15 @@ describe('airmx', () => { beforeEach(() => { new Airmx({ mqtt: mockMqttClient, devices: [testDevice] }) messageHandler = mockMqttClient.on.mock.calls.find( - ([event]) => event === 'message' + ([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"}' + 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(() => { diff --git a/packages/airmx/src/airmx.ts b/packages/airmx/src/airmx.ts index be67a58..5b397db 100644 --- a/packages/airmx/src/airmx.ts +++ b/packages/airmx/src/airmx.ts @@ -3,7 +3,12 @@ import { MqttClient } from 'mqtt' import { EagleStatus } from './eagle.js' import { EagleControlMesasge } from './messages.js' import { SnowStatus } from './snow.js' -import type { Config, SnowListener, EagleListener, EagleControlData } from './types.js' +import type { + Config, + SnowListener, + EagleListener, + EagleControlData, +} from './types.js' import { Command } from './types.js' import { Signer } from './util.js' import type { CommandMessage } from './messages.js' @@ -14,7 +19,7 @@ export class Topic { public readonly unknown2: boolean, public readonly unknown3: boolean, public readonly unknown4: boolean, - public readonly deviceId: number + public readonly deviceId: number, ) { // } @@ -36,13 +41,15 @@ export class Topic { 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 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)) { + if (deviceId === '' || !/^\d+$/.test(deviceId)) { throw new Error('The 7th part of the topic must be a device ID.') } @@ -51,27 +58,25 @@ export class Topic { components[3] === '1', components[4] === '1', components[5] === '1', - +deviceId + +deviceId, ) } } export class Airmx { #listeners: { - eagle: EagleListener[], + eagle: EagleListener[] snow: SnowListener[] } = { eagle: [], - snow: [] + snow: [], } #client: MqttClient #signer - constructor( - private readonly config: Config - ) { + 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)) @@ -126,7 +131,8 @@ export class Airmx { #validateMessage(deviceId: number, message: string, sig: string) { const device = this.#getDevice(deviceId) const plainText = message.slice(1, message.lastIndexOf('"sig"')) - const calculated = crypto.createHash('md5') + const calculated = crypto + .createHash('md5') .update(plainText) .update(device.key) .digest('hex') @@ -143,7 +149,10 @@ export class Airmx { 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)) + this.#client.publish( + `airmx/01/1/1/0/1/${deviceId}`, + JSON.stringify(payload), + ) } #getDevice(deviceId: number) { diff --git a/packages/airmx/src/eagle.test.ts b/packages/airmx/src/eagle.test.ts index ca33dae..84c5ebb 100644 --- a/packages/airmx/src/eagle.test.ts +++ b/packages/airmx/src/eagle.test.ts @@ -39,7 +39,10 @@ test('isOff determines if the power is off', () => { }) test('mode 2 is the silent mode', () => { - const status = new EagleStatus(12345, createStubStatusData({ mode: EagleMode.Silent })) + const status = new EagleStatus( + 12345, + createStubStatusData({ mode: EagleMode.Silent }), + ) expect(status.isSilentMode()).toBe(true) }) @@ -71,26 +74,26 @@ const createStubStatusData = (data: Partial<EagleStatusData> = {}) => ({ cmdId: 210, data: { cadr: 17, - carbonId: "0222222", + carbonId: '0222222', carbonPercent: 30, denoise: 0, diffPressure1: 99999, diffPressure2: 99999, - g4Id: "0111111", + g4Id: '0111111', g4Percent: 20, heatStatus: 0, - hepaId: "0333333", + hepaId: '0333333', hepaPercent: 40, mode: 2, power: 1, prm: 660, status: 0, t0: 28, - version: "10.00.17", - ...data + version: '10.00.17', + ...data, }, from: 2, - name: "eagleStatus", - sig: "foo", - time: 1700000000 + name: 'eagleStatus', + sig: 'foo', + time: 1700000000, }) diff --git a/packages/airmx/src/eagle.ts b/packages/airmx/src/eagle.ts index d50096c..b3f28e3 100644 --- a/packages/airmx/src/eagle.ts +++ b/packages/airmx/src/eagle.ts @@ -4,7 +4,7 @@ import { EagleMode } from './types.js' export class EagleStatus { constructor( public readonly deviceId: number, - public readonly message: Message<EagleStatusData> + public readonly message: Message<EagleStatusData>, ) {} static from(deviceId: number, message: Message<EagleStatusData>) { @@ -24,7 +24,7 @@ export class EagleStatus { } isOff() { - return ! this.isOn() + return !this.isOn() } get mode() { @@ -48,7 +48,7 @@ export class EagleStatus { } isDenoiseOff() { - return ! this.isDenoiseOn() + return !this.isDenoiseOn() } get heatStatus() { @@ -60,7 +60,7 @@ export class EagleStatus { } isHeaterOff() { - return ! this.isHeaterOn() + return !this.isHeaterOn() } get cadr() { diff --git a/packages/airmx/src/messages.ts b/packages/airmx/src/messages.ts index 6bac4f5..66c63d1 100644 --- a/packages/airmx/src/messages.ts +++ b/packages/airmx/src/messages.ts @@ -7,7 +7,7 @@ export class CommandMessage<T> { readonly commandName: string, readonly data: T, readonly time: number, - readonly from: number + readonly from: number, ) {} payload() { @@ -37,10 +37,14 @@ export class InstantPushMessage extends CommandMessage<InstantPushData> { static make(frequency: number, duration: number) { const data = { frequencyTime: frequency, - durationTime: duration + durationTime: duration, } return new InstantPushMessage( - this.commandId(), 'instantPush', data, current(), MessageSource.App_Android + this.commandId(), + 'instantPush', + data, + current(), + MessageSource.App_Android, ) } } @@ -53,7 +57,11 @@ export class EagleControlMesasge extends CommandMessage<EagleControlData> { static make(data: EagleControlData) { const timestamp = Math.floor(new Date().getTime() / 1000) return new EagleControlMesasge( - this.commandId(), 'control', data, timestamp, MessageSource.App_Android + this.commandId(), + 'control', + data, + timestamp, + MessageSource.App_Android, ) } } diff --git a/packages/airmx/src/snow.ts b/packages/airmx/src/snow.ts index 98191de..38242fa 100644 --- a/packages/airmx/src/snow.ts +++ b/packages/airmx/src/snow.ts @@ -1,16 +1,24 @@ -import { Command, type Message, type SnowStatusData, BatteryState, SensorState } from './types.js' +import { + Command, + type Message, + type SnowStatusData, + BatteryState, + SensorState, +} from './types.js' export class SnowStatus { constructor( public readonly deviceId: number, - public readonly message: Message<SnowStatusData> + public readonly message: Message<SnowStatusData>, ) { // } static from(deviceId: number, message: Message<SnowStatusData>) { if (message.cmdId !== Command.SnowStatus) { - throw new Error(`Snow status expects a message with command ID "${Command.SnowStatus}".`) + throw new Error( + `Snow status expects a message with command ID "${Command.SnowStatus}".`, + ) } return new this(deviceId, message) diff --git a/packages/airmx/src/types.ts b/packages/airmx/src/types.ts index 5d1e01e..6d6bbc7 100644 --- a/packages/airmx/src/types.ts +++ b/packages/airmx/src/types.ts @@ -17,14 +17,14 @@ export type EagleListener = (status: EagleStatus) => void export enum Command { SnowStatus = 200, - EagleStatus = 210 + EagleStatus = 210, } export enum MessageSource { Snow = 1, Eagle = 2, App_iOS = 3, - App_Android = 4 + App_Android = 4, } export interface Message<T> { @@ -45,9 +45,9 @@ export interface EagleStatusData { heatStatus: number cadr: number prm: number - diffPressure1: number, - diffPressure2: number, - t0: number, + diffPressure1: number + diffPressure2: number + t0: number g4Id: string g4Percent: number carbonId: string @@ -57,16 +57,16 @@ export interface EagleStatusData { } export enum EagleMode { - Silent = 2 + Silent = 2, } export enum SensorState { - Sampling = 'sampling' + Sampling = 'sampling', } export enum BatteryState { Charging = 'charging', - Discharge = 'discharge' + Discharge = 'discharge', } export interface SnowStatusData { diff --git a/packages/airmx/src/util.ts b/packages/airmx/src/util.ts index 0b21b0d..496ae56 100644 --- a/packages/airmx/src/util.ts +++ b/packages/airmx/src/util.ts @@ -4,7 +4,8 @@ import type { CommandMessage } from './messages.js' export class Signer { sign(message: CommandMessage<unknown>, key: string) { const plainText = JSON.stringify(message.payload()) - return crypto.createHash('md5') + return crypto + .createHash('md5') .update(plainText.slice(1, -1)) .update(',') .update(key) |
