diff --git a/.gitignore b/.gitignore index 5353fe5..9fec606 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,6 @@ # Dependencies node_modules/ -# Build output -dist/ - # Logs *.log npm-debug.log* @@ -20,3 +17,7 @@ Thumbs.db # TypeScript *.tsbuildinfo + +# Agent notes +AI_AGENT_REPORT.md +LEARNINGS.md diff --git a/README.md b/README.md index 5b23e51..4008239 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,34 @@ A Homebridge plugin for ESPHome RGBWW lights with native HSV support and automat ## Installation +### Install from GitHub (Homebridge Docker/Unraid, persistent) + +Run these commands inside the Homebridge container terminal: + ```bash -npm install -g homebridge-esphome-rgbww +cd /homebridge +npm uninstall homebridge-esphome-rgbww +npm install --save git+https://github.com/felixfoertsch/homebridge-esphome-rgbww.git ``` +Then restart Homebridge: + +```bash +hb-service restart +``` + +If `hb-service` is not available, restart the container from your Docker/Unraid UI. + +Verify the runtime entrypoint exists: + +```bash +ls -l /homebridge/node_modules/homebridge-esphome-rgbww/dist/index.js +``` + +Important: +- Install from `/homebridge` with `--save` so dependency metadata is persisted. +- Do not use `npm -g install` inside the container. + ## Configuration Add this to your Homebridge `config.json`: diff --git a/dist/accessory.js b/dist/accessory.js new file mode 100644 index 0000000..a9e7914 --- /dev/null +++ b/dist/accessory.js @@ -0,0 +1,247 @@ +"use strict"; +const mqtt = require("mqtt"); + +class ESPHomeRGBWWAccessory { + static MAX_BRIGHTNESS = 255; + + constructor(platform, accessory, config) { + this.platform = platform; + this.accessory = accessory; + this.config = config; + this.currentState = { + on: false, + brightness: 100, + hue: 0, + saturation: 0, + colorTemperature: 300, + }; + + 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); + }); + } + + rgbToHsv(r, g, b) { + 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 }; + } + + hsvToRgb(h, s, v) { + 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; + let g = 0; + let 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), + }; + } + + handleStateUpdate(message) { + try { + const state = 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); + } + } + + publishCommand(payload) { + this.mqttClient.publish(this.config.command_topic, JSON.stringify(payload)); + } + + async setOn(value) { + this.currentState.on = value; + this.publishCommand({ state: value ? "ON" : "OFF" }); + this.platform.log.debug("Set On ->", value); + } + + async getOn() { + return this.currentState.on; + } + + async setBrightness(value) { + this.currentState.brightness = value; + const brightness = Math.round(((value) / 100) * ESPHomeRGBWWAccessory.MAX_BRIGHTNESS); + this.publishCommand({ brightness }); + this.platform.log.debug("Set Brightness ->", value); + } + + async getBrightness() { + return this.currentState.brightness; + } + + async setHue(value) { + this.currentState.hue = value; + this.updateRGBColor(); + this.platform.log.debug("Set Hue ->", value); + } + + async getHue() { + return this.currentState.hue; + } + + async setSaturation(value) { + this.currentState.saturation = value; + this.updateRGBColor(); + this.platform.log.debug("Set Saturation ->", value); + } + + async getSaturation() { + return this.currentState.saturation; + } + + async setColorTemperature(value) { + this.currentState.colorTemperature = value; + this.publishCommand({ color_temp: value }); + this.platform.log.debug("Set ColorTemperature ->", value); + } + + async getColorTemperature() { + return this.currentState.colorTemperature; + } + + 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, + }, + }); + } +} + +module.exports = { ESPHomeRGBWWAccessory }; diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..89c3c61 --- /dev/null +++ b/dist/index.js @@ -0,0 +1,6 @@ +"use strict"; +const { ESPHomeRGBWWPlatform } = require("./platform"); + +module.exports = (api) => { + api.registerPlatform("homebridge-esphome-rgbww", "ESPHomeRGBWW", ESPHomeRGBWWPlatform); +}; diff --git a/dist/platform.js b/dist/platform.js new file mode 100644 index 0000000..15c37d9 --- /dev/null +++ b/dist/platform.js @@ -0,0 +1,45 @@ +"use strict"; +const { ESPHomeRGBWWAccessory } = require("./accessory"); + +class ESPHomeRGBWWPlatform { + constructor(log, config, api) { + this.log = log; + this.config = config; + this.api = api; + this.Service = this.api.hap.Service; + this.Characteristic = this.api.hap.Characteristic; + this.accessories = []; + + this.log.debug("Finished initializing platform:", this.config.name); + this.api.on("didFinishLaunching", () => { + this.discoverDevices(); + }); + } + + configureAccessory(accessory) { + 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]); + } + } + } +} + +module.exports = { ESPHomeRGBWWPlatform }; diff --git a/package-lock.json b/package-lock.json index 4bee64c..c7daf87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "homebridge-esphome-rgbww", - "version": "1.0.0", + "version": "2026.02.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "homebridge-esphome-rgbww", - "version": "1.0.0", + "version": "2026.02.23", "license": "MIT", "dependencies": { "mqtt": "^5.3.5" diff --git a/package.json b/package.json index 50e557a..fd30f51 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,21 @@ { "name": "homebridge-esphome-rgbww", - "version": "1.0.0", + "version": "2026.02.23", "description": "Homebridge plugin for ESPHome RGBWW lights with HSV support", "main": "dist/index.js", + "files": [ + "dist/", + "config.schema.json", + "README.md", + "LICENSE" + ], "scripts": { "build": "tsc", + "clean": "rm -rf dist", + "pretest": "npm run build", + "test": "node --test test/smoke.test.mjs", "watch": "tsc -w", + "prepare": "npm run build", "prepublishOnly": "npm run build" }, "keywords": [ diff --git a/test/smoke.test.mjs b/test/smoke.test.mjs new file mode 100644 index 0000000..d8ac078 --- /dev/null +++ b/test/smoke.test.mjs @@ -0,0 +1,7 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import pluginRegister from "../dist/index.js"; + +test("exports a register function", () => { + assert.equal(typeof pluginRegister, "function"); +});