homebridge-ratgdo
Version:
HomeKit integration using Ratgdo and Konnected devices for LiftMaster and Chamberlain garage door openers, without requiring myQ.
326 lines • 16.3 kB
JavaScript
import { Bonjour } from "bonjour-service";
import { EspHomeClient } from "esphome-client";
import { RatgdoAccessory } from "./ratgdo-device.js";
import { EventSource } from "undici";
import { FeatureOptions, MqttClient, sanitizeName } from "homebridge-plugin-utils";
import { PLATFORM_NAME, PLUGIN_NAME, RATGDO_API_HEARTBEAT_DURATION, RATGDO_AUTODISCOVERY_INTERVAL, RATGDO_AUTODISCOVERY_PROJECT_NAMES, RATGDO_AUTODISCOVERY_TYPES, RATGDO_MQTT_TOPIC } from "./settings.js";
import { featureOptionCategories, featureOptions } from "./ratgdo-options.js";
import { RatgdoVariant } from "./ratgdo-types.js";
import util from "node:util";
export class RatgdoPlatform {
accessories;
api;
discoveredDevices;
listeners;
espHomeApi;
espHomeEvents;
heartbeatTimers;
featureOptions;
config;
configOptions;
configuredDevices;
hap;
log;
mqtt;
constructor(log, config, api) {
this.accessories = [];
this.api = api;
this.config = {};
this.configOptions = [];
this.configuredDevices = {};
this.discoveredDevices = {};
this.espHomeApi = {};
this.espHomeEvents = {};
this.featureOptions = new FeatureOptions(featureOptionCategories, featureOptions, config?.options);
this.hap = api.hap;
this.listeners = {};
this.log = log;
this.log.debug = this.debug.bind(this);
this.mqtt = null;
this.heartbeatTimers = {};
// We can't start without being configured.
if (!config) {
return;
}
this.config = {
debug: config.debug === true,
mqttTopic: config.mqttTopic,
mqttUrl: config.mqttUrl,
options: config.options
};
// Initialize MQTT, if needed.
if (this.config.mqttUrl) {
this.mqtt = new MqttClient(this.config.mqttUrl, this.config.mqttTopic ?? RATGDO_MQTT_TOPIC, this.log);
}
this.log.debug("Debug logging on. Expect a lot of data.");
// Fire up the Ratgdo API once Homebridge has loaded all the cached accessories it knows about and called configureAccessory() on each.
api.on("didFinishLaunching" /* APIEvent.DID_FINISH_LAUNCHING */, () => this.configureRatgdo());
// Make sure we take ourselves offline when we shutdown.
api.on("shutdown" /* APIEvent.SHUTDOWN */, () => {
// Stop any open heartbeat timers.
Object.values(this.heartbeatTimers).map(timer => clearTimeout(timer));
// Cleanup and close our events connection.
Object.keys(this.espHomeEvents).map(mac => {
this.espHomeEvents[mac].removeEventListener("log", this.listeners[mac].log);
delete this.listeners[mac].log;
this.espHomeEvents[mac].close();
});
// Cleanup and close our API connection.
Object.keys(this.listeners).map(mac => {
Object.keys(this.listeners[mac]).map(event => {
this.espHomeApi[mac].off(event, this.listeners[mac][event]);
delete this.listeners[mac][event];
});
this.espHomeApi[mac]?.disconnect();
delete this.espHomeApi[mac];
});
// Inform our accessories we're going offline.
Object.values(this.configuredDevices).map(device => device.updateState({ id: "availability", state: "offline" }));
});
}
// This gets called when homebridge restores cached accessories at startup. We intentionally avoid doing anything significant here, and save all that logic for device
// discovery.
configureAccessory(accessory) {
// Add this to the accessory array so we can track it.
this.accessories.push(accessory);
}
// Configure and connect to Ratgdo ESPHome clients.
configureRatgdo() {
// The EventSource API client is currently flagged as experimental in undici, though it's quite stable and solid already. We work around this issue by forcibly
// filtering out the warning.
process.removeAllListeners("warning").on("warning", (warning) => {
if ((warning.code === "UNDICI-ES") && (warning.message === "EventSource is experimental, expect them to change at any time.")) {
return;
}
// eslint-disable-next-line no-console
console.warn(warning);
});
// Instantiate our mDNS stack.
const mdns = new Bonjour();
// Make sure we cleanup our mDNS client on shutdown.
this.api.on("shutdown" /* APIEvent.SHUTDOWN */, () => mdns.destroy());
// Start ESPHome device discovery.
for (const mdnsType of RATGDO_AUTODISCOVERY_TYPES) {
const mdnsBrowser = mdns.find({ type: mdnsType }, this.discoverRatgdoDevice.bind(this));
// Trigger an initial update of our discovery.
mdnsBrowser.update();
// Refresh device discovery regular intervals.
setInterval(() => mdnsBrowser.update(), RATGDO_AUTODISCOVERY_INTERVAL * 1000);
}
}
// Ratgdo ESPHome device discovery.
discoverRatgdoDevice(service) {
// We're only interested in ESPHome Ratgdo devices (and compatible variants) with valid MAC and IP addresses. Otherwise, we're done.
if ((!service.txt?.esphome_version && !service.txt?.version) || !service.txt?.mac || !service.addresses ||
!RATGDO_AUTODISCOVERY_PROJECT_NAMES.some(project => service.txt?.project_name?.match(project))) {
return;
}
// We grab the first address provided for the ESPHome device.
const address = service.addresses[0];
// Configure the device.
const ratgdo = this.configureGdo(address, service.txt.mac, service.txt);
// If we've already configured this one, we're done.
if (!ratgdo) {
return;
}
this.listeners[ratgdo.device.mac] = {};
// Battery status is only available to us through log entries made by the SSE/EventSource API. The native API doesn't expose battery state yet.
if (ratgdo.hints.discoBattery) {
// Connect to the Ratgdo ESPHome events API.
this.espHomeEvents[ratgdo.device.mac] = new EventSource("http://" + address + "/events");
// Capture log updates from the controller.
this.espHomeEvents[ratgdo.device.mac].addEventListener("log", this.listeners[ratgdo.device.mac].log = (event) => {
const message = event;
ratgdo.log.debug("Log event received: %s", util.inspect(message.data, { sorted: true }));
// Ratgdo occasionally sends empty status updates - we ignore them.
if (!message.data.length) {
return;
}
// Grab the battery state, when logged.
const batteryState = message.data.match(/\bBattery state=(.+?)\b/);
// We've got a battery state update, inform the Ratgdo.
if (batteryState) {
ratgdo.log.debug("BATTERY STATE UPDATE: \"%s\"", batteryState[1]);
ratgdo.updateState({ id: "battery", state: batteryState[1] });
}
});
}
this.espHomeApi[ratgdo.device.mac] = new EspHomeClient({
host: address,
logger: ratgdo.log,
psk: this.featureOptions.value("Device.Encryption.Key", ratgdo.device.mac)
});
// Kickoff our heartbeats only the very first time we connect.
this.espHomeApi[ratgdo.device.mac].once("deviceInfo", (info, encrypted) => {
this.beat(ratgdo, { encrypted, info });
// Heartbeat our Ratgdo.
this.espHomeApi[ratgdo.device.mac].on("message", this.listeners[ratgdo.device.mac].message = () => this.beat(ratgdo));
});
// Reconnect on disconnect.
this.espHomeApi[ratgdo.device.mac].on("disconnect", this.listeners[ratgdo.device.mac].disconnect = (reason) => {
switch (reason) {
case "encryption key invalid":
case "encryption key missing":
ratgdo.updateState({ id: "availability", state: "offline" });
ratgdo.log.error("%s encryption key. Please ensure you configure HBR with the correct base64-encoded encryption key for this device.", (reason === "encryption key invalid") ? "Invalid" : "Missing");
break;
default:
this.beat(ratgdo, { reconnecting: true, updateState: false });
break;
}
});
// Process telemetry from the Ratgdo.
this.espHomeApi[ratgdo.device.mac].on("telemetry", (data) => {
const payload = { id: (data.type + "-" + data.entity).replace(/ /g, "_").toLowerCase(), state: "" };
switch (data.type) {
case "binary_sensor":
case "light":
case "switch":
payload.state = data.value ? "ON" : "OFF";
break;
case "cover":
case "door_cover":
// data: {"id":"cover-door","value":0,"state":"CLOSED","current_operation":"IDLE","position":0}
payload.state = data.position ? "OPEN" : "CLOSED";
switch (data.currentOperation) {
case 1:
// eslint-disable-next-line camelcase
payload.current_operation = "OPENING";
break;
case 2:
// eslint-disable-next-line camelcase
payload.current_operation = "CLOSING";
break;
case undefined:
case 0:
default:
// eslint-disable-next-line camelcase
payload.current_operation = "IDLE";
break;
}
payload.position = data.position;
break;
case "lock":
switch (data.value) {
case 1:
payload.state = "LOCKED";
break;
case 2:
payload.state = "UNLOCKED";
break;
default:
payload.state = "UNKNOWN";
break;
}
break;
case "button":
case "sensor":
case "text_sensor":
default:
payload.state = (typeof data.value === "number") ? data.value.toString() : data.value;
break;
}
ratgdo.updateState(payload);
});
this.espHomeApi[ratgdo.device.mac].connect();
}
// Configure a discovered garage door opener.
configureGdo(address, mac, deviceInfo) {
// We uppercase the MAC and normalize it to the familiar colon notation before we do anything else.
mac = mac.toUpperCase().replace(/(.{2})(?=.)/g, "$1:");
// If we've already discovered this device, we're done.
if (this.discoveredDevices[mac]) {
return null;
}
// Generate this device's unique identifier.
const uuid = this.hap.uuid.generate(mac);
// See if we already know about this accessory or if it's truly new.
let accessory = this.accessories.find(x => x.UUID === uuid);
// Our device details.
const device = {
address: address,
firmwareVersion: deviceInfo.version ?? deviceInfo.esphome_version,
mac: mac.replace(/:/g, ""),
name: this.featureOptions.value("Device.LogName", mac.replace(/:/g, "")) ?? deviceInfo.friendly_name ?? "Ratgdo",
variant: (deviceInfo.project_name === "ratgdo.esphome") ? RatgdoVariant.RATGDO : RatgdoVariant.KONNECTED
};
// Inform the user that we've discovered a device.
this.log.info("Discovered: %s (address: %s mac: %s ESPHome firmware: v%s variant: %s).", device.name, device.address, device.mac, device.firmwareVersion, device.variant);
// Mark it as discovered.
this.discoveredDevices[mac] = true;
// Check to see if the user has disabled the device.
if (!this.featureOptions.test("Device", device.mac)) {
// If the accessory already exists, let's remove it.
if (accessory) {
// Inform the user.
this.log.info("%s: Removing device from HomeKit.", accessory.displayName);
// Unregister the accessory and delete it's remnants from HomeKit.
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
this.accessories.splice(this.accessories.indexOf(accessory), 1);
this.api.updatePlatformAccessories(this.accessories);
}
// We're done.
return null;
}
// If we've already configured this device before, we're done.
if (this.configuredDevices[uuid]) {
return null;
}
// It's a new device - let's add it to HomeKit.
if (!accessory) {
accessory = new this.api.platformAccessory(sanitizeName(device.name), uuid);
// Register this accessory with Homebridge and add it to the accessory array so we can track it.
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
this.accessories.push(accessory);
}
// Inform the user.
this.log.info("Configuring: %s (address: %s mac: %s ESPHome firmware: v%s variant: %s).", device.name, device.address, device.mac, device.firmwareVersion, device.variant);
// Add it to our list of configured devices.
this.configuredDevices[uuid] = new RatgdoAccessory(this, accessory, device);
// Refresh the accessory cache.
this.api.updatePlatformAccessories([accessory]);
return this.configuredDevices[uuid];
}
// Heartbeat the Ratgdo.
beat(ratgdo, options = {}) {
options.reconnecting ??= false;
options.updateState ??= true;
if (options.info) {
ratgdo.device.model = options.info.projectVersion;
}
if (this.heartbeatTimers[ratgdo.device.mac]) {
clearTimeout(this.heartbeatTimers[ratgdo.device.mac]);
clearTimeout(this.heartbeatTimers[ratgdo.device.mac + ".reconnect"]);
delete this.heartbeatTimers[ratgdo.device.mac];
delete this.heartbeatTimers[ratgdo.device.mac + ".reconnect"];
}
if (options.updateState) {
ratgdo.updateState({ id: "availability", state: "online", ...(options.encrypted !== undefined && { value: options.encrypted ? "encrypted" : "unencrypted" }) });
}
else if (options.reconnecting) {
ratgdo.updateState({ id: "availability", state: "offline" });
}
// Reset our timer.
this.heartbeatTimers[ratgdo.device.mac] = setTimeout(() => {
// The API instance is no longer available due to plugin shutdown. We're done.
if (!this.espHomeApi[ratgdo.device.mac]) {
return;
}
// No heartbeat detected. Let's attempt to reconnect, unless we already have one inflight.
if (!options.reconnecting) {
this.espHomeApi[ratgdo.device.mac].disconnect();
}
this.heartbeatTimers[ratgdo.device.mac + ".reconnect"] = setTimeout(() => {
this.espHomeApi[ratgdo.device.mac].connect();
this.beat(ratgdo, { updateState: false });
}, 2000);
}, (options.reconnecting ? 0.5 : RATGDO_API_HEARTBEAT_DURATION) * 1000);
}
// Utility for debug logging.
debug(message, ...parameters) {
if (this.config.debug) {
this.log.warn(util.format(message, ...parameters));
}
}
}
//# sourceMappingURL=ratgdo-platform.js.map