From e6279ae486eef2d549a4b5dd15e4e446d2180dc7 Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Sat, 19 Jul 2025 15:35:02 +0800 Subject: give up monorepo --- src/airmx.ts | 188 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 src/airmx.ts (limited to 'src/airmx.ts') 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() + } + + 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) { + 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) { + 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) + } +} -- cgit v1.2.3