UNPKG

@pmouli/isy-matter-server

Version:

Service to expose an ISY device as a Matter Border router

445 lines 20.5 kB
import { LogLevel, logLevelFromString, Logger as MatterLogger, Time } from '@matter/general'; import { Endpoint, EndpointServer, Environment, ServerNode, StorageService, VendorId } from '@matter/main'; import { GeneralCommissioning } from '@matter/main/clusters'; import { AggregatorEndpoint } from '@matter/main/endpoints/aggregator'; import { logEndpoint } from '@matter/main/protocol'; import { QrCode } from '@matter/main/types'; import '@matter/nodejs'; import { createHash } from 'crypto'; import { existsSync, writeFileSync } from 'fs'; import { includes, ISY, some } from 'isy-nodejs'; import { ISYDevice } from 'isy-nodejs/ISYDevice'; import 'isy-nodejs/Utils'; import path from 'path'; import { format, loggers, transports } from 'winston'; import { BehaviorRegistry } from '../Behaviors/BehaviorRegistry.js'; import '../Behaviors/Insteon/index.js'; import { getRequiredBehaviors } from '../Behaviors/Utils.js'; import '../Behaviors/ZigBee/index.js'; import '../Mappings/index.js'; import { MappingRegistry } from '../Mappings/MappingRegistry.js'; // #region Interfaces (1) export let instance; const SuccessResponse = { errorCode: GeneralCommissioning.CommissioningError.Ok, debugText: '' }; // #endregion Interfaces (1) // #region Functions (3) let t; export async function create(isy, config) { let s = await createMatterServer(isy, config); return s; } export function appliesTo(device, deviceOptions) { if ('device' in deviceOptions.applyTo) { return some((x) => device.constructor.name === x, ...deviceOptions.applyTo.device); } else if ('nodeDef' in deviceOptions.applyTo) { if (ISYDevice.isNode(device)) { return some((x) => device.nodeDefId.startsWith(x), ...deviceOptions.applyTo.nodeDef); } } else if ('address' in deviceOptions.applyTo) { return includes(deviceOptions.applyTo.address, device.address); } else if ('deviceType' in deviceOptions.applyTo && 'typeCode' in device) { return some((x) => device.typeCode.startsWith(x), ...deviceOptions.applyTo.deviceType); } else if ('predicate' in deviceOptions.applyTo) { return deviceOptions.applyTo.predicate(device); } return false; } const deviceOptionsCache = {}; export function getDeviceOptions(node, ...configs) { if (deviceOptionsCache[node.address]) { return deviceOptionsCache[node.address]; } if (configs) { if (Array.isArray(configs)) { for (const config of configs) { if (appliesTo(node, config)) { deviceOptionsCache[node.address] = config.options; //TODO: rank by specificity } } } /*for (const options of deviceOptions) { if (appliesTo(node, options)) { return options.options; //TODO: rank by specificity } }*/ } //deviceOptionsCache[node.address] = {exclude: false}; return (deviceOptionsCache[node.address] ??= { include: true }); } export let initialized = false; export async function createMatterServer(isy, config) { config.deviceConfig ??= [ { applyTo: { predicate: (p) => p.label.toLowerCase().endsWith('slave') }, options: { exclude: true } } ]; config.excludeDevicesByDefault ??= false; var logger = loggers.add('matter', { transports: isy.logger.transports, levels: isy.logger.levels, format: format.label({ label: 'iox-matter' }), level: config.logLevels?.['iox-matter'] ?? 'debug' }); if (isy === undefined) { isy = ISY.instance; } if (MatterLogger) { var loggermjs = loggers.add('matter.js', { transports: isy.logger.transports, levels: isy.logger.levels, format: format.label({ label: 'matter.js' }), level: config.logLevels?.['matter.js'] ?? 'info' }); var loggerendpoint = loggers.add('matter-endpoint', { transports: new transports.File({ filename: path.join(isy.storagePath, 'debug', 'matter-endpoint.log') }), levels: isy.logger.levels, format: format.combine(format.simple(), format.colorize()), level: 'info' }); } try { MatterLogger.addLogger('polyLogger', (lvl, message) => { let msg = message.slice(23).remove(LogLevel[lvl]).trimStart(); let level = LogLevel[lvl].toLowerCase().replace('notice', 'info').replace('fatal', 'error'); if (msg.startsWith('EndpointStructureLogger')) { loggerendpoint.log(level, msg); //if (lvl === LogLevel.INFO) level = 'debug'; } else { loggermjs.log(level, msg); } }, /*Preserve existing formatting, but trim off date*/ { defaultLogLevel: logLevelFromString('debug'), logFormat: 'plain' }); if (existsSync(path.join(isy.storagePath, 'debug', 'matter-endpoint.log'))) { writeFileSync(path.join(isy.storagePath, 'debug', 'matter-endpoint.log'), ''); } /*MatterLogger.addLogger( 'EndpointStructureLogger', (lvl, message) => { if(lvl === LogLevel.INFO) { appendFileSync(`matter-endpoint.log`, message); }, { defaultLogLevel: logLevelFromString('info'), logFormat: 'ansi' } );*/ } finally { try { //MatterLogger.defaultLogLevel = logLevelFromString('debug'); if (MatterLogger.getLoggerforIdentifier('default') !== undefined) { MatterLogger.removeLogger('default'); } } catch { } } config = await initializeConfiguration(isy, config); logger.info(`Matter config: ${JSON.stringify(config)}`); /*Remove existing logging*/ let s = ServerNode.RootEndpoint; let server = await ServerNode.create(s, { // Required: Give the Node a unique ID which is used to store the state of this node id: config.uniqueId, // Provide Network relevant configuration like the port // Optional when operating only one device on a host, Default port is 5540 network: { port: 5550, discoveryCapabilities: { onIpNetwork: true } }, // Provide Commissioning relevant settings // Optional for development/testing purposes commissioning: { passcode: config.passcode, discriminator: config.discriminator }, generalCommissioning: { basicCommissioningInfo: { failSafeExpiryLengthSeconds: 60 * 5 /*as of Matter.js 0.12.0, default timeout is 60s*/, maxCumulativeFailsafeSeconds: 60 * 10 } }, // Provide Node announcement settings // Optional: If Ommitted some development defaults are used productDescription: { name: isy.model, deviceType: AggregatorEndpoint.deviceType }, // Provide defaults for the BasicInformation cluster on the Root endpoint // Optional: If Omitted some development defaults are used basicInformation: { vendorName: isy.vendorName, vendorId: VendorId(config.vendorId), nodeLabel: config.productName, productName: config.productName, productLabel: config.productName, productId: config.productId, hardwareVersionString: isy.firmwareVersion, softwareVersionString: isy.firmwareVersion, serialNumber: isy.id.replaceAll(':', '-'), uniqueId: config.uniqueId } }); logger.info(`Bridge server added`); /** * Matter Nodes are a composition of endpoints. Create and add a single multiple endpoint to the node to make it a * composed device. This example uses the OnOffLightDevice or OnOffPlugInUnitDevice depending on the value of the type * parameter. It also assigns each Endpoint a unique ID to store the endpoint number for it in the storage to restore * the device on restart. * * In this case we directly use the default command implementation from matter.js. Check out the DeviceNodeFull example * to see how to customize the command handlers. */ const aggregator = new Endpoint(AggregatorEndpoint, { id: 'aggregator' }); await server.add(aggregator); logger.info(`Bridge aggregator added`); let endpoints = 0; let devices = []; if (config.excludeDevicesByDefault) { let addresses = []; for (const opt in config.deviceConfig) { let deviceConfig = config.deviceConfig[opt]; if ('address' in deviceConfig.applyTo) { if ('include' in deviceConfig.options) { if (typeof deviceConfig.applyTo.address === 'string') { addresses.push(deviceConfig.applyTo.address); } else if (Array.isArray(deviceConfig.applyTo.address)) { addresses.push(...deviceConfig.applyTo.address); } } } } for (const address of addresses) { devices.push(isy.getDevice(address)); } } if (devices.length === 0) { devices = Array.from(isy.devices.values()); } for (const node of devices) { let device = node; /*if (device.parentAddress !== device.address) { continue; }*/ try { let deviceOptions = getDeviceOptions(node, ...config.deviceConfig); if ('exclude' in deviceOptions) { logger.info(`Device excluded by config. ${node.label}`); continue; } let uniqueId = `${device.address.replaceAll(' ', '_').replaceAll('.', '_')}`; if (device.enabled) { await device.refreshNotes(); if (!device.initialized) { if (ISYDevice.isQueryable(device)) { logger.info('Device not initialized. Querying...'); await device.query(); await device.refreshState(); } } //const name = `OnOff ${isASocket ? "Socket" : "Light"} ${i}`; //@ts-ignore //of (DimmableLightDevice.with(BridgedDeviceBasicInformationServer, ISYBridgedDeviceBehavior, ISYOnOffBehavior, ISYDimmableBehavior)) | typeof (OnOffLightDevice.with(BridgedDeviceBasicInformationServer, ISYBridgedDeviceBehavior, ISYOnOffBehavior));*/ let deviceType = MappingRegistry.getMapping(device)?.deviceType; let baseBehavior = deviceType; if (baseBehavior !== undefined) { let b = getRequiredBehaviors(deviceType); for (let s in b) { let behavior = b[s]; if (behavior.cluster && behavior.cluster.name !== 'Unknown') { let b = BehaviorRegistry.get(device, behavior.cluster.name); if (b) { baseBehavior = baseBehavior.with(b); } } } /*if (DimmerLamp.isImplementedBy(device)) { baseBehavior = deviceType?.with(RelayOnOffBehavior, DimmerLevelControlBehavior); // if(device instanceof InsteonSwitchDevice) // { // baseBehavior = DimmerSwitchDevice.with(BridgedDeviceBasicInformationServer); } else if (RelayLamp.isImplementedBy(device)) { baseBehavior = deviceType?.with(RelayOnOffBehavior); // if(device instanceof InsteonSwitchDevice) // { // baseBehavior = OnOffLightSwitchDevice.with(BridgedDeviceBasicInformationServer); // } }*/ if (ISYDevice.isNode(device)) logger.info(`Device ${device.label} (${device.address}) with NodeDefId = ${device.nodeDefId} mapped to ${deviceType.name}`); else logger.info(`${device.constructor?.name} ${device.label} (${device.address}) mapped to ${deviceType.name}`); //@ts-ignore const endpoint = new Endpoint(baseBehavior, { id: uniqueId, isyNode: { address: device.address }, userLabel: { labelList: [ { label: 'Room', value: device.location ?? 'Unspecified' } ] }, bridgedDeviceBasicInformation: { nodeLabel: device.label.rightWithToken(32), vendorName: device.manufacturer?.leftWithToken(32) ?? config.vendorName.leftWithToken(32), vendorId: VendorId(config.vendorId), productName: device.productName?.leftWithToken(32), partNumber: device.modelNumber?.leftWithToken(32), productLabel: device.model?.leftWithToken(64), hardwareVersion: !isNaN(Number(device.version)) ? Number(device.version) : 0, hardwareVersionString: `v.${device.version}`, softwareVersion: !isNaN(Number(device.version)) ? Number(device.version) : 0, softwareVersionString: `v.${device.version}`, //softwareVersion: Number(device.version), //hardwareVersionString: `v.${device.version}`, serialNumber: uniqueId.replaceAll('_', '.'), reachable: true, uniqueId: uniqueId } }); await aggregator.add(endpoint); logger.info(`Endpoint added ${JSON.stringify(endpoint.id)} for ${device.label} (${device.address})`); endpoints++; //endpoints.push({0:endpoint,1:device}); } //endpoint.lifecycle.ready.on(()=> device.initialize(endpoint as any)); } } catch (e) { logger.error(`Error adding endpoint for ${device.label} (${device.address}): ${e.message}`); } /** * Register state change handlers and events of the endpoint for identify and onoff states to react to the commands. * * If the code in these change handlers fail then the change is also rolled back and not executed and an error is * reported back to the controller. */ } logger.info(`${endpoints} endpoints added to bridge.`); /** * In order to start the node and announce it into the network we use the run method which resolves when the node goes * offline again because we do not need anything more here. See the Full example for other starting options. * The QR Code is printed automatically. */ logger.info('Bringing server online'); await server.start(); logger.info('Matter Server is online'); /** * Log the endpoint structure for debugging reasons and to allow to verify anything is correct */ //MatterLogger.setLogger("EndpointStructureLogger", ((level, message) => logger.log(Level[level], message))); //logEndpoint(EndpointServer.forEndpoint(server)); //if(logger.isTraceEnabled()) // logEndpoint(EndpointServer.forEndpoint(server), {logAttributePrimitiveValues: true, logAttributeObjectValues: true}); //else if(logger.isDebugEnabled()) // { logEndpoint(EndpointServer.forEndpoint(server), { logAttributePrimitiveValues: true, logAttributeObjectValues: true, logNotSupportedClusterAttributes: true, logClusterCommands: true, logClusterEvents: true, logClusterGlobalAttributes: false }); // } if (server.lifecycle.online) { const { qrPairingCode, manualPairingCode } = server.state.commissioning.pairingCodes; logger.info('\n' + QrCode.get(qrPairingCode)); logger.info(`QR Code URL: https://project-chip.github.io/connectedhomeip/qrcode.html?data=${qrPairingCode}`); logger.info(`Manual pairing code: ${manualPairingCode}`); } instance = server; instance.lifecycle.destroyed.on(() => { try { logger.info('Server offline'); logger.info('Unhooking matter logger'); MatterLogger.removeLogger('polyLogger'); logger.close(); } finally { instance = null; } }); return server; } export function getPairingCode(server = instance) { let codes = server.state.commissioning.pairingCodes; codes.renderedQrPairingCode = QrCode.get(codes.qrPairingCode); codes.url = `https://project-chip.github.io/connectedhomeip/qrcode.html?data=${codes.qrPairingCode}`; return codes; } async function initializeConfiguration(isy, config) { var logger = isy.logger; const environment = Environment.default; const storageService = environment.get(StorageService); //storageService.factory = n => new StorageBackendMemory(n); const storagePath = path.resolve(isy.storagePath, 'server'); environment.vars.set('storage.path', storagePath); environment.vars.use(() => { storageService.location = storagePath; }); logger.info(`Matter storage location: ${storageService.location} (Directory)`); const stor = await storageService.open('bridge'); const deviceStorage = stor.createContext('data'); if (config.passcode) { environment.vars.set('passcode', config.passcode); } if (config.discriminator) { environment.vars.set('discriminator', config.discriminator); } if (config.vendorId) { environment.vars.set('vendorid', config.vendorId); } if (config.productId) { environment.vars.set('productid', config.productId); } //environment.vars.set('uniqueid', isy.id.replaceAll(':', '_')); //logger.info(`Matter configuration: ${JSON.stringify(environment.vars)}`); const vendorName = isy.vendorName; const passcode = environment.vars.number('passcode') ?? (await deviceStorage.get('passcode', 20202021)); const discriminator = environment.vars.number('discriminator') ?? (await deviceStorage.get('discriminator', 3840)); // product name / id and vendor id should match what is in the device certificate const vendorId = environment.vars.number('vendorid') ?? (await deviceStorage.get('vendorid', 0xfff1)); const productId = environment.vars.number('productid') ?? (await deviceStorage.get('productid', isy.productId)); const productName = environment.vars.string('productname') ?? (await deviceStorage.get('productname', isy.productName)); const port = environment.vars.number('port') ?? 5540; const uniqueId = environment.vars.string('uniqueid') ?? (await deviceStorage.get('uniqueid', createHash('md5').update(isy.id.replaceAll(':', '_')).update(Time.nowMs().toString()).digest('hex'))); // Persist basic data to keep them also on restart await deviceStorage.set({ passcode, discriminator, vendorid: vendorId, productid: productId, productName: productName, uniqueid: uniqueId }); await stor.close(); //storageService.factory = n => new StorageBackendMemory({}); //ogger.info(`Matter storage service type: ${storageService.factory); return { //deviceName, vendorName, passcode, discriminator, vendorId, productName, productId, port, uniqueId, ipv4: config.ipv4 ?? true, ipv6: config.ipv6 ?? true, deviceConfig: config.deviceConfig }; } // #endregion Functions (3) //# sourceMappingURL=Server.js.map