UNPKG

matterbridge-somfy-tahoma

Version:
306 lines (305 loc) 17.2 kB
import { MatterbridgeDynamicPlatform, bridgedNode, powerSource, MatterbridgeEndpoint, coverDevice } from 'matterbridge'; import { BLUE, debugStringify, rs, CYAN, ign, nf, YELLOW } from 'matterbridge/logger'; import { NodeStorageManager } from 'matterbridge/storage'; import { isValidNumber, isValidString } from 'matterbridge/utils'; import { WindowCovering } from 'matterbridge/matter/clusters'; import { Action, Client, Command, Execution } from 'overkiz-client'; import path from 'node:path'; import { promises as fs } from 'node:fs'; const Stopped = WindowCovering.MovementStatus.Stopped; const Opening = WindowCovering.MovementStatus.Opening; const Closing = WindowCovering.MovementStatus.Closing; export class SomfyTahomaPlatform extends MatterbridgeDynamicPlatform { tahomaDevices = []; bridgedDevices = []; covers = new Map(); nodeStorageManager; tahomaClient; movementDuration = {}; connected = false; constructor(matterbridge, log, config) { super(matterbridge, log, config); if (this.verifyMatterbridgeVersion === undefined || typeof this.verifyMatterbridgeVersion !== 'function' || !this.verifyMatterbridgeVersion('2.2.4')) { throw new Error(`This plugin requires Matterbridge version >= "2.2.4". Please update Matterbridge from ${this.matterbridge.matterbridgeVersion} to the latest version in the frontend."`); } this.log.info('Initializing platform:', this.config.name); if (config.movementDuration) this.movementDuration = config.movementDuration; this.nodeStorageManager = new NodeStorageManager({ dir: path.join(matterbridge.matterbridgePluginDirectory, 'matterbridge-somfy-tahoma'), logging: false, }); if (!isValidString(this.config.username) || !isValidString(this.config.password) || !isValidString(this.config.service)) { this.log.error('No service or username or password provided for:', this.config.name); return; } this.log.info('Finished initializing platform:', this.config.name); this.log.info(`Starting client Tahoma service ${this.config.service} with user ${this.config.username} password: ${this.config.password}`); this.tahomaClient = new Client(this.log, { service: this.config.service, user: this.config.username, password: this.config.password, }); this.tahomaClient.on('connect', () => { this.log.info('TaHoma service connected'); this.connected = true; }); this.tahomaClient.on('disconnect', () => { this.log.warn('TaHoma service disconnected'); this.connected = false; }); } async onStart(reason) { this.log.info('onStart called with reason:', reason ?? 'none'); if (!this.tahomaClient) { this.log.error('TaHoma service not created'); return; } try { await this.tahomaClient.connect(this.config.username, this.config.password); } catch (error) { this.log.error('Error connecting to TaHoma service:', error.response?.data); return; } await this.discoverDevices(); } async onConfigure() { await super.onConfigure(); this.log.info('onConfigure called'); if (!this.tahomaClient) { this.log.error('TaHoma service not created'); return; } for (const device of this.bridgedDevices) { const cover = this.covers.get(device.deviceName ?? ''); const position = await device.getAttribute(WindowCovering.Cluster.id, 'currentPositionLiftPercent100ths', device.log); cover?.bridgedDevice.log.info(`Setting ${device.deviceName} target to ${CYAN}${position / 100}%${nf} position and status to stopped. Movement duration: ${CYAN}${cover?.movementDuration}${nf}`); await device.setWindowCoveringTargetAsCurrentAndStopped(); } } async onShutdown(reason) { await super.onShutdown(reason); this.log.info('onShutdown called with reason:', reason ?? 'none'); if (!this.tahomaClient) { this.log.error('TaHoma service not created'); } else { this.tahomaClient.removeAllListeners(); } this.tahomaClient = undefined; this.covers.forEach((cover) => { clearInterval(cover.moveInterval); cover.moveInterval = undefined; clearTimeout(cover.commandTimeout); cover.commandTimeout = undefined; }); this.covers.clear(); if (this.config.unregisterOnShutdown === true) await this.unregisterAllDevices(); } async discoverDevices() { if (!this.tahomaClient) { this.log.error('TaHoma service not created'); return; } let devices = []; try { devices = await this.tahomaClient.getDevices(); } catch (error) { this.log.error('Error discovering TaHoma devices:', error.response?.data); return; } this.log.info(`Discovered ${devices.length} TaHoma devices`); await fs.mkdir(path.join(this.matterbridge.matterbridgePluginDirectory, 'matterbridge-somfy-tahoma'), { recursive: true }); const fileName = path.join(this.matterbridge.matterbridgePluginDirectory, 'matterbridge-somfy-tahoma', 'devices.json'); fs.writeFile(fileName, JSON.stringify(devices, null, 2)) .then(() => { this.log.debug(`Devices successfully written to ${fileName}`); }) .catch((error) => { this.log.error(`Error writing devices to ${fileName}:`, error); }); for (const device of devices) { this.log.debug(`Device: ${BLUE}${device.label}${rs}`); this.log.debug(`- uniqueName ${device.uniqueName}`); this.log.debug(`- uiClass ${device.definition.uiClass}`); this.log.debug(`- serial ${device.serialNumber}`); this.log.debug(`- deviceURL ${device.deviceURL}`); this.log.debug(`- commands ${debugStringify(device.commands)}`); this.log.debug(`- states ${debugStringify(device.states)}`); const supportedUniqueNames = [ 'Blind', 'BlindRTSComponent', 'ExteriorBlindRTSComponent', 'ExteriorVenetianBlindRTSComponent', 'Shutter', 'RollerShutterRTSComponent', 'HorizontalAwningRTSComponent', 'PergolaHorizontalUnoIOComponent', 'Awning', 'TiltOnlyVenetianBlindRTSComponent', ]; const supportedUiClasses = ['Screen', 'ExteriorScreen', 'Shutter', 'RollerShutter', 'VenetianBlind', 'ExteriorVenetianBlind', 'Awning', 'Pergola']; if (supportedUniqueNames.includes(device.uniqueName)) { this.tahomaDevices.push(device); this.log.debug(`- added with uniqueName`); } else if (supportedUiClasses.includes(device.definition.uiClass)) { this.tahomaDevices.push(device); this.log.debug(`- added with uiClass`); } else if (device.commands.includes('open') && device.commands.includes('close') && device.commands.includes('stop')) { this.tahomaDevices.push(device); this.log.debug(`- added with commands "open", "close" and "stop"`); } else if (device.commands.includes('rollOut') && device.commands.includes('rollUp') && device.commands.includes('stop')) { this.tahomaDevices.push(device); this.log.debug(`- added with commands "rollOut", "rollUp" and "stop"`); } else if (device.commands.includes('down') && device.commands.includes('up') && device.commands.includes('stop')) { this.tahomaDevices.push(device); this.log.debug(`- added with commands "down", "up" and "stop"`); } } this.log.info(`Discovered ${this.tahomaDevices.length} TaHoma screens`); for (const device of this.tahomaDevices) { if (!this.validateDevice([device.label, device.uniqueName, device.serialNumber])) { continue; } this.setSelectDevice(device.serialNumber, device.label); const duration = this.movementDuration[device.label] || 30; this.log.debug(`Adding device: ${BLUE}${device.label}${rs}`); this.log.debug(`- uniqueName ${device.uniqueName}`); this.log.debug(`- uiClass ${device.definition.uiClass}`); this.log.debug(`- serial ${device.serialNumber}`); this.log.debug(`- deviceURL ${device.deviceURL}`); this.log.debug(`- commands ${debugStringify(device.commands)}`); this.log.debug(`- states ${debugStringify(device.states)}`); this.log.debug(`- duration ${duration}`); const cover = new MatterbridgeEndpoint([coverDevice, bridgedNode, powerSource], { uniqueStorageKey: device.label }, this.config.debug); cover.createDefaultIdentifyClusterServer(); cover.createDefaultGroupsClusterServer(); cover.createDefaultWindowCoveringClusterServer(); cover.createDefaultBridgedDeviceBasicInformationClusterServer(device.label, device.serialNumber, 0xfff1, 'Somfy Tahoma', device.definition.uiClass); cover.createDefaultPowerSourceWiredClusterServer(); await this.registerDevice(cover); this.bridgedDevices.push(cover); this.covers.set(device.label, { tahomaDevice: device, bridgedDevice: cover, movementStatus: Stopped, movementDuration: duration }); cover.addCommandHandler('identify', async ({ request: { identifyTime } }) => { const cover = this.covers.get(device.label); if (!cover) return; cover.bridgedDevice.log.info(`Command ${ign}identify${rs}${nf} called identifyTime:${identifyTime}`); await this.sendCommand('identify', device, true); }); cover.addCommandHandler('upOrOpen', async () => { const cover = this.covers.get(device.label); if (!cover) return; cover.bridgedDevice.log.info(`Command ${ign}upOrOpen${rs}${nf} called for ${CYAN}${cover.tahomaDevice.label}`); await this.moveToPosition(cover, 0); }); cover.addCommandHandler('downOrClose', async () => { const cover = this.covers.get(device.label); if (!cover) return; cover.bridgedDevice.log.info(`Command ${ign}downOrClose${rs}${nf} called for ${CYAN}${cover.tahomaDevice.label}`); await this.moveToPosition(cover, 10000); }); cover.addCommandHandler('stopMotion', async () => { const cover = this.covers.get(device.label); if (!cover) return; cover.bridgedDevice.log.info(`Command ${ign}stopMotion${rs}${nf} called for ${CYAN}${cover.tahomaDevice.label}. Status ${cover.movementStatus}`); clearInterval(cover.moveInterval); if (cover.movementStatus !== WindowCovering.MovementStatus.Stopped) { await this.sendCommand('stop', cover.tahomaDevice, true); } cover.movementStatus = Stopped; await cover.bridgedDevice.setWindowCoveringTargetAsCurrentAndStopped(); }); cover.addCommandHandler('goToLiftPercentage', async ({ request: { liftPercent100thsValue } }) => { const cover = this.covers.get(device.label); if (!cover) return; await cover.bridgedDevice.setAttribute(WindowCovering.Cluster.id, 'targetPositionLiftPercent100ths', liftPercent100thsValue, cover.bridgedDevice.log); if (cover.commandTimeout) clearTimeout(cover.commandTimeout); cover.commandTimeout = setTimeout(async () => { cover.commandTimeout = undefined; cover.bridgedDevice.log.info(`Command ${ign}goToLiftPercentage${rs}${nf} ${CYAN}${liftPercent100thsValue}${nf} called for ${CYAN}${cover.tahomaDevice.label}`); await this.moveToPosition(cover, liftPercent100thsValue); }, 1000); }); } } async moveToPosition(cover, targetPosition) { const log = cover.bridgedDevice.log; let currentPosition = cover.bridgedDevice.getAttribute(WindowCovering.Cluster.id, 'currentPositionLiftPercent100ths', log); if (!isValidNumber(currentPosition, 0, 10000)) return; log.info(`Moving from ${currentPosition} to ${targetPosition}...`); if ((await cover.movementStatus) !== Stopped) { log.info('Stopping current movement.'); clearInterval(cover.moveInterval); await cover.bridgedDevice.setWindowCoveringTargetAsCurrentAndStopped(); await this.sendCommand('stop', cover.tahomaDevice, true); cover.movementStatus = Stopped; return; } if (targetPosition === currentPosition) { clearInterval(cover.moveInterval); await cover.bridgedDevice.setWindowCoveringTargetAsCurrentAndStopped(); cover.movementStatus = Stopped; log.info(`Moving from ${currentPosition} to ${targetPosition}. No movement needed.`); return; } const movement = targetPosition - currentPosition; const movementSeconds = Math.abs((movement * cover.movementDuration) / 10000); log.debug(`Moving from ${currentPosition} to ${targetPosition} in ${movementSeconds} seconds. Movement requested ${movement}`); cover.bridgedDevice.setAttribute(WindowCovering.Cluster.id, 'targetPositionLiftPercent100ths', targetPosition, log); await cover.bridgedDevice.setWindowCoveringStatus(targetPosition > currentPosition ? WindowCovering.MovementStatus.Closing : WindowCovering.MovementStatus.Opening); cover.movementStatus = targetPosition > currentPosition ? Closing : Opening; await this.sendCommand(targetPosition > currentPosition ? 'close' : 'open', cover.tahomaDevice, true); cover.moveInterval = setInterval(async () => { log.debug(`Moving interval from ${currentPosition} to ${targetPosition} with movement ${movement}`); if (currentPosition === null) return; currentPosition = Math.round(currentPosition + movement / movementSeconds); if (Math.abs(targetPosition - currentPosition) <= 100 || (movement > 0 && currentPosition >= targetPosition) || (movement < 0 && currentPosition <= targetPosition)) { clearInterval(cover.moveInterval); await cover.bridgedDevice.setWindowCoveringCurrentTargetStatus(targetPosition, targetPosition, WindowCovering.MovementStatus.Stopped); cover.movementStatus = Stopped; if (targetPosition !== 0 && targetPosition !== 10000) await this.sendCommand('stop', cover.tahomaDevice, true); log.debug(`Moving stopped at ${targetPosition}`); } else { log.debug(`Moving from ${currentPosition} to ${targetPosition} difference ${Math.abs(targetPosition - currentPosition)}`); await cover.bridgedDevice.setAttribute(WindowCovering.Cluster.id, 'currentPositionLiftPercent100ths', Math.max(0, Math.min(currentPosition, 10000)), log); } }, 1000); } async sendCommand(command, device, highPriority = false) { if (command === 'open' && !device.commands.includes('open') && device.commands.includes('rollOut')) command = 'rollOut'; if (command === 'close' && !device.commands.includes('close') && device.commands.includes('rollUp')) command = 'rollUp'; if (command === 'open' && !device.commands.includes('open') && device.commands.includes('up')) command = 'up'; if (command === 'close' && !device.commands.includes('close') && device.commands.includes('down')) command = 'down'; this.log.info(`Sending command ${YELLOW}${command}${nf} highPriority ${highPriority}`); try { const _command = new Command(command); const _action = new Action(device.deviceURL, [_command]); const _execution = new Execution('Sending ' + command, _action); await this.tahomaClient?.execute(highPriority ? 'apply/highPriority' : 'apply', _execution); } catch (error) { this.log.error(`Error sending command: ${error instanceof Error ? error.message : error}`); } } }