matterbridge-somfy-tahoma
Version:
Matterbridge somfy tahoma plugin
331 lines (330 loc) • 18.6 kB
JavaScript
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { bridgedNode, MatterbridgeDynamicPlatform, MatterbridgeEndpoint, powerSource, windowCovering } from 'matterbridge';
import { BLUE, CYAN, debugStringify, ign, nf, rs, stringify, YELLOW } from 'matterbridge/logger';
import { Identify, WindowCovering } from 'matterbridge/matter/clusters';
import { inspectError, isValidNumber, isValidString } from 'matterbridge/utils';
import { Action, Client, Command, Execution } from 'overkiz-client';
export const Stopped = WindowCovering.MovementStatus.Stopped;
export const Opening = WindowCovering.MovementStatus.Opening;
export const Closing = WindowCovering.MovementStatus.Closing;
export const WC_PERCENT100THS_MIN_OPEN = 0;
export const WC_PERCENT100THS_MAX_CLOSED = 10000;
export default function initializePlugin(matterbridge, log, config) {
return new SomfyTahomaPlatform(matterbridge, log, config);
}
export class SomfyTahomaPlatform extends MatterbridgeDynamicPlatform {
config;
tahomaDevices = [];
covers = new Map();
tahomaClient;
movementDuration = {};
connected = false;
constructor(matterbridge, log, config) {
super(matterbridge, log, config);
this.config = config;
if (typeof this.verifyMatterbridgeVersion !== 'function' || !this.verifyMatterbridgeVersion('3.9.0')) {
throw new Error(`This plugin requires Matterbridge version >= "3.9.0". 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;
if (!isValidString(this.config.username, 1) || !isValidString(this.config.password, 1) || !isValidString(this.config.service, 1)) {
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) {
await this.ready;
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) {
inspectError(this.log, 'Error connecting to TaHoma service', error);
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.getDevices()) {
const cover = this.covers.get(device.deviceName ?? '');
const position = device.getAttribute(WindowCovering, 'currentPositionLiftPercent100ths', device.log);
cover?.bridgedDevice.log.info(`Setting ${device.deviceName} target to ${CYAN}${isValidNumber(position, 0, 10000) ? position / 100 : 'unknown'} %${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.tahomaClient.removeAllListeners();
}
else {
this.log.error('TaHoma service not created');
}
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)
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) {
inspectError(this.log, 'Error discovering TaHoma devices', error);
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, stringify(devices, false, 0, 0, 0, 0, 0, 0, '"', '"', 2))
.then(() => {
this.log.debug(`Devices successfully written to ${fileName}`);
return;
})
.catch((error) => {
inspectError(this.log, `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}`);
device.on('states', (changedStates) => {
this.log.debug(`***Tahoma update for ${device.label}: ${debugStringify(changedStates)}`);
});
const cover = new MatterbridgeEndpoint([windowCovering, bridgedNode, powerSource], { id: device.label }, this.config.debug);
cover.createDefaultIdentifyClusterServer(1, Identify.IdentifyType.Actuator);
cover.createDefaultWindowCoveringClusterServer();
cover.createDefaultBridgedDeviceBasicInformationClusterServer(device.label, device.serialNumber, 0xfff1, 'Somfy Tahoma', device.definition.uiClass);
if (device.states.find((s) => s.name === 'core:BatteryDiscreteLevelState'))
cover.createDefaultPowerSourceRechargeableBatteryClusterServer();
else
cover.createDefaultPowerSourceWiredClusterServer();
cover.addRequiredClusterServers();
await this.registerDevice(cover);
this.covers.set(device.label, { tahomaDevice: device, bridgedDevice: cover, movementStatus: Stopped, movementDuration: duration });
cover.addCommandHandler('Identify.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('WindowCovering.upOrOpen', () => {
const cover = this.covers.get(device.label);
if (!cover)
return;
if (cover.commandTimeout)
clearTimeout(cover.commandTimeout);
cover.commandTimeout = setTimeout(async () => {
cover.commandTimeout = undefined;
cover.bridgedDevice.log.info(`Command ${ign}upOrOpen${rs}${nf} called for ${CYAN}${cover.tahomaDevice.label}`);
await this.moveToPosition(cover, WC_PERCENT100THS_MIN_OPEN);
}, 500);
});
cover.addCommandHandler('WindowCovering.downOrClose', () => {
const cover = this.covers.get(device.label);
if (!cover)
return;
if (cover.commandTimeout)
clearTimeout(cover.commandTimeout);
cover.commandTimeout = setTimeout(async () => {
cover.commandTimeout = undefined;
cover.bridgedDevice.log.info(`Command ${ign}downOrClose${rs}${nf} called for ${CYAN}${cover.tahomaDevice.label}`);
await this.moveToPosition(cover, WC_PERCENT100THS_MAX_CLOSED);
}, 500);
});
cover.addCommandHandler('WindowCovering.goToLiftPercentage', ({ request: { liftPercent100thsValue } }) => {
const cover = this.covers.get(device.label);
if (!cover)
return;
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);
}, 500);
});
cover.addCommandHandler('WindowCovering.stopMotion', async ({ attributes }) => {
attributes.targetPositionLiftPercent100ths = attributes.currentPositionLiftPercent100ths;
attributes.operationalStatus = {
global: WindowCovering.MovementStatus.Stopped,
lift: WindowCovering.MovementStatus.Stopped,
tilt: WindowCovering.MovementStatus.Stopped,
};
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;
});
}
}
async moveToPosition(cover, targetPosition) {
const log = cover.bridgedDevice.log;
const position = cover.bridgedDevice.getAttribute(WindowCovering, 'currentPositionLiftPercent100ths', log);
if (!isValidNumber(position, 0, 10000))
return;
let currentPosition = position;
log.info(`Moving from ${currentPosition} to ${targetPosition}...`);
if (cover.movementStatus !== Stopped) {
log.info('Stopping current movement.');
clearInterval(cover.moveInterval);
cover.moveInterval = undefined;
await cover.bridgedDevice.setWindowCoveringTargetAsCurrentAndStopped();
await this.sendCommand('stop', cover.tahomaDevice, true);
cover.movementStatus = Stopped;
return;
}
if (targetPosition === currentPosition) {
clearInterval(cover.moveInterval);
cover.moveInterval = undefined;
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}`);
await cover.bridgedDevice.setAttribute(WindowCovering, '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 !== WC_PERCENT100THS_MIN_OPEN && targetPosition !== WC_PERCENT100THS_MAX_CLOSED)
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, 'currentPositionLiftPercent100ths', Math.max(WC_PERCENT100THS_MIN_OPEN, Math.min(currentPosition, WC_PERCENT100THS_MAX_CLOSED)), log);
}
}, 1000);
}
async sendCommand(command, device, highPriority = false) {
let resolvedCommand = command;
if (resolvedCommand === 'open' && !device.commands.includes('open') && device.commands.includes('rollOut'))
resolvedCommand = 'rollOut';
if (resolvedCommand === 'close' && !device.commands.includes('close') && device.commands.includes('rollUp'))
resolvedCommand = 'rollUp';
if (resolvedCommand === 'open' && !device.commands.includes('open') && device.commands.includes('up'))
resolvedCommand = 'up';
if (resolvedCommand === 'close' && !device.commands.includes('close') && device.commands.includes('down'))
resolvedCommand = 'down';
this.log.info(`Sending command ${YELLOW}${resolvedCommand}${nf} highPriority ${highPriority}`);
try {
const newCommand = new Command(resolvedCommand);
const newAction = new Action(device.deviceURL, [newCommand]);
const newExecution = new Execution('Sending ' + resolvedCommand, newAction);
await this.tahomaClient?.execute(highPriority ? 'apply/highPriority' : 'apply', newExecution);
}
catch (error) {
inspectError(this.log, `Error sending command ${resolvedCommand} to ${device.label}`, error);
}
}
}