UNPKG

homebridge-rabbitair

Version:
696 lines (579 loc) 17.6 kB
--- name: homebridge-plugin-dev description: Homebridge plugin development with HAP-NodeJS, TypeScript, platform/accessory patterns, and HomeKit integration --- # Homebridge Plugin Development Use this skill when creating or maintaining Homebridge plugins, integrating IoT devices with HomeKit, or working with HAP-NodeJS. ## Plugin Architecture Overview ### Platform vs Accessory Plugins **Dynamic Platform Plugin** (Recommended): - Discovers multiple devices - Adds/removes accessories dynamically - Configuration-driven - Example: ISY controller with multiple devices **Accessory Plugin** (Simple): - Single static accessory - Fixed configuration - Example: Single smart light **Static Platform Plugin** (Legacy): - Multiple accessories defined in config - No dynamic discovery - Avoid for new plugins ## Project Setup ### 1. Initialize Plugin Package ```bash mkdir homebridge-my-device cd homebridge-my-device pnpm init ``` ### 2. Package.json Structure ```json { "name": "homebridge-my-device", "version": "1.0.0", "description": "Homebridge plugin for My Device", "type": "module", "main": "dist/index.js", "keywords": [ "homebridge-plugin", "homebridge", "homekit", "my-device" ], "engines": { "node": "^18.20 || ^20.17 || ^22.11", "homebridge": "^1.8.0 || ^2.0.0-beta.0" }, "scripts": { "build": "tsc", "dev": "tsc --watch", "test": "vitest", "lint": "oxlint", "lint:fix": "oxlint --fix", "format": "oxfmt", "prepublishOnly": "pnpm build" }, "dependencies": { "node-persist": "^4.0.0" }, "devDependencies": { "homebridge": "^1.8.0", "typescript": "^5.6.0", "vitest": "^2.0.0", "oxlint": "^0.15.0" } } ``` ### 3. TypeScript Configuration **tsconfig.json**: ```json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "lib": ["ES2022"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, "resolveJsonModule": true, "declaration": true, "outDir": "./dist", "rootDir": "./src", "noUnusedLocals": true, "noUnusedParameters": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ``` ## Platform Plugin Implementation ### 1. Main Plugin Entry (src/index.ts) ```typescript import type { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig, Service, Characteristic, } from 'homebridge'; const PLATFORM_NAME = 'MyDevicePlatform'; const PLUGIN_NAME = 'homebridge-my-device'; export default (api: API) => { api.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, MyDevicePlatform); }; interface MyDeviceConfig extends PlatformConfig { deviceIp?: string; pollingInterval?: number; debugMode?: boolean; } export class MyDevicePlatform implements DynamicPlatformPlugin { public readonly Service: typeof Service; public readonly Characteristic: typeof Characteristic; // Track restored cached accessories public readonly accessories: PlatformAccessory[] = []; constructor( public readonly log: Logger, public readonly config: MyDeviceConfig, public readonly api: API, ) { this.Service = api.hap.Service; this.Characteristic = api.hap.Characteristic; this.log.debug('Finished initializing platform:', this.config.name); // Homebridge emits didFinishLaunching when restored cached accessories are loaded this.api.on('didFinishLaunching', () => { log.debug('Executed didFinishLaunching callback'); this.discoverDevices(); }); } /** * Called when Homebridge restores cached accessories from disk */ configureAccessory(accessory: PlatformAccessory) { this.log.info('Loading accessory from cache:', accessory.displayName); this.accessories.push(accessory); } /** * Discover devices and register as accessories */ async discoverDevices() { // Example: Discover devices from network/API const devices = await this.fetchDevices(); for (const device of devices) { // Generate unique identifier const uuid = this.api.hap.uuid.generate(device.id); // Check if accessory already exists const existingAccessory = this.accessories.find(acc => acc.UUID === uuid); if (existingAccessory) { // Restore from cache this.log.info('Restoring existing accessory:', existingAccessory.displayName); new MyDeviceAccessory(this, existingAccessory, device); } else { // Create new accessory this.log.info('Adding new accessory:', device.name); const accessory = new this.api.platformAccessory(device.name, uuid); // Store device info in context accessory.context.device = device; // Create accessory handler new MyDeviceAccessory(this, accessory, device); // Register with Homebridge this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]); } } // Remove accessories that are no longer present const staleAccessories = this.accessories.filter( acc => !devices.find(dev => this.api.hap.uuid.generate(dev.id) === acc.UUID) ); if (staleAccessories.length > 0) { this.log.info('Removing stale accessories:', staleAccessories.map(a => a.displayName)); this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, staleAccessories); } } async fetchDevices(): Promise<Device[]> { // Implement device discovery logic return []; } } interface Device { id: string; name: string; type: string; // Add device-specific properties } ``` ### 2. Accessory Implementation (src/accessory.ts) ```typescript import type { Service, PlatformAccessory, CharacteristicValue, } from 'homebridge'; import type { MyDevicePlatform } from './index.js'; import type { Device } from './types.js'; export class MyDeviceAccessory { private service: Service; // Store current state private state = { On: false, Brightness: 100, }; constructor( private readonly platform: MyDevicePlatform, private readonly accessory: PlatformAccessory, private readonly device: Device, ) { // Set accessory information this.accessory.getService(this.platform.Service.AccessoryInformation)! .setCharacteristic(this.platform.Characteristic.Manufacturer, 'My Manufacturer') .setCharacteristic(this.platform.Characteristic.Model, device.type) .setCharacteristic(this.platform.Characteristic.SerialNumber, device.id); // Get or create service this.service = this.accessory.getService(this.platform.Service.Lightbulb) || this.accessory.addService(this.platform.Service.Lightbulb); // Set service name (this is what appears in the Home app) this.service.setCharacteristic(this.platform.Characteristic.Name, device.name); // Register handlers for required characteristics this.service.getCharacteristic(this.platform.Characteristic.On) .onSet(this.setOn.bind(this)) .onGet(this.getOn.bind(this)); this.service.getCharacteristic(this.platform.Characteristic.Brightness) .onSet(this.setBrightness.bind(this)) .onGet(this.getBrightness.bind(this)); // Start polling for state updates (if needed) this.startPolling(); } /** * Handle SET requests from HomeKit */ async setOn(value: CharacteristicValue) { const on = value as boolean; this.platform.log.debug('Set On ->', on); // Send command to device await this.sendCommand({ power: on ? 'on' : 'off' }); // Update internal state this.state.On = on; } /** * Handle GET requests from HomeKit */ async getOn(): Promise<CharacteristicValue> { const on = this.state.On; this.platform.log.debug('Get On ->', on); return on; } async setBrightness(value: CharacteristicValue) { const brightness = value as number; this.platform.log.debug('Set Brightness ->', brightness); await this.sendCommand({ brightness }); this.state.Brightness = brightness; } async getBrightness(): Promise<CharacteristicValue> { return this.state.Brightness; } /** * Send command to physical device */ async sendCommand(command: Record<string, unknown>) { try { // Implement device communication this.platform.log.debug('Sending command:', command); // Example HTTP request // await fetch(`http://${this.device.ip}/api/control`, { // method: 'POST', // body: JSON.stringify(command), // }); } catch (error) { this.platform.log.error('Failed to send command:', error); throw new this.platform.api.hap.HapStatusError( this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE ); } } /** * Poll device for state updates */ startPolling() { const interval = this.platform.config.pollingInterval || 30000; setInterval(async () => { try { const state = await this.fetchDeviceState(); // Update characteristics if state changed if (state.power !== this.state.On) { this.state.On = state.power; this.service.updateCharacteristic(this.platform.Characteristic.On, state.power); } if (state.brightness !== this.state.Brightness) { this.state.Brightness = state.brightness; this.service.updateCharacteristic(this.platform.Characteristic.Brightness, state.brightness); } } catch (error) { this.platform.log.error('Failed to poll device state:', error); } }, interval); } async fetchDeviceState() { // Implement state fetching from device return { power: this.state.On, brightness: this.state.Brightness, }; } } ``` ## Common Service Types ### Lightbulb ```typescript this.service = accessory.addService(this.platform.Service.Lightbulb); // Characteristics: On, Brightness, Hue, Saturation, ColorTemperature ``` ### Switch ```typescript this.service = accessory.addService(this.platform.Service.Switch); // Characteristics: On ``` ### Thermostat ```typescript this.service = accessory.addService(this.platform.Service.Thermostat); // Characteristics: CurrentTemperature, TargetTemperature, CurrentHeatingCoolingState, TargetHeatingCoolingState ``` ### Lock ```typescript this.service = accessory.addService(this.platform.Service.LockMechanism); // Characteristics: LockCurrentState, LockTargetState ``` ### Air Quality Sensor ```typescript this.service = accessory.addService(this.platform.Service.AirQualitySensor); // Characteristics: AirQuality, PM2_5Density, VOCDensity ``` ### Fan ```typescript this.service = accessory.addService(this.platform.Service.Fanv2); // Characteristics: Active, RotationSpeed, RotationDirection ``` ## Configuration Schema (config.schema.json) Create a schema for Homebridge Config UI X: ```json { "pluginAlias": "MyDevicePlatform", "pluginType": "platform", "singular": false, "schema": { "type": "object", "properties": { "name": { "title": "Name", "type": "string", "required": true, "default": "My Device" }, "deviceIp": { "title": "Device IP Address", "type": "string", "required": true, "format": "ipv4", "placeholder": "192.168.1.100" }, "pollingInterval": { "title": "Polling Interval (ms)", "type": "number", "default": 30000, "minimum": 1000, "description": "How often to poll device for state updates" }, "debugMode": { "title": "Enable Debug Logging", "type": "boolean", "default": false } } }, "layout": [ { "type": "flex", "flex-flow": "row wrap", "items": ["name", "deviceIp"] }, { "type": "flex", "flex-flow": "row wrap", "items": ["pollingInterval", "debugMode"] } ] } ``` ## Error Handling Best Practices ### HAP Status Errors ```typescript import { HapStatusError, HAPStatus } from 'homebridge'; async setOn(value: CharacteristicValue) { try { await this.sendCommand({ power: value ? 'on' : 'off' }); } catch (error) { this.platform.log.error('Failed to set power:', error); // Throw appropriate HAP error throw new HapStatusError(HAPStatus.SERVICE_COMMUNICATION_FAILURE); } } ``` ### Timeout Handling ```typescript async sendCommandWithTimeout(command: Record<string, unknown>, timeoutMs = 5000) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(this.deviceUrl, { method: 'POST', body: JSON.stringify(command), signal: controller.signal, }); return await response.json(); } catch (error) { if (error.name === 'AbortError') { throw new Error('Device request timed out'); } throw error; } finally { clearTimeout(timeout); } } ``` ## Testing Your Plugin ### 1. Link Locally ```bash pnpm build pnpm link --global ``` ### 2. Test in Homebridge ```bash # Install Homebridge globally if not already npm install -g homebridge # Link your plugin cd ~/.homebridge pnpm link --global homebridge-my-device # Run Homebridge homebridge -D ``` ### 3. Configuration (~/.homebridge/config.json) ```json { "platforms": [ { "platform": "MyDevicePlatform", "name": "My Device", "deviceIp": "192.168.1.100", "pollingInterval": 30000 } ] } ``` ### 4. Unit Tests with Vitest ```typescript import { describe, it, expect, vi } from 'vitest'; import type { API, Logger, PlatformConfig } from 'homebridge'; import { MyDevicePlatform } from '../src/index.js'; describe('MyDevicePlatform', () => { const mockAPI = { hap: { Service: {}, Characteristic: {}, uuid: { generate: vi.fn((id: string) => `uuid-${id}`), }, }, on: vi.fn(), registerPlatformAccessories: vi.fn(), } as unknown as API; const mockLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), } as unknown as Logger; const mockConfig: PlatformConfig = { platform: 'MyDevicePlatform', name: 'Test Device', deviceIp: '192.168.1.100', }; it('should initialize platform', () => { const platform = new MyDevicePlatform(mockLogger, mockConfig, mockAPI); expect(platform).toBeDefined(); expect(mockAPI.on).toHaveBeenCalledWith('didFinishLaunching', expect.any(Function)); }); it('should discover devices', async () => { const platform = new MyDevicePlatform(mockLogger, mockConfig, mockAPI); // Mock device discovery vi.spyOn(platform, 'fetchDevices').mockResolvedValue([ { id: 'device1', name: 'Light 1', type: 'light' }, ]); await platform.discoverDevices(); expect(mockAPI.registerPlatformAccessories).toHaveBeenCalled(); }); }); ``` ## Publishing Your Plugin ### 1. Verify Plugin (homebridge-plugin-verifier) ```bash npx -p homebridge homebridge-plugin-verifier ``` ### 2. Update package.json - Ensure `keywords` includes "homebridge-plugin" - Set `engines.homebridge` to minimum supported version - Add `config.schema.json` to published files ### 3. Publish to npm ```bash pnpm build pnpm publish ``` ### 4. Submit to Homebridge Verified - Open PR to https://github.com/homebridge/verified - Ensure plugin meets all requirements - Wait for review and approval ## Debugging Tips ### Enable Debug Logging ```bash # Set DEBUG environment variable DEBUG=* homebridge -D # Or specific namespace DEBUG=homebridge-my-device homebridge ``` ### Use logger.debug() ```typescript this.platform.log.debug('Device state:', state); ``` ### Check Homebridge Logs ```bash # macOS/Linux tail -f ~/.homebridge/homebridge.log # Or use Homebridge UI # http://localhost:8581 ``` ### Common Issues **Accessory not appearing in Home app**: - Check plugin is registered correctly - Verify `didFinishLaunching` event is handled - Ensure UUID is consistent - Try removing cached accessories **Characteristics not updating**: - Verify `updateCharacteristic()` is called - Check polling interval - Ensure device communication is working **"This accessory is not certified"**: - Normal for development plugins - Add to Home app anyway - Will disappear after certification ## Advanced Patterns ### Event-based Updates (vs Polling) ```typescript // Subscribe to device events this.device.on('stateChange', (newState) => { this.service.updateCharacteristic( this.platform.Characteristic.On, newState.power ); }); ``` ### Multiple Services per Accessory ```typescript // Add multiple services to one accessory const lightService = accessory.addService(this.platform.Service.Lightbulb, 'Light'); const fanService = accessory.addService(this.platform.Service.Fanv2, 'Fan'); ``` ### Custom Characteristics ```typescript // Use manufacturer-specific characteristics const customChar = new this.platform.Characteristic('CustomSetting', 'UUID-HERE', { format: this.platform.Characteristic.Formats.BOOL, perms: [this.platform.Characteristic.Perms.READ, this.platform.Characteristic.Perms.WRITE], }); this.service.addCharacteristic(customChar) .onSet(this.setCustomSetting.bind(this)); ``` ## Resources - Homebridge API: https://developers.homebridge.io/ - HAP-NodeJS: https://github.com/homebridge/HAP-NodeJS - Service/Characteristic Types: https://developers.homebridge.io/#/service - Plugin Development: https://developers.homebridge.io/#/ - Verified Plugins: https://github.com/homebridge/verified