summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLi Zhineng <[email protected]>2025-07-22 08:01:28 +0800
committerLi Zhineng <[email protected]>2025-07-22 08:01:28 +0800
commit391eeb229882870a1c2311c820cc910890b35d9d (patch)
tree407c6e346032444249effedaef305649fbb87493
downloadhomebridge-airmx-391eeb229882870a1c2311c820cc910890b35d9d.tar.gz
homebridge-airmx-391eeb229882870a1c2311c820cc910890b35d9d.zip
first commit
-rw-r--r--.editorconfig10
-rw-r--r--.gitignore4
-rw-r--r--.prettierrc3
-rw-r--r--LICENSE21
-rw-r--r--config.schema.json38
-rw-r--r--nodemon.json8
-rw-r--r--package.json46
-rw-r--r--src/index.ts324
-rw-r--r--tsconfig.json16
9 files changed, 470 insertions, 0 deletions
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..31a134f
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,10 @@
+# EditorConfig is awesome: https://editorconfig.org
+
+root = true
+
+[*]
+end_of_line = lf
+insert_final_newline = true
+charset = utf-8
+indent_style = space
+indent_size = 2
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..10eae5e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+dist/
+test/homebridge/
+package-lock.json
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..eca9e73
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,3 @@
+semi: false
+singleQuote: true
+trailingComma: none
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..25e5857
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Zhineng Li
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/config.schema.json b/config.schema.json
new file mode 100644
index 0000000..f53abf7
--- /dev/null
+++ b/config.schema.json
@@ -0,0 +1,38 @@
+{
+ "pluginAlias": "Airmx",
+ "pluginType": "platform",
+ "singular": true,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "mqtt": {
+ "title": "MQTT Broker URL",
+ "type": "string",
+ "placeholder": "mqtt://192.168.10.10",
+ "required": true
+ },
+ "devices": {
+ "title": "Devices",
+ "type": "array",
+ "items": {
+ "title": "Device",
+ "type": "object",
+ "properties": {
+ "id": {
+ "title": "ID",
+ "type": "number",
+ "placeholder": "12345",
+ "required": true
+ },
+ "key": {
+ "title": "Key",
+ "type": "string",
+ "placeholder": "XOXOXOXOXOXOXOXOXOXOXOXOXOXOXOXO",
+ "required": true
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/nodemon.json b/nodemon.json
new file mode 100644
index 0000000..22bea52
--- /dev/null
+++ b/nodemon.json
@@ -0,0 +1,8 @@
+{
+ "watch": [
+ "src"
+ ],
+ "ext": "ts",
+ "exec": "npm run build && homebridge -U ./test/homebridge -D",
+ "signal": "SIGTERM"
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..9ff864d
--- /dev/null
+++ b/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "homebridge-airmx",
+ "displayName": "AIRMX",
+ "version": "1.0.0",
+ "description": "Homebridge plugin for AIRMX Pro.",
+ "keywords": [
+ "homebridge-plugin",
+ "airmx"
+ ],
+ "homepage": "https://github.com/openairmx/homebridge-airmx#readme",
+ "bugs": {
+ "url": "https://github.com/openairmx/homebridge-airmx/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+ssh://[email protected]/openairmx/homebridge-airmx.git"
+ },
+ "license": "MIT",
+ "author": "Zhineng Li",
+ "type": "module",
+ "main": "dist/index.js",
+ "files": [
+ "dist",
+ "config.schema.json"
+ ],
+ "engines": {
+ "node": "^18.20.4 || ^20.18.0 || ^22.10.0",
+ "homebridge": "^1.8.0 || ^2.0.0-beta.0"
+ },
+ "scripts": {
+ "start": "nodemon",
+ "build": "tsc",
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "format": "prettier --write --cache ."
+ },
+ "dependencies": {
+ "airmx": "^0.0.1",
+ "mqtt": "^5.13.3"
+ },
+ "devDependencies": {
+ "homebridge": "^2.0.0-beta.0",
+ "nodemon": "^3.1.10",
+ "prettier": "3.6.2",
+ "typescript": "^5.8.3"
+ }
+}
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<EagleStatusData, 'power' | 'mode' | 'cadr' | 'g4Percent' | 'carbonPercent' | 'hepaPercent' | 'version'>
+}
+
+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<AccessoryContext>
+ > = 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<AccessoryContext>)
+ }
+
+ 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<AccessoryContext>(
+ '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<AccessoryContext>
+ ) {
+ 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<EagleControlData>) {
+ this.platform.airmx.control(this.accessory.context.device.id, {
+ ...stubControl,
+ ...data
+ })
+ }
+}
+
+export default (api: API) => {
+ api.registerPlatform(platformName, AirmxPlatform)
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..52f0ac6
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "lib": ["DOM", "ES2022"],
+ "module": "nodenext",
+ "moduleResolution": "nodenext",
+ "declaration": true,
+ "sourceMap": true,
+ "allowSyntheticDefaultImports": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "outDir": "dist"
+ }
+}