homebridge-hubitat-tonesto7
Version:
Hubitat plugin for HomeBridge/HomeKit
560 lines (510 loc) • 27.4 kB
JavaScript
// HE_Platform.js
const { pluginName, platformName, platformDesc, pluginVersion } = require("./libs/Constants"),
events = require("events"),
myUtils = require("./libs/MyUtils"),
HEClient = require("./HE_Client"),
HEAccessories = require("./HE_Accessories"),
// EveTypes = require("./types/eve_types.js"),
express = require("express"),
bodyParser = require("body-parser"),
chalk = require("chalk"),
webApp = express(),
fs = require("fs"),
_ = require("lodash"),
portFinderSync = require("portfinder-sync");
var PlatformAccessory;
module.exports = class HE_Platform {
constructor(log, config, api) {
this.config = config;
this.homebridge = api;
this.hap = api.hap;
this.Service = api.hap.Service;
this.Characteristic = api.hap.Characteristic;
this.Categories = api.hap.Categories;
PlatformAccessory = api.platformAccessory;
this.uuid = api.hap.uuid;
if (config === undefined || config === null || config.app_url_local === undefined || config.app_url_local === null || config.app_url_cloud === undefined || config.app_url_cloud === null || config.app_id === undefined || config.app_id === null) {
log(`${platformName} Plugin is not Configured | Skipping...`);
return;
}
this.ok2Run = true;
this.direct_port = this.findDirectPort();
this.logConfig = this.getLogConfig();
this.appEvts = new events.EventEmitter();
this.log = log;
this.logInfo = this.logInfo.bind(this);
this.logGreen = this.logGreen.bind(this);
this.logAlert = this.logAlert.bind(this);
this.logNotice = this.logNotice.bind(this);
this.logError = this.logError.bind(this);
this.logInfo = this.logInfo.bind(this);
this.logDebug = this.logDebug.bind(this);
this.logInfo(`Homebridge Version: ${this.homebridge.version}`);
this.logInfo(`Plugin Version: ${pluginVersion}`);
this.polling_seconds = config.polling_seconds || 3600;
this.excludedAttributes = this.config.excluded_attributes || [];
this.excludedCapabilities = this.config.excluded_capabilities || [];
this.update_method = this.config.update_method || "direct";
this.temperature_unit = this.config.temperature_unit || "F";
this.local_hub_ip = undefined;
this.myUtils = new myUtils(this);
this.configItems = this.getConfigItems();
// console.log("pluginConfig: ", this.loadConfig());
this.unknownCapabilities = [];
this.client = new HEClient(this);
this.HEAccessories = new HEAccessories(this);
this.homebridge.on("didFinishLaunching", this.didFinishLaunching.bind(this));
this.appEvts.emit("event:plugin_upd_status");
}
/**
* Sanitize accessory names to ensure they are clean and consistent.
* Removes unwanted characters and trims spaces.
* @param {string} name - The original accessory name.
* @returns {string} - The sanitized accessory name.
*/
sanitizeName(name) {
// Remove all characters except alphanumerics, spaces, and apostrophes
let sanitized = name
.replace(/[^a-zA-Z0-9 ']/g, "")
.trim()
.replace(/^[^a-zA-Z0-9]+/, "") // Remove leading non-alphanumeric characters
.replace(/[^a-zA-Z0-9]+$/, "") // Remove trailing non-alphanumeric characters
.replace(/\s{2,}/g, " "); // Replace multiple spaces with a single space
// If the name becomes empty after sanitization, use a default name
sanitized = sanitized.length === 0 ? "Unnamed Device" : sanitized;
// Log if the name was sanitized
if (name !== sanitized) {
this.logWarn(`Sanitized Name: "${name}" => "${sanitized}"`);
}
return sanitized;
}
/**
* Add or update an accessory's name after sanitizing it.
* @param {PlatformAccessory} accessory - The accessory to sanitize and update.
*/
sanitizeAndUpdateAccessoryName(accessory) {
const originalName = accessory.context.deviceData.name;
const sanitizedName = this.sanitizeName(originalName);
if (sanitizedName !== originalName) {
// Update the name properties
accessory.name = sanitizedName;
// accessory.context.name = sanitizedName;
// Important: Update displayName like this
// accessory._associatedHAPAccessory.displayName = sanitizedName;
// Update the AccessoryInformation service
const accessoryInformation = accessory.getService(this.Service.AccessoryInformation);
if (accessoryInformation) {
accessoryInformation.getCharacteristic(this.Characteristic.Name).updateValue(sanitizedName);
// Verify that the displayName was updated
const displayName = accessoryInformation.getCharacteristic(this.Characteristic.Name).value;
if (displayName !== sanitizedName) {
this.logWarn(`Failed to update displayName for device ID: ${accessory.deviceid}`);
} else {
this.logInfo(`AccessoryInformation service updated successfully for device ID: ${accessory.deviceid} | Old Name: "${originalName}" | Display Name: "${displayName}"`);
this.homebridge.updatePlatformAccessories([accessory]);
}
} else {
this.logWarn(`AccessoryInformation service not found for device ID: ${accessory.deviceid}`);
}
// this.logDebug(`Accessory name updated successfully to "${sanitizedName}"`);
} else {
// this.logDebug(`No name update needed for accessory "${originalName}"`);
}
}
getLogConfig() {
let config = this.config;
return {
debug: config.logConfig ? config.logConfig.debug === true : false,
showChanges: config.logConfig ? config.logConfig.showChanges === true : true,
};
}
findDirectPort() {
let port = this.config.direct_port || 8000;
if (port) port = portFinderSync.getPort(port);
return (this.direct_port = port);
}
getConfigItems() {
return {
app_url_local: this.config.app_url_local,
app_url_cloud: this.config.app_url_cloud,
app_id: this.config.app_id,
access_token: this.config.access_token,
use_cloud: this.config.use_cloud === true,
app_platform: this.config.app_platform,
polling_seconds: this.config.polling_seconds || 3600,
round_levels: this.config.round_levels !== false,
direct_port: this.direct_port,
direct_ip: this.config.direct_ip || this.myUtils.getIPAddress(),
validateTokenId: this.config.validateTokenId === true,
consider_fan_by_name: this.config.consider_fan_by_name !== false,
consider_light_by_name: this.config.consider_light_by_name === true,
adaptive_lighting: this.config.adaptive_lighting !== false,
adaptive_lighting_offset: this.config.adaptive_lighting !== false && this.config.adaptive_lighting_offset !== undefined ? this.config.adaptive_lighting_offset : undefined,
};
}
logAlert(args) {
this.log.info(chalk.yellow(args));
}
logGreen(args) {
this.log.info(chalk.green(args));
}
logNotice(args) {
this.log.info(chalk.blueBright(args));
}
logWarn(args) {
this.log.warn(chalk.keyword("orange").bold(args));
}
logError(args) {
this.log.error(chalk.bold.red(args));
}
logInfo(args) {
this.log.info(chalk.white(args));
}
logDebug(args) {
if (this.logConfig.debug === true) this.log.debug(chalk.gray(args));
}
loadConfig() {
const configPath = this.homebridge.user.configPath();
const file = fs.readFileSync(configPath);
const config = JSON.parse(file);
return config.platforms.find((x) => x.name === this.config.name);
}
updateConfig(newConfig) {
const configPath = this.homebridge.user.configPath();
const file = fs.readFileSync(configPath);
const config = JSON.parse(file);
const platConfig = config.platforms.find((x) => x.name === this.config.name);
_.extend(platConfig, newConfig);
const serializedConfig = JSON.stringify(config, null, " ");
fs.writeFileSync(configPath, serializedConfig, "utf8");
_.extend(this.config, newConfig);
// Update local configItems
// this.configItems =
}
updateTempUnit(unit) {
this.logNotice(`Temperature Unit is Now: (${unit})`);
this.temperature_unit = unit;
}
getTempUnit() {
return this.temperature_unit;
}
didFinishLaunching() {
this.logInfo(`Fetching ${platformName} Devices. NOTICE: This may take a moment if you have a large number of devices being loaded!`);
setInterval(this.refreshDevices.bind(this), this.polling_seconds * 1000);
let that = this;
this.refreshDevices("First Launch")
.then(() => {
that.WebServerInit(that)
.catch((err) => that.logError("WebServerInit Error: ", err))
.then((resp) => {
if (resp && resp.status === "OK") this.appEvts.emit("event:plugin_start_direct");
});
})
.catch((err) => {
that.logError(`didFinishLaunching | refreshDevices Exception:` + err);
});
}
refreshDevices(src = undefined) {
let that = this;
let starttime = new Date();
return new Promise((resolve, reject) => {
try {
that.logInfo(`Refreshing All Device Data${src ? " | Source: (" + src + ")" : ""}`);
this.client
.getDevices()
.catch((err) => {
that.logError("getDevices Exception: " + err);
reject(err.message);
})
.then((resp) => {
if (resp && resp.location) {
that.updateTempUnit(resp.location.temperature_scale);
if (resp.location.hubIP) {
that.local_hub_ip = resp.location.hubIP;
that.configItems.use_cloud = resp.location.use_cloud === true;
that.client.updateGlobals(that.local_hub_ip, that.configItems.use_cloud);
}
}
if (resp && resp.deviceList && resp.deviceList instanceof Array) {
// that.logDebug("Received All Device Data");
const toCreate = this.HEAccessories.diffAdd(resp.deviceList);
const toUpdate = this.HEAccessories.intersection(resp.deviceList);
const toRemove = this.HEAccessories.diffRemove(resp.deviceList);
that.logWarn(`Devices to Remove: (${Object.keys(toRemove).length}) ` + toRemove.map((i) => i.name));
that.log.info(`Devices to Update: (${Object.keys(toUpdate).length})`); // + toUpdate.map((i) => i.name));
that.logGreen(`Devices to Create: (${Object.keys(toCreate).length}) ` + toCreate.map((i) => i.name));
toRemove.forEach((accessory) => this.removeAccessory(accessory));
toUpdate.forEach((device) => this.updateDevice(device));
toCreate.forEach((device) => this.addDevice(device));
}
that.logAlert(`Total Initialization Time: (${Math.round((new Date() - starttime) / 1000)} seconds)`);
that.logNotice(`Unknown Capabilities: ${JSON.stringify(that.unknownCapabilities)}`);
that.logInfo(`${platformDesc} DeviceCache Size: (${Object.keys(this.HEAccessories.getAllAccessoriesFromCache()).length})`);
if (src !== "First Launch") this.appEvts.emit("event:plugin_upd_status");
resolve(true);
});
} catch (ex) {
this.logError("refreshDevices Error: ", ex);
resolve(false);
}
});
}
getNewAccessory(device, UUID) {
let accessory = new PlatformAccessory(device.name, UUID);
accessory.context.deviceData = device;
this.HEAccessories.initializeAccessory(accessory);
this.sanitizeAndUpdateAccessoryName(accessory); // Added name sanitization
return accessory;
}
addDevice(device) {
let accessory;
const new_uuid = this.uuid.generate(`hubitat_v2_${device.deviceid}`);
device.excludedCapabilities = this.excludedCapabilities[device.deviceid] || [];
this.logDebug(`Initializing New Device (${device.name} | ${device.deviceid})`);
accessory = this.getNewAccessory(device, new_uuid);
this.homebridge.registerPlatformAccessories(pluginName, platformName, [accessory]);
this.HEAccessories.addAccessoryToCache(accessory);
this.logInfo(`Added Device: (${accessory.name} | ${accessory.deviceid})`);
}
updateDevice(device) {
let cachedAccessory = this.HEAccessories.getAccessoryFromCache(device);
device.excludedCapabilities = this.excludedCapabilities[device.deviceid] || [];
cachedAccessory.context.deviceData = device;
this.logDebug(`Loading Existing Device | Name: (${device.name}) | ID: (${device.deviceid})`);
cachedAccessory = this.HEAccessories.initializeAccessory(cachedAccessory);
this.sanitizeAndUpdateAccessoryName(cachedAccessory); // Added name sanitization
this.HEAccessories.addAccessoryToCache(cachedAccessory);
}
removeAccessory(accessory) {
if (this.HEAccessories.removeAccessoryFromCache(accessory)) {
this.homebridge.unregisterPlatformAccessories(pluginName, platformName, [accessory]);
this.logInfo(`Removed: ${accessory.name} (${accessory.deviceid})`);
}
}
configureAccessory(accessory) {
if (!this.ok2Run) return;
this.logDebug(`Configure Cached Accessory: ${accessory.displayName}, UUID: ${accessory.UUID}`);
let cachedAccessory = this.HEAccessories.initializeAccessory(accessory, true);
this.sanitizeAndUpdateAccessoryName(cachedAccessory); // Added name sanitization
this.HEAccessories.addAccessoryToCache(cachedAccessory);
}
processIncrementalUpdate(data, that) {
that.logDebug("new data: " + data);
if (data && data.attributes && data.attributes instanceof Array) {
for (let i = 0; i < data.attributes.length; i++) {
that.processDeviceAttributeUpdate(data.attributes[i], that);
}
}
}
isValidRequestor(access_token, app_id, src) {
if (this.configItems.validateTokenId !== true) {
return true;
}
if (app_id && access_token && this.getConfigItems().app_id && this.getConfigItems().access_token && access_token === this.getConfigItems().access_token && parseInt(app_id) === parseInt(this.getConfigItems().app_id)) return true;
this.logError(`(${src}) | We received a request from a client that didn't provide a valid access_token and app_id`);
return false;
}
WebServerInit() {
let that = this;
// Get the IP address that we will send to the Hubitat App. This can be overridden in the config file.
return new Promise((resolve) => {
try {
let ip = that.configItems.direct_ip || that.myUtils.getIPAddress();
that.logInfo("WebServer Initiated...");
// Start the HTTP Server
webApp.listen(that.configItems.direct_port, () => {
that.logInfo(`Direct Connect Active | Listening at ${ip}:${that.configItems.direct_port}`);
});
webApp.use(
bodyParser.urlencoded({
extended: false,
}),
);
webApp.use(bodyParser.json());
webApp.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
next();
});
webApp.get("/", (req, res) => {
res.send("WebApp is running...");
});
webApp.post("/initial", (req, res) => {
let body = JSON.parse(JSON.stringify(req.body));
if (body && that.isValidRequestor(body.access_token, body.app_id, "initial")) {
that.logGreen(`${platformName} Hub Communication Established`);
res.send({
status: "OK",
});
} else {
res.send({
status: "Failed: Missing access_token or app_id",
});
}
});
webApp.get("/debugOpts", (req, res) => {
that.logInfo(`${platformName} Debug Option Request(${req.query.option})...`);
if (req.query && req.query.option) {
let accs = this.HEAccessories.getAllAccessoriesFromCache();
// let accsKeys = Object.keys(accs);
// console.log(accsKeys);
switch (req.query.option) {
case "allAccData":
res.send(JSON.stringify(accs));
break;
// case 'accServices':
// var o = accsKeys.forEach(s => s.services.forEach(s1 => s1.UUID));
// res.send(JSON.stringify(o));
// break;
// case 'accCharacteristics':
// var o = accsKeys.forEach(s => s.services.forEach(s1 => s1.characteristics.forEach(c => c.displayName)));
// res.send(JSON.stringify(o));
// break;
// case 'accContext':
// res.send(JSON.stringify(this.HEAccessories.getAllAccessoriesFromCache()));
// break;
default:
res.send(`Error: Invalid Option Parameter Received | Option: ${req.query.option}`);
break;
}
} else {
res.send("Error: Missing Valid Debug Query Parameter");
}
});
webApp.get("/pluginTest", (req, res) => {
that.logInfo(`${platformName} Plugin Test Request Received...`);
res.status(200).send(
JSON.stringify(
{
status: "OK",
homebridge_version: this.homebridge.version,
plugin: {
name: pluginName,
platform_name: platformName,
platform_desc: platformDesc,
version: pluginVersion,
config: this.configItems,
},
},
null,
4,
),
);
});
webApp.post("/restartService", (req, res) => {
let body = JSON.parse(JSON.stringify(req.body));
if (body && that.isValidRequestor(body.access_token, body.app_id, "restartService")) {
let delay = 10 * 1000;
that.logInfo(`Received request from ${platformName} to restart homebridge service in (${delay / 1000} seconds) | NOTICE: If you using PM2 or Systemd the Homebridge Service should start back up`);
setTimeout(() => {
process.exit(1);
}, parseInt(delay));
res.send({
status: "OK",
});
} else {
res.send({
status: "Failed: Missing access_token or app_id",
});
}
});
webApp.post("/refreshDevices", (req, res) => {
let body = JSON.parse(JSON.stringify(req.body));
if (body && that.isValidRequestor(body.access_token, body.app_id, "refreshDevices")) {
that.logGreen(`Received request from ${platformName} to refresh devices`);
that.refreshDevices("Hubitat App Requested");
res.send({
status: "OK",
});
} else {
that.logError(`Unable to start device refresh because we didn't receive a valid access_token and app_id`);
res.send({
status: "Failed: Missing access_token or app_id",
});
}
});
webApp.post("/updateprefs", (req, res) => {
let body = JSON.parse(JSON.stringify(req.body));
if (body && that.isValidRequestor(body.access_token, body.app_id, "updateprefs")) {
that.logInfo(platformName + " Hub Sent Preference Updates");
let sendUpd = false;
// if (body && Object.keys(body).length > 0) {
// Object.keys(body).forEach((key) => {});
// }
if (body.use_cloud && that.configItems.use_cloud !== body.use_cloud) {
sendUpd = true;
that.logInfo(`${platformName} Updated Use Cloud Preference | Before: ${that.configItems.use_cloud} | Now: ${body.use_cloud}`);
that.configItems.use_cloud = body.use_cloud;
}
if (body.validateTokenId && that.configItems.validateTokenId !== body.validateTokenId) {
that.logInfo(`${platformName} Updated Validate Token & Id Preference | Before: ${that.configItems.validateTokenId} | Now: ${body.validateTokenId}`);
that.configItems.validateTokenId = body.validateTokenId;
}
if (body.local_hub_ip && that.local_hub_ip !== body.local_hub_ip) {
sendUpd = true;
that.logInfo(`${platformName} Updated Hub IP Preference | Before: ${that.local_hub_ip} | Now: ${body.local_hub_ip}`);
that.local_hub_ip = body.local_hub_ip;
}
if (sendUpd) {
that.client.updateGlobals(that.local_hub_ip, that.configItems.use_cloud);
}
res.send({
status: "OK",
});
} else {
res.send({
status: "Failed: Missing access_token or app_id",
});
}
});
webApp.post("/update", (req, res) => {
if (req.body.length < 3) return;
let body = JSON.parse(JSON.stringify(req.body));
if (body && that.isValidRequestor(body.access_token, body.app_id, "update")) {
if (Object.keys(body).length > 3) {
let newChange = {
deviceid: body.change_device,
attribute: body.change_attribute,
value: body.change_value,
data: body.change_data,
date: body.change_date,
};
that.HEAccessories.processDeviceAttributeUpdate(newChange).then((resp) => {
if (that.logConfig.showChanges) {
that.logInfo(chalk`[{keyword('orange') Device Event}]: ({blueBright ${body.change_name}}) [{yellow.bold ${body.change_attribute ? body.change_attribute.toUpperCase() : "unknown"}}] is {keyword('pink') ${body.change_value}}`);
}
res.send({
evtSource: `Homebridge_${platformName}_${this.configItems.app_id}`,
evtType: "attrUpdStatus",
evtDevice: body.change_name,
evtAttr: body.change_attribute,
evtStatus: resp ? "OK" : "Failed",
});
});
} else {
res.send({
evtSource: `Homebridge_${platformName}_${this.configItems.app_id}`,
evtType: "attrUpdStatus",
evtDevice: body.change_name,
evtAttr: body.change_attribute,
evtStatus: "Failed",
});
}
} else {
res.send({
status: "Failed: Missing access_token or app_id",
});
}
});
resolve({
status: "OK",
});
} catch (ex) {
that.logError("WebServerInit Exception: ", ex.message);
resolve({
status: ex.message,
});
}
});
}
};