From 391eeb229882870a1c2311c820cc910890b35d9d Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Tue, 22 Jul 2025 08:01:28 +0800 Subject: first commit --- src/index.ts | 324 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 src/index.ts (limited to 'src/index.ts') diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..5d5871c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,324 @@ +import type { + API, + Characteristic, + CharacteristicValue, + DynamicPlatformPlugin, + Logging, + PlatformAccessory, + PlatformConfig, + Service +} from 'homebridge' + +import mqtt from 'mqtt' +import { Airmx, type EagleStatusData, type EagleControlData, type EagleStatus } from 'airmx' + +interface Device { + id: number + key: string +} + +interface AirmxPlatformConfig extends PlatformConfig { + mqtt: string + devices: Device[] +} + +interface AccessoryContext { + device: Device + status?: Pick +} + +enum EagleMode { + Manual = 0, + Ai = 1 +} + +const pluginIdentifier = 'homebridge-airmx' +const platformName = 'Airmx' + +/** + * The stub control data for initial sending when we don’t have the latest + * device status available. + */ +const stubControl: EagleControlData = { + power: 0, + heatStatus: 0, + mode: 0, + cadr: 47, + denoise: 0 +} + +class AirmxPlatform implements DynamicPlatformPlugin { + readonly service: typeof Service + readonly characteristic: typeof Characteristic + + public readonly accessories: Map< + string, + PlatformAccessory + > = new Map() + + public readonly discoveredUuids: string[] = [] + + /** + * The AIRMX client. + */ + airmx: Airmx + + constructor( + readonly log: Logging, + readonly config: PlatformConfig | AirmxPlatformConfig, + readonly api: API + ) { + this.service = api.hap.Service + this.characteristic = api.hap.Characteristic + + this.airmx = new Airmx({ + mqtt: mqtt.connect(this.config.mqtt), + devices: this.config.devices + }) + + this.airmx.onEagleUpdate((status) => { + this.log.info('Receive a status update from device:', status.deviceId) + + const uuid = this.api.hap.uuid.generate(status.deviceId.toString()) + const accessory = this.accessories.get(uuid) + if (accessory) { + this.log.info('Update the current status to device:', status.deviceId) + accessory.context.status = { + power: status.power, + mode: status.mode, + cadr: status.cadr, + g4Percent: status.g4Percent, + carbonPercent: status.carbonPercent, + hepaPercent: status.hepaPercent, + version: status.version + } + } + }) + + this.api.on('didFinishLaunching', () => { + this.registerDevices() + }) + } + + configureAccessory(accessory: PlatformAccessory): void { + this.log.info('Loading accessory from cache:', accessory.context.device.id) + this.accessories.set(accessory.UUID, accessory as PlatformAccessory) + } + + private registerDevices() { + for (const device of this.config.devices) { + const uuid = this.api.hap.uuid.generate(device.id.toString()) + const existingAccessory = this.accessories.get(uuid) + + if (existingAccessory) { + this.log.info('Restoring existing accessory from cache:', device.id) + new AirmxProAccessory(this, existingAccessory) + } else { + this.log.info('Adding new accessory:', device.id) + const accessory = new this.api.platformAccessory( + 'AIRMX Pro', + uuid + ) + accessory.context.device = device + this.accessories.set(uuid, accessory) + new AirmxProAccessory(this, accessory) + this.api.registerPlatformAccessories(pluginIdentifier, platformName, [ + accessory + ]) + } + + this.discoveredUuids.push(uuid) + } + + for (const [uuid, accessory] of this.accessories) { + if (!this.discoveredUuids.includes(uuid)) { + this.api.unregisterPlatformAccessories(pluginIdentifier, platformName, [ + accessory + ]) + } + } + } +} + +export class AirmxProAccessory { + constructor( + private readonly platform: AirmxPlatform, + private readonly accessory: PlatformAccessory + ) { + this.registerAccessoryInformation() + this.registerAirPurifier() + this.registerFilter() + } + + private registerAccessoryInformation() { + const service = this.accessory + .getService(this.platform.service.AccessoryInformation)! + .setCharacteristic(this.platform.characteristic.Manufacturer, 'Beijing Miaoxin technology Co., Ltd') + .setCharacteristic(this.platform.characteristic.Model, 'AIRMX Pro') + .setCharacteristic(this.platform.characteristic.SerialNumber, 'N/A') + + service + .getCharacteristic(this.platform.characteristic.FirmwareRevision) + .onGet(this.handleFirmwareRevisionGet.bind(this)) + } + + private registerAirPurifier() { + const service = + this.accessory.getService(this.platform.service.AirPurifier) || + this.accessory.addService(this.platform.service.AirPurifier) + + service.setCharacteristic(this.platform.characteristic.Name, 'AIRMX Pro') + + service + .getCharacteristic(this.platform.characteristic.Active) + .onGet(this.handleActiveGet.bind(this)) + .onSet(this.handleActiveSet.bind(this)) + + service + .getCharacteristic(this.platform.characteristic.CurrentAirPurifierState) + .onGet(this.handleCurrentAirPurifierStateGet.bind(this)) + + service + .getCharacteristic(this.platform.characteristic.TargetAirPurifierState) + .onGet(this.handleTargetAirPurifierStateGet.bind(this)) + .onSet(this.handleTargetAirPurifierStateSet.bind(this)) + + service + .getCharacteristic(this.platform.characteristic.RotationSpeed) + .onGet(this.handleRotationSpeedGet.bind(this)) + .onSet(this.handleRotationSpeedSet.bind(this)) + } + + private registerFilter() { + const service = + this.accessory.getService(this.platform.service.FilterMaintenance) || + this.accessory.addService(this.platform.service.FilterMaintenance) + + service + .getCharacteristic(this.platform.characteristic.FilterChangeIndication) + .onGet(this.handleFilterChangeIndicationGet.bind(this)) + + service + .getCharacteristic(this.platform.characteristic.FilterLifeLevel) + .onGet(this.handleFilterLifeLevelGet.bind(this)) + } + + handleFirmwareRevisionGet() { + const { status } = this.accessory.context + return status?.version || 'N/A' + } + + handleActiveGet() { + const { status } = this.accessory.context + return status !== undefined && status.power === 1 + ? this.platform.characteristic.Active.ACTIVE + : this.platform.characteristic.Active.INACTIVE + } + + handleActiveSet(value: CharacteristicValue) { + const isOn = value === this.platform.characteristic.Active.ACTIVE + + if (!this.accessory.context.status) { + this.sendRawControl({ power: isOn ? 1 : 0 }) + return + } + + const device = this.platform.airmx.device(this.accessory.context.device.id) + isOn ? device.on() : device.off() + } + + handleCurrentAirPurifierStateGet() { + const { status } = this.accessory.context + return status !== undefined && status.power === 1 + ? this.platform.characteristic.CurrentAirPurifierState.PURIFYING_AIR + : this.platform.characteristic.CurrentAirPurifierState.INACTIVE + } + + handleTargetAirPurifierStateGet() { + const { status } = this.accessory.context + return status !== undefined && status.mode === EagleMode.Ai + ? this.platform.characteristic.TargetAirPurifierState.AUTO + : this.platform.characteristic.TargetAirPurifierState.MANUAL + } + + handleTargetAirPurifierStateSet(value: CharacteristicValue) { + const { status } = this.accessory.context + const isAuto = value === this.platform.characteristic.TargetAirPurifierState.AUTO + + if (!status) { + this.sendRawControl({ + power: 1, + mode: isAuto ? EagleMode.Ai : EagleMode.Manual + }) + return + } + + const device = this.platform.airmx.device(this.accessory.context.device.id) + + isAuto + ? device.ai() + : device.cadr(status.cadr) + } + + handleRotationSpeedGet() { + const { status } = this.accessory.context + return status?.cadr || 0 + } + + handleRotationSpeedSet(value: CharacteristicValue) { + const { device, status } = this.accessory.context + + if (!status) { + this.sendRawControl({ + power: 1, + mode: EagleMode.Manual, + cadr: value as number + }) + return + } + + this.platform.airmx + .device(device.id) + .cadr(value as number) + } + + handleFilterChangeIndicationGet() { + const { status } = this.accessory.context + if (status === undefined) { + return this.platform.characteristic.FilterChangeIndication.FILTER_OK + } + const maxPercent = Math.max( + status.g4Percent, + status.carbonPercent, + status.hepaPercent + ) + const threshold = 80 + return maxPercent > threshold + ? this.platform.characteristic.FilterChangeIndication.CHANGE_FILTER + : this.platform.characteristic.FilterChangeIndication.FILTER_OK + } + + handleFilterLifeLevelGet() { + const { status } = this.accessory.context + if (status === undefined) { + return 100 + } + const maxPercent = Math.max( + status.g4Percent, + status.carbonPercent, + status.hepaPercent + ) + return 100 - maxPercent + } + + private sendRawControl(data: Partial) { + this.platform.airmx.control(this.accessory.context.device.id, { + ...stubControl, + ...data + }) + } +} + +export default (api: API) => { + api.registerPlatform(platformName, AirmxPlatform) +} -- cgit v1.2.3