homebridge-ratgdo
Version:
HomeKit integration using Ratgdo and Konnected devices for LiftMaster and Chamberlain garage door openers, without requiring myQ.
262 lines • 13.9 kB
JavaScript
import { Bonjour } from "bonjour-service";
import { FeatureOptions, MqttClient, validateName } from "homebridge-plugin-utils";
import { PLATFORM_NAME, PLUGIN_NAME, RATGDO_AUTODISCOVERY_INTERVAL, RATGDO_AUTODISCOVERY_PROJECT_NAMES, RATGDO_AUTODISCOVERY_TYPES, RATGDO_EVENT_API_HEARTBEAT_DURATION, RATGDO_HEARTBEAT_DURATION, RATGDO_HEARTBEAT_INTERVAL, RATGDO_MQTT_TOPIC } from "./settings.js";
import { featureOptionCategories, featureOptions } from "./ratgdo-options.js";
import EventSource from "eventsource";
import { RatgdoAccessory } from "./ratgdo-device.js";
import { RatgdoVariant } from "./ratgdo-types.js";
import net from "node:net";
import util from "node:util";
export class RatgdoPlatform {
accessories;
api;
discoveredDevices;
espHomeEvents;
pingTimers;
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.espHomeEvents = {};
this.featureOptions = new FeatureOptions(featureOptionCategories, featureOptions, config?.options);
this.hap = api.hap;
this.log = log;
this.log.debug = this.debug.bind(this);
this.mqtt = null;
this.pingTimers = {};
// 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 */, () => {
// Close our events connection.
Object.values(this.espHomeEvents).map(deviceEvents => deviceEvents.close());
// Clear any open ping timers.
Object.values(this.pingTimers).map(timer => clearTimeout(timer));
// 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() {
// 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];
// Grab the MAC address. We uppercase it and put it in the familiar colon notation first.
const mac = service.txt.mac.toUpperCase().replace(/(.{2})(?=.)/g, "$1:");
// Configure the device.
const ratgdoAccessory = this.configureGdo(address, mac, service.txt);
// If we've already configured this one, we're done.
if (!ratgdoAccessory) {
return;
}
try {
// Connect to the Ratgdo ESPHome events API.
this.espHomeEvents[mac] = new EventSource("http://" + address + "/events");
// Handle errors in the events API.
this.espHomeEvents[mac].addEventListener("error", (payload) => {
// The eventsource library returns unknown network errors at times. We ignore them.
if (typeof payload.message === "undefined") {
return;
}
const getErrorMessage = (payload) => {
const { message } = payload;
const errorMessage = "Unrecognized error: " + util.inspect(payload, { sorted: true });
if (typeof message !== "string") {
return errorMessage;
}
if (message.startsWith("connect ECONNREFUSED ")) {
return "Connection to the Ratgdo controller refused";
}
if (message.startsWith("connect ETIMEDOUT ")) {
return "Connection to the Ratgdo controller has timed out";
}
if (message.startsWith("connect EHOSTDOWN ")) {
return "Unable to connect to the Ratgdo controller. The host appears to be down";
}
const errorMessages = {
"read ECONNRESET": "Connection to the Ratgdo controller has been reset",
"read ETIMEDOUT": "Connection to the Ratgdo controller has timed out while listening for events",
"unknown error.": "An unknown error on the Ratgdo controller has occurred. This will happen occasionally and can generally be ignored"
};
return errorMessages[message] ?? errorMessage;
};
ratgdoAccessory.log.error("%s.", getErrorMessage(payload));
});
// Inform the user when we've successfully connected.
this.espHomeEvents[mac].addEventListener("open", () => {
ratgdoAccessory.updateState({ id: "availability", state: "online" });
});
// Inform the user about the availability of the events API.
this.espHomeEvents[mac].addEventListener("ping", () => {
if (this.pingTimers[mac]) {
clearTimeout(this.pingTimers[mac]);
delete this.pingTimers[mac];
}
ratgdoAccessory.updateState({ id: "availability", state: "online" });
this.pingTimers[mac] = setTimeout(() => ratgdoAccessory.updateState({ id: "availability", state: "offline" }), RATGDO_EVENT_API_HEARTBEAT_DURATION * 1000);
});
// Capture log updates from the controller.
this.espHomeEvents[mac].addEventListener("log", (message) => {
ratgdoAccessory.log.debug("Log event received: %s", message);
// 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) {
ratgdoAccessory.updateState({ id: "battery", state: batteryState[1] });
}
});
// Capture state updates from the controller.
this.espHomeEvents[mac].addEventListener("state", (message) => {
let event;
ratgdoAccessory.log.debug("State event received: %s", util.inspect(message.data, { sorted: true }));
// Ratgdo occasionally sends empty status updates - we ignore them.
if (!message.data.length) {
return;
}
try {
event = JSON.parse(message.data);
}
catch (error) {
ratgdoAccessory.log.error("Unable to parse state message: \"%s\". Invalid JSON.", message.data);
return;
}
ratgdoAccessory.updateState(event);
});
// Heartbeat the Ratgdo controller at regular intervals. We need to do this because the ESPHome firmware for Ratgdo has a failsafe that will autoreboot the
// Ratgdo every 15 minutes if it doesn't receive a native API connection. Fortunately, the failsafe only looks for an open connection to the API, allowing us the
// opportunity to heartbeat it with a connection we periodically reopen.
const heartbeat = () => {
// Connect to the Ratgdo, and setup our heartbeat to close after a configured duration.
const socket = net.createConnection({ host: address, port: 6053 }, () => setTimeout(() => {
socket.destroy();
}, RATGDO_HEARTBEAT_DURATION * 1000));
// Handle heartbeat errors.
socket.on("error", (err) => ratgdoAccessory.log.debug("Heartbeat error: %s.", util.inspect(err, { sorted: true })));
// Perpetually restart our heartbeat when it ends.
socket.on("close", () => setTimeout(() => heartbeat(), RATGDO_HEARTBEAT_INTERVAL * 1000));
};
heartbeat();
}
catch (error) {
if (error instanceof Error) {
ratgdoAccessory.log.error("Ratgdo API error: %s", error.message);
}
}
}
// Configure a discovered garage door opener.
configureGdo(address, mac, deviceInfo) {
// 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: 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(validateName(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];
}
// Utility for debug logging.
debug(message, ...parameters) {
if (this.config.debug) {
this.log.error(util.format(message, ...parameters));
}
}
}
//# sourceMappingURL=ratgdo-platform.js.map