Implement Homebridge plugin for ESPHome RGBWW lights (#1)
* Initial plan * Create all plugin files and verify build Co-authored-by: felixfoertsch <6586185+felixfoertsch@users.noreply.github.com> * Fix HSV to RGB conversion to use actual brightness and extract magic number Co-authored-by: felixfoertsch <6586185+felixfoertsch@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: felixfoertsch <6586185+felixfoertsch@users.noreply.github.com>
This commit is contained in:
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Felix Foertsch
|
||||
|
||||
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.
|
||||
65
config.schema.json
Normal file
65
config.schema.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"pluginAlias": "ESPHomeRGBWW",
|
||||
"pluginType": "platform",
|
||||
"singular": false,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"title": "Name",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"default": "ESPHome RGBWW"
|
||||
},
|
||||
"lights": {
|
||||
"title": "Lights",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"title": "Unique ID",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"placeholder": "bedside-lamp-4387-1962"
|
||||
},
|
||||
"name": {
|
||||
"title": "Display Name",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"placeholder": "Bedside Lamp Rechts"
|
||||
},
|
||||
"manufacturer": {
|
||||
"title": "Manufacturer",
|
||||
"type": "string",
|
||||
"placeholder": "Yeelight"
|
||||
},
|
||||
"model": {
|
||||
"title": "Model",
|
||||
"type": "string",
|
||||
"placeholder": "MJCTD02YL"
|
||||
},
|
||||
"mqtt_broker": {
|
||||
"title": "MQTT Broker URL",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"placeholder": "mqtt://192.168.23.20:1883"
|
||||
},
|
||||
"state_topic": {
|
||||
"title": "State Topic",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"placeholder": "bedside-lamp-4387-1962/light/bedside_lamp_rechts/state"
|
||||
},
|
||||
"command_topic": {
|
||||
"title": "Command Topic",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"placeholder": "bedside-lamp-4387-1962/light/bedside_lamp_rechts/command"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2030
package-lock.json
generated
Normal file
2030
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
package.json
Normal file
40
package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "homebridge-esphome-rgbww",
|
||||
"version": "1.0.0",
|
||||
"description": "Homebridge plugin for ESPHome RGBWW lights with HSV support",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc -w",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"homebridge-plugin",
|
||||
"esphome",
|
||||
"rgbww",
|
||||
"xiaomi",
|
||||
"yeelight"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.0.0",
|
||||
"homebridge": ">=1.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"mqtt": "^5.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"homebridge": "^1.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"author": "Felix Foertsch",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/felixfoertsch/homebridge-esphome-rgbww.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/felixfoertsch/homebridge-esphome-rgbww/issues"
|
||||
},
|
||||
"homepage": "https://github.com/felixfoertsch/homebridge-esphome-rgbww#readme"
|
||||
}
|
||||
248
src/accessory.ts
Normal file
248
src/accessory.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { Service, PlatformAccessory, CharacteristicValue } from 'homebridge';
|
||||
import { ESPHomeRGBWWPlatform } from './platform';
|
||||
import * as mqtt from 'mqtt';
|
||||
|
||||
interface LightState {
|
||||
state: string;
|
||||
brightness: number;
|
||||
color?: {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
};
|
||||
color_temp?: number;
|
||||
color_mode?: string;
|
||||
}
|
||||
|
||||
export class ESPHomeRGBWWAccessory {
|
||||
private service: Service;
|
||||
private mqttClient: mqtt.MqttClient;
|
||||
private static readonly MAX_BRIGHTNESS = 255;
|
||||
|
||||
private currentState = {
|
||||
on: false,
|
||||
brightness: 100,
|
||||
hue: 0,
|
||||
saturation: 0,
|
||||
colorTemperature: 300,
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly platform: ESPHomeRGBWWPlatform,
|
||||
private readonly accessory: PlatformAccessory,
|
||||
private readonly config: any,
|
||||
) {
|
||||
this.accessory.getService(this.platform.Service.AccessoryInformation)!
|
||||
.setCharacteristic(this.platform.Characteristic.Manufacturer, config.manufacturer || 'ESPHome')
|
||||
.setCharacteristic(this.platform.Characteristic.Model, config.model || 'RGBWW Light')
|
||||
.setCharacteristic(this.platform.Characteristic.SerialNumber, config.id);
|
||||
|
||||
this.service = this.accessory.getService(this.platform.Service.Lightbulb)
|
||||
|| this.accessory.addService(this.platform.Service.Lightbulb);
|
||||
|
||||
this.service.setCharacteristic(this.platform.Characteristic.Name, config.name);
|
||||
|
||||
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));
|
||||
|
||||
this.service.getCharacteristic(this.platform.Characteristic.Hue)
|
||||
.onSet(this.setHue.bind(this))
|
||||
.onGet(this.getHue.bind(this));
|
||||
|
||||
this.service.getCharacteristic(this.platform.Characteristic.Saturation)
|
||||
.onSet(this.setSaturation.bind(this))
|
||||
.onGet(this.getSaturation.bind(this));
|
||||
|
||||
this.service.getCharacteristic(this.platform.Characteristic.ColorTemperature)
|
||||
.onSet(this.setColorTemperature.bind(this))
|
||||
.onGet(this.getColorTemperature.bind(this));
|
||||
|
||||
this.mqttClient = mqtt.connect(config.mqtt_broker || 'mqtt://localhost:1883');
|
||||
|
||||
this.mqttClient.on('connect', () => {
|
||||
this.platform.log.info('Connected to MQTT broker');
|
||||
this.mqttClient.subscribe(config.state_topic);
|
||||
});
|
||||
|
||||
this.mqttClient.on('message', (topic, message) => {
|
||||
if (topic === config.state_topic) {
|
||||
this.handleStateUpdate(message.toString());
|
||||
}
|
||||
});
|
||||
|
||||
this.mqttClient.on('error', (error) => {
|
||||
this.platform.log.error('MQTT error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
private rgbToHsv(r: number, g: number, b: number): { h: number; s: number; v: number } {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const delta = max - min;
|
||||
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const v = max;
|
||||
|
||||
if (delta > 0) {
|
||||
s = delta / max;
|
||||
|
||||
if (max === r) {
|
||||
h = ((g - b) / delta) % 6;
|
||||
} else if (max === g) {
|
||||
h = (b - r) / delta + 2;
|
||||
} else {
|
||||
h = (r - g) / delta + 4;
|
||||
}
|
||||
|
||||
h *= 60;
|
||||
if (h < 0) {
|
||||
h += 360;
|
||||
}
|
||||
}
|
||||
|
||||
return { h, s: s * 100, v: v * 100 };
|
||||
}
|
||||
|
||||
private hsvToRgb(h: number, s: number, v: number): { r: number; g: number; b: number } {
|
||||
s /= 100;
|
||||
v /= 100;
|
||||
|
||||
const c = v * s;
|
||||
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
|
||||
const m = v - c;
|
||||
|
||||
let r = 0, g = 0, b = 0;
|
||||
|
||||
if (h >= 0 && h < 60) {
|
||||
r = c; g = x; b = 0;
|
||||
} else if (h < 120) {
|
||||
r = x; g = c; b = 0;
|
||||
} else if (h < 180) {
|
||||
r = 0; g = c; b = x;
|
||||
} else if (h < 240) {
|
||||
r = 0; g = x; b = c;
|
||||
} else if (h < 300) {
|
||||
r = x; g = 0; b = c;
|
||||
} else {
|
||||
r = c; g = 0; b = x;
|
||||
}
|
||||
|
||||
return {
|
||||
r: Math.round((r + m) * 255),
|
||||
g: Math.round((g + m) * 255),
|
||||
b: Math.round((b + m) * 255),
|
||||
};
|
||||
}
|
||||
|
||||
private handleStateUpdate(message: string) {
|
||||
try {
|
||||
const state: LightState = JSON.parse(message);
|
||||
|
||||
this.currentState.on = state.state === 'ON';
|
||||
this.currentState.brightness = Math.round((state.brightness / ESPHomeRGBWWAccessory.MAX_BRIGHTNESS) * 100);
|
||||
|
||||
if (state.color && state.color.r !== undefined) {
|
||||
const hsv = this.rgbToHsv(state.color.r, state.color.g, state.color.b);
|
||||
this.currentState.hue = hsv.h;
|
||||
this.currentState.saturation = hsv.s;
|
||||
}
|
||||
|
||||
if (state.color_temp) {
|
||||
this.currentState.colorTemperature = state.color_temp;
|
||||
}
|
||||
|
||||
this.service.updateCharacteristic(this.platform.Characteristic.On, this.currentState.on);
|
||||
this.service.updateCharacteristic(this.platform.Characteristic.Brightness, this.currentState.brightness);
|
||||
this.service.updateCharacteristic(this.platform.Characteristic.Hue, this.currentState.hue);
|
||||
this.service.updateCharacteristic(this.platform.Characteristic.Saturation, this.currentState.saturation);
|
||||
this.service.updateCharacteristic(this.platform.Characteristic.ColorTemperature, this.currentState.colorTemperature);
|
||||
|
||||
} catch (error) {
|
||||
this.platform.log.error('Failed to parse state update:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private publishCommand(payload: any) {
|
||||
this.mqttClient.publish(
|
||||
this.config.command_topic,
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
}
|
||||
|
||||
async setOn(value: CharacteristicValue) {
|
||||
this.currentState.on = value as boolean;
|
||||
this.publishCommand({ state: value ? 'ON' : 'OFF' });
|
||||
this.platform.log.debug('Set On ->', value);
|
||||
}
|
||||
|
||||
async getOn(): Promise<CharacteristicValue> {
|
||||
return this.currentState.on;
|
||||
}
|
||||
|
||||
async setBrightness(value: CharacteristicValue) {
|
||||
this.currentState.brightness = value as number;
|
||||
const brightness = Math.round((value as number / 100) * ESPHomeRGBWWAccessory.MAX_BRIGHTNESS);
|
||||
this.publishCommand({ brightness });
|
||||
this.platform.log.debug('Set Brightness ->', value);
|
||||
}
|
||||
|
||||
async getBrightness(): Promise<CharacteristicValue> {
|
||||
return this.currentState.brightness;
|
||||
}
|
||||
|
||||
async setHue(value: CharacteristicValue) {
|
||||
this.currentState.hue = value as number;
|
||||
this.updateRGBColor();
|
||||
this.platform.log.debug('Set Hue ->', value);
|
||||
}
|
||||
|
||||
async getHue(): Promise<CharacteristicValue> {
|
||||
return this.currentState.hue;
|
||||
}
|
||||
|
||||
async setSaturation(value: CharacteristicValue) {
|
||||
this.currentState.saturation = value as number;
|
||||
this.updateRGBColor();
|
||||
this.platform.log.debug('Set Saturation ->', value);
|
||||
}
|
||||
|
||||
async getSaturation(): Promise<CharacteristicValue> {
|
||||
return this.currentState.saturation;
|
||||
}
|
||||
|
||||
async setColorTemperature(value: CharacteristicValue) {
|
||||
this.currentState.colorTemperature = value as number;
|
||||
this.publishCommand({ color_temp: value });
|
||||
this.platform.log.debug('Set ColorTemperature ->', value);
|
||||
}
|
||||
|
||||
async getColorTemperature(): Promise<CharacteristicValue> {
|
||||
return this.currentState.colorTemperature;
|
||||
}
|
||||
|
||||
private updateRGBColor() {
|
||||
const rgb = this.hsvToRgb(
|
||||
this.currentState.hue,
|
||||
this.currentState.saturation,
|
||||
this.currentState.brightness,
|
||||
);
|
||||
|
||||
this.publishCommand({
|
||||
color: {
|
||||
r: rgb.r,
|
||||
g: rgb.g,
|
||||
b: rgb.b,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
6
src/index.ts
Normal file
6
src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { API } from 'homebridge';
|
||||
import { ESPHomeRGBWWPlatform } from './platform';
|
||||
|
||||
export = (api: API) => {
|
||||
api.registerPlatform('homebridge-esphome-rgbww', 'ESPHomeRGBWW', ESPHomeRGBWWPlatform);
|
||||
};
|
||||
53
src/platform.ts
Normal file
53
src/platform.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
API,
|
||||
DynamicPlatformPlugin,
|
||||
Logger,
|
||||
PlatformAccessory,
|
||||
PlatformConfig,
|
||||
Service,
|
||||
Characteristic,
|
||||
} from 'homebridge';
|
||||
import { ESPHomeRGBWWAccessory } from './accessory';
|
||||
|
||||
export class ESPHomeRGBWWPlatform implements DynamicPlatformPlugin {
|
||||
public readonly Service: typeof Service = this.api.hap.Service;
|
||||
public readonly Characteristic: typeof Characteristic = this.api.hap.Characteristic;
|
||||
public readonly accessories: PlatformAccessory[] = [];
|
||||
|
||||
constructor(
|
||||
public readonly log: Logger,
|
||||
public readonly config: PlatformConfig,
|
||||
public readonly api: API,
|
||||
) {
|
||||
this.log.debug('Finished initializing platform:', this.config.name);
|
||||
|
||||
this.api.on('didFinishLaunching', () => {
|
||||
this.discoverDevices();
|
||||
});
|
||||
}
|
||||
|
||||
configureAccessory(accessory: PlatformAccessory) {
|
||||
this.log.info('Loading accessory from cache:', accessory.displayName);
|
||||
this.accessories.push(accessory);
|
||||
}
|
||||
|
||||
discoverDevices() {
|
||||
const devices = this.config.lights || [];
|
||||
|
||||
for (const device of devices) {
|
||||
const uuid = this.api.hap.uuid.generate(device.id);
|
||||
const existingAccessory = this.accessories.find(accessory => accessory.UUID === uuid);
|
||||
|
||||
if (existingAccessory) {
|
||||
this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName);
|
||||
new ESPHomeRGBWWAccessory(this, existingAccessory, device);
|
||||
} else {
|
||||
this.log.info('Adding new accessory:', device.name);
|
||||
const accessory = new this.api.platformAccessory(device.name, uuid);
|
||||
accessory.context.device = device;
|
||||
new ESPHomeRGBWWAccessory(this, accessory, device);
|
||||
this.api.registerPlatformAccessories('homebridge-esphome-rgbww', 'ESPHomeRGBWW', [accessory]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"removeComments": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user