diff --git a/src/main.ts b/src/main.ts index 1c3d4d9f..f5213cab 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,20 +10,21 @@ import { Level, Logger } from '@project-chip/matter.js/log'; import { IoBrokerNodeStorage } from './matter/IoBrokerNodeStorage'; import { DeviceFactory, SubscribeManager } from './lib'; import { DetectedDevice, DeviceOptions } from './lib/devices/GenericDevice'; -import BridgedDevice from './matter/BridgedDevicesNode'; -import MatterDevice from './matter/DeviceNode'; +import BridgedDevice, { BridgeCreateOptions } from './matter/BridgedDevicesNode'; +import MatterDevice, { DeviceCreateOptions } from './matter/DeviceNode'; import { BridgeDescription, BridgeDeviceDescription, DeviceDescription, MatterAdapterConfig } from './ioBrokerStorageTypes'; -import MatterController, { ControllerOptions } from './matter/ControllerNode'; +import MatterController from './matter/ControllerNode'; import MatterAdapterDeviceManagement from './lib/DeviceManagement'; import { Environment, StorageService } from '@project-chip/matter.js/environment'; import { MatterControllerConfig } from '../src-admin/src/types'; import { NodeStateResponse } from './matter/BaseServerNode'; +import { MessageResponse } from './matter/GeneralNode'; const IOBROKER_USER_API = 'https://iobroker.pro:3001'; @@ -75,7 +76,7 @@ interface NodeStatesOptions { export class MatterAdapter extends utils.Adapter { private devices = new Map(); private bridges = new Map(); - private controller: MatterController | null = null; + private controller?: MatterController; private detector: ChannelDetector; @@ -105,7 +106,7 @@ export class MatterAdapter extends utils.Adapter { }); this.on('ready', () => this.onReady()); this.on('stateChange', (id, state) => this.onStateChange(id, state)); - this.on('objectChange', (id /* , object */) => this.onObjectChange(id)); + this.on('objectChange', (id , object) => this.onObjectChange(id, object)); this.on('unload', callback => this.onUnload(callback)); this.on('message', this.onMessage.bind(this)); this.deviceManagement = new MatterAdapterDeviceManagement(this); @@ -232,14 +233,11 @@ export class MatterAdapter extends utils.Adapter { case 'updateControllerSettings': { const newControllerConfig: MatterControllerConfig = JSON.parse(obj.message); this.log.info(JSON.stringify(newControllerConfig)); - // TODO: perform logic @Apollon77 - await new Promise(resolve => { - // just wait to simulate some time for frontend - setTimeout(() => resolve(), 2_000); - }); - - await this.extendObject(`${this.namespace}.controller`, { native: newControllerConfig }); - this.sendTo(obj.from, obj.command, { result: true }, obj.callback); + const result = await this.applyControllerConfiguration(newControllerConfig); + if (result && 'result' in result) { // was successfull + await this.extendObject(`${this.namespace}.controller`, { native: newControllerConfig }); + } + this.sendTo(obj.from, obj.command, result, obj.callback); break; } default: @@ -360,12 +358,12 @@ export class MatterAdapter extends utils.Adapter { const systemConfig: ioBroker.SystemConfigObject = await this.getForeignObjectAsync('system.config') as ioBroker.SystemConfigObject; this.sysLanguage = systemConfig?.common?.language || 'en'; - await this.loadDevices(); + await this.syncDevices(); if (!this.subscribed) { this.subscribed = true; - await this.subscribeForeignObjectsAsync(`${this.namespace}.0.bridges.*`); - await this.subscribeForeignObjectsAsync(`${this.namespace}.0.devices.*`); - await this.subscribeForeignObjectsAsync(`${this.namespace}.0.controller.*`); + await this.subscribeForeignObjectsAsync(`${this.namespace}.bridges.*`); + await this.subscribeForeignObjectsAsync(`${this.namespace}.devices.*`); + await this.subscribeForeignObjectsAsync(`${this.namespace}.controller.*`); } /** @@ -416,10 +414,10 @@ export class MatterAdapter extends utils.Adapter { } } - async onObjectChange(id: string/*, obj: ioBroker.Object | null | undefined*/): Promise { + async onObjectChange(id: string, obj: ioBroker.Object | null | undefined): Promise { // matter.0.bridges.a6e61de9-e450-47bb-8f27-ee360350bdd8 - if (id.startsWith(`${this.namespace}.`) && id.split('.').length === 4) { - await this.loadDevices(); + if (id.startsWith(`${this.namespace}.`) && obj?.common?.type === 'channel') { + await this.syncDevices(obj as ioBroker.ChannelObject); } } @@ -580,7 +578,10 @@ export class MatterAdapter extends utils.Adapter { return this.license[key]; } - async createMatterBridge(options: BridgeDescription): Promise { + async prepareMatterBridgeConfiguration(options: BridgeDescription): Promise { + if (options.enabled === false) { + return null; // Not startup + } const devices = []; const optionsList = (options.list || []).filter(item => item.enabled !== false); for (let l = 0; l < optionsList.length; l++) { @@ -618,8 +619,7 @@ export class MatterAdapter extends utils.Adapter { } if (devices.length) { - const bridge = new BridgedDevice({ - adapter: this, + return { parameters: { port: this.nextPortNumber++, uuid: options.uuid, @@ -630,7 +630,16 @@ export class MatterAdapter extends utils.Adapter { }, devices, devicesOptions: optionsList, - }); + }; + } + return null; + } + + async createMatterBridge(options: BridgeDescription): Promise { + const config = await this.prepareMatterBridgeConfiguration(options); + + if (config) { + const bridge = new BridgedDevice(this, config); await bridge.init(); // add bridge to server @@ -640,7 +649,10 @@ export class MatterAdapter extends utils.Adapter { return null; } - async createMatterDevice(deviceName: string, options: DeviceDescription): Promise { + async prepareMatterDeviceConfiguration(deviceName: string, options: DeviceDescription): Promise { + if (options.enabled === false) { + return null; // Not startup + } let device; let detectedDevice = await this.getDeviceStates(options.oid) as DetectedDevice; if (!options.auto && (!detectedDevice || detectedDevice.type !== options.type)) { @@ -666,8 +678,7 @@ export class MatterAdapter extends utils.Adapter { } } if (device) { - const matterDevice = new MatterDevice({ - adapter: this, + return { parameters: { port: this.nextPortNumber++, uuid: options.uuid, @@ -678,7 +689,15 @@ export class MatterAdapter extends utils.Adapter { }, device, deviceOptions: options, - }); + }; + } + return null; + } + + async createMatterDevice(deviceName: string, options: DeviceDescription): Promise { + const config = await this.prepareMatterDeviceConfiguration(deviceName, options); + if (config) { + const matterDevice = new MatterDevice(this, config); await matterDevice.init(); // add bridge to server return matterDevice; @@ -687,7 +706,7 @@ export class MatterAdapter extends utils.Adapter { return null; } - async createMatterController(controllerOptions: ControllerOptions): Promise { + async createMatterController(controllerOptions: MatterControllerConfig): Promise { const matterController = new MatterController({ adapter: this, controllerOptions, @@ -698,66 +717,93 @@ export class MatterAdapter extends utils.Adapter { return matterController; } - - async loadDevices(): Promise { + /** + * Synchronize Devices, Bridges and the controller with the configuration + * @param obj Hand over one object to just handle updates for this and no complete resync. + */ + async syncDevices(obj?: ioBroker.ChannelObject | null | undefined): Promise { const devices: ioBroker.Object[] = []; const bridges: ioBroker.Object[] = []; - const objects = await this.getObjectViewAsync( - 'system', 'channel', - { - startkey: `${this.namespace}.`, - endkey: `${this.namespace}.\u9999`, - }, - ); + const objects: ioBroker.ChannelObject[] = []; + if (obj) { + objects.push(obj); + } else { + const devicesObjects = await this.getObjectViewAsync( + 'system', 'channel', + { + startkey: `${this.namespace}.devices.`, + endkey: `${this.namespace}.devices.\u9999`, + }, + ); + devicesObjects.rows.forEach(row => objects.push(row.value)); + const bridgesObjects = await this.getObjectViewAsync( + 'system', 'channel', + { + startkey: `${this.namespace}.bridges.`, + endkey: `${this.namespace}.bridges.\u9999`, + }, + ); + bridgesObjects.rows.forEach(row => objects.push(row.value)); + } + + for (const object of objects) { + // No valid object or a sub-channel + if (!object || !object.native || object._id.split('.').length !== 4) continue; - for (let r = 0; r < objects.rows.length; r++) { - const object = objects.rows[r]?.value; - if (!object) { - return; - } if (object._id.startsWith(`${this.namespace}.devices.`)) { - if (object.native.enabled !== false && !object.native.deleted) { - devices.push(object); - } else if (object.native.deleted) { + if (object.native.deleted) { // delete device - await this.delObjectAsync(object._id); - // how to delete information in the matter server? + this.log.info(`Delete Device "${object.native.uuid}" because deleted in the frontend.`); + await this.deleteBridgeOrDevice('device', object._id, object.native.uuid); + await this.delObjectAsync(object._id, { recursive: true }); + } else if (object.native.enabled !== false) { + devices.push(object); } } else if (object._id.startsWith(`${this.namespace}.bridges.`)) { - if (object.native.enabled !== false && - !object.native.deleted && + if (object.native.deleted) { + // delete bridge + this.log.info(`Delete bridge "${object.native.uuid}" because deleted in the frontend.`); + await this.deleteBridgeOrDevice('bridge', object._id, object.native.uuid); + await this.delObjectAsync(object._id); + } else if ( + object.native.enabled !== false && object.native.list?.length && - object.native.list.find((item: BridgeDeviceDescription) => item.enabled !== false) + object.native.list.some((item: BridgeDeviceDescription) => item.enabled) ) { bridges.push(object); - } else if (object.native.deleted) { - // delete bridge - await this.delObjectAsync(object._id); - - // how to delete information in the matter server? } } } - // Delete old non-existing bridges - for (const [bridgeId, bridge] of this.bridges.entries()) { - if (!bridges.find(obj => obj._id === bridgeId)) { - await bridge.stop(); - this.bridges.delete(bridgeId); + // When we just handle one object we do not need to sync with running devices and bridges + if (obj === undefined) { + // Objects existing, not deleted, so disable not enabled bridges or devices + for (const bridgeId of this.bridges.keys()) { + if (!bridges.find(obj => obj._id === bridgeId)) { + this.log.info(`Bridge "${bridgeId}" is not enabled anymore, so stop it.`); + await this.stopBridgeOrDevice('bridge', bridgeId); + } + } + for (const deviceId of this.devices.keys()) { + if (!devices.find(obj => obj._id === deviceId)) { + this.log.info(`Device "${deviceId}" is not enabled anymore, so stop it.`); + await this.stopBridgeOrDevice('device', deviceId); + } } } - // Create new bridges + // Objects exist and enabled: Sync bridges for (const bridge of bridges) { - if (!this.bridges.has(bridge._id)) { + const existingBridge = this.bridges.get(bridge._id); + if (existingBridge === undefined) { // if one bridge already exists, check the license const matterBridge = await this.createMatterBridge(bridge.native as BridgeDescription); if (matterBridge) { if (Object.keys(this.bridges).length) { // check license if (!(await this.checkLicense())) { - this.log.error(`You cannot use more than one bridge without ioBroker.pro subscription. Bridge ${bridge._id} will be ignored.}`); + this.log.error(`You cannot use more than one bridge without ioBroker.pro subscription. Bridge "${bridge._id}" will be ignored.}`); await matterBridge.stop(); break; } @@ -765,23 +811,26 @@ export class MatterAdapter extends utils.Adapter { this.bridges.set(bridge._id, matterBridge); } + } else { + const config = await this.prepareMatterBridgeConfiguration(bridge.native as BridgeDescription); + if (config) { + this.log.info(`Apply configuration update for bridge "${bridge._id}".`); + await existingBridge.applyConfiguration(config); + } else { + this.log.info(`Configuration for bridge "${bridge._id}" is no longer valid or bridge disabled. Stopping it now.`); + await existingBridge.stop(); + } } } - // Delete old non-existing devices - for (const [deviceId, device] of this.devices.entries()) { - if (!devices.find(obj => obj._id === deviceId)) { - await device.stop(); - this.devices.delete(deviceId); - } - } - - // Create new devices + // Objects exist and enabled: Sync devices for (const device of devices) { - if (!this.devices.has(device._id)) { + const deviceName = typeof device.common.name === 'object' ? + (device.common.name[this.sysLanguage] ? device.common.name[this.sysLanguage] as string : device.common.name.en) : device.common.name; + const existingDevice = this.devices.get(device._id); + if (existingDevice === undefined) { const matterDevice = await this.createMatterDevice( - typeof device.common.name === 'object' ? - (device.common.name[this.sysLanguage] ? device.common.name[this.sysLanguage] as string : device.common.name.en) : device.common.name, + deviceName, device.native as DeviceDescription ); if (matterDevice) { @@ -794,15 +843,57 @@ export class MatterAdapter extends utils.Adapter { } this.devices.set(device._id, matterDevice); } + } else { + const config = await this.prepareMatterDeviceConfiguration(deviceName, device.native as DeviceDescription); + if (config) { + this.log.info(`Apply configuration update for device "${device._id}".`); + await existingDevice.applyConfiguration(config); + } else { + this.log.info(`Configuration for device "${device._id}" is no longer valid or bridge disabled. Stopping it now.`); + await existingDevice.stop(); + } } } - const controllerObj = await this.getObjectAsync('controller'); - if (controllerObj?.native?.enabled && !this.controller) { - this.controller = await this.createMatterController(controllerObj.native as ControllerOptions); - } else if (!controllerObj?.native?.enabled && this.controller) { + + if (!obj) { + // Sync controller + const controllerObj = await this.getObjectAsync('controller'); + const controllerConfig = (controllerObj?.native ?? { enabled: false }) as MatterControllerConfig; + await this.applyControllerConfiguration(controllerConfig); + } + + // TODO Anything to do for controller sub object changes? + } + + async applyControllerConfiguration(config: MatterControllerConfig): Promise { + if (config.enabled) { + if (this.controller) { + return this.controller.applyConfiguration(config); + } + + this.controller = await this.createMatterController(config); + } else if (this.controller) { + // Controller should be disabled but is not await this.controller.stop(); - this.controller = null; + this.controller = undefined; } + + return { result: true }; + } + + async stopBridgeOrDevice(type: 'bridge' | 'device', id: string): Promise { + const nodes = type === 'bridge' ? this.bridges : this.devices; + const node = nodes.get(id); + if (node) { + await node.stop(); + nodes.delete(id); + } + } + + async deleteBridgeOrDevice(type: 'bridge' | 'device', id: string, uuid: string): Promise { + await this.stopBridgeOrDevice(type, id); + const storage = new IoBrokerNodeStorage(this, uuid); + await storage.clearAll(); } } diff --git a/src/matter/BaseServerNode.ts b/src/matter/BaseServerNode.ts index 760cc71d..cd632774 100644 --- a/src/matter/BaseServerNode.ts +++ b/src/matter/BaseServerNode.ts @@ -2,10 +2,6 @@ import { GeneralNode, MessageResponse } from './GeneralNode'; import type { MatterAdapter } from '../main'; import { ServerNode } from '@project-chip/matter.js/node'; -export interface BaseCreateOptions { - adapter: MatterAdapter; -} - export enum NodeStates { Creating = 'creating', WaitingForCommissioning = 'waitingForCommissioning', @@ -27,11 +23,11 @@ export interface NodeStateResponse { } export abstract class BaseServerNode implements GeneralNode { - protected adapter: MatterAdapter; protected serverNode?: ServerNode; - protected constructor(options: BaseCreateOptions) { - this.adapter = options.adapter; + abstract uuid: string; + + protected constructor(protected adapter: MatterAdapter) { } async advertise(): Promise { diff --git a/src/matter/BridgedDevicesNode.ts b/src/matter/BridgedDevicesNode.ts index d92cda58..199700f7 100644 --- a/src/matter/BridgedDevicesNode.ts +++ b/src/matter/BridgedDevicesNode.ts @@ -15,10 +15,9 @@ import { AggregatorEndpoint } from '@project-chip/matter.js/endpoint/definitions import { BridgedDeviceBasicInformationServer } from '@project-chip/matter.js/behavior/definitions/bridged-device-basic-information'; -import { BaseCreateOptions, BaseServerNode, ConnectionInfo, NodeStateResponse, NodeStates } from './BaseServerNode'; +import { BaseServerNode, ConnectionInfo, NodeStateResponse, NodeStates } from './BaseServerNode'; -export interface BridgeCreateOptions extends BaseCreateOptions { - adapter: MatterAdapter; +export interface BridgeCreateOptions { parameters: BridgeOptions, devices: GenericDevice[]; devicesOptions: BridgeDeviceDescription[]; @@ -39,13 +38,17 @@ class BridgedDevices extends BaseServerNode { private devicesOptions: BridgeDeviceDescription[]; private commissioned: boolean | null = null; - constructor(options: BridgeCreateOptions) { - super(options); + constructor(adapter: MatterAdapter, options: BridgeCreateOptions) { + super(adapter); this.parameters = options.parameters; this.devices = options.devices; this.devicesOptions = options.devicesOptions; } + get uuid(): string { + return this.parameters.uuid; + } + async init(): Promise { await this.adapter.extendObject(`bridges.${this.parameters.uuid}.commissioned`, { type: 'state', @@ -167,6 +170,10 @@ class BridgedDevices extends BaseServerNode { this.serverNode.events.sessions.subscriptionsChanged.on(sessionChange); } + async applyConfiguration(_options: BridgeCreateOptions): Promise { + // TODO + } + async getState(): Promise { if (!this.serverNode) { return { diff --git a/src/matter/ControllerNode.ts b/src/matter/ControllerNode.ts index a1d43bf1..1bbe302a 100644 --- a/src/matter/ControllerNode.ts +++ b/src/matter/ControllerNode.ts @@ -34,23 +34,14 @@ import Factories from './clusters/factories'; import Base from './clusters/Base'; import { Environment } from '@project-chip/matter.js/environment'; import { GeneralNode, MessageResponse } from './GeneralNode'; +import { MatterControllerConfig } from '../../src-admin/src/types'; export interface ControllerCreateOptions { adapter: MatterAdapter; - controllerOptions: ControllerOptions; + controllerOptions: MatterControllerConfig; matterEnvironment: Environment; } -export interface ControllerOptions { - ble?: boolean; - uuid: string; - wifiSSID?: string; - wifiPassword?: string; - threadNetworkName?: string; - threadOperationalDataSet?: string; - hciId?: number; -} - interface AddDeviceResult { result: boolean; error?: Error; @@ -98,16 +89,15 @@ interface Device { } class Controller implements GeneralNode { - private parameters: ControllerOptions; + private parameters: MatterControllerConfig; private readonly adapter: MatterAdapter; private readonly matterEnvironment: Environment; private commissioningController?: CommissioningController; private devices= new Map(); private delayedStates: { [nodeId: string]: NodeStateInformation } = {}; private connected: { [nodeId: string]: boolean } = {}; - private discovering: boolean = false; - private useBle: boolean = false; - private useThread: boolean = false; + private discovering = false; + private useBle = false; constructor(options: ControllerCreateOptions) { this.adapter = options.adapter; @@ -116,15 +106,7 @@ class Controller implements GeneralNode { } async init(): Promise { - if (this.parameters.ble) { - try { - Ble.get = singleton(() => new BleNode({ hciId: this.parameters.hciId })); - } catch (error) { - this.adapter.log.warn(`Failed to initialize BLE: ${error.message}`); - this.parameters.ble = false; - } - } - + await this.applyConfiguration(this.parameters, true); this.commissioningController = new CommissioningController({ autoConnect: false, // TODO add listeningAddressIpv4 and listeningAddressIpv6 to limit controller to one network interface @@ -135,6 +117,30 @@ class Controller implements GeneralNode { }); } + async applyConfiguration(config: MatterControllerConfig, isInitialization = false): Promise { + const currentConfig: MatterControllerConfig = isInitialization ? { enabled: true } : this.parameters; + + this.useBle = false; + if (config.ble !== currentConfig.ble || config.hciId !== currentConfig.hciId) { + if (config.ble && ( + (config.wifiSSID && config.wifiPassword) || + (config.threadNetworkName !== undefined && config.threadOperationalDataSet !== undefined) + )) { + try { + const hciId = config.hciId === undefined ? undefined : parseInt(config.hciId); + Ble.get = singleton(() => new BleNode({ hciId })); + this.useBle = true; + } catch (error) { + this.adapter.log.warn(`Failed to initialize BLE: ${error.message}`); + config.ble = false; + return { error: `Can not adjust configuration and enable BLE because of error: ${error.message}` }; + } + } + } + this.parameters = config; + return { result: true }; + } + async handleCommand(command: string, message: ioBroker.MessagePayload): Promise { if (this.commissioningController === undefined) { return { error: 'Controller is not initialized.' }; @@ -675,9 +681,8 @@ class Controller implements GeneralNode { regulatoryCountryCode: 'XX', }; - if (this.parameters.ble) { + if (this.useBle) { if (this.parameters.wifiSSID && this.parameters.wifiPassword) { - this.useBle = true; this.adapter.log.debug(`Registering Commissioning over BLE with WiFi: ${this.parameters.wifiSSID}`); commissioningOptions.wifiNetwork = { wifiSsid: this.parameters.wifiSSID, @@ -686,7 +691,6 @@ class Controller implements GeneralNode { } if (this.parameters.threadNetworkName !== undefined && this.parameters.threadOperationalDataSet !== undefined) { this.adapter.log.debug(`Registering Commissioning over BLE with Thread: ${this.parameters.threadNetworkName}`); - this.useThread = true; commissioningOptions.threadNetwork = { networkName: this.parameters.threadNetworkName, operationalDataset: this.parameters.threadOperationalDataSet, diff --git a/src/matter/DeviceNode.ts b/src/matter/DeviceNode.ts index 699dc695..8278e949 100644 --- a/src/matter/DeviceNode.ts +++ b/src/matter/DeviceNode.ts @@ -9,9 +9,10 @@ import matterDeviceFactory from './matterFactory'; import VENDOR_IDS from './vendorIds'; import { ServerNode } from '@project-chip/matter.js/node'; import { SessionsBehavior } from '@project-chip/matter.js/behavior/system/sessions'; -import { BaseCreateOptions, BaseServerNode, NodeStateResponse, NodeStates } from './BaseServerNode'; +import { BaseServerNode, NodeStateResponse, NodeStates } from './BaseServerNode'; +import type { MatterAdapter } from '../main'; -export interface DeviceCreateOptions extends BaseCreateOptions { +export interface DeviceCreateOptions { parameters: DeviceOptions, device: GenericDevice; deviceOptions: DeviceDescription; @@ -32,13 +33,17 @@ class Device extends BaseServerNode { private deviceOptions: DeviceDescription; private commissioned: boolean | null = null; - constructor(options: DeviceCreateOptions) { - super(options); + constructor(adapter: MatterAdapter, options: DeviceCreateOptions) { + super(adapter); this.parameters = options.parameters; this.device = options.device; this.deviceOptions = options.deviceOptions; } + get uuid(): string { + return this.parameters.uuid; + } + async init(): Promise { await this.adapter.extendObject(`devices.${this.parameters.uuid}.commissioned`, { type: 'state', @@ -147,6 +152,10 @@ class Device extends BaseServerNode { } + async applyConfiguration(_options: DeviceCreateOptions): Promise { + // TODO + } + async getState(): Promise { if (!this.serverNode) { return {