@pmouli/isy-matter-server
Version:
Service to expose an ISY device as a Matter Border router
564 lines (488 loc) • 19.2 kB
text/typescript
import { LogFormat, LogLevel, logLevelFromString, MatterFlowError, Logger as MatterLogger, Time } from '@matter/general';
import { ClusterBehavior, Endpoint, EndpointServer, Environment, MutableEndpoint, ServerNode, StorageService, VendorId, type EndpointLifecycle, type ServerEndpointInitializer } from '@matter/main';
import { GeneralCommissioning } from '@matter/main/clusters';
import { AggregatorEndpoint } from '@matter/main/endpoints/aggregator';
import { logEndpoint, type CommissioningMode } from '@matter/main/protocol';
import { QrCode } from '@matter/main/types';
import '@matter/nodejs';
import { createHash } from 'crypto';
import { appendFileSync, existsSync, writeFileSync } from 'fs';
import type { Devices, Factory, Family } from 'isy-nodejs';
import { DeviceNode, includes, ISY, some, writeDebugFile } from 'isy-nodejs';
import { KeypadButton } from 'isy-nodejs/Devices/Insteon/index';
import { ISYDevice } from 'isy-nodejs/ISYDevice';
import type { ISYNode } from 'isy-nodejs/ISYNode';
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: ServerNode;
const SuccessResponse = { errorCode: GeneralCommissioning.CommissioningError.Ok, debugText: '' };
export interface Config {
// #region Properties (8)
discriminator: number;
passcode: number;
port: number;
productId: number;
productName?: string;
uniqueId: string;
vendorId: number;
vendorName?: string;
ipv4?: boolean;
ipv6?: boolean;
deviceConfig?: DeviceConfig[];
excludeDevicesByDefault?: boolean;
logLevels?: {
'iox-matter'?: ServerLogLevel;
'matter.js'?: ServerLogLevel;
isy: ServerLogLevel;
};
// #endregion Properties (8)
}
export type ServerLogLevel = 'silly' | 'debug' | 'info' | 'warn' | 'error';
export interface DeviceConfig {
applyTo:
| {
device: string | string[];
}
| { family: string | string[] }
| { nodeDef: string | string[] }
| { family: keyof typeof Family | (keyof typeof Family)[] }
| { address: string | string[] }
| { deviceType: string | string[] }
| { predicate: (node: ISYDevice.Any) => boolean };
options:
| {
exclude: true;
}
| {
include: true;
label?: string;
//@ts-expect-error
mappings?: { [x in Devices[keyof Devices]]: DeviceToClusterMap<unknown, any> };
};
}
// #endregion Interfaces (1)
// #region Functions (3)
let t: MutableEndpoint;
export async function create(isy?: ISY, config?: Config): Promise<ServerNode> {
let s = await createMatterServer(isy, config);
return s;
}
export function appliesTo(device: ISYDevice.Any, deviceOptions: DeviceConfig): boolean {
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: { [x: string]: DeviceConfig['options'] } = {};
export function getDeviceOptions(node: ISYDevice.Any, ...configs: DeviceConfig[]): DeviceConfig['options'] {
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?: ISY, config?: Config): Promise<ServerNode> {
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: ISYDevice.Any[] = [];
if (config.excludeDevicesByDefault) {
let addresses = [] as string[];
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 as ISYDevice.Any & { refreshNotes: () => Promise<void> };
/*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] as ClusterBehavior.Type;
if (behavior.cluster && behavior.cluster.name !== 'Unknown') {
let b = BehaviorRegistry.get(device, behavior.cluster.name);
if (b) {
baseBehavior = baseBehavior.with(b) as any;
}
}
}
/*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;
}
type PairingCodeData = {
qrPairingCode: string;
manualPairingCode: string;
renderedQrPairingCode: string;
url: string | URL;
};
export function getPairingCode(server: ServerNode = instance): PairingCodeData {
let codes = server.state.commissioning.pairingCodes as PairingCodeData;
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: ISY, config?: Config): Promise<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)