UNPKG

matterbridge-webhooks

Version:
299 lines (298 loc) 18.8 kB
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; } }