homebridge-ratgdo
Version:
HomeKit integration using Ratgdo and Konnected devices for LiftMaster and Chamberlain garage door openers, without requiring myQ.
330 lines • 16.8 kB
JavaScript
import { Bonjour } from "bonjour-service";
import { EspHomeClient, LogLevel } from "esphome-client";
import { RatgdoAccessory } from "./ratgdo-device.js";
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;
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.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 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() {
// 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] = {};
this.espHomeApi[ratgdo.device.mac] = new EspHomeClient({
clientId: "homebridge-ratgdo",
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) => {
this.beat(ratgdo, { encrypted: this.espHomeApi[ratgdo.device.mac].isEncrypted, 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: "" };
ratgdo.log.debug("%s", util.inspect(data, { colors: true, depth: null, sorted: true }));
switch (data.type) {
case "binary_sensor":
case "switch":
payload.state = data.state ? "ON" : "OFF";
break;
case "cover":
// @ts-expect-error For Konnected devices, they use door_cover instead of cover. We capture it here, but it doesn't really comply with ESPHome's client protocol.
// eslint-disable-next-line no-fallthrough
case "door_cover":
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 "light":
payload.state = data.state ? "ON" : "OFF";
break;
case "lock":
switch (data.state) {
case 1:
payload.state = "LOCKED";
break;
case 2:
payload.state = "UNLOCKED";
break;
default:
payload.state = "UNKNOWN";
break;
}
break;
case "button":
payload.state = data.pressed ? "PRESSED" : "";
break;
case "sensor":
payload.state = (typeof data.state === "number") ? data.state.toString() : "";
break;
default:
break;
}
ratgdo.updateState(payload);
});
// Battery status is only available to us through log entries made by the Ratgdo.
if (ratgdo.hints.discoBattery) {
// Subscribe to our logs when we connect.
this.espHomeApi[ratgdo.device.mac].on("connect", this.listeners[ratgdo.device.mac].logSubscribe = () => this.espHomeApi[ratgdo.device.mac].subscribeToLogs(LogLevel.VERBOSE));
// Process log events from the Ratgdo.
this.espHomeApi[ratgdo.device.mac].on("log", this.listeners[ratgdo.device.mac].log = (logEntry) => {
ratgdo.log.debug("Log event received: %s", util.inspect(logEntry, { sorted: true }));
// Ratgdo occasionally sends empty status updates - we ignore them.
if (!logEntry.message.length) {
return;
}
// Grab the battery state, when logged.
const batteryState = logEntry.message.match(/\bBattery state=(.+?)\b/);
// We've got a battery state update, inform the Ratgdo.
if (batteryState) {
let actualState;
// Unfortunately, the current Ratgdo firmwares seems to conflate charging and full status. We workaround that here.
switch (batteryState[1]) {
case "CHARGING":
actualState = "FULL";
break;
case "FULL":
actualState = "CHARGING";
break;
default:
actualState = batteryState[1];
break;
}
ratgdo.log.debug("Battery state update: \"%s\" mapping it to: %s", batteryState[1], actualState);
ratgdo.updateState({ id: "battery", state: actualState });
}
});
}
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 ?? "0.0.0",
mac: mac.replace(/:/g, ""),
model: deviceInfo.project_version,
name: this.featureOptions.value("Device.LogName", mac.replace(/:/g, "")) ?? deviceInfo.friendly_name ?? "Ratgdo",
variant: deviceInfo.project_name?.startsWith("ratgdo.") ? RatgdoVariant.RATGDO : RatgdoVariant.KONNECTED
};
// Inform the user that we've discovered a device.
this.log.info("Discovered: %s (address: %s mac: %s firmware: v%s variant: %s%s).", device.name, device.address, device.mac, device.firmwareVersion, device.variant, device.model ? (" [" + device.model + "]") : "");
// 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 firmware: v%s variant: %s%s).", device.name, device.address, device.mac, device.firmwareVersion, device.variant, device.model ? (" [" + device.model + "]") : "");
// 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;
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
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.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
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