matterbridge-webhooks
Version:
Matterbridge webhooks plugin
299 lines (298 loc) • 18.8 kB
JavaScript
import { bridgedNode, colorTemperatureLight, dimmableLight, extendedColorLight, MatterbridgeColorControlServer, MatterbridgeDynamicPlatform, MatterbridgeEndpoint, MatterbridgeLevelControlServer, onOffLight, onOffOutlet, onOffSwitch, } from 'matterbridge';
import { rs } from 'matterbridge/logger';
import { hslColorToRgbColor, isValidNumber, isValidObject, isValidString, miredToKelvin, wait } from 'matterbridge/utils';
import { fetch } from './fetch.js';
export default function initializePlugin(matterbridge, log, config) {
return new WebhooksPlatform(matterbridge, log, config);
}
export class WebhooksPlatform extends MatterbridgeDynamicPlatform {
config;
constructor(matterbridge, log, config) {
super(matterbridge, log, config);
this.config = config;
if (typeof this.verifyMatterbridgeVersion !== 'function' || !this.verifyMatterbridgeVersion('3.7.0')) {
throw new Error(`This plugin requires Matterbridge version >= "3.7.0". Please update Matterbridge to the latest version in the frontend.`);
}
this.log.info('Initializing platform:', this.config.name);
this.log.info('Finished initializing platform:', this.config.name);
}
async onStart(reason) {
this.log.info('onStart called with reason:', reason ?? 'none');
await this.ready;
await this.clearSelect();
let i = 1;
for (const webhookName in this.config.webhooks) {
this.log.debug(`Loading webhook ${i} ${webhookName} with method ${this.config.webhooks[webhookName].method} and url ${this.config.webhooks[webhookName].httpUrl}`);
const webhook = this.config.webhooks[webhookName];
this.setSelectDevice('webhook' + i, webhookName, undefined, 'hub');
if (!this.validateDevice(['webhook' + i, webhookName], true))
continue;
this.log.info(`Registering device: ${webhookName} with method ${webhook.method} and url ${webhook.httpUrl}`);
const device = new MatterbridgeEndpoint([this.config.deviceType === 'Outlet' ? onOffOutlet : this.config.deviceType === 'Light' ? onOffLight : onOffSwitch, bridgedNode], { id: webhookName }, this.config.debug)
.createDefaultBridgedDeviceBasicInformationClusterServer(webhookName, 'webhook' + i++, this.matterbridge.aggregatorVendorId, 'Matterbridge', 'Matterbridge Webhook', 0, this.config.version)
.createOnOffClusterServer(false)
.addRequiredClusterServers()
.addCommandHandler('on', async () => {
this.log.info(`Webhook ${webhookName} triggered`);
await device.setAttribute('onOff', 'onOff', false, device.log);
this.log.debug(`Fetching ${webhook.httpUrl} with ${webhook.method}...`);
fetch(webhook.httpUrl, webhook.method)
.then(() => this.log.notice(`Webhook ${webhookName} successful!`))
.catch((err) => {
this.log.error(`Webhook ${webhookName} failed: ${err instanceof Error ? err.message : err}`);
});
});
await this.registerDevice(device);
}
i = 1;
for (const outletName in this.config.outlets) {
this.log.debug(`Loading outlet ${i} ${outletName}...`);
const webhook = this.config.outlets[outletName];
this.setSelectDevice('outlet' + i, outletName, undefined, 'hub');
if (!this.validateDevice(['outlet' + i, outletName], true))
continue;
this.log.info(`Registering outlet: ${outletName}...`);
const device = new MatterbridgeEndpoint([onOffOutlet, bridgedNode], { id: outletName }, this.config.debug)
.createDefaultBridgedDeviceBasicInformationClusterServer(outletName, 'outlet' + i++, this.matterbridge.aggregatorVendorId, 'Matterbridge', 'Matterbridge Webhook Outlet', 0, this.config.version)
.createOnOffClusterServer(false)
.addRequiredClusterServers()
.addCommandHandler('on', async (data) => {
await this.parseUrl('outlet', outletName, 'on', webhook.onUrl, data);
})
.addCommandHandler('off', async (data) => {
await this.parseUrl('outlet', outletName, 'off', webhook.offUrl, data);
});
await this.registerDevice(device);
}
i = 1;
for (const lightName in this.config.lights) {
this.log.debug(`Loading light ${i} ${lightName}...`);
const webhook = this.config.lights[lightName];
this.setSelectDevice('light' + i, lightName, undefined, 'hub');
if (!this.validateDevice(['light' + i, lightName], true))
continue;
this.log.info(`Registering light: ${lightName}...`);
let deviceType = onOffLight;
if (isValidString(webhook.brightnessUrl, 1))
deviceType = dimmableLight;
if (isValidString(webhook.colorTempUrl, 1))
deviceType = colorTemperatureLight;
if (isValidString(webhook.rgbUrl, 1))
deviceType = extendedColorLight;
const device = new MatterbridgeEndpoint([deviceType, bridgedNode], { id: lightName }, this.config.debug)
.createDefaultBridgedDeviceBasicInformationClusterServer(lightName, 'light' + i++, this.matterbridge.aggregatorVendorId, 'Matterbridge', 'Matterbridge Webhook Light', 0, this.config.version)
.createOnOffClusterServer(false)
.createDefaultColorControlClusterServer(undefined, undefined, undefined, undefined, 250, webhook.minMireds, webhook.maxMireds)
.createDefaultLevelControlClusterServer()
.addRequiredClusterServers()
.addCommandHandler('on', async (data) => {
await this.parseUrl('light', lightName, 'on', webhook.onUrl, data);
})
.addCommandHandler('off', async (data) => {
await this.parseUrl('light', lightName, 'off', webhook.offUrl, data);
})
.addCommandHandler('moveToLevel', async (data) => {
await this.parseUrl('light', lightName, 'moveToLevel', webhook.brightnessUrl, data);
})
.addCommandHandler('moveToLevelWithOnOff', async (data) => {
await this.parseUrl('light', lightName, 'moveToLevelWithOnOff', webhook.brightnessUrl, data);
})
.addCommandHandler('moveToColorTemperature', async (data) => {
await this.parseUrl('light', lightName, 'moveToColorTemperature', webhook.colorTempUrl, data);
})
.addCommandHandler('moveToHueAndSaturation', async (data) => {
await this.parseUrl('light', lightName, 'moveToHueAndSaturation', webhook.rgbUrl, data);
})
.addCommandHandler('moveToHue', async (data) => {
await this.parseUrl('light', lightName, 'moveToHue', webhook.rgbUrl, data);
})
.addCommandHandler('moveToSaturation', async (data) => {
await this.parseUrl('light', lightName, 'moveToSaturation', webhook.rgbUrl, data);
})
.addCommandHandler('moveToColor', async (data) => {
await this.parseUrl('light', lightName, 'moveToColor', webhook.rgbUrl, data);
});
await this.registerDevice(device);
}
}
async onConfigure() {
await super.onConfigure();
this.log.info('onConfigure called');
for (const device of this.getDevices()) {
if (device.deviceName && device.deviceName in this.config.webhooks) {
this.log.info(`Configuring device: ${device.deviceName}`);
await device.setAttribute('onOff', 'onOff', false, device.log);
}
}
}
async onAction(action, value, id, formData) {
this.log.info('onAction called with action:', action, 'and value:', value ?? 'none', 'and id:', id ?? 'none');
this.log.debug('onAction called with formData:', formData ?? 'none');
if (id?.startsWith('root_webhooks_'))
id = id.replace('root_webhooks_', '');
if (id?.endsWith('_test'))
id = id.replace('_test', '');
if (action === 'test') {
if (isValidObject(formData, 1) && isValidObject(formData.webhooks, 1)) {
const webhooks = formData.webhooks;
for (const webhookName in webhooks) {
if (Object.prototype.hasOwnProperty.call(webhooks, webhookName)) {
const webhook = webhooks[webhookName];
if (id?.includes(webhookName)) {
this.log.info(`Testing new webhook ${webhookName} method ${webhook.method} url ${webhook.httpUrl}`);
fetch(webhook.httpUrl, webhook.method)
.then(() => {
this.log.notice(`Webhook test ${webhookName} successful!`);
return;
})
.catch((err) => {
this.log.error(`Webhook test ${webhookName} failed: ${err instanceof Error ? err.message : err}`);
});
}
}
}
return;
}
for (const webhookName in this.config.webhooks) {
if (Object.prototype.hasOwnProperty.call(this.config.webhooks, webhookName)) {
const webhook = this.config.webhooks[webhookName];
if (id?.includes(webhookName)) {
this.log.info(`Testing webhook ${webhookName} method ${webhook.method} url ${webhook.httpUrl}`);
fetch(webhook.httpUrl, webhook.method)
.then(() => {
this.log.notice(`Webhook test ${webhookName} successful!`);
return;
})
.catch((err) => {
this.log.error(`Webhook test ${webhookName} failed: ${err instanceof Error ? err.message : err}`);
});
}
}
}
}
}
async onShutdown(reason) {
await super.onShutdown(reason);
this.log.info('onShutdown called with reason:', reason ?? 'none');
if (this.config.unregisterOnShutdown === true)
await this.unregisterAllDevices();
}
async parseUrl(deviceType, deviceName, command, url, data) {
this.log.info(`Webhook ${deviceType} ${deviceName} ${command} triggered`);
const endpoint = data.endpoint;
this.log.debug(`Webhook ${deviceType} ${deviceName} ${command} triggered on endpoint ${endpoint?.deviceName}`);
let method = 'GET';
let parsedUrl = url;
if (url.startsWith('GET#')) {
method = 'GET';
parsedUrl = url.replace('GET#', '');
}
else if (url.startsWith('POST#')) {
method = 'POST';
parsedUrl = url.replace('POST#', '');
}
else if (url.startsWith('PUT#')) {
method = 'PUT';
parsedUrl = url.replace('PUT#', '');
}
if (parsedUrl.includes('${LEVEL}') && data.cluster === 'levelControl' && isValidNumber(data.request.level)) {
parsedUrl = parsedUrl.replace('${LEVEL}', data.request.level.toString());
}
if (url.includes('${LEVEL100}') && data.cluster === 'levelControl' && isValidNumber(data.request.level)) {
parsedUrl = parsedUrl.replace('${LEVEL100}', Math.round((data.request.level / 254) * 100).toString());
}
if (parsedUrl.includes('${KELVIN}') && data.cluster === 'colorControl' && data.command === 'moveToColorTemperature' && isValidNumber(data.request.colorTemperatureMireds)) {
parsedUrl = parsedUrl.replace('${KELVIN}', Math.round(miredToKelvin(data.request.colorTemperatureMireds)).toString());
}
if (parsedUrl.includes('${MIRED}') && data.cluster === 'colorControl' && data.command === 'moveToColorTemperature' && isValidNumber(data.request.colorTemperatureMireds)) {
parsedUrl = parsedUrl.replace('${MIRED}', Math.round(data.request.colorTemperatureMireds).toString());
}
if (parsedUrl.includes('${COLORX}') && data.cluster === 'colorControl' && data.command === 'moveToColor' && isValidNumber(data.request.colorX, 0, 65279)) {
parsedUrl = parsedUrl.replace('${COLORX}', this.roundTo(data.request.colorX / 65536, 4).toString());
}
if (parsedUrl.includes('${COLORY}') && data.cluster === 'colorControl' && data.command === 'moveToColor' && isValidNumber(data.request.colorY, 0, 65279)) {
parsedUrl = parsedUrl.replace('${COLORY}', this.roundTo(data.request.colorY / 65536, 4).toString());
}
if (parsedUrl.includes('${HUE}') && data.cluster === 'colorControl' && data.command === 'moveToHueAndSaturation' && isValidNumber(data.request.hue, 0, 254)) {
parsedUrl = parsedUrl.replace('${HUE}', Math.round((data.request.hue * 360) / 254).toString());
}
if (parsedUrl.includes('${SATURATION}') && data.cluster === 'colorControl' && data.command === 'moveToHueAndSaturation' && isValidNumber(data.request.saturation, 0, 254)) {
parsedUrl = parsedUrl.replace('${SATURATION}', Math.round((data.request.saturation * 100) / 254).toString());
}
if ((parsedUrl.includes('${level}') || parsedUrl.includes('${level100}')) && data.cluster === 'levelControl' && isValidNumber(data.attributes.currentLevel, 1, 254)) {
await wait(100);
const attributes = endpoint.getCluster(MatterbridgeLevelControlServer);
if (isValidNumber(attributes?.currentLevel, 1, 254)) {
if (url.includes('${level}'))
parsedUrl = parsedUrl.replace('${level}', attributes.currentLevel.toString());
if (url.includes('${level100}'))
parsedUrl = parsedUrl.replace('${level100}', Math.round((attributes.currentLevel / 254) * 100).toString());
}
}
if ((parsedUrl.includes('${mired}') || parsedUrl.includes('${kelvin}')) &&
data.cluster === 'colorControl' &&
isValidNumber(data.attributes.colorTempPhysicalMinMireds) &&
isValidNumber(data.attributes.colorTempPhysicalMaxMireds) &&
isValidNumber(data.attributes.colorTemperatureMireds, data.attributes.colorTempPhysicalMinMireds, data.attributes.colorTempPhysicalMaxMireds)) {
await wait(100);
const attributes = endpoint.getCluster(MatterbridgeColorControlServer);
if (isValidNumber(attributes?.colorTemperatureMireds)) {
const kelvin = miredToKelvin(attributes.colorTemperatureMireds);
this.log.debug(`Attribute colorTemperatureMireds is ${attributes.colorTemperatureMireds}, which is ${kelvin}K`);
if (url.includes('${mired}'))
parsedUrl = parsedUrl.replace('${mired}', attributes.colorTemperatureMireds.toString());
if (url.includes('${kelvin}'))
parsedUrl = parsedUrl.replace('${kelvin}', kelvin.toString());
}
}
if ((parsedUrl.includes('${hue}') || parsedUrl.includes('${saturation}') || parsedUrl.includes('${red}') || parsedUrl.includes('${green}') || parsedUrl.includes('${blue}')) &&
data.cluster === 'colorControl' &&
isValidNumber(data.attributes.currentHue, 0, 254) &&
isValidNumber(data.attributes.currentSaturation, 0, 254)) {
await wait(100);
const attributes = endpoint.getCluster(MatterbridgeColorControlServer);
if (isValidNumber(attributes?.currentHue, 0, 254) && isValidNumber(attributes?.currentSaturation, 0, 254)) {
const rgb = hslColorToRgbColor((attributes.currentHue * 360) / 254, (attributes.currentSaturation * 100) / 254, 50);
this.log.debug(`Converted hue ${attributes.currentHue} and saturation ${attributes.currentSaturation} to RGB r: ${rgb.r} g: ${rgb.g} b: ${rgb.b}`);
if (url.includes('${hue}'))
parsedUrl = parsedUrl.replace('${hue}', Math.round((attributes.currentHue * 360) / 254).toString());
if (url.includes('${saturation}'))
parsedUrl = parsedUrl.replace('${saturation}', Math.round((attributes.currentSaturation * 100) / 254).toString());
if (url.includes('${red}') && rgb)
parsedUrl = parsedUrl.replace('${red}', rgb.r.toString());
if (url.includes('${green}') && rgb)
parsedUrl = parsedUrl.replace('${green}', rgb.g.toString());
if (url.includes('${blue}') && rgb)
parsedUrl = parsedUrl.replace('${blue}', rgb.b.toString());
}
}
if ((parsedUrl.includes('${colorX}') || parsedUrl.includes('${colorY}')) &&
data.cluster === 'colorControl' &&
isValidNumber(data.attributes.currentX, 0, 65279) &&
isValidNumber(data.attributes.currentY, 0, 65279)) {
await wait(100);
const attributes = endpoint.getCluster(MatterbridgeColorControlServer);
if (isValidNumber(attributes?.currentX, 0, 65279) && isValidNumber(attributes?.currentY, 0, 65279)) {
if (url.includes('${colorX}'))
parsedUrl = parsedUrl.replace('${colorX}', this.roundTo(attributes.currentX / 65536, 4).toString());
if (url.includes('${colorY}'))
parsedUrl = parsedUrl.replace('${colorY}', this.roundTo(attributes.currentY / 65536, 4).toString());
}
}
this.log.debug(`Fetching ${parsedUrl} with ${method}...`);
fetch(parsedUrl, method)
.then((response) => {
this.log.notice(`Webhook ${deviceType} ${deviceName} ${command} successful!`);
this.log.debug(`Webhook ${deviceType} ${deviceName} ${command} response:${rs}\n`, response);
return;
})
.catch((err) => {
this.log.error(`Webhook ${deviceType} ${deviceName} ${command} failed: ${err instanceof Error ? err.message : err}`);
});
return { method, url: parsedUrl };
}
roundTo(value, digits) {
const factor = Math.pow(10, digits);
return Math.round(value * factor) / factor;
}
}