diff --git a/test/homematic/test.homematic.discovery.node.ts b/test/homematic/test.homematic.discovery.node.ts new file mode 100644 index 0000000..b5309af --- /dev/null +++ b/test/homematic/test.homematic.discovery.node.ts @@ -0,0 +1,42 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createHomematicDiscoveryDescriptor } from '../../ts/integrations/homematic/index.js'; + +tap.test('matches manual Homematic CCU entries', async () => { + const descriptor = createHomematicDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'homematic-manual-match'); + const result = await matcher!.matches({ + host: '192.168.1.20', + model: 'Homematic CCU3', + manufacturer: 'eQ-3', + interfaceName: 'rf', + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('homematic'); + expect(result.candidate?.port).toEqual(2001); + expect(result.candidate?.metadata?.interfaceName).toEqual('rf'); +}); + +tap.test('matches Homematic metadata records and validates candidates', async () => { + const descriptor = createHomematicDiscoveryDescriptor(); + const metadataMatcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'homematic-metadata-match'); + const metadataResult = await metadataMatcher!.matches({ + domain: 'homematic', + name: 'Homematic', + metadata: { upstreamDomain: 'homematic' }, + }, {}); + expect(metadataResult.matched).toBeTrue(); + expect(metadataResult.candidate?.manufacturer).toEqual('eQ-3'); + + const validator = descriptor.getValidators()[0]; + const validation = await validator.validate({ + source: 'manual', + integrationDomain: 'homematic', + host: 'ccu3.local', + manufacturer: 'eQ-3', + model: 'CCU3', + }, {}); + expect(validation.matched).toBeTrue(); + expect(validation.candidate?.port).toEqual(2001); +}); + +export default tap.start(); diff --git a/test/homematic/test.homematic.mapper.node.ts b/test/homematic/test.homematic.mapper.node.ts new file mode 100644 index 0000000..db422b1 --- /dev/null +++ b/test/homematic/test.homematic.mapper.node.ts @@ -0,0 +1,120 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomematicMapper, type IHomematicSnapshot } from '../../ts/integrations/homematic/index.js'; + +const snapshot: IHomematicSnapshot = { + ccu: { + id: 'ccu3-1234', + name: 'Main CCU', + host: '192.168.1.20', + model: 'CCU3', + online: true, + serviceMessages: [], + }, + interfaces: [{ id: 'rf', name: 'rf', host: '192.168.1.20', port: 2001, family: 'bidcos-rf', online: true }], + devices: [ + { + address: 'SWITCH001', + name: 'Kitchen Plug', + type: 'SwitchPowermeter', + interfaceName: 'rf', + available: true, + channels: [ + { address: 'SWITCH001:1', index: 1, parentAddress: 'SWITCH001', datapoints: [{ name: 'STATE', value: true, kind: 'write', writable: true, readable: true }] }, + { address: 'SWITCH001:2', index: 2, parentAddress: 'SWITCH001', datapoints: [{ name: 'POWER', value: 42.5, kind: 'sensor', readable: true }, { name: 'ENERGY_COUNTER', value: 1234, kind: 'sensor', readable: true }] }, + ], + }, + { + address: 'DIMMER001', + name: 'Dining Light', + type: 'Dimmer', + interfaceName: 'rf', + available: true, + channels: [{ address: 'DIMMER001:1', index: 1, parentAddress: 'DIMMER001', datapoints: [{ name: 'LEVEL', value: 0.5, kind: 'write', writable: true, readable: true }] }], + }, + { + address: 'MOTION001', + name: 'Hall Motion', + type: 'Motion', + interfaceName: 'rf', + available: true, + channels: [{ address: 'MOTION001:1', index: 1, parentAddress: 'MOTION001', datapoints: [{ name: 'MOTION', value: false, kind: 'binary', readable: true }, { name: 'BRIGHTNESS', value: 80, kind: 'sensor', readable: true }] }], + }, + { + address: 'BLIND001', + name: 'Office Blind', + type: 'Blind', + interfaceName: 'rf', + available: true, + channels: [{ address: 'BLIND001:1', index: 1, parentAddress: 'BLIND001', datapoints: [{ name: 'LEVEL', value: 0.75, kind: 'write', writable: true, readable: true }] }], + }, + { + address: 'THERMO001', + name: 'Bedroom Thermostat', + type: 'Thermostat', + interfaceName: 'rf', + available: true, + channels: [{ address: 'THERMO001:4', index: 4, parentAddress: 'THERMO001', datapoints: [{ name: 'SET_TEMPERATURE', value: 21, kind: 'write', writable: true, readable: true }, { name: 'ACTUAL_TEMPERATURE', value: 20.5, kind: 'sensor', readable: true }, { name: 'CONTROL_MODE', value: 1, kind: 'attribute', readable: true }] }], + }, + { + address: 'LOCK001', + name: 'Front Door', + type: 'KeyMatic', + interfaceName: 'rf', + available: true, + channels: [{ address: 'LOCK001:1', index: 1, parentAddress: 'LOCK001', datapoints: [{ name: 'STATE', value: false, kind: 'write', writable: true, readable: true }, { name: 'OPEN', value: false, kind: 'action', writable: true, readable: false }] }], + }, + ], + events: [], + connected: true, + updatedAt: '2026-01-01T00:00:00.000Z', + source: 'manual', +}; + +tap.test('maps Homematic devices, channels, and datapoints to canonical devices and entities', async () => { + const devices = HomematicMapper.toDevices(snapshot); + const entities = HomematicMapper.toEntities(snapshot); + + expect(devices.some((deviceArg) => deviceArg.id === 'homematic.ccu.ccu3_1234')).toBeTrue(); + expect(devices.some((deviceArg) => deviceArg.id === 'homematic.device.ccu3_1234.switch001')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'switch.kitchen_plug' && entityArg.state === 'on')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'sensor.kitchen_plug_2_power' && entityArg.state === 42.5)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'light.dining_light' && entityArg.attributes?.brightness === 128)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.hall_motion_motion' && entityArg.state === 'off')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'cover.office_blind' && entityArg.attributes?.position === 75)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'climate.bedroom_thermostat' && entityArg.attributes?.currentTemperature === 20.5)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.id === 'lock.front_door' && entityArg.state === 'locked')).toBeTrue(); +}); + +tap.test('maps Homematic services to XML-RPC command models', async () => { + const setCommand = HomematicMapper.commandForService(snapshot, { + domain: 'homematic', + service: 'set_value', + target: {}, + data: { address: 'SWITCH001', channel: 1, datapoint: 'STATE', value: false }, + }); + expect(setCommand).toEqual({ type: 'set_value', address: 'SWITCH001', channel: 1, datapoint: 'STATE', value: false, interfaceName: undefined, entityId: undefined, deviceId: undefined }); + + const turnOffCommand = HomematicMapper.commandForService(snapshot, { + domain: 'switch', + service: 'turn_off', + target: { entityId: 'switch.kitchen_plug' }, + }); + expect(turnOffCommand).toEqual({ type: 'turn_off', address: 'SWITCH001', channel: 1, datapoint: 'STATE', value: false, interfaceName: 'rf', entityId: 'switch.kitchen_plug', deviceId: undefined }); + + const openCommand = HomematicMapper.commandForService(snapshot, { + domain: 'cover', + service: 'open', + target: { entityId: 'cover.office_blind' }, + }); + expect(openCommand).toEqual({ type: 'open', address: 'BLIND001', channel: 1, datapoint: 'LEVEL', value: 1, interfaceName: 'rf', entityId: 'cover.office_blind', deviceId: undefined }); + + const pressCommand = HomematicMapper.commandForService(snapshot, { + domain: 'homematic', + service: 'press', + target: {}, + data: { address: 'SWITCH001', channel: 1, param: 'PRESS_SHORT' }, + }); + expect(pressCommand).toEqual({ type: 'press', address: 'SWITCH001', channel: 1, datapoint: 'PRESS_SHORT', value: undefined, interfaceName: undefined, entityId: undefined, deviceId: undefined }); +}); + +export default tap.start(); diff --git a/test/knx/test.knx.discovery.node.ts b/test/knx/test.knx.discovery.node.ts new file mode 100644 index 0000000..09077aa --- /dev/null +++ b/test/knx/test.knx.discovery.node.ts @@ -0,0 +1,34 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createKnxDiscoveryDescriptor } from '../../ts/integrations/knx/index.js'; + +tap.test('matches KNXnet/IP gateway descriptors', async () => { + const descriptor = createKnxDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'knx-gateway-descriptor-match'); + const result = await matcher!.matches({ + ip_addr: '192.168.1.50', + port: 3671, + name: 'MDT SCN-IP000.03', + individual_address: '1.1.1', + supports_tunnelling: true, + supports_tunnelling_tcp: true, + supports_routing: true, + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('knx'); + expect(result.candidate?.metadata?.connectionType).toEqual('tunneling_tcp'); +}); + +tap.test('matches manual KNX/IP entries', async () => { + const descriptor = createKnxDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'knx-manual-match'); + const result = await matcher!.matches({ + host: '192.168.1.51', + connectionType: 'tunneling', + individualAddress: '0.0.240', + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.host).toEqual('192.168.1.51'); + expect(result.candidate?.port).toEqual(3671); +}); + +export default tap.start(); diff --git a/test/knx/test.knx.mapper.node.ts b/test/knx/test.knx.mapper.node.ts new file mode 100644 index 0000000..414155e --- /dev/null +++ b/test/knx/test.knx.mapper.node.ts @@ -0,0 +1,87 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { KnxMapper } from '../../ts/integrations/knx/index.js'; + +const snapshot = KnxMapper.toSnapshot({ + connectionType: 'tunneling_tcp', + host: '192.168.1.50', + port: 3671, + gateway: { + ip_addr: '192.168.1.50', + port: 3671, + name: 'KNX IP Interface', + supports_tunnelling_tcp: true, + }, + entities: [ + { + platform: 'light', + name: 'Kitchen light', + entityId: 'light.kitchen_light', + uniqueId: 'knx_light_kitchen', + address: '1/1/1', + brightnessAddress: '1/1/2', + state: true, + }, + { + platform: 'switch', + name: 'Kitchen outlet', + address: '1/2/1', + state: false, + }, + { + platform: 'sensor', + name: 'Kitchen temperature', + stateAddress: '2/1/1', + type: 'temperature', + state: 21.4, + unit: 'degC', + }, + { + platform: 'cover', + name: 'Living blind', + moveLongAddress: '3/1/1', + positionAddress: '3/1/2', + currentCoverPosition: 75, + }, + { + platform: 'climate', + name: 'Hall thermostat', + temperatureAddress: '4/1/1', + targetTemperatureAddress: '4/1/2', + hvacMode: 'heat', + targetTemperature: 22, + }, + ], +}); + +tap.test('maps KNX group address entities to canonical entities', async () => { + const entities = KnxMapper.toEntities(snapshot); + expect(entities.some((entityArg) => entityArg.id === 'light.kitchen_light' && entityArg.state === 'on')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'switch' && entityArg.state === 'off')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'sensor' && entityArg.state === 21.4)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'cover' && entityArg.state === 75)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'climate' && entityArg.state === 'heat')).toBeTrue(); +}); + +tap.test('maps KNX devices and entity commands', async () => { + const devices = KnxMapper.toDevices(snapshot); + const command = KnxMapper.commandForService(snapshot, { + domain: 'light', + service: 'turn_on', + target: { entityId: 'light.kitchen_light' }, + data: { brightness: 128 }, + }); + expect(devices.some((deviceArg) => deviceArg.id.startsWith('knx.interface.'))).toBeTrue(); + expect(command?.telegrams[0].address).toEqual('1/1/2'); + expect(command?.telegrams[0].payload).toEqual(128); +}); + +tap.test('maps KNX group write and read commands', async () => { + const writeCommand = KnxMapper.groupWriteCommand(['1/0/1'], true, '1.001'); + const readCommand = KnxMapper.groupReadCommand(['1/0/2']); + expect(writeCommand?.type).toEqual('group.write'); + expect(writeCommand?.telegrams[0].action).toEqual('write'); + expect(readCommand?.type).toEqual('group.read'); + expect(readCommand?.telegrams[0].action).toEqual('read'); +}); + +export default tap.start(); diff --git a/test/modbus/test.modbus.discovery.node.ts b/test/modbus/test.modbus.discovery.node.ts new file mode 100644 index 0000000..096941f --- /dev/null +++ b/test/modbus/test.modbus.discovery.node.ts @@ -0,0 +1,44 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createModbusDiscoveryDescriptor } from '../../ts/integrations/modbus/index.js'; + +tap.test('matches manual Modbus TCP entries', async () => { + const descriptor = createModbusDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'modbus-manual-tcp-match'); + const result = await matcher!.matches({ + host: '192.168.1.50', + port: 502, + type: 'tcp', + name: 'Heat pump Modbus', + unitId: 2, + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('modbus'); + expect(result.candidate?.port).toEqual(502); + expect(result.candidate?.metadata?.unitId).toEqual(2); +}); + +tap.test('does not guess serial RTU ports', async () => { + const descriptor = createModbusDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'modbus-manual-tcp-match'); + const result = await matcher!.matches({ + type: 'serial', + host: '/dev/ttyUSB0', + name: 'RS485 adapter', + }, {}); + expect(result.matched).toBeFalse(); + expect(result.reason).toContain('Serial RTU'); +}); + +tap.test('validates Modbus TCP candidates', async () => { + const descriptor = createModbusDiscoveryDescriptor(); + const validator = descriptor.getValidators()[0]; + const result = await validator.validate({ + source: 'manual', + integrationDomain: 'modbus', + host: 'plc.local', + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.port).toEqual(502); +}); + +export default tap.start(); diff --git a/test/modbus/test.modbus.mapper.node.ts b/test/modbus/test.modbus.mapper.node.ts new file mode 100644 index 0000000..5caedc1 --- /dev/null +++ b/test/modbus/test.modbus.mapper.node.ts @@ -0,0 +1,102 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { ModbusClient, ModbusMapper, type IModbusConfig } from '../../ts/integrations/modbus/index.js'; + +const config: IModbusConfig = { + hubs: [{ + name: 'plant_hub', + type: 'tcp', + host: '192.168.1.50', + port: 502, + registers: [{ + name: 'Supply Temp', + address: 100, + slave: 2, + dataType: 'int16', + registers: [215], + scale: 0.1, + precision: 1, + unitOfMeasurement: 'C', + }], + numbers: [{ + name: 'Setpoint', + address: 101, + slave: 2, + dataType: 'uint16', + value: 22, + min: 10, + max: 30, + step: 0.5, + writable: true, + }], + coils: [{ + name: 'Pump Running', + address: 5, + slave: 2, + value: true, + }], + switches: [{ + name: 'Pump Enable', + address: 6, + slave: 2, + writeType: 'coil', + value: false, + commandOn: 1, + commandOff: 0, + }], + }], +}; + +tap.test('maps configured Modbus registers and coils to devices and entities', async () => { + const snapshot = await new ModbusClient(config).getSnapshot(); + const devices = ModbusMapper.toDevices(snapshot); + const entities = ModbusMapper.toEntities(snapshot); + + expect(devices.some((deviceArg) => deviceArg.id === 'modbus.hub.plant_hub')).toBeTrue(); + expect(devices.some((deviceArg) => deviceArg.id === 'modbus.slave.plant_hub.2')).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'sensor.supply_temp')?.state).toEqual(21.5); + expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.pump_running')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'switch.pump_enable')?.state).toEqual('off'); + expect(entities.find((entityArg) => entityArg.id === 'number.setpoint')?.state).toEqual(22); +}); + +tap.test('maps Modbus services to coil and register commands', async () => { + const snapshot = await new ModbusClient(config).getSnapshot(); + const switchCommand = ModbusMapper.commandForService(snapshot, { + domain: 'switch', + service: 'turn_on', + target: { entityId: 'switch.pump_enable' }, + }); + expect(switchCommand).toEqual({ + type: 'write_coil', + hub: 'plant_hub', + hubId: 'plant_hub', + unitId: 2, + entityId: 'switch.pump_enable', + deviceId: undefined, + uniqueId: 'modbus_plant_hub_slave_2_switch_6', + address: 6, + value: 1, + }); + + const readCommand = ModbusMapper.commandForService(snapshot, { + domain: 'modbus', + service: 'read_register', + target: {}, + data: { hub: 'plant_hub', slave: 2, address: 100, count: 1, inputType: 'holding' }, + }); + expect(readCommand).toEqual({ + type: 'read_register', + hub: 'plant_hub', + hubId: 'plant_hub', + unitId: 2, + entityId: undefined, + deviceId: undefined, + uniqueId: undefined, + address: 100, + count: 1, + inputType: 'holding', + dataType: undefined, + }); +}); + +export default tap.start(); diff --git a/test/opentherm_gw/test.opentherm_gw.discovery.node.ts b/test/opentherm_gw/test.opentherm_gw.discovery.node.ts new file mode 100644 index 0000000..8b79d1a --- /dev/null +++ b/test/opentherm_gw/test.opentherm_gw.discovery.node.ts @@ -0,0 +1,31 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createOpenthermGwDiscoveryDescriptor } from '../../ts/integrations/opentherm_gw/index.js'; + +tap.test('matches manual OpenTherm Gateway TCP entries', async () => { + const descriptor = createOpenthermGwDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ host: 'otgw.local', port: 25238, name: 'Boiler OTGW' }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('opentherm_gw'); + expect(result.candidate?.host).toEqual('otgw.local'); + expect(result.candidate?.port).toEqual(25238); +}); + +tap.test('validates manual OpenTherm Gateway candidates', async () => { + const descriptor = createOpenthermGwDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ host: '192.168.1.40', metadata: { otgw: true } }, {}); + const validator = descriptor.getValidators()[0]; + const validation = await validator.validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + expect(validation.normalizedDeviceId).toEqual('192.168.1.40:23'); +}); + +tap.test('rejects unrelated manual entries', async () => { + const descriptor = createOpenthermGwDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ name: 'Kitchen Speaker', model: 'MPD' }, {}); + expect(result.matched).toBeFalse(); +}); + +export default tap.start(); diff --git a/test/opentherm_gw/test.opentherm_gw.mapper.node.ts b/test/opentherm_gw/test.opentherm_gw.mapper.node.ts new file mode 100644 index 0000000..ec6f175 --- /dev/null +++ b/test/opentherm_gw/test.opentherm_gw.mapper.node.ts @@ -0,0 +1,74 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { OpenthermGwMapper, type IOpenthermGwSnapshot } from '../../ts/integrations/opentherm_gw/index.js'; + +const snapshot: IOpenthermGwSnapshot = { + gateway: { + id: 'otgw-living-room', + name: 'Living Room OTGW', + host: '192.168.1.40', + port: 25238, + firmwareVersion: 'OpenTherm Gateway 5.1', + }, + status: { + gateway: { + otgw_about: 'OpenTherm Gateway 5.1', + otgw_mode: 'G', + otgw_dhw_ovrd: 'A', + otgw_gpio_a: 6, + otgw_gpio_a_state: 0, + central_heating_1_override: 1, + }, + boiler: { + slave_ch_active: 1, + slave_dhw_active: 1, + slave_flame_on: 1, + slave_cooling_active: 0, + slave_fault_indication: 0, + control_setpoint: 55.5, + ch_water_temp: 46.2, + dhw_temp: 51.3, + dhw_setpoint: 60, + relative_mod_level: 34, + ch_water_pressure: 1.7, + master_ch_enabled: 1, + }, + thermostat: { + master_ch_enabled: 1, + master_dhw_enabled: 1, + room_temp: 20.4, + room_setpoint: 21, + outside_temp: 5.5, + }, + }, + online: true, + source: 'manual', + updatedAt: '2026-01-01T00:00:00.000Z', +}; + +tap.test('maps OpenTherm Gateway devices', async () => { + const devices = OpenthermGwMapper.toDevices(snapshot); + expect(devices.map((deviceArg) => deviceArg.id)).toContain('opentherm_gw.gateway.otgw_living_room'); + expect(devices.map((deviceArg) => deviceArg.id)).toContain('opentherm_gw.boiler.otgw_living_room'); + expect(devices.map((deviceArg) => deviceArg.id)).toContain('opentherm_gw.thermostat.otgw_living_room'); +}); + +tap.test('maps climate sensors binary sensors and switches', async () => { + const entities = OpenthermGwMapper.toEntities(snapshot); + const climate = entities.find((entityArg) => entityArg.id === 'climate.living_room_otgw_thermostat'); + const waterTemp = entities.find((entityArg) => entityArg.id === 'sensor.living_room_otgw_boiler_central_heating_1_water_temperature'); + const flame = entities.find((entityArg) => entityArg.id === 'binary_sensor.living_room_otgw_boiler_flame'); + const switchEntity = entities.find((entityArg) => entityArg.id === 'switch.living_room_otgw_central_heating_1_override'); + + expect(climate?.platform).toEqual('climate'); + expect(climate?.state).toEqual('heat'); + expect(climate?.attributes?.hvacAction).toEqual('heating'); + expect(climate?.attributes?.currentTemperature).toEqual(20.4); + expect(waterTemp?.state).toEqual(46.2); + expect(waterTemp?.attributes?.unitOfMeasurement).toEqual('C'); + expect(flame?.platform).toEqual('binary_sensor'); + expect(flame?.state).toEqual('on'); + expect(switchEntity?.platform).toEqual('switch'); + expect(switchEntity?.state).toEqual('on'); +}); + +export default tap.start(); diff --git a/test/rflink/test.rflink.discovery.node.ts b/test/rflink/test.rflink.discovery.node.ts new file mode 100644 index 0000000..c923251 --- /dev/null +++ b/test/rflink/test.rflink.discovery.node.ts @@ -0,0 +1,49 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createRflinkDiscoveryDescriptor } from '../../ts/integrations/rflink/index.js'; + +tap.test('matches manual RFLink serial gateway entries', async () => { + const descriptor = createRflinkDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + connectionType: 'serial', + port: '/dev/serial/by-id/usb-Nodo_RFLink_Gateway', + baudRate: 57600, + name: 'RFLink Gateway', + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('rflink'); + expect(result.candidate?.metadata?.connectionType).toEqual('serial'); + expect(result.candidate?.metadata?.serialPort).toEqual('/dev/serial/by-id/usb-Nodo_RFLink_Gateway'); +}); + +tap.test('matches manual RFLink TCP bridge entries', async () => { + const descriptor = createRflinkDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + connectionType: 'tcp', + host: '192.168.1.34', + port: 1234, + metadata: { rflink: true }, + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.host).toEqual('192.168.1.34'); + expect(result.candidate?.port).toEqual(1234); + expect(result.candidate?.metadata?.connectionType).toEqual('tcp'); +}); + +tap.test('validates RFLink gateway candidates', async () => { + const descriptor = createRflinkDiscoveryDescriptor(); + const validator = descriptor.getValidators()[0]; + const result = await validator.validate({ + source: 'manual', + integrationDomain: 'rflink', + name: 'RFLink over TCP', + host: '192.168.1.35', + port: 1234, + metadata: { connectionType: 'tcp' }, + }, {}); + expect(result.matched).toBeTrue(); + expect(result.normalizedDeviceId).toEqual('192.168.1.35'); +}); + +export default tap.start(); diff --git a/test/rflink/test.rflink.mapper.node.ts b/test/rflink/test.rflink.mapper.node.ts new file mode 100644 index 0000000..74a3a40 --- /dev/null +++ b/test/rflink/test.rflink.mapper.node.ts @@ -0,0 +1,76 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { RflinkClient, RflinkMapper } from '../../ts/integrations/rflink/index.js'; +import type { IRflinkSnapshot } from '../../ts/integrations/rflink/index.js'; + +const snapshot: IRflinkSnapshot = { + gateway: { + id: 'rflink-gateway', + name: 'Nodo RFLink', + connectionType: 'tcp', + host: '192.168.1.34', + port: 1234, + firmware: 'RFLink Gateway', + version: '1.1', + revision: '46', + online: true, + }, + connected: true, + updatedAt: '2026-05-05T00:00:00.000Z', + devices: [], + events: [], + entities: [ + { id: 'newkaku_0000c6c2_1', platform: 'light', name: 'Kitchen Lamp', type: 'hybrid', state: 'on', brightness: 170, aliases: ['kaku_000001_a'] }, + { id: 'conrad_00785c_0a', platform: 'switch', name: 'Ceiling Fan', state: 'off' }, + { id: 'alectov1_0334_temp', platform: 'sensor', name: 'Outdoor Temperature', sensorType: 'temperature', value: 7.4, unitOfMeasurement: '°C' }, + { id: 'pt2262_00174754_0', platform: 'binary_sensor', name: 'PIR Entrance', deviceClass: 'motion', state: 'on' }, + { id: 'newkaku_0a8720_0', platform: 'cover', name: 'Office Blind', type: 'inverted', state: 'closed' }, + ], +}; + +tap.test('maps RFLink snapshots to gateway and RF device definitions', async () => { + const devices = RflinkMapper.toDevices(snapshot); + expect(devices.length).toEqual(6); + expect(devices[0].id).toEqual('rflink.gateway.rflink_gateway'); + expect(devices.find((deviceArg) => deviceArg.id === 'rflink.light.newkaku_0000c6c2_1')?.features.some((featureArg) => featureArg.id === 'brightness')).toBeTrue(); + expect(devices.find((deviceArg) => deviceArg.id === 'rflink.sensor.alectov1_0334_temp')?.state[0].value).toEqual(7.4); +}); + +tap.test('maps RFLink entities to Home Assistant-style platforms', async () => { + const entities = RflinkMapper.toEntities(snapshot); + expect(entities.map((entityArg) => entityArg.platform)).toContain('light'); + expect(entities.map((entityArg) => entityArg.platform)).toContain('switch'); + expect(entities.map((entityArg) => entityArg.platform)).toContain('sensor'); + expect(entities.map((entityArg) => entityArg.platform)).toContain('binary_sensor'); + expect(entities.map((entityArg) => entityArg.platform)).toContain('cover'); + expect(entities.find((entityArg) => entityArg.id === 'sensor.outdoor_temperature')?.attributes?.unitOfMeasurement).toEqual('°C'); + expect(entities.find((entityArg) => entityArg.id === 'cover.office_blind')?.state).toEqual('closed'); +}); + +tap.test('maps RFLink services to line protocol commands', async () => { + const closeCommand = RflinkMapper.commandForService(snapshot, { + domain: 'cover', + service: 'close_cover', + target: { entityId: 'cover.office_blind' }, + data: {}, + }); + expect(closeCommand?.rflinkCommand).toEqual('UP'); + expect(RflinkClient.commandShape(closeCommand?.deviceId || '', closeCommand?.rflinkCommand || '').line).toEqual('10;newkaku;0a8720;0;UP;'); + + const dimCommand = RflinkMapper.commandForService(snapshot, { + domain: 'light', + service: 'turn_on', + target: { entityId: 'light.kitchen_lamp' }, + data: { brightness: 128 }, + }); + expect(dimCommand?.type).toEqual('set_value'); + expect(dimCommand?.rflinkCommand).toEqual('7'); +}); + +tap.test('decodes RFLink sensor line packets into events', async () => { + const packet = RflinkClient.decodeLine('20;00;Alecto V1;ID=0334;TEMP=004a;HUM=26;BAT=OK;'); + const events = packet ? RflinkClient.eventsFromPacket(packet) : []; + expect(events.find((eventArg) => eventArg.id === 'alectov1_0334_temp')?.value).toEqual(7.4); + expect(events.find((eventArg) => eventArg.id === 'alectov1_0334_hum')?.value).toEqual(26); +}); + +export default tap.start(); diff --git a/test/velbus/test.velbus.discovery.node.ts b/test/velbus/test.velbus.discovery.node.ts new file mode 100644 index 0000000..7c9b97a --- /dev/null +++ b/test/velbus/test.velbus.discovery.node.ts @@ -0,0 +1,64 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createVelbusDiscoveryDescriptor } from '../../ts/integrations/velbus/index.js'; + +tap.test('matches manual Velbus serial entries', async () => { + const descriptor = createVelbusDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'velbus-manual-match'); + const result = await matcher!.matches({ + connection: 'serial', + serialPath: '/dev/ttyACM0', + manufacturer: 'Velleman', + model: 'Velbus USB interface', + serialNumber: 'VBUS123', + }, {}); + expect(result.matched).toBeTrue(); + expect(result.normalizedDeviceId).toEqual('VBUS123'); + expect(result.candidate?.integrationDomain).toEqual('velbus'); + expect(result.candidate?.metadata?.connection).toEqual('serial'); + expect(result.candidate?.metadata?.serialPath).toEqual('/dev/ttyACM0'); +}); + +tap.test('matches manual Velbus TCP entries', async () => { + const descriptor = createVelbusDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'velbus-manual-match'); + const result = await matcher!.matches({ + connection: 'tcp', + host: '192.168.1.60', + tls: true, + password: 'secret', + name: 'Velbus Signum', + }, {}); + expect(result.matched).toBeTrue(); + expect(result.normalizedDeviceId).toEqual('tcp:192.168.1.60:27015'); + expect(result.candidate?.port).toEqual(27015); + expect(result.candidate?.metadata?.dsn).toEqual('tls://secret@192.168.1.60:27015'); +}); + +tap.test('matches manual Velbus TCP DSN entries', async () => { + const descriptor = createVelbusDiscoveryDescriptor(); + const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'velbus-manual-match'); + const result = await matcher!.matches({ + connection: 'tcp', + dsn: 'tls://secret@192.168.1.61:27015', + model: 'Velbus TCP/IP interface', + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.host).toEqual('192.168.1.61'); + expect(result.candidate?.metadata?.password).toEqual('secret'); +}); + +tap.test('validates Velbus candidates', async () => { + const descriptor = createVelbusDiscoveryDescriptor(); + const validator = descriptor.getValidators()[0]; + const result = await validator.validate({ + source: 'manual', + integrationDomain: 'velbus', + host: 'velbus.local', + manufacturer: 'Velleman', + metadata: { connection: 'tcp' }, + }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.manufacturer).toEqual('Velleman'); +}); + +export default tap.start(); diff --git a/test/velbus/test.velbus.mapper.node.ts b/test/velbus/test.velbus.mapper.node.ts new file mode 100644 index 0000000..b547594 --- /dev/null +++ b/test/velbus/test.velbus.mapper.node.ts @@ -0,0 +1,92 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { VelbusMapper, type IVelbusSnapshot } from '../../ts/integrations/velbus/index.js'; + +const snapshot: IVelbusSnapshot = { + gateway: { + id: 'velbus-gateway', + name: 'Velbus Gateway', + connection: 'tcp', + host: '192.168.1.60', + port: 27015, + tls: true, + }, + connected: true, + updatedAt: '2026-01-01T00:00:00.000Z', + modules: [{ + address: 1, + name: 'Cabinet Module', + type: 0x26, + typeName: 'VMB4RYLD-20', + swVersion: '1.0', + serialNumber: 'MOD001', + channels: [ + { id: 'relay-1', channelNumber: 1, kind: 'relay', name: 'Kitchen Relay', state: true }, + { id: 'dimmer-1', channelNumber: 2, kind: 'dimmer', name: 'Living Dimmer', state: true, brightness: 75 }, + { id: 'input-1', channelNumber: 3, kind: 'button', name: 'Door Contact', state: 'closed', deviceClass: 'door' }, + { id: 'blind-1', channelNumber: 4, kind: 'blind', name: 'Kitchen Blind', state: 'open', position: 60 }, + { id: 'temp-1', channelNumber: 5, kind: 'temperature', name: 'Hall Temperature', currentTemperature: 21.5, unit: 'C' }, + { id: 'thermostat-1', channelNumber: 6, kind: 'climate', name: 'Hall Thermostat', currentTemperature: 21.5, targetTemperature: 22, hvacMode: 'heat', presetMode: 'home', unit: 'C' }, + ], + }], +}; + +tap.test('maps Velbus modules and channels to canonical devices and entities', async () => { + const devices = VelbusMapper.toDevices(snapshot); + const entities = VelbusMapper.toEntities(snapshot); + expect(devices.some((deviceArg) => deviceArg.id === 'velbus.gateway.velbus_gateway')).toBeTrue(); + expect(devices.some((deviceArg) => deviceArg.id === 'velbus.module.mod001')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'light' && entityArg.id === 'light.living_dimmer' && entityArg.attributes?.brightness === 75)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'switch' && entityArg.id === 'switch.kitchen_relay' && entityArg.state === 'on')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'binary_sensor' && entityArg.id === 'binary_sensor.door_contact' && entityArg.state === 'on')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'sensor' && entityArg.id === 'sensor.hall_temperature' && entityArg.state === 21.5)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'cover' && entityArg.id === 'cover.kitchen_blind' && entityArg.attributes?.currentPosition === 60)).toBeTrue(); + expect(entities.some((entityArg) => entityArg.platform === 'climate' && entityArg.id === 'climate.hall_thermostat' && entityArg.attributes?.targetTemperature === 22)).toBeTrue(); +}); + +tap.test('maps supported Velbus services to commands', async () => { + const lightCommand = VelbusMapper.commandForService(snapshot, { + domain: 'light', + service: 'turn_on', + target: { entityId: 'light.living_dimmer' }, + data: { brightness: 128 }, + }); + expect(lightCommand).toEqual({ type: 'turn_on', moduleAddress: 1, channelId: 'dimmer-1', channelNumber: 2, platform: 'light', entityId: 'light.living_dimmer', value: 50 }); + + const switchCommand = VelbusMapper.commandForService(snapshot, { + domain: 'switch', + service: 'turn_off', + target: { entityId: 'switch.kitchen_relay' }, + }); + expect(switchCommand).toEqual({ type: 'turn_off', moduleAddress: 1, channelId: 'relay-1', channelNumber: 1, platform: 'switch', entityId: 'switch.kitchen_relay' }); + + const velbusTurnOffCommand = VelbusMapper.commandForService(snapshot, { + domain: 'velbus', + service: 'turn_off', + target: { entityId: 'light.living_dimmer' }, + }); + expect(velbusTurnOffCommand?.type).toEqual('turn_off'); + + const setValueCommand = VelbusMapper.commandForService(snapshot, { + domain: 'velbus', + service: 'set_value', + target: { entityId: 'cover.kitchen_blind' }, + data: { value: 30 }, + }); + expect(setValueCommand).toEqual({ type: 'set_value', moduleAddress: 1, channelId: 'blind-1', channelNumber: 4, platform: 'cover', entityId: 'cover.kitchen_blind', value: 30 }); + + const openCommand = VelbusMapper.commandForService(snapshot, { + domain: 'cover', + service: 'open_cover', + target: { entityId: 'cover.kitchen_blind' }, + }); + expect(openCommand?.type).toEqual('open'); + + const closeCommand = VelbusMapper.commandForService(snapshot, { + domain: 'cover', + service: 'close_cover', + target: { entityId: 'cover.kitchen_blind' }, + }); + expect(closeCommand?.type).toEqual('close'); +}); + +export default tap.start(); diff --git a/ts/index.ts b/ts/index.ts index 96e9849..bbf5808 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -12,15 +12,20 @@ import { DenonavrIntegration } from './integrations/denonavr/index.js'; import { DlnaDmrIntegration } from './integrations/dlna_dmr/index.js'; import { EsphomeIntegration } from './integrations/esphome/index.js'; import { HomekitControllerIntegration } from './integrations/homekit_controller/index.js'; +import { HomematicIntegration } from './integrations/homematic/index.js'; import { JellyfinIntegration } from './integrations/jellyfin/index.js'; +import { KnxIntegration } from './integrations/knx/index.js'; import { KodiIntegration } from './integrations/kodi/index.js'; import { MatterIntegration } from './integrations/matter/index.js'; +import { ModbusIntegration } from './integrations/modbus/index.js'; import { MqttIntegration } from './integrations/mqtt/index.js'; import { MpdIntegration } from './integrations/mpd/index.js'; import { NanoleafIntegration } from './integrations/nanoleaf/index.js'; +import { OpenthermGwIntegration } from './integrations/opentherm_gw/index.js'; import { OnvifIntegration } from './integrations/onvif/index.js'; import { PlexIntegration } from './integrations/plex/index.js'; import { RainbirdIntegration } from './integrations/rainbird/index.js'; +import { RflinkIntegration } from './integrations/rflink/index.js'; import { RokuIntegration } from './integrations/roku/index.js'; import { SamsungtvIntegration } from './integrations/samsungtv/index.js'; import { ShellyIntegration } from './integrations/shelly/index.js'; @@ -29,6 +34,7 @@ import { SonosIntegration } from './integrations/sonos/index.js'; import { TplinkIntegration } from './integrations/tplink/index.js'; import { TradfriIntegration } from './integrations/tradfri/index.js'; import { UnifiIntegration } from './integrations/unifi/index.js'; +import { VelbusIntegration } from './integrations/velbus/index.js'; import { VolumioIntegration } from './integrations/volumio/index.js'; import { WolfSmartsetIntegration } from './integrations/wolf_smartset/index.js'; import { WizIntegration } from './integrations/wiz/index.js'; @@ -50,16 +56,21 @@ export const integrations = [ new DlnaDmrIntegration(), new EsphomeIntegration(), new HomekitControllerIntegration(), + new HomematicIntegration(), new HueIntegration(), new JellyfinIntegration(), + new KnxIntegration(), new KodiIntegration(), new MatterIntegration(), + new ModbusIntegration(), new MqttIntegration(), new MpdIntegration(), new NanoleafIntegration(), + new OpenthermGwIntegration(), new OnvifIntegration(), new PlexIntegration(), new RainbirdIntegration(), + new RflinkIntegration(), new RokuIntegration(), new SamsungtvIntegration(), new ShellyIntegration(), @@ -68,6 +79,7 @@ export const integrations = [ new TplinkIntegration(), new TradfriIntegration(), new UnifiIntegration(), + new VelbusIntegration(), new VolumioIntegration(), new WolfSmartsetIntegration(), new WizIntegration(), diff --git a/ts/integrations/generated/index.ts b/ts/integrations/generated/index.ts index cf55f2a..b248b04 100644 --- a/ts/integrations/generated/index.ts +++ b/ts/integrations/generated/index.ts @@ -514,7 +514,6 @@ import { HomeAssistantHomeassistantSkyConnectIntegration } from '../homeassistan import { HomeAssistantHomeassistantYellowIntegration } from '../homeassistant_yellow/index.js'; import { HomeAssistantHomeeIntegration } from '../homee/index.js'; import { HomeAssistantHomekitIntegration } from '../homekit/index.js'; -import { HomeAssistantHomematicIntegration } from '../homematic/index.js'; import { HomeAssistantHomematicipCloudIntegration } from '../homematicip_cloud/index.js'; import { HomeAssistantHomevoltIntegration } from '../homevolt/index.js'; import { HomeAssistantHomewizardIntegration } from '../homewizard/index.js'; @@ -628,7 +627,6 @@ import { HomeAssistantKitchenSinkIntegration } from '../kitchen_sink/index.js'; import { HomeAssistantKiwiIntegration } from '../kiwi/index.js'; import { HomeAssistantKmtronicIntegration } from '../kmtronic/index.js'; import { HomeAssistantKnockiIntegration } from '../knocki/index.js'; -import { HomeAssistantKnxIntegration } from '../knx/index.js'; import { HomeAssistantKonnectedIntegration } from '../konnected/index.js'; import { HomeAssistantKonnectedEsphomeIntegration } from '../konnected_esphome/index.js'; import { HomeAssistantKostalPlenticoreIntegration } from '../kostal_plenticore/index.js'; @@ -756,7 +754,6 @@ import { HomeAssistantMjpegIntegration } from '../mjpeg/index.js'; import { HomeAssistantMoatIntegration } from '../moat/index.js'; import { HomeAssistantMobileAppIntegration } from '../mobile_app/index.js'; import { HomeAssistantMochadIntegration } from '../mochad/index.js'; -import { HomeAssistantModbusIntegration } from '../modbus/index.js'; import { HomeAssistantModemCalleridIntegration } from '../modem_callerid/index.js'; import { HomeAssistantModernFormsIntegration } from '../modern_forms/index.js'; import { HomeAssistantMoehlenhoffAlpha2Integration } from '../moehlenhoff_alpha2/index.js'; @@ -881,7 +878,6 @@ import { HomeAssistantOpenhomeIntegration } from '../openhome/index.js'; import { HomeAssistantOpenrgbIntegration } from '../openrgb/index.js'; import { HomeAssistantOpensensemapIntegration } from '../opensensemap/index.js'; import { HomeAssistantOpenskyIntegration } from '../opensky/index.js'; -import { HomeAssistantOpenthermGwIntegration } from '../opentherm_gw/index.js'; import { HomeAssistantOpenuvIntegration } from '../openuv/index.js'; import { HomeAssistantOpenweathermapIntegration } from '../openweathermap/index.js'; import { HomeAssistantOpnsenseIntegration } from '../opnsense/index.js'; @@ -1022,7 +1018,6 @@ import { HomeAssistantRepetierIntegration } from '../repetier/index.js'; import { HomeAssistantRestIntegration } from '../rest/index.js'; import { HomeAssistantRestCommandIntegration } from '../rest_command/index.js'; import { HomeAssistantRexelIntegration } from '../rexel/index.js'; -import { HomeAssistantRflinkIntegration } from '../rflink/index.js'; import { HomeAssistantRfxtrxIntegration } from '../rfxtrx/index.js'; import { HomeAssistantRhasspyIntegration } from '../rhasspy/index.js'; import { HomeAssistantRidwellIntegration } from '../ridwell/index.js'; @@ -1319,7 +1314,6 @@ import { HomeAssistantValloxIntegration } from '../vallox/index.js'; import { HomeAssistantValveIntegration } from '../valve/index.js'; import { HomeAssistantVasttrafikIntegration } from '../vasttrafik/index.js'; import { HomeAssistantVegehubIntegration } from '../vegehub/index.js'; -import { HomeAssistantVelbusIntegration } from '../velbus/index.js'; import { HomeAssistantVeluxIntegration } from '../velux/index.js'; import { HomeAssistantVenstarIntegration } from '../venstar/index.js'; import { HomeAssistantVeraIntegration } from '../vera/index.js'; @@ -1940,7 +1934,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeassistantSkyCon generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeassistantYellowIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomekitIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomematicIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomematicipCloudIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomevoltIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomewizardIntegration()); @@ -2054,7 +2047,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantKitchenSinkIntegrat generatedHomeAssistantPortIntegrations.push(new HomeAssistantKiwiIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKmtronicIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKnockiIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantKnxIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKonnectedIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKonnectedEsphomeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKostalPlenticoreIntegration()); @@ -2182,7 +2174,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantMjpegIntegration()) generatedHomeAssistantPortIntegrations.push(new HomeAssistantMoatIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantMobileAppIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantMochadIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantModbusIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantModemCalleridIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantModernFormsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantMoehlenhoffAlpha2Integration()); @@ -2307,7 +2298,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenhomeIntegration generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenrgbIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpensensemapIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenskyIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenthermGwIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenuvIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenweathermapIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpnsenseIntegration()); @@ -2448,7 +2438,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantRepetierIntegration generatedHomeAssistantPortIntegrations.push(new HomeAssistantRestIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantRestCommandIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantRexelIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantRflinkIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantRfxtrxIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantRhasspyIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantRidwellIntegration()); @@ -2745,7 +2734,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantValloxIntegration() generatedHomeAssistantPortIntegrations.push(new HomeAssistantValveIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantVasttrafikIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantVegehubIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantVelbusIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantVeluxIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantVenstarIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantVeraIntegration()); @@ -2852,7 +2840,7 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration()); -export const generatedHomeAssistantPortCount = 1424; +export const generatedHomeAssistantPortCount = 1418; export const handwrittenHomeAssistantPortDomains = [ "androidtv", "axis", @@ -2863,16 +2851,21 @@ export const handwrittenHomeAssistantPortDomains = [ "dlna_dmr", "esphome", "homekit_controller", + "homematic", "hue", "jellyfin", + "knx", "kodi", "matter", + "modbus", "mpd", "mqtt", "nanoleaf", "onvif", + "opentherm_gw", "plex", "rainbird", + "rflink", "roku", "samsungtv", "shelly", @@ -2881,6 +2874,7 @@ export const handwrittenHomeAssistantPortDomains = [ "tplink", "tradfri", "unifi", + "velbus", "volumio", "wiz", "xiaomi_miio", diff --git a/ts/integrations/homematic/.generated-by-smarthome-exchange b/ts/integrations/homematic/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/homematic/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/homematic/homematic.classes.client.ts b/ts/integrations/homematic/homematic.classes.client.ts new file mode 100644 index 0000000..198f310 --- /dev/null +++ b/ts/integrations/homematic/homematic.classes.client.ts @@ -0,0 +1,780 @@ +import { + homematicDefaultInterfacePort, + homematicDefaultJsonPort, + type IHomematicChannel, + type IHomematicCommand, + type IHomematicCommandResult, + type IHomematicConfig, + type IHomematicCcu, + type IHomematicDatapoint, + type IHomematicDevice, + type IHomematicEvent, + type IHomematicInterface, + type IHomematicInterfaceConfig, + type IHomematicSnapshot, + type IHomematicValue, + type IHomematicXmlRpcCommandShape, + type IHomematicXmlRpcDeviceDescription, + type IHomematicXmlRpcParamsetDescription, + type THomematicDatapointKind, + type THomematicParamsetKey, + type THomematicProtocol, + type THomematicValueType, +} from './homematic.types.js'; + +const defaultTimeoutMs = 10000; +const defaultInterfaceName = 'default'; +const defaultInterfaceId = 'smarthome-exchange'; + +type THomematicEventHandler = (eventArg: IHomematicEvent) => void; + +interface IXmlNode { + name: string; + text: string; + children: IXmlNode[]; +} + +export class HomematicXmlRpcError extends Error { + constructor(messageArg: string, public readonly status?: number, public readonly fault?: unknown) { + super(messageArg); + this.name = 'HomematicXmlRpcError'; + } +} + +export class HomematicXmlRpcClient { + constructor(private readonly config: IHomematicInterfaceConfig, private readonly timeoutMs = defaultTimeoutMs) {} + + public endpoint(): string { + const protocol = this.protocol(); + const host = this.config.host || '127.0.0.1'; + const port = this.config.port || homematicDefaultInterfacePort; + const path = this.config.path ? (this.config.path.startsWith('/') ? this.config.path : `/${this.config.path}`) : ''; + return `${protocol}://${host}:${port}${path}`; + } + + public async call(methodArg: string, paramsArg: unknown[] = []): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await fetch(this.endpoint(), { + method: 'POST', + headers: { + 'content-type': 'text/xml', + accept: 'text/xml', + }, + body: this.encodeMethodCall(methodArg, paramsArg), + signal: controller.signal, + }); + const body = await response.text(); + if (!response.ok) { + throw new HomematicXmlRpcError(`Homematic XML-RPC ${methodArg} failed with HTTP ${response.status}.`, response.status, body); + } + return this.decodeMethodResponse(body); + } finally { + clearTimeout(timeout); + } + } + + private protocol(): THomematicProtocol { + return this.config.protocol || (this.config.ssl ? 'https' : 'http'); + } + + private encodeMethodCall(methodArg: string, paramsArg: unknown[]): string { + const params = paramsArg.map((paramArg) => `${this.encodeValue(paramArg)}`).join(''); + return `${escapeXml(methodArg)}${params}`; + } + + private encodeValue(valueArg: unknown): string { + if (valueArg === null || valueArg === undefined) { + return ''; + } + if (typeof valueArg === 'boolean') { + return `${valueArg ? '1' : '0'}`; + } + if (typeof valueArg === 'number') { + return Number.isInteger(valueArg) ? `${valueArg}` : `${valueArg}`; + } + if (typeof valueArg === 'string') { + return `${escapeXml(valueArg)}`; + } + if (Array.isArray(valueArg)) { + return `${valueArg.map((itemArg) => this.encodeValue(itemArg)).join('')}`; + } + if (typeof valueArg === 'object') { + const members = Object.entries(valueArg as Record) + .map(([keyArg, memberValueArg]) => `${escapeXml(keyArg)}${this.encodeValue(memberValueArg)}`) + .join(''); + return `${members}`; + } + return `${escapeXml(String(valueArg))}`; + } + + private decodeMethodResponse(xmlArg: string): unknown { + const root = parseXml(xmlArg); + const response = firstChild(root, 'methodResponse') || root; + const faultValue = firstChild(firstChild(response, 'fault'), 'value'); + if (faultValue) { + const fault = this.decodeValue(faultValue); + const message = typeof fault === 'object' && fault && 'faultString' in fault ? String((fault as { faultString?: unknown }).faultString) : 'Homematic XML-RPC fault.'; + throw new HomematicXmlRpcError(message, undefined, fault); + } + const valueNode = firstChild(firstChild(firstChild(response, 'params'), 'param'), 'value'); + return valueNode ? this.decodeValue(valueNode) : undefined; + } + + private decodeValue(valueNodeArg: IXmlNode): unknown { + const typedNode = valueNodeArg.children.find((childArg) => childArg.name !== '#text'); + if (!typedNode) { + return decodeXml(valueNodeArg.text.trim()); + } + if (typedNode.name === 'value') { + return this.decodeValue(typedNode); + } + const text = decodeXml(typedNode.text.trim()); + if (typedNode.name === 'int' || typedNode.name === 'i4' || typedNode.name === 'i8') { + return Number.parseInt(text, 10); + } + if (typedNode.name === 'double') { + return Number.parseFloat(text); + } + if (typedNode.name === 'boolean') { + return text === '1' || text.toLowerCase() === 'true'; + } + if (typedNode.name === 'string' || typedNode.name === 'dateTime.iso8601' || typedNode.name === 'base64') { + return text; + } + if (typedNode.name === 'nil') { + return null; + } + if (typedNode.name === 'array') { + const dataNode = firstChild(typedNode, 'data'); + return (dataNode?.children || []).filter((childArg) => childArg.name === 'value').map((childArg) => this.decodeValue(childArg)); + } + if (typedNode.name === 'struct') { + const result: Record = {}; + for (const member of typedNode.children.filter((childArg) => childArg.name === 'member')) { + const name = decodeXml(firstChild(member, 'name')?.text.trim() || ''); + const value = firstChild(member, 'value'); + if (name && value) { + result[name] = this.decodeValue(value); + } + } + return result; + } + return text; + } +} + +export class HomematicClient { + private snapshot?: IHomematicSnapshot; + private readonly events: IHomematicEvent[] = []; + private readonly eventHandlers = new Set(); + + constructor(private readonly config: IHomematicConfig) { + this.snapshot = config.snapshot ? this.normalizeSnapshot(clone(config.snapshot), 'snapshot') : undefined; + } + + public async getSnapshot(): Promise { + if (this.config.snapshot) { + this.snapshot = this.normalizeSnapshot(clone(this.config.snapshot), 'snapshot'); + return this.snapshot; + } + if (this.hasLiveEndpoint()) { + try { + this.snapshot = this.normalizeSnapshot(await this.fetchSnapshot(), 'xmlrpc'); + return this.snapshot; + } catch (errorArg) { + this.emit({ type: 'error', data: errorArg instanceof Error ? errorArg.message : String(errorArg), timestamp: Date.now() }); + this.snapshot = this.normalizeSnapshot(this.snapshotFromConfig(false), 'runtime'); + return this.snapshot; + } + } + this.snapshot = this.normalizeSnapshot(this.snapshotFromConfig(this.config.connected ?? true), this.hasManualSnapshotData() ? 'manual' : 'runtime'); + return this.snapshot; + } + + public onEvent(handlerArg: THomematicEventHandler): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async sendCommand(commandArg: IHomematicCommand): Promise { + const shape = commandArg.type === 'refresh' ? undefined : this.xmlRpcCommandShape(commandArg); + this.emit({ type: 'command_mapped', command: commandArg, data: shape, entityId: commandArg.type === 'refresh' ? undefined : commandArg.entityId, deviceId: commandArg.type === 'refresh' ? undefined : commandArg.deviceId, timestamp: Date.now() }); + + try { + if (this.config.commandExecutor) { + const result = this.commandResult(await this.config.commandExecutor(commandArg), commandArg, shape); + if (result.success) { + await this.patchSnapshot(commandArg); + } + this.emit({ type: result.success ? 'command_executed' : 'command_failed', command: commandArg, data: result, timestamp: Date.now() }); + return result; + } + + if (commandArg.type === 'refresh') { + this.snapshot = undefined; + const snapshot = await this.getSnapshot(); + this.emit({ type: 'snapshot_refreshed', data: snapshot, timestamp: Date.now() }); + return { success: true, data: snapshot }; + } + + let xmlRpcResult: unknown; + if (shape && this.hasLiveEndpoint(commandArg.interfaceName)) { + xmlRpcResult = await this.xmlRpcClient(commandArg.interfaceName).call(shape.xmlRpcMethod, shape.params); + } + await this.patchSnapshot(commandArg); + const result = { success: true, data: { shape, xmlRpcResult } }; + this.emit({ type: 'command_executed', command: commandArg, data: result, timestamp: Date.now() }); + return result; + } catch (errorArg) { + const result = { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg), data: { command: commandArg, shape } }; + this.emit({ type: 'command_failed', command: commandArg, data: result, timestamp: Date.now() }); + return result; + } + } + + public xmlRpcCommandShape(commandArg: Exclude): IHomematicXmlRpcCommandShape { + const interfaceConfig = this.interfaceConfig(commandArg.interfaceName); + const endpoint = new HomematicXmlRpcClient(interfaceConfig, this.config.timeoutMs || defaultTimeoutMs).endpoint(); + const params = this.xmlRpcParams(commandArg); + return { + protocol: 'xmlrpc', + endpoint, + method: 'POST', + contentType: 'text/xml', + xmlRpcMethod: commandArg.type === 'raw_xmlrpc' ? commandArg.method : 'setValue', + params, + interfaceName: commandArg.interfaceName || interfaceConfig.name || defaultInterfaceName, + host: interfaceConfig.host, + port: interfaceConfig.port, + path: interfaceConfig.path, + }; + } + + public async destroy(): Promise { + this.eventHandlers.clear(); + } + + private xmlRpcParams(commandArg: Exclude): unknown[] { + if (commandArg.type === 'raw_xmlrpc') { + return commandArg.params || []; + } + return [this.channelAddress(commandArg.address, commandArg.channel), commandArg.datapoint, this.commandValue(commandArg)]; + } + + private commandValue(commandArg: Exclude): IHomematicValue { + if (commandArg.type === 'press') { + return commandArg.value ?? true; + } + return commandArg.value; + } + + private async fetchSnapshot(): Promise { + const updatedAt = new Date().toISOString(); + const interfaces = this.configInterfaces().filter((interfaceArg) => interfaceArg.connect !== false); + const allDevices: IHomematicDevice[] = []; + const raw: Record = {}; + + for (const interfaceConfig of interfaces) { + const interfaceName = interfaceConfig.name || interfaceConfig.id || defaultInterfaceName; + const client = new HomematicXmlRpcClient(interfaceConfig, this.config.timeoutMs || defaultTimeoutMs); + const interfaceId = `${this.config.interfaceId || defaultInterfaceId}-${interfaceName}`; + const descriptions = await this.listDevices(client, interfaceId); + const channelDescriptions = descriptions.filter((descriptionArg) => Boolean(descriptionArg.ADDRESS && descriptionArg.PARENT)); + const paramsets: Record> = {}; + const paramsetDescriptions: Record> = {}; + + for (const description of channelDescriptions) { + const address = description.ADDRESS; + if (!address || !description.PARAMSETS?.includes('VALUES')) { + continue; + } + const valueSet = await optionalRecord(() => client.call('getParamset', [address, 'VALUES'])); + const descriptionSet = await optionalRecord(() => client.call('getParamsetDescription', [address, 'VALUES'])); + paramsets[address] = valueSet as Record; + paramsetDescriptions[address] = descriptionSet as Record; + } + + allDevices.push(...this.devicesFromDescriptions(interfaceName, descriptions, paramsets, paramsetDescriptions)); + raw[interfaceName] = { descriptions, paramsets, paramsetDescriptions }; + } + + return { + ccu: this.ccuInfo(true), + interfaces: interfaces.map((interfaceArg) => this.interfaceInfo(interfaceArg, true)), + devices: allDevices, + events: [...(this.config.events || []), ...this.events], + connected: true, + updatedAt, + source: 'xmlrpc', + raw, + }; + } + + private async listDevices(clientArg: HomematicXmlRpcClient, interfaceIdArg: string): Promise { + const noArgResult = await optionalArray(() => clientArg.call('listDevices', [])); + if (noArgResult.length) { + return noArgResult as IHomematicXmlRpcDeviceDescription[]; + } + const interfaceResult = await optionalArray(() => clientArg.call('listDevices', [interfaceIdArg])); + return interfaceResult as IHomematicXmlRpcDeviceDescription[]; + } + + private devicesFromDescriptions(interfaceNameArg: string, descriptionsArg: IHomematicXmlRpcDeviceDescription[], paramsetsArg: Record>, paramsetDescriptionsArg: Record>): IHomematicDevice[] { + const parents = descriptionsArg.filter((descriptionArg) => descriptionArg.ADDRESS && !descriptionArg.PARENT); + const channels = descriptionsArg.filter((descriptionArg) => descriptionArg.ADDRESS && descriptionArg.PARENT); + const devices = new Map(); + + for (const parent of parents) { + const address = parent.ADDRESS || 'unknown'; + devices.set(address, { + address, + name: stringValue(parent.NAME) || address, + type: parent.TYPE, + interfaceName: interfaceNameArg, + manufacturer: 'eQ-3', + model: parent.TYPE, + firmwareVersion: parent.FIRMWARE || parent.AVAILABLE_FIRMWARE, + available: true, + channels: [], + paramsets: (parent.PARAMSETS || []) as THomematicParamsetKey[], + raw: parent, + }); + } + + for (const channel of channels) { + const address = channel.ADDRESS || 'unknown:0'; + const parentAddress = channel.PARENT || address.split(':')[0]; + const parent = devices.get(parentAddress) || { + address: parentAddress, + name: parentAddress, + type: channel.PARENT_TYPE, + interfaceName: interfaceNameArg, + manufacturer: 'eQ-3', + model: channel.PARENT_TYPE, + available: true, + channels: [], + }; + const index = numberValue(channel.INDEX) ?? channelIndex(address); + const datapoints = this.datapointsFromParamset(address, index, paramsetsArg[address] || {}, paramsetDescriptionsArg[address] || {}); + parent.channels.push({ + address, + index, + name: stringValue(channel.NAME) || `${parent.name || parentAddress} ${index}`, + type: channel.TYPE || channel.PARENT_TYPE, + parentAddress, + datapoints, + paramsets: (channel.PARAMSETS || []) as THomematicParamsetKey[], + raw: channel, + }); + parent.available = !datapoints.some((datapointArg) => datapointArg.name === 'UNREACH' && Boolean(datapointArg.value)); + devices.set(parentAddress, parent); + } + + return [...devices.values()]; + } + + private datapointsFromParamset(addressArg: string, channelArg: number, valuesArg: Record, descriptionsArg: Record): IHomematicDatapoint[] { + const keys = new Set([...Object.keys(valuesArg), ...Object.keys(descriptionsArg)]); + return [...keys].sort().map((keyArg) => { + const description = descriptionsArg[keyArg] || {}; + const operations = numberValue(description.OPERATIONS); + const value = valuesArg[keyArg] ?? null; + const valueType = this.valueType(description.TYPE, value); + return { + name: keyArg, + value, + valueType, + channel: channelArg, + address: addressArg, + kind: this.datapointKind(keyArg, valueType, operations), + paramsetKey: 'VALUES', + operations, + readable: operations === undefined ? true : Boolean(operations & 1), + writable: operations === undefined ? undefined : Boolean(operations & 2), + eventable: operations === undefined ? undefined : Boolean(operations & 4), + unit: stringValue(description.UNIT), + min: numberValue(description.MIN), + max: numberValue(description.MAX), + valueList: Array.isArray(description.VALUE_LIST) ? description.VALUE_LIST.map(String) : undefined, + attributes: cleanRecord(description), + }; + }); + } + + private snapshotFromConfig(connectedArg: boolean): IHomematicSnapshot { + const updatedAt = new Date().toISOString(); + const interfaces = this.configInterfaces().map((interfaceArg) => this.interfaceInfo(interfaceArg, connectedArg && interfaceArg.connect !== false)); + return { + ccu: this.ccuInfo(connectedArg), + interfaces, + devices: clone(this.config.devices || this.config.snapshot?.devices || []), + events: [...(this.config.events || []), ...this.events], + connected: connectedArg, + updatedAt, + source: this.hasManualSnapshotData() ? 'manual' : 'runtime', + }; + } + + private normalizeSnapshot(snapshotArg: IHomematicSnapshot, sourceArg: IHomematicSnapshot['source']): IHomematicSnapshot { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + const ccu = { + manufacturer: 'eQ-3', + model: 'Homematic CCU', + name: this.config.name || 'Homematic CCU', + ...snapshotArg.ccu, + }; + ccu.id = ccu.id || this.config.uniqueId || ccu.serialNumber || ccu.host || this.config.host || 'homematic-ccu'; + ccu.host = ccu.host || this.config.host; + ccu.port = ccu.port || this.config.port || homematicDefaultInterfacePort; + ccu.online = snapshotArg.connected && ccu.online !== false; + const interfaces = snapshotArg.interfaces.length ? snapshotArg.interfaces : this.configInterfaces().map((interfaceArg) => this.interfaceInfo(interfaceArg, snapshotArg.connected)); + const devices = snapshotArg.devices.map((deviceArg) => this.normalizeDevice(deviceArg, updatedAt)); + return { + ...snapshotArg, + ccu, + interfaces, + devices, + events: [...(snapshotArg.events || []), ...this.events], + connected: snapshotArg.connected, + updatedAt, + source: sourceArg, + }; + } + + private normalizeDevice(deviceArg: IHomematicDevice, updatedAtArg: string): IHomematicDevice { + const address = deviceArg.address; + const channels: IHomematicChannel[] = (deviceArg.channels || []).map((channelArg) => { + const index = numberValue(channelArg.index) ?? channelIndex(channelArg.address); + const channelAddress = channelArg.address || this.channelAddress(address, index); + return { + ...channelArg, + address: channelAddress, + index, + parentAddress: channelArg.parentAddress || address, + name: channelArg.name || `${deviceArg.name || address} ${index}`, + type: channelArg.type || deviceArg.type, + datapoints: (channelArg.datapoints || []).map((datapointArg) => this.normalizeDatapoint(datapointArg, channelAddress, index, updatedAtArg)), + }; + }); + + if (deviceArg.datapoints?.length) { + const byIndex = new Map(); + for (const channel of channels) { + byIndex.set(channel.index, channel); + } + for (const datapoint of deviceArg.datapoints) { + const index = datapoint.channel ?? channelIndex(datapoint.address || '') ?? 0; + const channel = byIndex.get(index) || { + address: this.channelAddress(address, index), + index, + parentAddress: address, + name: `${deviceArg.name || address} ${index}`, + type: deviceArg.type, + datapoints: [], + }; + channel.datapoints.push(this.normalizeDatapoint(datapoint, channel.address, index, updatedAtArg)); + byIndex.set(index, channel); + } + channels.splice(0, channels.length, ...[...byIndex.values()].sort((leftArg, rightArg) => leftArg.index - rightArg.index)); + } + + return { + ...deviceArg, + name: deviceArg.name || address, + manufacturer: deviceArg.manufacturer || 'eQ-3', + model: deviceArg.model || deviceArg.type, + available: deviceArg.available ?? !deviceArg.unreach, + channels, + }; + } + + private normalizeDatapoint(datapointArg: IHomematicDatapoint, addressArg: string, channelArg: number, updatedAtArg: string): IHomematicDatapoint { + const valueType = datapointArg.valueType || this.valueType(undefined, datapointArg.value); + return { + ...datapointArg, + name: datapointArg.name.toUpperCase(), + value: datapointArg.value ?? null, + valueType, + channel: datapointArg.channel ?? channelArg, + address: datapointArg.address || addressArg, + kind: datapointArg.kind || this.datapointKind(datapointArg.name, valueType, datapointArg.operations), + readable: datapointArg.readable ?? true, + updatedAt: datapointArg.updatedAt || updatedAtArg, + }; + } + + private async patchSnapshot(commandArg: IHomematicCommand): Promise { + if (commandArg.type === 'refresh' || commandArg.type === 'raw_xmlrpc') { + return; + } + const snapshot = this.snapshot || await this.getSnapshot(); + if (commandArg.type === 'press') { + this.emit({ type: 'keypress', command: commandArg, address: commandArg.address, channel: commandArg.channel, datapoint: commandArg.datapoint, value: commandArg.value ?? true, timestamp: Date.now() }); + return; + } + const targetAddress = this.channelAddress(commandArg.address, commandArg.channel); + for (const device of snapshot.devices) { + for (const channel of device.channels) { + if (channel.address !== targetAddress && !(device.address === commandArg.address && channel.index === commandArg.channel)) { + continue; + } + const datapoint = channel.datapoints.find((datapointArg) => datapointArg.name === commandArg.datapoint); + if (datapoint) { + datapoint.value = commandArg.value; + datapoint.updatedAt = new Date().toISOString(); + this.emit({ type: 'datapoint_changed', command: commandArg, address: device.address, channel: channel.index, datapoint: datapoint.name, value: datapoint.value, timestamp: Date.now() }); + return; + } + } + } + } + + private ccuInfo(onlineArg: boolean): IHomematicCcu { + return { + id: this.config.ccu?.id || this.config.uniqueId || this.config.host || 'homematic-ccu', + name: this.config.ccu?.name || this.config.name || 'Homematic CCU', + host: this.config.ccu?.host || this.config.host, + port: this.config.ccu?.port || this.config.port || homematicDefaultInterfacePort, + manufacturer: this.config.ccu?.manufacturer || 'eQ-3', + model: this.config.ccu?.model || 'Homematic CCU', + serialNumber: this.config.ccu?.serialNumber, + firmwareVersion: this.config.ccu?.firmwareVersion, + online: onlineArg, + serviceMessages: this.config.ccu?.serviceMessages || [], + variables: this.config.ccu?.variables || {}, + attributes: this.config.ccu?.attributes, + }; + } + + private configInterfaces(): IHomematicInterfaceConfig[] { + if (this.config.interfaces && Object.keys(this.config.interfaces).length) { + return Object.entries(this.config.interfaces).map(([nameArg, interfaceArg]) => ({ name: interfaceArg.name || nameArg, ...interfaceArg })); + } + return [{ + name: this.config.interfaceName || defaultInterfaceName, + host: this.config.host, + port: this.config.port || homematicDefaultInterfacePort, + path: this.config.path, + protocol: this.config.ssl ? 'https' : 'http', + ssl: this.config.ssl, + verifySsl: this.config.verifySsl, + username: this.config.username, + password: this.config.password, + jsonPort: this.config.jsonPort || homematicDefaultJsonPort, + resolvenames: this.config.resolvenames, + connect: true, + callbackIp: this.config.callbackIp, + callbackPort: this.config.callbackPort, + }]; + } + + private interfaceInfo(interfaceArg: IHomematicInterfaceConfig, onlineArg: boolean): IHomematicInterface { + const name = interfaceArg.name || interfaceArg.id || defaultInterfaceName; + return { + id: interfaceArg.id || name, + name, + host: interfaceArg.host, + port: interfaceArg.port || homematicDefaultInterfacePort, + path: interfaceArg.path, + protocol: interfaceArg.protocol || (interfaceArg.ssl ? 'https' : 'http'), + family: interfaceArg.family || this.interfaceFamily(interfaceArg.port), + online: onlineArg, + attributes: interfaceArg.attributes, + }; + } + + private interfaceConfig(interfaceNameArg?: string): IHomematicInterfaceConfig { + const interfaces = this.configInterfaces(); + const found = interfaces.find((interfaceArg) => interfaceArg.name === interfaceNameArg || interfaceArg.id === interfaceNameArg) || interfaces[0]; + return { + ...found, + name: found.name || found.id || interfaceNameArg || defaultInterfaceName, + port: found.port || homematicDefaultInterfacePort, + jsonPort: found.jsonPort || homematicDefaultJsonPort, + }; + } + + private xmlRpcClient(interfaceNameArg?: string): HomematicXmlRpcClient { + return new HomematicXmlRpcClient(this.interfaceConfig(interfaceNameArg), this.config.timeoutMs || defaultTimeoutMs); + } + + private hasLiveEndpoint(interfaceNameArg?: string): boolean { + const config = this.interfaceConfig(interfaceNameArg); + return Boolean(config.host && config.connect !== false); + } + + private hasManualSnapshotData(): boolean { + return Boolean(this.config.ccu || this.config.devices?.length || this.config.interfaces && Object.keys(this.config.interfaces).length); + } + + private valueType(typeArg: unknown, valueArg: unknown): THomematicValueType { + const type = typeof typeArg === 'string' ? typeArg.toUpperCase() : ''; + if (['BOOL', 'BOOLEAN'].includes(type)) { + return 'boolean'; + } + if (['INTEGER', 'ENUM'].includes(type)) { + return 'integer'; + } + if (['FLOAT', 'DOUBLE'].includes(type)) { + return 'double'; + } + if (type === 'STRING') { + return 'string'; + } + if (typeof valueArg === 'boolean') { + return 'boolean'; + } + if (typeof valueArg === 'number') { + return Number.isInteger(valueArg) ? 'integer' : 'double'; + } + if (typeof valueArg === 'string') { + return 'string'; + } + if (Array.isArray(valueArg)) { + return 'array'; + } + if (valueArg && typeof valueArg === 'object') { + return 'struct'; + } + return 'unknown'; + } + + private datapointKind(nameArg: string, typeArg: THomematicValueType, operationsArg?: number): THomematicDatapointKind { + const name = nameArg.toUpperCase(); + if (name.startsWith('PRESS_') || name === 'PRESS' || name === 'STOP' || name.endsWith('_MODE')) { + return 'action'; + } + if (operationsArg !== undefined && Boolean(operationsArg & 2)) { + return 'write'; + } + if (operationsArg !== undefined && Boolean(operationsArg & 4) && !Boolean(operationsArg & 1)) { + return 'event'; + } + if (['LOWBAT', 'LOW_BAT', 'UNREACH', 'MOTION', 'STATE', 'RAINING', 'DUTY_CYCLE'].includes(name) || typeArg === 'boolean') { + return 'binary'; + } + if (['RSSI_PEER', 'RSSI_DEVICE', 'ERROR', 'SABOTAGE', 'CONTROL_MODE', 'BATTERY_STATE'].includes(name)) { + return 'attribute'; + } + return 'sensor'; + } + + private interfaceFamily(portArg?: number): IHomematicInterface['family'] { + if (portArg === 2010 || portArg === 32010 || portArg === 42010) { + return 'hmip-rf'; + } + if (portArg === 2000) { + return 'bidcos-wired'; + } + if (portArg === 9292) { + return 'homegear'; + } + return portArg ? 'bidcos-rf' : 'unknown'; + } + + private channelAddress(addressArg: string, channelArg?: number): string { + if (addressArg.includes(':') || channelArg === undefined) { + return addressArg; + } + return `${addressArg}:${channelArg}`; + } + + private commandResult(resultArg: IHomematicCommandResult | unknown, commandArg: IHomematicCommand, shapeArg?: IHomematicXmlRpcCommandShape): IHomematicCommandResult { + if (resultArg && typeof resultArg === 'object' && 'success' in resultArg && typeof (resultArg as { success?: unknown }).success === 'boolean') { + return resultArg as IHomematicCommandResult; + } + return { success: true, data: { result: resultArg, command: commandArg, shape: shapeArg } }; + } + + private emit(eventArg: IHomematicEvent): void { + this.events.push(eventArg); + for (const handler of this.eventHandlers) { + handler(eventArg); + } + } +} + +const optionalArray = async (factoryArg: () => Promise): Promise => { + try { + const value = await factoryArg(); + return Array.isArray(value) ? value : []; + } catch { + return []; + } +}; + +const optionalRecord = async (factoryArg: () => Promise): Promise> => { + try { + const value = await factoryArg(); + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; + } catch { + return {}; + } +}; + +const channelIndex = (addressArg: string): number => { + const part = addressArg.split(':')[1]; + return part && Number.isFinite(Number(part)) ? Number(part) : 0; +}; + +const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; +const numberValue = (valueArg: unknown): number | undefined => typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg)) ? Number(valueArg) : undefined; + +const cleanRecord = (recordArg: Record): Record => Object.fromEntries(Object.entries(recordArg).filter(([, valueArg]) => valueArg !== undefined)); + +const clone = (valueArg: TValue): TValue => JSON.parse(JSON.stringify(valueArg)) as TValue; + +const parseXml = (xmlArg: string): IXmlNode => { + const root: IXmlNode = { name: 'root', text: '', children: [] }; + const stack: IXmlNode[] = [root]; + const tagRegex = /<([^>]+)>/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = tagRegex.exec(xmlArg))) { + const text = xmlArg.slice(lastIndex, match.index); + if (text) { + stack[stack.length - 1].text += text; + } + const tag = match[1].trim(); + lastIndex = tagRegex.lastIndex; + if (!tag || tag.startsWith('?') || tag.startsWith('!')) { + continue; + } + if (tag.startsWith('/')) { + if (stack.length > 1) { + stack.pop(); + } + continue; + } + const selfClosing = tag.endsWith('/'); + const name = tag.replace(/\/$/, '').split(/\s+/)[0]; + const node: IXmlNode = { name, text: '', children: [] }; + stack[stack.length - 1].children.push(node); + if (!selfClosing) { + stack.push(node); + } + } + const rest = xmlArg.slice(lastIndex); + if (rest) { + stack[stack.length - 1].text += rest; + } + return root; +}; + +const firstChild = (nodeArg: IXmlNode | undefined, nameArg: string): IXmlNode | undefined => nodeArg?.children.find((childArg) => childArg.name === nameArg); + +const escapeXml = (valueArg: string): string => valueArg + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const decodeXml = (valueArg: string): string => valueArg + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); diff --git a/ts/integrations/homematic/homematic.classes.configflow.ts b/ts/integrations/homematic/homematic.classes.configflow.ts new file mode 100644 index 0000000..f018728 --- /dev/null +++ b/ts/integrations/homematic/homematic.classes.configflow.ts @@ -0,0 +1,78 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import { homematicDefaultInterfacePort, homematicDefaultJsonPort, type IHomematicConfig } from './homematic.types.js'; + +export class HomematicConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect Homematic CCU', + description: 'Configure the local Homematic XML-RPC interface. Live callback event server registration is not part of this runtime.', + fields: [ + { name: 'host', label: 'Host', type: 'text', required: true }, + { name: 'port', label: 'XML-RPC port', type: 'number', required: false }, + { name: 'interfaceName', label: 'Interface name', type: 'text', required: false }, + { name: 'username', label: 'Username', type: 'text', required: false }, + { name: 'password', label: 'Password', type: 'password', required: false }, + { name: 'ssl', label: 'Use HTTPS', type: 'boolean', required: false }, + ], + submit: async (valuesArg) => { + const host = this.stringValue(valuesArg.host) || candidateArg.host || ''; + const port = this.numberValue(valuesArg.port) || candidateArg.port || homematicDefaultInterfacePort; + const interfaceName = this.stringValue(valuesArg.interfaceName) || this.stringValue(candidateArg.metadata?.interfaceName) || 'default'; + const ssl = this.booleanValue(valuesArg.ssl) ?? this.booleanValue(candidateArg.metadata?.ssl) ?? false; + return { + kind: 'done', + title: 'Homematic CCU configured', + config: { + host, + port, + ssl, + username: this.stringValue(valuesArg.username), + password: this.stringValue(valuesArg.password), + interfaceName, + uniqueId: candidateArg.id || candidateArg.serialNumber || candidateArg.macAddress || host, + name: candidateArg.name || 'Homematic CCU', + jsonPort: homematicDefaultJsonPort, + interfaces: { + [interfaceName]: { + name: interfaceName, + host, + port, + ssl, + username: this.stringValue(valuesArg.username), + password: this.stringValue(valuesArg.password), + jsonPort: homematicDefaultJsonPort, + connect: true, + }, + }, + }, + }; + }, + }; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg)) ? Number(valueArg) : undefined; + } + + private booleanValue(valueArg: unknown): boolean | undefined { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'string') { + const normalized = valueArg.trim().toLowerCase(); + if (['true', '1', 'yes', 'on'].includes(normalized)) { + return true; + } + if (['false', '0', 'no', 'off'].includes(normalized)) { + return false; + } + } + return undefined; + } +} diff --git a/ts/integrations/homematic/homematic.classes.integration.ts b/ts/integrations/homematic/homematic.classes.integration.ts index 07491cb..857f142 100644 --- a/ts/integrations/homematic/homematic.classes.integration.ts +++ b/ts/integrations/homematic/homematic.classes.integration.ts @@ -1,26 +1,99 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { HomematicClient } from './homematic.classes.client.js'; +import { HomematicConfigFlow } from './homematic.classes.configflow.js'; +import { createHomematicDiscoveryDescriptor } from './homematic.discovery.js'; +import { HomematicMapper } from './homematic.mapper.js'; +import type { IHomematicConfig } from './homematic.types.js'; -export class HomeAssistantHomematicIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "homematic", - displayName: "Homematic", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/homematic", - "upstreamDomain": "homematic", - "iotClass": "local_push", - "qualityScale": "legacy", - "requirements": [ - "pyhomematic==0.1.77" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@pvizeli" - ] -}, - }); +export class HomematicIntegration extends BaseIntegration { + public readonly domain = 'homematic'; + public readonly displayName = 'Homematic'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createHomematicDiscoveryDescriptor(); + public readonly configFlow = new HomematicConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/homematic', + upstreamDomain: 'homematic', + integrationType: 'hub', + iotClass: 'local_push', + qualityScale: 'legacy', + requirements: ['pyhomematic==0.1.77'], + dependencies: [], + afterDependencies: [], + codeowners: ['@pvizeli'], + configFlow: true, + documentation: 'https://www.home-assistant.io/integrations/homematic', + discovery: { + manual: true, + metadata: true, + dhcp: false, + mdns: false, + note: 'Home Assistant Homematic is YAML/manual XML-RPC configuration; this runtime provides manual and metadata discovery only.', + }, + runtime: { + type: 'control-runtime', + polling: 'snapshot/manual or XML-RPC snapshot when a host is configured', + services: ['set_value', 'press', 'turn_on', 'turn_off', 'open', 'close', 'stop', 'set_temperature', 'raw_xmlrpc', 'refresh'], + eventServer: 'unsupported; subscribe emits local command/runtime events only', + }, + localApi: { + implemented: [ + 'manual/snapshot CCU interface/device/channel/datapoint state', + 'XML-RPC methodCall encoding for setValue and raw calls', + 'XML-RPC listDevices/getParamset/getParamsetDescription snapshot best effort', + 'Home Assistant-style set_device_value/virtualkey service aliases', + ], + explicitUnsupported: [ + 'live XML-RPC callback/event server registration via init()', + 'BidCos BIN-RPC binary transport', + 'CCU JSON-RPC authentication/session flows for variables and names', + 'automatic zeroconf/dhcp discovery', + ], + }, + }; + + public async setup(configArg: IHomematicConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new HomematicRuntime(new HomematicClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantHomematicIntegration extends HomematicIntegration {} + +class HomematicRuntime implements IIntegrationRuntime { + public domain = 'homematic'; + + constructor(private readonly client: HomematicClient) {} + + public async devices(): Promise { + return HomematicMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return HomematicMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => handlerArg(HomematicMapper.toIntegrationEvent(eventArg))); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + const snapshot = await this.client.getSnapshot(); + const command = HomematicMapper.commandForService(snapshot, requestArg); + if (!command) { + return { success: false, error: `Unsupported Homematic service: ${requestArg.domain}.${requestArg.service}` }; + } + const result = await this.client.sendCommand(command); + return { success: result.success, error: result.error, data: result.data }; + } + + public async destroy(): Promise { + await this.client.destroy(); } } diff --git a/ts/integrations/homematic/homematic.constants.ts b/ts/integrations/homematic/homematic.constants.ts new file mode 100644 index 0000000..93c0a6f --- /dev/null +++ b/ts/integrations/homematic/homematic.constants.ts @@ -0,0 +1,160 @@ +export const homematicDomain = 'homematic'; +export const homematicManufacturer = 'eQ-3'; + +export const homematicPressEvents = ['PRESS_SHORT', 'PRESS_LONG', 'PRESS_CONT', 'PRESS_LONG_RELEASE', 'PRESS'] as const; +export const homematicImpulseEvents = ['SEQUENCE_OK'] as const; + +export const homematicSwitchDeviceTypes = [ + 'Switch', 'SwitchPowermeter', 'IOSwitch', 'IOSwitchNoInhibit', 'IPSwitch', 'IPSwitchRssiDevice', 'RFSiren', + 'IPSwitchPowermeter', 'HMWIOSwitch', 'Rain', 'EcoLogic', 'IPKeySwitchPowermeter', 'IPGarage', 'IPKeySwitch', + 'IPKeySwitchLevel', 'IPMultiIO', 'IPWSwitch', 'IOSwitchWireless', 'IPWIODevice', 'IPSwitchBattery', + 'IPMultiIOPCB', 'IPGarageSwitch', 'IPWHS2', +]; + +export const homematicLightDeviceTypes = [ + 'Dimmer', 'KeyDimmer', 'IPKeyDimmer', 'IPDimmer', 'ColorEffectLight', 'IPKeySwitchLevel', 'ColdWarmDimmer', 'IPWDimmer', +]; + +export const homematicSensorDeviceTypes = [ + 'SwitchPowermeter', 'Motion', 'MotionV2', 'MotionIPV2', 'MotionIPContactSabotage', 'RemoteMotion', 'MotionIP', + 'ThermostatWall', 'AreaThermostat', 'RotaryHandleSensor', 'WaterSensor', 'PowermeterGas', 'LuxSensor', + 'WeatherSensor', 'WeatherStation', 'ThermostatWall2', 'TemperatureDiffSensor', 'TemperatureSensor', 'CO2Sensor', + 'IPSwitchPowermeter', 'HMWIOSwitch', 'FillingLevel', 'ValveDrive', 'EcoLogic', 'IPThermostatWall', 'IPSmoke', + 'RFSiren', 'PresenceIP', 'IPAreaThermostat', 'IPWeatherSensor', 'RotaryHandleSensorIP', 'IPPassageSensor', + 'IPKeySwitchPowermeter', 'IPThermostatWall230V', 'IPWeatherSensorPlus', 'IPWeatherSensorBasic', 'IPBrightnessSensor', + 'IPGarage', 'UniversalSensor', 'IPMultiIO', 'IPThermostatWall2', 'IPRemoteMotionV2', 'HBUNISenWEA', 'PresenceIPW', + 'IPRainSensor', 'ValveBox', 'IPKeyBlind', 'IPKeyBlindTilt', 'IPLanRouter', 'TempModuleSTE2', 'IPMultiIOPCB', + 'ValveBoxW', 'CO2SensorIP', 'IPLockDLD', 'ParticulateMatterSensorIP', 'IPRemoteMotionV2W', +]; + +export const homematicBinarySensorDeviceTypes = [ + 'ShutterContact', 'Smoke', 'SmokeV2', 'SmokeV2Team', 'Motion', 'MotionV2', 'MotionIP', 'MotionIPV2', + 'MotionIPContactSabotage', 'RemoteMotion', 'WeatherSensor', 'TiltSensor', 'IPShutterContact', 'HMWIOSwitch', + 'MaxShutterContact', 'Rain', 'WiredSensor', 'PresenceIP', 'PresenceIPW', 'IPWeatherSensor', 'IPPassageSensor', + 'SmartwareMotion', 'IPWeatherSensorPlus', 'WaterIP', 'IPMultiIO', 'TiltIP', 'IPShutterContactSabotage', 'IPContact', + 'IPRemoteMotionV2', 'IPWInputDevice', 'IPWMotionDection', 'IPAlarmSensor', 'IPRainSensor', 'IPLanRouter', + 'IPMultiIOPCB', 'IPWHS2', 'IPRemoteMotionV2W', +]; + +export const homematicCoverDeviceTypes = ['Blind', 'KeyBlind', 'IPKeyBlind', 'IPKeyBlindTilt', 'IPGarage', 'IPKeyBlindMulti', 'IPWKeyBlindMulti']; + +export const homematicClimateDeviceTypes = [ + 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2', 'MAXWallThermostat', 'IPThermostat', + 'IPThermostatWall', 'ThermostatGroup', 'IPThermostatWall230V', 'IPThermostatWall2', 'IPWThermostatWall', +]; + +export const homematicLockDeviceTypes = ['KeyMatic']; + +export const homematicIgnoredSensorDatapoints = ['ACTUAL_TEMPERATURE', 'ACTUAL_HUMIDITY']; + +export const homematicIgnoredSensorDatapointExceptions: Record = { + ACTUAL_TEMPERATURE: [ + 'IPAreaThermostat', 'IPWeatherSensor', 'IPWeatherSensorPlus', 'IPWeatherSensorBasic', 'IPThermostatWall', + 'IPThermostatWall2', 'ParticulateMatterSensorIP', 'CO2SensorIP', 'TempModuleSTE2', + ], +}; + +export const homematicAttributeSupport: Record }> = { + LOWBAT: { attribute: 'battery', values: { '0': 'High', '1': 'Low' } }, + LOW_BAT: { attribute: 'battery', values: { '0': 'High', '1': 'Low' } }, + ERROR: { attribute: 'error', values: { '0': 'No' } }, + ERROR_SABOTAGE: { attribute: 'sabotage', values: { '0': 'No', '1': 'Yes' } }, + SABOTAGE: { attribute: 'sabotage', values: { '0': 'No', '1': 'Yes' } }, + RSSI_PEER: { attribute: 'rssi_peer' }, + RSSI_DEVICE: { attribute: 'rssi_device' }, + VALVE_STATE: { attribute: 'valve' }, + LEVEL: { attribute: 'level' }, + BATTERY_STATE: { attribute: 'battery' }, + CONTROL_MODE: { attribute: 'mode', values: { '0': 'Auto', '1': 'Manual', '2': 'Away', '3': 'Boost', '4': 'Comfort', '5': 'Lowering' } }, + POWER: { attribute: 'power' }, + CURRENT: { attribute: 'current' }, + VOLTAGE: { attribute: 'voltage' }, + OPERATING_VOLTAGE: { attribute: 'voltage' }, + WORKING: { attribute: 'working', values: { '0': 'No', '1': 'Yes' } }, + STATE_UNCERTAIN: { attribute: 'state_uncertain' }, + SENDERID: { attribute: 'last_senderid' }, + SENDERADDRESS: { attribute: 'last_senderaddress' }, +}; + +export const homematicSensorMetadata: Record = { + HUMIDITY: { unit: '%', deviceClass: 'humidity', stateClass: 'measurement' }, + ACTUAL_HUMIDITY: { unit: '%', deviceClass: 'humidity', stateClass: 'measurement' }, + ACTUAL_TEMPERATURE: { unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + TEMPERATURE: { unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + LUX: { unit: 'lx', deviceClass: 'illuminance', stateClass: 'measurement' }, + CURRENT_ILLUMINATION: { unit: 'lx', deviceClass: 'illuminance', stateClass: 'measurement' }, + ILLUMINATION: { unit: 'lx', deviceClass: 'illuminance', stateClass: 'measurement' }, + AVERAGE_ILLUMINATION: { unit: 'lx', deviceClass: 'illuminance', stateClass: 'measurement' }, + LOWEST_ILLUMINATION: { unit: 'lx', deviceClass: 'illuminance', stateClass: 'measurement' }, + HIGHEST_ILLUMINATION: { unit: 'lx', deviceClass: 'illuminance', stateClass: 'measurement' }, + POWER: { unit: 'W', deviceClass: 'power', stateClass: 'measurement' }, + IEC_POWER: { unit: 'W', deviceClass: 'power', stateClass: 'measurement' }, + CURRENT: { unit: 'mA', deviceClass: 'current', stateClass: 'measurement' }, + CONCENTRATION: { unit: 'ppm', deviceClass: 'carbon_dioxide', stateClass: 'measurement' }, + ENERGY_COUNTER: { unit: 'Wh', deviceClass: 'energy', stateClass: 'total_increasing' }, + IEC_ENERGY_COUNTER: { unit: 'kWh', deviceClass: 'energy', stateClass: 'total_increasing' }, + VOLTAGE: { unit: 'V', deviceClass: 'voltage', stateClass: 'measurement' }, + OPERATING_VOLTAGE: { unit: 'V', deviceClass: 'voltage', stateClass: 'measurement' }, + GAS_POWER: { unit: 'm3', deviceClass: 'gas' }, + GAS_ENERGY_COUNTER: { unit: 'm3', deviceClass: 'gas', stateClass: 'total_increasing' }, + RAIN_COUNTER: { unit: 'mm', deviceClass: 'precipitation' }, + WIND_SPEED: { unit: 'km/h', deviceClass: 'wind_speed', icon: 'mdi:weather-windy' }, + WIND_DIRECTION: { unit: 'degree', deviceClass: 'wind_direction', stateClass: 'measurement_angle' }, + WIND_DIR: { unit: 'degree', deviceClass: 'wind_direction', stateClass: 'measurement_angle' }, + WIND_DIRECTION_RANGE: { unit: 'degree' }, + WIND_DIR_RANGE: { unit: 'degree' }, + SUNSHINEDURATION: { unit: '#' }, + AIR_PRESSURE: { unit: 'hPa', deviceClass: 'pressure', stateClass: 'measurement' }, + FREQUENCY: { unit: 'Hz', deviceClass: 'frequency' }, + VALUE: { unit: '#' }, + VALVE_STATE: { unit: '%' }, + CARRIER_SENSE_LEVEL: { unit: '%' }, + DUTY_CYCLE_LEVEL: { unit: '%' }, + BRIGHTNESS: { unit: '#', icon: 'mdi:invert-colors' }, + MASS_CONCENTRATION_PM_1: { unit: 'ug/m3', deviceClass: 'pm1', stateClass: 'measurement' }, + MASS_CONCENTRATION_PM_2_5: { unit: 'ug/m3', deviceClass: 'pm25', stateClass: 'measurement' }, + MASS_CONCENTRATION_PM_10: { unit: 'ug/m3', deviceClass: 'pm10', stateClass: 'measurement' }, + MASS_CONCENTRATION_PM_1_24H_AVERAGE: { unit: 'ug/m3', deviceClass: 'pm1', stateClass: 'measurement' }, + MASS_CONCENTRATION_PM_2_5_24H_AVERAGE: { unit: 'ug/m3', deviceClass: 'pm25', stateClass: 'measurement' }, + MASS_CONCENTRATION_PM_10_24H_AVERAGE: { unit: 'ug/m3', deviceClass: 'pm10', stateClass: 'measurement' }, + FILLING_LEVEL: { unit: '%' }, + LEVEL: { unit: '%' }, + LEVEL_2: { unit: '%' }, +}; + +export const homematicBinaryDeviceClassByType: Record = { + IPShutterContact: 'opening', + IPShutterContactSabotage: 'opening', + MaxShutterContact: 'opening', + Motion: 'motion', + MotionV2: 'motion', + PresenceIP: 'motion', + ShutterContact: 'opening', + Smoke: 'smoke', + SmokeV2: 'smoke', + IPContact: 'opening', + MotionIP: 'motion', + MotionIPV2: 'motion', + MotionIPContactSabotage: 'motion', + IPRemoteMotionV2: 'motion', +}; + +export const homematicStateCasts: Record> = { + IPGarage: { '0': 'closed', '1': 'open', '2': 'ventilation', '3': null }, + RotaryHandleSensor: { '0': 'closed', '1': 'tilted', '2': 'open' }, + RotaryHandleSensorIP: { '0': 'closed', '1': 'tilted', '2': 'open' }, + WaterSensor: { '0': 'dry', '1': 'wet', '2': 'water' }, + CO2Sensor: { '0': 'normal', '1': 'added', '2': 'strong' }, + IPSmoke: { '0': 'off', '1': 'primary', '2': 'intrusion', '3': 'secondary' }, + RFSiren: { '0': 'disarmed', '1': 'extsens_armed', '2': 'allsens_armed', '3': 'alarm_blocked' }, + IPLockDLD: { '0': null, '1': 'locked', '2': 'unlocked' }, +}; + +export const homematicClimateTemperatureDatapoints = ['SET_TEMPERATURE', 'SETPOINT', 'SET_POINT_TEMPERATURE']; +export const homematicClimateCurrentTemperatureDatapoints = ['ACTUAL_TEMPERATURE', 'TEMPERATURE']; +export const homematicClimateHumidityDatapoints = ['ACTUAL_HUMIDITY', 'HUMIDITY']; +export const homematicControlModeDatapoints = ['CONTROL_MODE', 'SET_POINT_MODE']; + +export const homematicBooleanControlDatapoints = ['STATE', 'INHIBIT']; +export const homematicLevelControlDatapoints = ['LEVEL']; +export const homematicBatteryDatapoints = ['LOWBAT', 'LOW_BAT']; diff --git a/ts/integrations/homematic/homematic.discovery.ts b/ts/integrations/homematic/homematic.discovery.ts new file mode 100644 index 0000000..1d9f926 --- /dev/null +++ b/ts/integrations/homematic/homematic.discovery.ts @@ -0,0 +1,126 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import { homematicDefaultInterfacePort, type IHomematicManualEntry, type IHomematicMetadataDiscoveryRecord } from './homematic.types.js'; + +export class HomematicManualMatcher implements IDiscoveryMatcher { + public id = 'homematic-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Homematic CCU/Homegear XML-RPC setup entries.'; + + public async matches(inputArg: IHomematicManualEntry): Promise { + const haystack = `${inputArg.name || ''} ${inputArg.model || ''} ${inputArg.manufacturer || ''} ${inputArg.metadata?.domain || ''}`.toLowerCase(); + const matched = Boolean( + inputArg.metadata?.homematic + || inputArg.metadata?.homeMatic + || inputArg.metadata?.ccu + || inputArg.metadata?.domain === 'homematic' + || haystack.includes('homematic') + || haystack.includes('homegear') + || haystack.includes('ccu') + || haystack.includes('eq-3') + ); + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Homematic setup hints.' }; + } + + const id = normalizeId(inputArg.id || inputArg.serialNumber || inputArg.macAddress || inputArg.host || inputArg.name || 'homematic'); + return { + matched: true, + confidence: inputArg.host ? 'high' : 'medium', + reason: 'Manual entry can start Homematic XML-RPC setup.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: 'homematic', + id, + host: inputArg.host, + port: inputArg.port || homematicDefaultInterfacePort, + name: inputArg.name || 'Homematic CCU', + manufacturer: inputArg.manufacturer || 'eQ-3', + model: inputArg.model || 'Homematic CCU', + serialNumber: inputArg.serialNumber, + macAddress: inputArg.macAddress, + metadata: { + ...inputArg.metadata, + interfaceName: inputArg.interfaceName || inputArg.metadata?.interfaceName || 'default', + ssl: inputArg.ssl, + path: inputArg.path, + }, + }, + }; + } +} + +export class HomematicMetadataMatcher implements IDiscoveryMatcher { + public id = 'homematic-metadata-match'; + public source = 'custom' as const; + public description = 'Recognize metadata records from Home Assistant/HomeMatic manifests or inventories.'; + + public async matches(inputArg: IHomematicMetadataDiscoveryRecord): Promise { + const metadata = inputArg.metadata || {}; + const domain = inputArg.domain || inputArg.integrationDomain || metadata.domain || metadata.integrationDomain || metadata.upstreamDomain; + const haystack = `${inputArg.name || ''} ${inputArg.model || ''} ${inputArg.manufacturer || ''} ${domain || ''}`.toLowerCase(); + const matched = domain === 'homematic' || Boolean(metadata.homematic || metadata.homeMatic || haystack.includes('homematic')); + return { + matched, + confidence: matched && inputArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Metadata identifies Homematic.' : 'Metadata is not Homematic.', + normalizedDeviceId: matched ? normalizeId(String(inputArg.id || inputArg.host || inputArg.name || 'homematic')) : undefined, + candidate: matched ? { + source: 'custom', + integrationDomain: 'homematic', + id: normalizeId(String(inputArg.id || inputArg.host || inputArg.name || 'homematic')), + host: inputArg.host, + port: inputArg.port || homematicDefaultInterfacePort, + name: inputArg.name || 'Homematic CCU', + manufacturer: inputArg.manufacturer || 'eQ-3', + model: inputArg.model || 'Homematic CCU', + metadata, + } : undefined, + }; + } +} + +export class HomematicCandidateValidator implements IDiscoveryValidator { + public id = 'homematic-candidate-validator'; + public description = 'Validate that a discovery candidate can be configured as a Homematic CCU/Homegear endpoint.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const metadata = candidateArg.metadata || {}; + const haystack = `${candidateArg.name || ''} ${candidateArg.model || ''} ${candidateArg.manufacturer || ''} ${candidateArg.integrationDomain || ''}`.toLowerCase(); + const matched = candidateArg.integrationDomain === 'homematic' + || Boolean(metadata.homematic || metadata.homeMatic || metadata.ccu) + || haystack.includes('homematic') + || haystack.includes('homegear') + || haystack.includes('ccu') + || haystack.includes('eq-3'); + const id = normalizeId(candidateArg.id || candidateArg.serialNumber || candidateArg.macAddress || candidateArg.host || candidateArg.name || 'homematic'); + return { + matched, + confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has Homematic metadata.' : 'Candidate is not Homematic.', + normalizedDeviceId: id, + candidate: matched ? { + ...candidateArg, + integrationDomain: 'homematic', + id, + manufacturer: candidateArg.manufacturer || 'eQ-3', + model: candidateArg.model || 'Homematic CCU', + port: candidateArg.port || homematicDefaultInterfacePort, + } : undefined, + }; + } +} + +export const createHomematicDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'homematic', displayName: 'Homematic' }) + .addMatcher(new HomematicManualMatcher()) + .addMatcher(new HomematicMetadataMatcher()) + .addValidator(new HomematicCandidateValidator()); +}; + +const normalizeId = (valueArg: string | undefined): string => { + const value = (valueArg || 'homematic').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, ''); + return value || 'homematic'; +}; diff --git a/ts/integrations/homematic/homematic.mapper.ts b/ts/integrations/homematic/homematic.mapper.ts new file mode 100644 index 0000000..79a2af8 --- /dev/null +++ b/ts/integrations/homematic/homematic.mapper.ts @@ -0,0 +1,663 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest } from '../../core/types.js'; +import { + homematicAttributeSupport, + homematicBatteryDatapoints, + homematicBinaryDeviceClassByType, + homematicBinarySensorDeviceTypes, + homematicBooleanControlDatapoints, + homematicClimateCurrentTemperatureDatapoints, + homematicClimateDeviceTypes, + homematicClimateHumidityDatapoints, + homematicClimateTemperatureDatapoints, + homematicControlModeDatapoints, + homematicCoverDeviceTypes, + homematicDomain, + homematicIgnoredSensorDatapointExceptions, + homematicIgnoredSensorDatapoints, + homematicLevelControlDatapoints, + homematicLightDeviceTypes, + homematicLockDeviceTypes, + homematicPressEvents, + homematicSensorDeviceTypes, + homematicSensorMetadata, + homematicStateCasts, + homematicSwitchDeviceTypes, +} from './homematic.constants.js'; +import type { + IHomematicChannel, + IHomematicCommand, + IHomematicDatapoint, + IHomematicDevice, + IHomematicEvent, + IHomematicSnapshot, + IHomematicValue, +} from './homematic.types.js'; + +type THomematicEntityPlatform = IIntegrationEntity['platform'] | 'lock'; + +export class HomematicMapper { + public static toDevices(snapshotArg: IHomematicSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [{ + id: this.ccuDeviceId(snapshotArg), + integrationDomain: homematicDomain, + name: snapshotArg.ccu.name || 'Homematic CCU', + protocol: 'http', + manufacturer: snapshotArg.ccu.manufacturer || 'eQ-3', + model: snapshotArg.ccu.model || 'Homematic CCU', + online: snapshotArg.connected && snapshotArg.ccu.online !== false, + features: [ + { id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false }, + { id: 'device_count', capability: 'sensor', name: 'Device count', readable: true, writable: false }, + { id: 'service_messages', capability: 'sensor', name: 'Service messages', readable: true, writable: false }, + ], + state: [ + { featureId: 'connectivity', value: snapshotArg.connected ? 'online' : 'offline', updatedAt }, + { featureId: 'device_count', value: snapshotArg.devices.length, updatedAt }, + { featureId: 'service_messages', value: snapshotArg.ccu.serviceMessages?.length ?? 0, updatedAt }, + ], + metadata: this.cleanAttributes({ + host: snapshotArg.ccu.host, + port: snapshotArg.ccu.port, + serialNumber: snapshotArg.ccu.serialNumber, + firmwareVersion: snapshotArg.ccu.firmwareVersion, + source: snapshotArg.source, + eventServer: 'unsupported', + }), + }]; + + for (const device of snapshotArg.devices) { + const features: plugins.shxInterfaces.data.IDeviceFeature[] = []; + const state: plugins.shxInterfaces.data.IDeviceState[] = []; + for (const channel of device.channels) { + for (const datapoint of channel.datapoints) { + const id = `${channel.index}_${this.slug(datapoint.name)}`; + const metadata = this.sensorMetadata(datapoint.name); + features.push({ + id, + capability: this.capabilityForDatapoint(device, channel, datapoint), + name: this.datapointName(channel, datapoint), + readable: datapoint.readable !== false, + writable: datapoint.writable === true || datapoint.kind === 'write' || datapoint.kind === 'action', + unit: datapoint.unit || metadata.unit, + }); + state.push({ featureId: id, value: this.deviceStateValue(datapoint.value), updatedAt: datapoint.updatedAt || updatedAt }); + } + } + devices.push({ + id: this.deviceId(snapshotArg, device), + integrationDomain: homematicDomain, + name: device.name || device.address, + protocol: 'http', + manufacturer: device.manufacturer || 'eQ-3', + model: device.model || device.type, + online: snapshotArg.connected && device.available !== false, + features, + state, + metadata: this.cleanAttributes({ + address: device.address, + interfaceName: device.interfaceName, + type: device.type, + firmwareVersion: device.firmwareVersion, + viaDevice: this.ccuDeviceId(snapshotArg), + ...device.attributes, + }), + }); + } + + return devices; + } + + public static toEntities(snapshotArg: IHomematicSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + const usedIds = new Map(); + const ccuId = this.ccuDeviceId(snapshotArg); + const uniqueBase = this.uniqueBase(snapshotArg); + + entities.push(this.entity('sensor', 'Homematic service messages', ccuId, `homematic_${uniqueBase}_service_messages`, snapshotArg.ccu.serviceMessages?.length ?? 0, usedIds, { + host: snapshotArg.ccu.host, + source: snapshotArg.source, + eventServer: 'unsupported', + }, snapshotArg.connected && snapshotArg.ccu.online !== false)); + + for (const device of snapshotArg.devices) { + this.addClimateEntities(snapshotArg, device, usedIds, entities); + this.addCoverEntities(snapshotArg, device, usedIds, entities); + this.addLightEntities(snapshotArg, device, usedIds, entities); + this.addSwitchEntities(snapshotArg, device, usedIds, entities); + this.addLockEntities(snapshotArg, device, usedIds, entities); + this.addBinarySensorEntities(snapshotArg, device, usedIds, entities); + this.addSensorEntities(snapshotArg, device, usedIds, entities); + } + + return entities; + } + + public static commandForService(snapshotArg: IHomematicSnapshot, requestArg: IServiceCallRequest): IHomematicCommand | undefined { + if (requestArg.domain === homematicDomain && ['refresh', 'reload', 'snapshot'].includes(requestArg.service)) { + return { type: 'refresh' }; + } + + if (requestArg.domain === homematicDomain && ['raw_xmlrpc', 'xmlrpc'].includes(requestArg.service)) { + const method = this.stringData(requestArg, 'method'); + return method ? { type: 'raw_xmlrpc', method, params: this.arrayData(requestArg, 'params') || [] } : undefined; + } + + if (requestArg.domain === homematicDomain && ['set_value', 'set_device_value'].includes(requestArg.service)) { + const address = this.stringData(requestArg, 'address'); + const datapoint = this.stringData(requestArg, 'datapoint') || this.stringData(requestArg, 'parameter') || this.stringData(requestArg, 'param'); + const value = requestArg.data?.value as IHomematicValue | undefined; + const channel = this.numberData(requestArg, 'channel'); + if (address && datapoint && value !== undefined) { + return { type: 'set_value', address, channel, datapoint: datapoint.toUpperCase(), value, interfaceName: this.stringData(requestArg, 'interface') || this.stringData(requestArg, 'interfaceName'), entityId: requestArg.target.entityId, deviceId: requestArg.target.deviceId }; + } + } + + if (requestArg.domain === homematicDomain && ['press', 'virtualkey'].includes(requestArg.service)) { + const address = this.stringData(requestArg, 'address'); + const channel = this.numberData(requestArg, 'channel'); + const datapoint = (this.stringData(requestArg, 'datapoint') || this.stringData(requestArg, 'parameter') || this.stringData(requestArg, 'param') || 'PRESS_SHORT').toUpperCase(); + if (address && channel !== undefined) { + return { type: 'press', address, channel, datapoint, value: requestArg.data?.value as IHomematicValue | undefined, interfaceName: this.stringData(requestArg, 'interface') || this.stringData(requestArg, 'interfaceName'), entityId: requestArg.target.entityId, deviceId: requestArg.target.deviceId }; + } + } + + if (['turn_on', 'turn_off', 'open', 'close', 'stop'].includes(requestArg.service)) { + return this.commandForTargetService(snapshotArg, requestArg); + } + + if (requestArg.domain === 'climate' && requestArg.service === 'set_temperature') { + const entity = this.findEntity(snapshotArg, requestArg); + const temperature = this.numberData(requestArg, 'temperature') ?? this.numberData(requestArg, 'value'); + if (entity && temperature !== undefined) { + return { + type: 'set_temperature', + address: String(entity.attributes?.address || ''), + channel: Number(entity.attributes?.channel || 0), + datapoint: String(entity.attributes?.targetTemperatureDatapoint || 'SET_TEMPERATURE'), + value: temperature, + interfaceName: this.stringAttribute(entity, 'interfaceName'), + entityId: requestArg.target.entityId, + deviceId: requestArg.target.deviceId, + }; + } + } + + return undefined; + } + + public static toIntegrationEvent(eventArg: IHomematicEvent): IIntegrationEvent { + return { + type: eventArg.type === 'error' || eventArg.type === 'command_failed' ? 'error' : 'state_changed', + integrationDomain: homematicDomain, + deviceId: eventArg.deviceId, + entityId: eventArg.entityId, + data: eventArg.data || eventArg.command || eventArg, + timestamp: eventArg.timestamp, + }; + } + + public static ccuDeviceId(snapshotArg: IHomematicSnapshot): string { + return `homematic.ccu.${this.uniqueBase(snapshotArg)}`; + } + + public static deviceId(snapshotArg: IHomematicSnapshot, deviceArg: IHomematicDevice): string { + return `homematic.device.${this.uniqueBase(snapshotArg)}.${this.slug(deviceArg.address)}`; + } + + private static addClimateEntities(snapshotArg: IHomematicSnapshot, deviceArg: IHomematicDevice, usedIdsArg: Map, entitiesArg: IIntegrationEntity[]): void { + if (!this.isClimateDevice(deviceArg)) { + return; + } + for (const channel of deviceArg.channels) { + const target = this.firstDatapoint(channel, homematicClimateTemperatureDatapoints); + if (!target) { + continue; + } + const currentTemperature = this.firstDatapointValue(channel, homematicClimateCurrentTemperatureDatapoints); + const currentHumidity = this.firstDatapointValue(channel, homematicClimateHumidityDatapoints); + const controlMode = this.firstDatapointValue(channel, homematicControlModeDatapoints); + entitiesArg.push(this.entity('climate', this.entityName(deviceArg, channel), this.deviceId(snapshotArg, deviceArg), `homematic_${this.uniqueBase(snapshotArg)}_${this.slug(deviceArg.address)}_${channel.index}_climate`, target.value ?? null, usedIdsArg, { + address: deviceArg.address, + channel: channel.index, + channelAddress: channel.address, + interfaceName: deviceArg.interfaceName, + targetTemperatureDatapoint: target.name, + currentTemperature, + currentHumidity, + controlMode, + hvacModes: ['auto', 'heat', 'off'], + presetModes: ['boost', 'comfort', 'eco'], + minTemp: target.min ?? 4.5, + maxTemp: target.max ?? 30.5, + step: 0.5, + }, this.available(snapshotArg, deviceArg))); + } + } + + private static addCoverEntities(snapshotArg: IHomematicSnapshot, deviceArg: IHomematicDevice, usedIdsArg: Map, entitiesArg: IIntegrationEntity[]): void { + if (!this.isCoverDevice(deviceArg)) { + return; + } + for (const channel of deviceArg.channels) { + const level = this.firstDatapoint(channel, ['LEVEL']); + const doorState = this.firstDatapoint(channel, ['DOOR_STATE']); + if (!level && !doorState) { + continue; + } + const position = level && typeof level.value === 'number' ? Math.round(level.value * 100) : undefined; + const state = doorState ? this.castState(deviceArg, doorState) : position === 0 ? 'closed' : 'open'; + const isGarage = deviceArg.type === 'IPGarage' || Boolean(doorState); + entitiesArg.push(this.entity('cover', this.entityName(deviceArg, channel), this.deviceId(snapshotArg, deviceArg), `homematic_${this.uniqueBase(snapshotArg)}_${this.slug(deviceArg.address)}_${channel.index}_cover`, state, usedIdsArg, { + address: deviceArg.address, + channel: channel.index, + channelAddress: channel.address, + interfaceName: deviceArg.interfaceName, + position, + tiltPosition: this.levelPercent(this.firstDatapointValue(channel, ['LEVEL_2'])), + datapoint: level?.name || doorState?.name, + coverCommandDatapoint: isGarage ? 'DOOR_COMMAND' : 'LEVEL', + deviceClass: isGarage ? 'garage' : undefined, + }, this.available(snapshotArg, deviceArg))); + } + } + + private static addLightEntities(snapshotArg: IHomematicSnapshot, deviceArg: IHomematicDevice, usedIdsArg: Map, entitiesArg: IIntegrationEntity[]): void { + if (!this.isLightDevice(deviceArg) || this.isCoverDevice(deviceArg) || this.isClimateDevice(deviceArg)) { + return; + } + for (const channel of deviceArg.channels) { + const level = this.firstDatapoint(channel, homematicLevelControlDatapoints); + if (!level) { + continue; + } + entitiesArg.push(this.entity('light', this.entityName(deviceArg, channel), this.deviceId(snapshotArg, deviceArg), `homematic_${this.uniqueBase(snapshotArg)}_${this.slug(deviceArg.address)}_${channel.index}_light`, this.numericValue(level.value) > 0 ? 'on' : 'off', usedIdsArg, { + address: deviceArg.address, + channel: channel.index, + channelAddress: channel.address, + interfaceName: deviceArg.interfaceName, + datapoint: level.name, + brightness: Math.round(this.numericValue(level.value) * 255), + level: level.value, + supportsTransition: true, + supportsColor: Boolean(this.firstDatapoint(channel, ['COLOR'])), + supportsColorTemperature: deviceArg.type === 'ColdWarmDimmer', + effect: this.firstDatapointValue(channel, ['PROGRAM']), + }, this.available(snapshotArg, deviceArg))); + } + } + + private static addSwitchEntities(snapshotArg: IHomematicSnapshot, deviceArg: IHomematicDevice, usedIdsArg: Map, entitiesArg: IIntegrationEntity[]): void { + if (!this.isSwitchDevice(deviceArg) || this.isLightDevice(deviceArg) || this.isCoverDevice(deviceArg) || this.isClimateDevice(deviceArg)) { + return; + } + for (const channel of deviceArg.channels) { + const state = this.firstDatapoint(channel, homematicBooleanControlDatapoints); + if (!state) { + continue; + } + entitiesArg.push(this.entity('switch', this.primaryEntityName(deviceArg, channel, ['STATE']), this.deviceId(snapshotArg, deviceArg), `homematic_${this.uniqueBase(snapshotArg)}_${this.slug(deviceArg.address)}_${channel.index}_switch`, this.onOffState(state.value), usedIdsArg, { + address: deviceArg.address, + channel: channel.index, + channelAddress: channel.address, + interfaceName: deviceArg.interfaceName, + datapoint: state.name, + }, this.available(snapshotArg, deviceArg))); + } + } + + private static addLockEntities(snapshotArg: IHomematicSnapshot, deviceArg: IHomematicDevice, usedIdsArg: Map, entitiesArg: IIntegrationEntity[]): void { + if (!this.isLockDevice(deviceArg)) { + return; + } + for (const channel of deviceArg.channels) { + const state = this.firstDatapoint(channel, ['STATE', 'LOCK_STATE']); + if (!state) { + continue; + } + entitiesArg.push(this.entity('lock', this.entityName(deviceArg, channel), this.deviceId(snapshotArg, deviceArg), `homematic_${this.uniqueBase(snapshotArg)}_${this.slug(deviceArg.address)}_${channel.index}_lock`, this.lockState(deviceArg, state), usedIdsArg, { + address: deviceArg.address, + channel: channel.index, + channelAddress: channel.address, + interfaceName: deviceArg.interfaceName, + datapoint: state.name, + supportsOpen: true, + openDatapoint: 'OPEN', + }, this.available(snapshotArg, deviceArg))); + } + } + + private static addBinarySensorEntities(snapshotArg: IHomematicSnapshot, deviceArg: IHomematicDevice, usedIdsArg: Map, entitiesArg: IIntegrationEntity[]): void { + for (const channel of deviceArg.channels) { + for (const datapoint of channel.datapoints) { + if (!this.shouldCreateBinarySensor(deviceArg, channel, datapoint)) { + continue; + } + entitiesArg.push(this.entity('binary_sensor', this.datapointEntityName(deviceArg, channel, datapoint), this.deviceId(snapshotArg, deviceArg), `homematic_${this.uniqueBase(snapshotArg)}_${this.slug(deviceArg.address)}_${channel.index}_${this.slug(datapoint.name)}`, this.onOffState(datapoint.value), usedIdsArg, { + address: deviceArg.address, + channel: channel.index, + channelAddress: channel.address, + interfaceName: deviceArg.interfaceName, + datapoint: datapoint.name, + deviceClass: this.binaryDeviceClass(deviceArg, datapoint), + }, this.available(snapshotArg, deviceArg))); + } + } + } + + private static addSensorEntities(snapshotArg: IHomematicSnapshot, deviceArg: IHomematicDevice, usedIdsArg: Map, entitiesArg: IIntegrationEntity[]): void { + for (const channel of deviceArg.channels) { + for (const datapoint of channel.datapoints) { + if (!this.shouldCreateSensor(deviceArg, channel, datapoint)) { + continue; + } + const metadata = this.sensorMetadata(datapoint.name); + entitiesArg.push(this.entity('sensor', this.datapointEntityName(deviceArg, channel, datapoint), this.deviceId(snapshotArg, deviceArg), `homematic_${this.uniqueBase(snapshotArg)}_${this.slug(deviceArg.address)}_${channel.index}_${this.slug(datapoint.name)}`, this.sensorState(deviceArg, datapoint), usedIdsArg, { + address: deviceArg.address, + channel: channel.index, + channelAddress: channel.address, + interfaceName: deviceArg.interfaceName, + datapoint: datapoint.name, + unit: datapoint.unit || metadata.unit, + deviceClass: datapoint.deviceClass || metadata.deviceClass, + stateClass: datapoint.stateClass || metadata.stateClass, + icon: metadata.icon, + min: datapoint.min, + max: datapoint.max, + valueList: datapoint.valueList, + }, this.available(snapshotArg, deviceArg))); + } + } + } + + private static commandForTargetService(snapshotArg: IHomematicSnapshot, requestArg: IServiceCallRequest): IHomematicCommand | undefined { + const entity = this.findEntity(snapshotArg, requestArg); + if (!entity) { + return undefined; + } + const address = this.stringAttribute(entity, 'address'); + const channel = this.numberAttribute(entity, 'channel'); + const interfaceName = this.stringAttribute(entity, 'interfaceName'); + if (!address || channel === undefined) { + return undefined; + } + if (requestArg.service === 'turn_on' || requestArg.service === 'turn_off') { + const on = requestArg.service === 'turn_on'; + const datapoint = this.stringAttribute(entity, 'datapoint') || (entity.platform === 'light' ? 'LEVEL' : 'STATE'); + const brightness = this.numberData(requestArg, 'brightness'); + const value = datapoint === 'LEVEL' ? (on ? brightness !== undefined ? Math.max(0, Math.min(1, brightness > 1 ? brightness / 255 : brightness)) : 1 : 0) : on; + return { type: on ? 'turn_on' : 'turn_off', address, channel, datapoint, value, interfaceName, entityId: requestArg.target.entityId, deviceId: requestArg.target.deviceId }; + } + if (requestArg.service === 'open' || requestArg.service === 'close' || requestArg.service === 'stop') { + const datapoint = this.stringAttribute(entity, 'coverCommandDatapoint') || (this.stringAttribute(entity, 'openDatapoint') ? 'OPEN' : 'LEVEL'); + const value = datapoint === 'DOOR_COMMAND' ? requestArg.service === 'open' ? 1 : requestArg.service === 'close' ? 3 : 2 : datapoint === 'OPEN' ? true : requestArg.service === 'open' ? 1 : requestArg.service === 'close' ? 0 : true; + return { type: requestArg.service, address, channel, datapoint, value, interfaceName, entityId: requestArg.target.entityId, deviceId: requestArg.target.deviceId }; + } + return undefined; + } + + private static findEntity(snapshotArg: IHomematicSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined { + const target = requestArg.target.entityId || requestArg.target.deviceId; + const entities = this.toEntities(snapshotArg); + if (!target) { + const address = this.stringData(requestArg, 'address'); + const channel = this.numberData(requestArg, 'channel'); + return address ? entities.find((entityArg) => entityArg.attributes?.address === address && (channel === undefined || entityArg.attributes?.channel === channel)) : undefined; + } + return entities.find((entityArg) => entityArg.id === target || entityArg.uniqueId === target || entityArg.deviceId === target); + } + + private static entity(platformArg: THomematicEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map, attributesArg: Record, availableArg: boolean): IIntegrationEntity { + const baseId = `${platformArg}.${this.slug(nameArg) || this.slug(uniqueIdArg)}`; + const seen = usedIdsArg.get(baseId) || 0; + usedIdsArg.set(baseId, seen + 1); + return { + id: seen ? `${baseId}_${seen + 1}` : baseId, + uniqueId: uniqueIdArg, + integrationDomain: homematicDomain, + deviceId: deviceIdArg, + platform: platformArg as IIntegrationEntity['platform'], + name: nameArg, + state: stateArg, + attributes: this.cleanAttributes(attributesArg), + available: availableArg, + }; + } + + private static capabilityForDatapoint(deviceArg: IHomematicDevice, channelArg: IHomematicChannel, datapointArg: IHomematicDatapoint): plugins.shxInterfaces.data.TDeviceCapability { + void channelArg; + if (this.isLightDevice(deviceArg) && datapointArg.name === 'LEVEL') { + return 'light'; + } + if (this.isSwitchDevice(deviceArg) && datapointArg.name === 'STATE') { + return 'switch'; + } + if (this.isCoverDevice(deviceArg) && ['LEVEL', 'LEVEL_2', 'DOOR_STATE'].includes(datapointArg.name)) { + return 'cover'; + } + if (this.isClimateDevice(deviceArg) && [...homematicClimateTemperatureDatapoints, ...homematicClimateCurrentTemperatureDatapoints].includes(datapointArg.name)) { + return 'climate'; + } + if (this.isLockDevice(deviceArg)) { + return 'lock'; + } + return 'sensor'; + } + + private static shouldCreateBinarySensor(deviceArg: IHomematicDevice, channelArg: IHomematicChannel, datapointArg: IHomematicDatapoint): boolean { + if (this.isPrimaryControlDatapoint(deviceArg, datapointArg)) { + return false; + } + if (homematicBatteryDatapoints.includes(datapointArg.name)) { + return true; + } + if (datapointArg.kind === 'binary') { + return true; + } + if (homematicBinarySensorDeviceTypes.includes(deviceArg.type || '') && typeof datapointArg.value === 'boolean') { + return true; + } + void channelArg; + return false; + } + + private static shouldCreateSensor(deviceArg: IHomematicDevice, channelArg: IHomematicChannel, datapointArg: IHomematicDatapoint): boolean { + if (this.shouldCreateBinarySensor(deviceArg, channelArg, datapointArg) || this.isPrimaryControlDatapoint(deviceArg, datapointArg) || homematicPressEvents.includes(datapointArg.name as typeof homematicPressEvents[number])) { + return false; + } + if (homematicIgnoredSensorDatapoints.includes(datapointArg.name) && !homematicIgnoredSensorDatapointExceptions[datapointArg.name]?.includes(deviceArg.type || '')) { + return false; + } + return datapointArg.kind === 'sensor' || datapointArg.kind === 'attribute' || homematicSensorDeviceTypes.includes(deviceArg.type || '') || datapointArg.readable !== false; + } + + private static isPrimaryControlDatapoint(deviceArg: IHomematicDevice, datapointArg: IHomematicDatapoint): boolean { + if (this.isClimateDevice(deviceArg) && homematicClimateTemperatureDatapoints.includes(datapointArg.name)) { + return true; + } + if (this.isCoverDevice(deviceArg) && ['LEVEL', 'LEVEL_2', 'DOOR_STATE'].includes(datapointArg.name)) { + return true; + } + if (this.isLightDevice(deviceArg) && ['LEVEL', 'COLOR', 'PROGRAM'].includes(datapointArg.name)) { + return true; + } + if (this.isSwitchDevice(deviceArg) && !this.isLightDevice(deviceArg) && datapointArg.name === 'STATE') { + return true; + } + if (this.isLockDevice(deviceArg) && ['STATE', 'OPEN'].includes(datapointArg.name)) { + return true; + } + return false; + } + + private static isClimateDevice(deviceArg: IHomematicDevice): boolean { + return homematicClimateDeviceTypes.includes(deviceArg.type || '') || deviceArg.channels.some((channelArg) => Boolean(this.firstDatapoint(channelArg, homematicClimateTemperatureDatapoints))); + } + + private static isCoverDevice(deviceArg: IHomematicDevice): boolean { + return homematicCoverDeviceTypes.includes(deviceArg.type || '') || deviceArg.channels.some((channelArg) => Boolean(this.firstDatapoint(channelArg, ['DOOR_STATE']))); + } + + private static isLightDevice(deviceArg: IHomematicDevice): boolean { + return homematicLightDeviceTypes.includes(deviceArg.type || '') || deviceArg.channels.some((channelArg) => Boolean(this.firstDatapoint(channelArg, ['LEVEL'])?.writable) && !this.isCoverDevice(deviceArg)); + } + + private static isSwitchDevice(deviceArg: IHomematicDevice): boolean { + return homematicSwitchDeviceTypes.includes(deviceArg.type || '') || deviceArg.channels.some((channelArg) => Boolean(this.firstDatapoint(channelArg, ['STATE']))); + } + + private static isLockDevice(deviceArg: IHomematicDevice): boolean { + return homematicLockDeviceTypes.includes(deviceArg.type || ''); + } + + private static firstDatapoint(channelArg: IHomematicChannel, namesArg: string[]): IHomematicDatapoint | undefined { + return channelArg.datapoints.find((datapointArg) => namesArg.includes(datapointArg.name)); + } + + private static firstDatapointValue(channelArg: IHomematicChannel, namesArg: string[]): IHomematicValue | undefined { + return this.firstDatapoint(channelArg, namesArg)?.value; + } + + private static binaryDeviceClass(deviceArg: IHomematicDevice, datapointArg: IHomematicDatapoint): string | undefined { + if (homematicBatteryDatapoints.includes(datapointArg.name)) { + return 'battery'; + } + if (datapointArg.name === 'MOTION') { + return 'motion'; + } + if (datapointArg.name === 'RAINING' || datapointArg.name === 'MOISTURE_DETECTED') { + return 'moisture'; + } + return homematicBinaryDeviceClassByType[deviceArg.type || '']; + } + + private static sensorMetadata(nameArg: string): { unit?: string; deviceClass?: string; stateClass?: string; icon?: string } { + return homematicSensorMetadata[nameArg] || {}; + } + + private static sensorState(deviceArg: IHomematicDevice, datapointArg: IHomematicDatapoint): unknown { + const cast = this.castState(deviceArg, datapointArg); + if (cast !== undefined) { + return cast; + } + const attr = homematicAttributeSupport[datapointArg.name]; + if (attr?.values && datapointArg.value !== undefined && datapointArg.value !== null) { + return attr.values[String(datapointArg.value)] ?? datapointArg.value; + } + if (datapointArg.name === 'LEVEL' && typeof datapointArg.value === 'number') { + return Math.round(datapointArg.value * 100); + } + return datapointArg.value ?? null; + } + + private static castState(deviceArg: IHomematicDevice, datapointArg: IHomematicDatapoint): string | null | undefined { + const cast = homematicStateCasts[deviceArg.type || '']; + if (!cast || datapointArg.value === undefined || datapointArg.value === null) { + return undefined; + } + return cast[String(datapointArg.value)]; + } + + private static lockState(deviceArg: IHomematicDevice, datapointArg: IHomematicDatapoint): string { + if (deviceArg.type === 'KeyMatic') { + return datapointArg.value ? 'unlocked' : 'locked'; + } + const cast = this.castState(deviceArg, datapointArg); + return typeof cast === 'string' ? cast : datapointArg.value ? 'unlocked' : 'locked'; + } + + private static entityName(deviceArg: IHomematicDevice, channelArg: IHomematicChannel): string { + if (deviceArg.channels.length <= 1) { + return deviceArg.name || channelArg.name || deviceArg.address; + } + return channelArg.name || `${deviceArg.name || deviceArg.address} ${channelArg.index}`; + } + + private static primaryEntityName(deviceArg: IHomematicDevice, channelArg: IHomematicChannel, datapointsArg: string[]): string { + const matchingChannels = deviceArg.channels.filter((channelItemArg) => datapointsArg.some((datapointArg) => Boolean(this.firstDatapoint(channelItemArg, [datapointArg])))); + return matchingChannels.length === 1 ? deviceArg.name || channelArg.name || deviceArg.address : this.entityName(deviceArg, channelArg); + } + + private static datapointEntityName(deviceArg: IHomematicDevice, channelArg: IHomematicChannel, datapointArg: IHomematicDatapoint): string { + const base = this.entityName(deviceArg, channelArg); + return `${base} ${this.humanName(datapointArg.name)}`; + } + + private static datapointName(channelArg: IHomematicChannel, datapointArg: IHomematicDatapoint): string { + return `${channelArg.index} ${this.humanName(datapointArg.name)}`; + } + + private static humanName(valueArg: string): string { + return valueArg.toLowerCase().split('_').map((partArg) => partArg.charAt(0).toUpperCase() + partArg.slice(1)).join(' '); + } + + private static available(snapshotArg: IHomematicSnapshot, deviceArg: IHomematicDevice): boolean { + return snapshotArg.connected && deviceArg.available !== false; + } + + private static uniqueBase(snapshotArg: IHomematicSnapshot): string { + return this.slug(snapshotArg.ccu.serialNumber || snapshotArg.ccu.id || snapshotArg.ccu.host || snapshotArg.ccu.name || 'homematic'); + } + + private static onOffState(valueArg: unknown): 'on' | 'off' { + return this.numericValue(valueArg) > 0 || valueArg === true ? 'on' : 'off'; + } + + private static levelPercent(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' ? Math.round(valueArg * 100) : undefined; + } + + private static numericValue(valueArg: unknown): number { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : typeof valueArg === 'boolean' ? valueArg ? 1 : 0 : typeof valueArg === 'string' && Number.isFinite(Number(valueArg)) ? Number(valueArg) : 0; + } + + private static numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined { + const value = requestArg.data?.[keyArg]; + return this.numberValue(value); + } + + private static stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined { + const value = requestArg.data?.[keyArg]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; + } + + private static arrayData(requestArg: IServiceCallRequest, keyArg: string): unknown[] | undefined { + const value = requestArg.data?.[keyArg]; + return Array.isArray(value) ? value : undefined; + } + + private static stringAttribute(entityArg: IIntegrationEntity, keyArg: string): string | undefined { + const value = entityArg.attributes?.[keyArg]; + return typeof value === 'string' && value ? value : undefined; + } + + private static numberAttribute(entityArg: IIntegrationEntity, keyArg: string): number | undefined { + return this.numberValue(entityArg.attributes?.[keyArg]); + } + + private static numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg)) ? Number(valueArg) : undefined; + } + + private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue { + if (valueArg === undefined) { + return null; + } + if (valueArg === null || typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'object' && !Array.isArray(valueArg)) { + return valueArg as Record; + } + return { value: valueArg }; + } + + private static cleanAttributes(attributesArg: Record): Record { + return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)); + } + + public static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'homematic'; + } +} diff --git a/ts/integrations/homematic/homematic.types.ts b/ts/integrations/homematic/homematic.types.ts index 492571f..ba041a9 100644 --- a/ts/integrations/homematic/homematic.types.ts +++ b/ts/integrations/homematic/homematic.types.ts @@ -1,4 +1,333 @@ -export interface IHomeAssistantHomematicConfig { - // TODO: replace with the TypeScript-native config for homematic. +export const homematicDefaultInterfacePort = 2001; +export const homematicDefaultJsonPort = 80; + +export type THomematicProtocol = 'http' | 'https'; +export type THomematicSnapshotSource = 'snapshot' | 'manual' | 'xmlrpc' | 'runtime'; +export type THomematicValueType = 'boolean' | 'integer' | 'double' | 'string' | 'dateTime.iso8601' | 'array' | 'struct' | 'unknown'; +export type THomematicDatapointKind = 'sensor' | 'binary' | 'attribute' | 'write' | 'action' | 'event' | 'unknown'; +export type THomematicParamsetKey = 'VALUES' | 'MASTER' | 'LINK' | (string & {}); +export type THomematicInterfaceFamily = 'bidcos-rf' | 'hmip-rf' | 'bidcos-wired' | 'virtual' | 'homegear' | 'unknown' | (string & {}); + +export type THomematicEventType = + | 'snapshot_refreshed' + | 'command_mapped' + | 'command_executed' + | 'command_failed' + | 'datapoint_changed' + | 'keypress' + | 'impulse' + | 'error'; + +export type IHomematicValue = string | number | boolean | null | Record | unknown[]; + +export interface IHomematicConfig { + host?: string; + port?: number; + path?: string; + ssl?: boolean; + verifySsl?: boolean; + username?: string; + password?: string; + jsonPort?: number; + timeoutMs?: number; + name?: string; + uniqueId?: string; + localIp?: string; + localPort?: number; + callbackIp?: string; + callbackPort?: number; + interfaceId?: string; + interfaceName?: string; + resolvenames?: 'metadata' | 'json' | 'xml' | false; + connected?: boolean; + ccu?: IHomematicCcu; + interfaces?: Record; + devices?: IHomematicDevice[]; + events?: IHomematicEvent[]; + snapshot?: IHomematicSnapshot; + commandExecutor?: (commandArg: IHomematicCommand) => Promise; +} + +export interface IHomeAssistantHomematicConfig extends IHomematicConfig {} + +export interface IHomematicInterfaceConfig { + id?: string; + name?: string; + host?: string; + port?: number; + path?: string; + protocol?: THomematicProtocol; + ssl?: boolean; + verifySsl?: boolean; + username?: string; + password?: string; + jsonPort?: number; + resolvenames?: 'metadata' | 'json' | 'xml' | false; + connect?: boolean; + family?: THomematicInterfaceFamily; + callbackIp?: string; + callbackPort?: number; + attributes?: Record; +} + +export interface IHomematicCcu { + id?: string; + name?: string; + host?: string; + port?: number; + manufacturer?: string; + model?: string; + serialNumber?: string; + firmwareVersion?: string; + online?: boolean; + serviceMessages?: IHomematicServiceMessage[]; + variables?: Record; + attributes?: Record; +} + +export interface IHomematicServiceMessage { + address?: string; + channel?: number; + parameter?: string; + message?: string; + value?: IHomematicValue; + attributes?: Record; +} + +export interface IHomematicInterface { + id: string; + name: string; + host?: string; + port?: number; + path?: string; + protocol?: THomematicProtocol; + family?: THomematicInterfaceFamily; + online: boolean; + attributes?: Record; +} + +export interface IHomematicDevice { + address: string; + name?: string; + type?: string; + interfaceName?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + firmwareVersion?: string; + available?: boolean; + unreach?: boolean; + channels: IHomematicChannel[]; + datapoints?: IHomematicDatapoint[]; + paramsets?: THomematicParamsetKey[]; + attributes?: Record; + raw?: Record; +} + +export interface IHomematicChannel { + address: string; + index: number; + name?: string; + type?: string; + parentAddress?: string; + datapoints: IHomematicDatapoint[]; + paramsets?: THomematicParamsetKey[]; + attributes?: Record; + raw?: Record; +} + +export interface IHomematicDatapoint { + name: string; + value?: IHomematicValue; + valueType?: THomematicValueType; + channel?: number; + address?: string; + kind?: THomematicDatapointKind; + paramsetKey?: THomematicParamsetKey; + operations?: number; + readable?: boolean; + writable?: boolean; + eventable?: boolean; + unit?: string; + min?: number; + max?: number; + valueList?: string[]; + deviceClass?: string; + stateClass?: string; + updatedAt?: string; + attributes?: Record; +} + +export interface IHomematicSnapshot { + ccu: IHomematicCcu; + interfaces: IHomematicInterface[]; + devices: IHomematicDevice[]; + events: IHomematicEvent[]; + connected: boolean; + updatedAt: string; + source: THomematicSnapshotSource; + raw?: Record; +} + +export interface IHomematicEvent { + type: THomematicEventType; + interfaceName?: string; + address?: string; + channel?: number; + datapoint?: string; + value?: IHomematicValue; + command?: IHomematicCommand; + entityId?: string; + deviceId?: string; + data?: unknown; + timestamp: number; +} + +export type IHomematicCommand = + | IHomematicSetValueCommand + | IHomematicPressCommand + | IHomematicTurnCommand + | IHomematicCoverCommand + | IHomematicSetTemperatureCommand + | IHomematicRefreshCommand + | IHomematicRawXmlRpcCommand; + +export interface IHomematicCommandBase { + interfaceName?: string; + address?: string; + channel?: number; + entityId?: string; + deviceId?: string; + uniqueId?: string; +} + +export interface IHomematicSetValueCommand extends IHomematicCommandBase { + type: 'set_value'; + address: string; + datapoint: string; + value: IHomematicValue; + valueType?: THomematicValueType; +} + +export interface IHomematicPressCommand extends IHomematicCommandBase { + type: 'press'; + address: string; + channel: number; + datapoint: string; + value?: IHomematicValue; +} + +export interface IHomematicTurnCommand extends IHomematicCommandBase { + type: 'turn_on' | 'turn_off'; + address: string; + channel: number; + datapoint: string; + value: IHomematicValue; +} + +export interface IHomematicCoverCommand extends IHomematicCommandBase { + type: 'open' | 'close' | 'stop'; + address: string; + channel: number; + datapoint: string; + value: IHomematicValue; +} + +export interface IHomematicSetTemperatureCommand extends IHomematicCommandBase { + type: 'set_temperature'; + address: string; + channel: number; + datapoint: string; + value: number; +} + +export interface IHomematicRefreshCommand { + type: 'refresh'; +} + +export interface IHomematicRawXmlRpcCommand extends IHomematicCommandBase { + type: 'raw_xmlrpc'; + method: string; + params?: unknown[]; +} + +export interface IHomematicCommandResult { + success: boolean; + error?: string; + data?: unknown; +} + +export interface IHomematicXmlRpcCommandShape { + protocol: 'xmlrpc'; + endpoint: string; + method: 'POST'; + contentType: 'text/xml'; + xmlRpcMethod: string; + params: unknown[]; + interfaceName?: string; + host?: string; + port?: number; + path?: string; +} + +export interface IHomematicManualEntry { + host?: string; + port?: number; + path?: string; + ssl?: boolean; + username?: string; + password?: string; + id?: string; + name?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + macAddress?: string; + interfaceName?: string; + metadata?: Record; +} + +export interface IHomematicMetadataDiscoveryRecord { + domain?: string; + integrationDomain?: string; + name?: string; + manufacturer?: string; + model?: string; + host?: string; + port?: number; + metadata?: Record; + [key: string]: unknown; +} + +export interface IHomematicXmlRpcDeviceDescription { + ADDRESS?: string; + TYPE?: string; + PARENT?: string; + PARENT_TYPE?: string; + INDEX?: number; + PARAMSETS?: string[]; + CHILDREN?: string[]; + VERSION?: number; + FIRMWARE?: string; + AVAILABLE_FIRMWARE?: string; + DIRECTION?: number; + AES_ACTIVE?: number | boolean; + LINK_SOURCE_ROLES?: string; + LINK_TARGET_ROLES?: string; + CHANNEL?: number; + INTERFACE?: string; + FLAGS?: number; + ID?: string; + [key: string]: unknown; +} + +export interface IHomematicXmlRpcParamsetDescription { + TYPE?: string; + OPERATIONS?: number; + MIN?: number; + MAX?: number; + UNIT?: string; + DEFAULT?: IHomematicValue; + VALUE_LIST?: string[]; [key: string]: unknown; } diff --git a/ts/integrations/homematic/index.ts b/ts/integrations/homematic/index.ts index 2022545..0c5b099 100644 --- a/ts/integrations/homematic/index.ts +++ b/ts/integrations/homematic/index.ts @@ -1,2 +1,7 @@ export * from './homematic.classes.integration.js'; +export * from './homematic.classes.client.js'; +export * from './homematic.classes.configflow.js'; +export * from './homematic.constants.js'; +export * from './homematic.discovery.js'; +export * from './homematic.mapper.js'; export * from './homematic.types.js'; diff --git a/ts/integrations/knx/.generated-by-smarthome-exchange b/ts/integrations/knx/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/knx/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/knx/index.ts b/ts/integrations/knx/index.ts index c780621..9e951fb 100644 --- a/ts/integrations/knx/index.ts +++ b/ts/integrations/knx/index.ts @@ -1,2 +1,6 @@ export * from './knx.classes.integration.js'; +export * from './knx.classes.client.js'; +export * from './knx.classes.configflow.js'; +export * from './knx.discovery.js'; +export * from './knx.mapper.js'; export * from './knx.types.js'; diff --git a/ts/integrations/knx/knx.classes.client.ts b/ts/integrations/knx/knx.classes.client.ts new file mode 100644 index 0000000..d609569 --- /dev/null +++ b/ts/integrations/knx/knx.classes.client.ts @@ -0,0 +1,65 @@ +import type { IKnxClientCommand, IKnxCommandResult, IKnxConfig, IKnxEvent, IKnxSnapshot } from './knx.types.js'; +import { KnxMapper } from './knx.mapper.js'; + +type TKnxEventHandler = (eventArg: IKnxEvent) => void; + +export class KnxClient { + private readonly events: IKnxEvent[] = []; + private readonly eventHandlers = new Set(); + + constructor(private readonly config: IKnxConfig) {} + + public async getSnapshot(): Promise { + return KnxMapper.toSnapshot(this.config, undefined, this.events); + } + + public onEvent(handlerArg: TKnxEventHandler): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async sendCommand(commandArg: IKnxClientCommand): Promise { + this.emit({ type: 'command_mapped', command: commandArg, entityId: commandArg.entityId, uniqueId: commandArg.uniqueId, timestamp: Date.now() }); + if (this.config.commandExecutor) { + const result = await this.config.commandExecutor(commandArg); + return this.commandResult(result, commandArg); + } + return { + success: false, + error: 'KNX live bus telegrams require the xknx KNXnet/IP stack and DPT transcoding. This dependency-free TypeScript port maps commands but does not send them to the bus.', + data: { command: commandArg }, + }; + } + + public async connectLive(): Promise { + throw new Error(this.unsupportedMessage()); + } + + public async destroy(): Promise { + this.eventHandlers.clear(); + } + + private emit(eventArg: IKnxEvent): void { + this.events.push(eventArg); + for (const handler of this.eventHandlers) { + handler(eventArg); + } + } + + private commandResult(resultArg: unknown, commandArg: IKnxClientCommand): IKnxCommandResult { + if (this.isCommandResult(resultArg)) { + return resultArg; + } + return { success: true, data: resultArg ?? { command: commandArg } }; + } + + private isCommandResult(valueArg: unknown): valueArg is IKnxCommandResult { + return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg; + } + + private unsupportedMessage(): string { + const connectionType = this.config.connectionType || this.config.connection_type || (this.config.host ? 'tunneling' : 'automatic'); + const target = this.config.host ? `${this.config.host}:${this.config.port || 3671}` : connectionType; + return `KNX live bus control for ${target} requires xknx-compatible KNXnet/IP transport and DPT conversion. This TypeScript port intentionally does not guess those internals.`; + } +} diff --git a/ts/integrations/knx/knx.classes.configflow.ts b/ts/integrations/knx/knx.classes.configflow.ts new file mode 100644 index 0000000..0e9223c --- /dev/null +++ b/ts/integrations/knx/knx.classes.configflow.ts @@ -0,0 +1,69 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IKnxConfig, TKnxConnectionType } from './knx.types.js'; + +export class KnxConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect KNX/IP interface', + description: 'Configure KNXnet/IP automatic, routing, or tunneling metadata. Live bus transport is intentionally not implemented in this dependency-free TypeScript port.', + fields: [ + { name: 'connectionType', label: 'Connection type', type: 'select', required: true, options: [ + { label: 'Automatic', value: 'automatic' }, + { label: 'Routing', value: 'routing' }, + { label: 'Secure routing', value: 'routing_secure' }, + { label: 'Tunneling UDP', value: 'tunneling' }, + { label: 'Tunneling TCP', value: 'tunneling_tcp' }, + { label: 'Secure tunneling TCP', value: 'tunneling_tcp_secure' }, + ] }, + { name: 'host', label: 'Gateway host', type: 'text' }, + { name: 'port', label: 'Gateway port', type: 'number' }, + { name: 'individualAddress', label: 'Individual address', type: 'text' }, + { name: 'localIp', label: 'Local IP', type: 'text' }, + { name: 'routeBack', label: 'Route back', type: 'boolean' }, + { name: 'multicastGroup', label: 'Routing multicast group', type: 'text' }, + { name: 'multicastPort', label: 'Routing multicast port', type: 'number' }, + { name: 'stateUpdater', label: 'Enable state updater', type: 'boolean' }, + { name: 'rateLimit', label: 'Telegram rate limit', type: 'number' }, + ], + submit: async (valuesArg) => ({ + kind: 'done', + title: 'KNX configured', + config: this.configFromValues(candidateArg, valuesArg), + }), + }; + } + + private configFromValues(candidateArg: IDiscoveryCandidate, valuesArg: Record): IKnxConfig { + const metadata = candidateArg.metadata || {}; + const connectionType = this.stringValue(valuesArg.connectionType) + || this.stringValue(metadata.connectionType || metadata.connection_type) + || (candidateArg.host ? 'tunneling' : 'automatic'); + return { + connectionType: connectionType as TKnxConnectionType, + host: this.stringValue(valuesArg.host) || candidateArg.host, + port: this.numberValue(valuesArg.port) || candidateArg.port || 3671, + individualAddress: this.stringValue(valuesArg.individualAddress) || this.stringValue(metadata.individualAddress || metadata.individual_address) || '0.0.240', + localIp: this.stringValue(valuesArg.localIp) || this.stringValue(metadata.localIp || metadata.local_ip), + routeBack: this.booleanValue(valuesArg.routeBack) ?? this.booleanValue(metadata.routeBack || metadata.route_back) ?? false, + multicastGroup: this.stringValue(valuesArg.multicastGroup) || this.stringValue(metadata.multicastGroup || metadata.multicast_group) || '224.0.23.12', + multicastPort: this.numberValue(valuesArg.multicastPort) || this.numberValue(metadata.multicastPort || metadata.multicast_port) || 3671, + stateUpdater: this.booleanValue(valuesArg.stateUpdater) ?? true, + rateLimit: this.numberValue(valuesArg.rateLimit) || 0, + gateway: metadata.descriptor as IKnxConfig['gateway'], + }; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined; + } + + private booleanValue(valueArg: unknown): boolean | undefined { + return typeof valueArg === 'boolean' ? valueArg : undefined; + } +} diff --git a/ts/integrations/knx/knx.classes.integration.ts b/ts/integrations/knx/knx.classes.integration.ts index 022bb5e..d414010 100644 --- a/ts/integrations/knx/knx.classes.integration.ts +++ b/ts/integrations/knx/knx.classes.integration.ts @@ -1,37 +1,113 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { KnxClient } from './knx.classes.client.js'; +import { KnxConfigFlow } from './knx.classes.configflow.js'; +import { createKnxDiscoveryDescriptor } from './knx.discovery.js'; +import { KnxMapper } from './knx.mapper.js'; +import type { IKnxClientCommand, IKnxConfig } from './knx.types.js'; -export class HomeAssistantKnxIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "knx", - displayName: "KNX", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/knx", - "upstreamDomain": "knx", - "integrationType": "hub", - "iotClass": "local_push", - "qualityScale": "platinum", - "requirements": [ - "xknx==3.15.0", - "xknxproject==3.9.0", - "knx-frontend==2026.4.30.60856" - ], - "dependencies": [ - "file_upload", - "http", - "websocket_api" - ], - "afterDependencies": [ - "panel_custom" - ], - "codeowners": [ - "@Julius2342", - "@farmio", - "@marvin-w" - ] -}, +export class KnxIntegration extends BaseIntegration { + public readonly domain = 'knx'; + public readonly displayName = 'KNX'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createKnxDiscoveryDescriptor(); + public readonly configFlow = new KnxConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/knx', + upstreamDomain: 'knx', + integrationType: 'hub', + iotClass: 'local_push', + qualityScale: 'platinum', + requirements: [ + 'xknx==3.15.0', + 'xknxproject==3.9.0', + 'knx-frontend==2026.4.30.60856', + ], + dependencies: [ + 'file_upload', + 'http', + 'websocket_api', + ], + afterDependencies: [ + 'panel_custom', + ], + codeowners: [ + '@Julius2342', + '@farmio', + '@marvin-w', + ], + }; + + public async setup(configArg: IKnxConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new KnxRuntime(new KnxClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantKnxIntegration extends KnxIntegration {} + +class KnxRuntime implements IIntegrationRuntime { + public domain = 'knx'; + + constructor(private readonly client: KnxClient) {} + + public async devices(): Promise { + return KnxMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return KnxMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => { + handlerArg(KnxMapper.toIntegrationEvent(eventArg)); }); + await this.client.getSnapshot(); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + const command = requestArg.domain === 'knx' + ? this.commandFromKnxService(requestArg) + : KnxMapper.commandForService(await this.client.getSnapshot(), requestArg); + if (!command) { + return { success: false, error: `Unsupported KNX service: ${requestArg.domain}.${requestArg.service}` }; + } + return this.client.sendCommand(command); + } + + public async destroy(): Promise { + await this.client.destroy(); + } + + private commandFromKnxService(requestArg: IServiceCallRequest): IKnxClientCommand | undefined { + if (requestArg.service === 'send' || requestArg.service === 'group_write') { + const addresses = this.addressesFromData(requestArg.data?.address ?? requestArg.data?.addresses); + const dpt = this.stringValue(requestArg.data?.type ?? requestArg.data?.dpt); + return KnxMapper.groupWriteCommand(addresses, requestArg.data?.payload, dpt, requestArg.data?.response === true); + } + if (requestArg.service === 'read' || requestArg.service === 'group_read') { + return KnxMapper.groupReadCommand(this.addressesFromData(requestArg.data?.address ?? requestArg.data?.addresses)); + } + return undefined; + } + + private addressesFromData(valueArg: unknown): string[] { + if (typeof valueArg === 'string' && valueArg.trim()) { + return [valueArg.trim()]; + } + if (Array.isArray(valueArg)) { + return valueArg.filter((itemArg): itemArg is string => typeof itemArg === 'string' && Boolean(itemArg.trim())).map((itemArg) => itemArg.trim()); + } + return []; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; } } diff --git a/ts/integrations/knx/knx.discovery.ts b/ts/integrations/knx/knx.discovery.ts new file mode 100644 index 0000000..e8446fa --- /dev/null +++ b/ts/integrations/knx/knx.discovery.ts @@ -0,0 +1,220 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IKnxGatewayDescriptor, IKnxManualDiscoveryEntry, IKnxMdnsRecord, TKnxConnectionType } from './knx.types.js'; + +const defaultKnxPort = 3671; + +export class KnxManualMatcher implements IDiscoveryMatcher { + public id = 'knx-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual KNX/IP connection entries.'; + + public async matches(inputArg: IKnxManualDiscoveryEntry, contextArg: IDiscoveryContext): Promise { + void contextArg; + const connectionType = this.connectionType(inputArg.connectionType || inputArg.connection_type, inputArg.host); + const model = inputArg.model?.toLowerCase() || ''; + const metadataKnx = inputArg.metadata?.knx === true; + const matched = Boolean(inputArg.host || connectionType !== 'automatic' || metadataKnx || model.includes('knx')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain KNX/IP setup hints.' }; + } + return { + matched: true, + confidence: inputArg.host || inputArg.gateway ? 'high' : 'medium', + reason: 'Manual entry can configure a KNX/IP connection.', + normalizedDeviceId: inputArg.id || inputArg.host || inputArg.individualAddress || inputArg.individual_address, + candidate: { + source: 'manual', + integrationDomain: 'knx', + id: inputArg.id || inputArg.host || inputArg.individualAddress || inputArg.individual_address, + host: inputArg.host || this.gatewayHost(inputArg.gateway), + port: inputArg.port || inputArg.gateway?.port || defaultKnxPort, + name: inputArg.name || inputArg.gateway?.name || 'KNX/IP interface', + manufacturer: inputArg.manufacturer || 'KNX Association', + model: inputArg.model || this.connectionLabel(connectionType), + metadata: { + ...inputArg.metadata, + knx: true, + connectionType, + individualAddress: inputArg.individualAddress || inputArg.individual_address, + localIp: inputArg.localIp || inputArg.local_ip, + multicastGroup: inputArg.multicastGroup || inputArg.multicast_group, + multicastPort: inputArg.multicastPort || inputArg.multicast_port, + routeBack: inputArg.routeBack ?? inputArg.route_back, + gateway: inputArg.gateway, + }, + }, + }; + } + + private connectionType(valueArg: unknown, hostArg?: string): TKnxConnectionType { + return typeof valueArg === 'string' && valueArg ? valueArg : hostArg ? 'tunneling' : 'automatic'; + } + + private gatewayHost(gatewayArg?: IKnxGatewayDescriptor): string | undefined { + return gatewayArg?.host || gatewayArg?.ipAddr || gatewayArg?.ip_addr || gatewayArg?.ipAddress; + } + + private connectionLabel(connectionTypeArg: TKnxConnectionType): string { + return `KNX/IP ${String(connectionTypeArg).replace(/_/g, ' ')}`; + } +} + +export class KnxGatewayDescriptorMatcher implements IDiscoveryMatcher { + public id = 'knx-gateway-descriptor-match'; + public source = 'custom' as const; + public description = 'Recognize xknx GatewayDescriptor-like KNXnet/IP discovery results.'; + + public async matches(inputArg: IKnxGatewayDescriptor, contextArg: IDiscoveryContext): Promise { + void contextArg; + const host = this.gatewayHost(inputArg); + const supportsRouting = inputArg.supportsRouting ?? inputArg.supports_routing ?? false; + const supportsTunneling = inputArg.supportsTunneling ?? inputArg.supportsTunnelling ?? inputArg.supports_tunnelling ?? false; + const supportsTunnelingTcp = inputArg.supportsTunnelingTcp ?? inputArg.supportsTunnellingTcp ?? inputArg.supports_tunnelling_tcp ?? false; + const supportsSecure = inputArg.supportsSecure ?? inputArg.supports_secure ?? false; + const matched = Boolean(host && (inputArg.port === defaultKnxPort || supportsRouting || supportsTunneling || supportsTunnelingTcp || supportsSecure)); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Gateway descriptor does not describe a KNXnet/IP interface.' }; + } + const connectionType = this.connectionType(inputArg); + const individualAddress = this.individualAddressString(inputArg.individualAddress || inputArg.individual_address); + return { + matched: true, + confidence: supportsRouting || supportsTunneling || supportsTunnelingTcp ? 'certain' : 'high', + reason: 'Gateway descriptor exposes KNXnet/IP service families.', + normalizedDeviceId: inputArg.id || individualAddress || host, + candidate: { + source: 'custom', + integrationDomain: 'knx', + id: inputArg.id || individualAddress || host, + host, + port: inputArg.port || defaultKnxPort, + name: inputArg.name || 'KNX/IP interface', + manufacturer: 'KNX Association', + model: this.connectionLabel(connectionType), + metadata: { + knx: true, + connectionType, + individualAddress, + localIp: inputArg.localIp || inputArg.local_ip, + localInterface: inputArg.localInterface || inputArg.local_interface, + supportsRouting, + supportsTunneling, + supportsTunnelingTcp, + supportsSecure, + routingRequiresSecure: inputArg.routingRequiresSecure ?? inputArg.routing_requires_secure, + tunnelingRequiresSecure: inputArg.tunnelingRequiresSecure ?? inputArg.tunnellingRequiresSecure ?? inputArg.tunnelling_requires_secure, + tunnelingSlots: inputArg.tunnelingSlots || inputArg.tunnellingSlots || inputArg.tunnelling_slots, + descriptor: inputArg, + }, + }, + }; + } + + private connectionType(gatewayArg: IKnxGatewayDescriptor): TKnxConnectionType { + const tunnelingRequiresSecure = gatewayArg.tunnelingRequiresSecure ?? gatewayArg.tunnellingRequiresSecure ?? gatewayArg.tunnelling_requires_secure; + const routingRequiresSecure = gatewayArg.routingRequiresSecure ?? gatewayArg.routing_requires_secure; + if (tunnelingRequiresSecure) { + return 'tunneling_tcp_secure'; + } + if (gatewayArg.supportsTunnelingTcp || gatewayArg.supportsTunnellingTcp || gatewayArg.supports_tunnelling_tcp) { + return 'tunneling_tcp'; + } + if (gatewayArg.supportsTunneling || gatewayArg.supportsTunnelling || gatewayArg.supports_tunnelling) { + return 'tunneling'; + } + if (routingRequiresSecure) { + return 'routing_secure'; + } + if (gatewayArg.supportsRouting || gatewayArg.supports_routing) { + return 'routing'; + } + return 'automatic'; + } + + private gatewayHost(gatewayArg: IKnxGatewayDescriptor): string | undefined { + return gatewayArg.host || gatewayArg.ipAddr || gatewayArg.ip_addr || gatewayArg.ipAddress; + } + + private individualAddressString(valueArg: unknown): string | undefined { + if (typeof valueArg === 'string') { + return valueArg; + } + if (valueArg && typeof valueArg === 'object' && 'toString' in valueArg) { + const rendered = String(valueArg); + return rendered === '[object Object]' ? undefined : rendered; + } + return undefined; + } + + private connectionLabel(connectionTypeArg: TKnxConnectionType): string { + return `KNX/IP ${String(connectionTypeArg).replace(/_/g, ' ')}`; + } +} + +export class KnxMdnsMatcher implements IDiscoveryMatcher { + public id = 'knx-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize KNXnet/IP mDNS service records when a discovery broker provides them.'; + + public async matches(inputArg: IKnxMdnsRecord, contextArg: IDiscoveryContext): Promise { + void contextArg; + const service = `${inputArg.type || ''} ${inputArg.name || ''}`.toLowerCase(); + const txt = inputArg.txt || inputArg.properties || {}; + const matched = service.includes('knx') || service.includes('knxnet-ip') || txt.knx === 'true'; + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not a KNXnet/IP service.' }; + } + const host = inputArg.host || inputArg.hostname || inputArg.addresses?.[0]; + return { + matched: true, + confidence: host ? 'high' : 'medium', + reason: 'mDNS service name indicates KNXnet/IP.', + normalizedDeviceId: txt.id || host, + candidate: { + source: 'mdns', + integrationDomain: 'knx', + id: txt.id || host, + host, + port: inputArg.port || defaultKnxPort, + name: inputArg.name || 'KNX/IP interface', + manufacturer: 'KNX Association', + model: 'KNX/IP interface', + metadata: { + knx: true, + connectionType: txt.connection_type || txt.connectionType || 'automatic', + txt, + }, + }, + }; + } +} + +export class KnxCandidateValidator implements IDiscoveryValidator { + public id = 'knx-candidate-validator'; + public description = 'Validate KNX/IP gateway or manual setup candidates.'; + + public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise { + void contextArg; + const metadataConnectionType = candidateArg.metadata?.connectionType || candidateArg.metadata?.connection_type; + const matched = candidateArg.integrationDomain === 'knx' + || candidateArg.metadata?.knx === true + || candidateArg.port === defaultKnxPort + || typeof metadataConnectionType === 'string' && ['automatic', 'routing', 'routing_secure', 'tunneling', 'tunneling_tcp', 'tunneling_tcp_secure'].includes(metadataConnectionType); + return { + matched, + confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has KNX/IP metadata.' : 'Candidate is not KNX/IP.', + candidate: matched ? candidateArg : undefined, + normalizedDeviceId: candidateArg.id || candidateArg.host, + }; + } +} + +export const createKnxDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'knx', displayName: 'KNX' }) + .addMatcher(new KnxManualMatcher()) + .addMatcher(new KnxGatewayDescriptorMatcher()) + .addMatcher(new KnxMdnsMatcher()) + .addValidator(new KnxCandidateValidator()); +}; diff --git a/ts/integrations/knx/knx.mapper.ts b/ts/integrations/knx/knx.mapper.ts new file mode 100644 index 0000000..689e15f --- /dev/null +++ b/ts/integrations/knx/knx.mapper.ts @@ -0,0 +1,559 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import type { IKnxClientCommand, IKnxConfig, IKnxEntityDescriptor, IKnxEvent, IKnxGatewayDescriptor, IKnxGroupAddressDescriptor, IKnxSnapshot, IKnxTelegramCommand, TKnxEntityPlatform } from './knx.types.js'; + +const defaultPort = 3671; +const defaultMulticastGroup = '224.0.23.12'; +const defaultRoutingAddress = '0.0.240'; +const mappedPlatforms = ['light', 'switch', 'sensor', 'cover', 'climate'] as const; + +export class KnxMapper { + public static toSnapshot(configArg: IKnxConfig, connectedArg?: boolean, eventsArg: IKnxEvent[] = []): IKnxSnapshot { + const source = configArg.snapshot; + const gateways = [ + ...(source?.gateways || []), + ...(configArg.gateways || []), + ...(configArg.tunnels || []), + ...(configArg.gateway ? [configArg.gateway] : []), + ]; + const groupAddresses = [ + ...(source?.groupAddresses || []), + ...(configArg.groupAddresses || []), + ...(configArg.group_addresses || []), + ]; + + return { + connected: connectedArg ?? source?.connected ?? false, + connection: this.connectionSnapshot(configArg, source), + gateways, + groupAddresses, + entities: [ + ...(source?.entities || []), + ...(configArg.entities || []), + ...this.entitiesFromPlatformConfig(configArg), + ...this.entitiesFromGroupAddresses(groupAddresses), + ], + events: [...(source?.events || []), ...(configArg.events || []), ...eventsArg], + }; + } + + public static toDevices(snapshotArg: IKnxSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = new Date().toISOString(); + const devices = new Map(); + const interfaceId = this.interfaceDeviceId(snapshotArg); + devices.set(interfaceId, { + id: interfaceId, + integrationDomain: 'knx', + name: this.interfaceName(snapshotArg), + protocol: 'unknown', + manufacturer: 'KNX Association', + model: this.connectionLabel(snapshotArg.connection.connectionType), + online: snapshotArg.connected || Boolean(snapshotArg.entities.length || snapshotArg.gateways.length), + features: [ + { id: 'connection', capability: 'sensor', name: 'Connection', readable: true, writable: false }, + { id: 'entity_count', capability: 'sensor', name: 'Entity count', readable: true, writable: false }, + { id: 'gateway_count', capability: 'sensor', name: 'Gateway count', readable: true, writable: false }, + ], + state: [ + { featureId: 'connection', value: snapshotArg.connected ? 'connected' : 'configured', updatedAt }, + { featureId: 'entity_count', value: snapshotArg.entities.length, updatedAt }, + { featureId: 'gateway_count', value: snapshotArg.gateways.length, updatedAt }, + ], + metadata: { + protocol: 'knx', + connection: snapshotArg.connection, + gateways: snapshotArg.gateways, + }, + }); + + for (const entity of this.allEntities(snapshotArg)) { + const platform = this.corePlatform(entity.platform || 'sensor'); + const deviceId = this.entityDeviceId(entity); + const feature = this.featureForEntity(entity, platform); + const state = { featureId: feature.id, value: this.deviceStateValue(this.entityState(entity, platform)), updatedAt }; + const existing = devices.get(deviceId); + if (existing) { + existing.features.push(feature); + existing.state.push(state); + continue; + } + devices.set(deviceId, { + id: deviceId, + integrationDomain: 'knx', + name: this.deviceName(entity, platform), + protocol: 'unknown', + manufacturer: 'KNX', + model: `KNX ${platform}`, + online: entity.available !== false, + features: [feature], + state: [state], + metadata: { + protocol: 'knx', + addresses: this.entityAddresses(entity), + dpt: entity.dpt || entity.type, + deviceClass: entity.deviceClass || entity.device_class, + syncState: entity.syncState ?? entity.sync_state, + raw: entity.raw, + }, + }); + } + + return [...devices.values()]; + } + + public static toEntities(snapshotArg: IKnxSnapshot): IIntegrationEntity[] { + return this.allEntities(snapshotArg).map((entityArg) => { + const platform = this.corePlatform(entityArg.platform || 'sensor'); + return { + id: entityArg.entityId || entityArg.entity_id || `${platform}.${this.slug(this.entityName(entityArg, platform))}`, + uniqueId: this.uniqueIdForEntity(entityArg, platform), + integrationDomain: 'knx', + deviceId: this.entityDeviceId(entityArg), + platform, + name: this.entityName(entityArg, platform), + state: this.entityState(entityArg, platform), + attributes: { + ...entityArg.attributes, + knxPlatform: entityArg.platform, + addresses: this.entityAddresses(entityArg), + dpt: entityArg.dpt || entityArg.type, + deviceClass: entityArg.deviceClass || entityArg.device_class, + unit: entityArg.unit || entityArg.unitOfMeasurement || entityArg.unit_of_measurement, + writable: this.entityWritable(entityArg, platform), + }, + available: entityArg.available !== false, + }; + }); + } + + public static commandForService(snapshotArg: IKnxSnapshot, requestArg: IServiceCallRequest): IKnxClientCommand | undefined { + const entity = this.findTargetEntity(snapshotArg, requestArg); + if (!entity) { + return undefined; + } + const platform = this.corePlatform(entity.platform || requestArg.domain || 'sensor'); + const telegrams = this.telegramsForEntityService(entity, platform, requestArg); + if (!telegrams.length) { + return undefined; + } + return { + type: 'entity.command', + service: requestArg.service, + platform, + entityId: entity.entityId || entity.entity_id, + uniqueId: this.uniqueIdForEntity(entity, platform), + telegrams, + payload: { + serviceData: requestArg.data || {}, + entityName: this.entityName(entity, platform), + }, + target: requestArg.target, + }; + } + + public static groupWriteCommand(addressesArg: string[], payloadArg: unknown, dptArg?: string, responseArg = false): IKnxClientCommand | undefined { + const telegrams = this.uniqueAddresses(addressesArg).map((addressArg) => ({ + action: responseArg ? 'response' as const : 'write' as const, + address: addressArg, + payload: payloadArg, + dpt: dptArg, + })); + if (!telegrams.length) { + return undefined; + } + return { + type: 'group.write', + service: 'send', + telegrams, + payload: { + payload: payloadArg, + dpt: dptArg, + response: responseArg, + }, + }; + } + + public static groupReadCommand(addressesArg: string[]): IKnxClientCommand | undefined { + const telegrams = this.uniqueAddresses(addressesArg).map((addressArg) => ({ + action: 'read' as const, + address: addressArg, + })); + if (!telegrams.length) { + return undefined; + } + return { + type: 'group.read', + service: 'read', + telegrams, + payload: {}, + }; + } + + public static toIntegrationEvent(eventArg: IKnxEvent) { + return { + type: eventArg.type === 'device_removed' ? 'device_removed' as const : eventArg.type === 'availability_changed' ? 'availability_changed' as const : 'state_changed' as const, + integrationDomain: 'knx', + deviceId: eventArg.deviceId, + entityId: eventArg.entityId, + data: eventArg, + timestamp: eventArg.timestamp || Date.now(), + }; + } + + public static corePlatform(platformArg: TKnxEntityPlatform): TEntityPlatform { + const platform = String(platformArg).toLowerCase(); + if (platform === 'binary_sensor') { + return 'sensor'; + } + const supported: TEntityPlatform[] = ['light', 'switch', 'sensor', 'cover', 'climate']; + return supported.includes(platform as TEntityPlatform) ? platform as TEntityPlatform : 'sensor'; + } + + private static connectionSnapshot(configArg: IKnxConfig, sourceArg?: IKnxSnapshot) { + const source = sourceArg?.connection; + const connectionType = this.stringValue(configArg.connectionType || configArg.connection_type || source?.connectionType) + || (configArg.host || source?.host ? 'tunneling' : 'automatic'); + const port = this.numberValue(configArg.port ?? source?.port) || defaultPort; + const multicastPort = this.numberValue(configArg.multicastPort ?? configArg.multicast_port ?? source?.multicastPort ?? source?.multicast_port) || defaultPort; + return { + ...source, + connectionType, + host: this.stringValue(configArg.host || source?.host), + port, + individualAddress: this.stringValue(configArg.individualAddress || configArg.individual_address || source?.individualAddress || source?.individual_address) || defaultRoutingAddress, + localIp: this.stringValue(configArg.localIp || configArg.local_ip || source?.localIp || source?.local_ip), + multicastGroup: this.stringValue(configArg.multicastGroup || configArg.multicast_group || source?.multicastGroup || source?.multicast_group) || defaultMulticastGroup, + multicastPort, + routeBack: Boolean(configArg.routeBack ?? configArg.route_back ?? source?.routeBack ?? source?.route_back), + tunnelEndpointIa: this.stringValue(configArg.tunnelEndpointIa || configArg.tunnel_endpoint_ia || source?.tunnelEndpointIa || source?.tunnel_endpoint_ia), + stateUpdater: configArg.stateUpdater ?? configArg.state_updater ?? source?.stateUpdater ?? source?.state_updater ?? true, + rateLimit: this.numberValue(configArg.rateLimit ?? configArg.rate_limit ?? source?.rateLimit ?? source?.rate_limit) || 0, + telegramLogSize: this.numberValue(configArg.telegramLogSize ?? configArg.telegram_log_size ?? source?.telegramLogSize ?? source?.telegram_log_size), + secure: connectionType.includes('secure') || Boolean(configArg.userId ?? configArg.user_id ?? configArg.backboneKey ?? configArg.backbone_key), + }; + } + + private static entitiesFromPlatformConfig(configArg: IKnxConfig): IKnxEntityDescriptor[] { + const entities: IKnxEntityDescriptor[] = []; + for (const platform of mappedPlatforms) { + for (const item of this.asArray(configArg[platform])) { + const entity = this.asRecord(item); + if (entity) { + entities.push({ ...entity, platform } as IKnxEntityDescriptor); + } + } + } + return entities; + } + + private static entitiesFromGroupAddresses(groupAddressesArg: IKnxGroupAddressDescriptor[]): IKnxEntityDescriptor[] { + const entities: IKnxEntityDescriptor[] = []; + for (const groupAddress of groupAddressesArg) { + const platform = groupAddress.platform ? this.corePlatform(groupAddress.platform) : undefined; + if (!platform) { + continue; + } + const role = String(groupAddress.role || '').toLowerCase(); + entities.push({ + platform, + entityId: groupAddress.entityId || groupAddress.entity_id, + uniqueId: groupAddress.uniqueId || groupAddress.unique_id, + deviceId: groupAddress.deviceId || groupAddress.device_id, + name: groupAddress.name, + address: role.includes('state') || role.includes('status') ? undefined : groupAddress.address, + stateAddress: role.includes('state') || role.includes('status') ? groupAddress.address : undefined, + dpt: groupAddress.dpt || groupAddress.type, + state: groupAddress.state ?? groupAddress.value, + writable: groupAddress.writable, + unit: groupAddress.unit, + attributes: groupAddress.metadata, + }); + } + return entities; + } + + private static allEntities(snapshotArg: IKnxSnapshot): IKnxEntityDescriptor[] { + return snapshotArg.entities.filter((entityArg) => mappedPlatforms.includes(this.corePlatform(entityArg.platform || 'sensor') as typeof mappedPlatforms[number])); + } + + private static findTargetEntity(snapshotArg: IKnxSnapshot, requestArg: IServiceCallRequest): IKnxEntityDescriptor | undefined { + const entities = this.allEntities(snapshotArg); + if (requestArg.target.entityId) { + const normalized = this.toEntities(snapshotArg).find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId); + if (normalized) { + return entities.find((entityArg) => this.uniqueIdForEntity(entityArg, this.corePlatform(entityArg.platform || 'sensor')) === normalized.uniqueId); + } + return entities.find((entityArg) => entityArg.entityId === requestArg.target.entityId || entityArg.entity_id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId || entityArg.unique_id === requestArg.target.entityId); + } + if (requestArg.target.deviceId) { + return entities.find((entityArg) => this.entityDeviceId(entityArg) === requestArg.target.deviceId && this.entityWritable(entityArg, this.corePlatform(entityArg.platform || 'sensor'))) + || entities.find((entityArg) => this.entityDeviceId(entityArg) === requestArg.target.deviceId); + } + return entities.find((entityArg) => this.corePlatform(entityArg.platform || 'sensor') === requestArg.domain && this.entityWritable(entityArg, this.corePlatform(entityArg.platform || 'sensor'))) + || entities.find((entityArg) => this.entityWritable(entityArg, this.corePlatform(entityArg.platform || 'sensor'))); + } + + private static telegramsForEntityService(entityArg: IKnxEntityDescriptor, platformArg: TEntityPlatform, requestArg: IServiceCallRequest): IKnxTelegramCommand[] { + if (platformArg === 'light' || platformArg === 'switch') { + return this.switchTelegrams(entityArg, platformArg, requestArg); + } + if (platformArg === 'cover') { + return this.coverTelegrams(entityArg, requestArg); + } + if (platformArg === 'climate') { + return this.climateTelegrams(entityArg, requestArg); + } + return []; + } + + private static switchTelegrams(entityArg: IKnxEntityDescriptor, platformArg: TEntityPlatform, requestArg: IServiceCallRequest): IKnxTelegramCommand[] { + const switchAddresses = this.addressesFrom(entityArg, ['address', 'groupAddress', 'group_address', 'switchAddress', 'switch_address']); + const brightnessAddresses = this.addressesFrom(entityArg, ['brightnessAddress', 'brightness_address']); + if (requestArg.service === 'turn_on') { + if (platformArg === 'light' && typeof requestArg.data?.brightness === 'number' && brightnessAddresses.length) { + return this.writeTelegrams(brightnessAddresses, requestArg.data.brightness, entityArg.dpt || '5.001'); + } + return this.writeTelegrams(switchAddresses, this.booleanPayload(true, entityArg.invert === true), entityArg.dpt || '1.001'); + } + if (requestArg.service === 'turn_off') { + if (!switchAddresses.length && platformArg === 'light' && brightnessAddresses.length) { + return this.writeTelegrams(brightnessAddresses, 0, entityArg.dpt || '5.001'); + } + return this.writeTelegrams(switchAddresses, this.booleanPayload(false, entityArg.invert === true), entityArg.dpt || '1.001'); + } + return []; + } + + private static coverTelegrams(entityArg: IKnxEntityDescriptor, requestArg: IServiceCallRequest): IKnxTelegramCommand[] { + const longAddresses = this.addressesFrom(entityArg, ['moveLongAddress', 'move_long_address', 'address', 'groupAddress', 'group_address']); + const stopAddresses = this.addressesFrom(entityArg, ['stopAddress', 'stop_address']); + const positionAddresses = this.addressesFrom(entityArg, ['positionAddress', 'position_address']); + const invertUpdown = entityArg.invertUpdown === true || entityArg.invert_updown === true; + const invertPosition = entityArg.invertPosition === true || entityArg.invert_position === true; + if (requestArg.service === 'open_cover') { + return this.writeTelegrams(longAddresses, this.booleanPayload(false, invertUpdown), '1.001'); + } + if (requestArg.service === 'close_cover') { + return this.writeTelegrams(longAddresses, this.booleanPayload(true, invertUpdown), '1.001'); + } + if (requestArg.service === 'stop_cover') { + return this.writeTelegrams(stopAddresses, true, '1.001'); + } + if (requestArg.service === 'set_cover_position' || requestArg.service === 'set_position') { + const position = this.numberValue(requestArg.data?.position ?? requestArg.data?.percentage); + if (position === undefined) { + return []; + } + const knxPosition = invertPosition ? position : 100 - position; + return this.writeTelegrams(positionAddresses, knxPosition, '5.001'); + } + return []; + } + + private static climateTelegrams(entityArg: IKnxEntityDescriptor, requestArg: IServiceCallRequest): IKnxTelegramCommand[] { + const targetTemperatureAddresses = this.addressesFrom(entityArg, ['targetTemperatureAddress', 'target_temperature_address']); + const onOffAddresses = this.addressesFrom(entityArg, ['onOffAddress', 'on_off_address']); + const modeAddresses = this.addressesFrom(entityArg, ['controllerModeAddress', 'controller_mode_address', 'operationModeAddress', 'operation_mode_address']); + if (requestArg.service === 'set_temperature') { + const temperature = this.numberValue(requestArg.data?.temperature); + return temperature === undefined ? [] : this.writeTelegrams(targetTemperatureAddresses, temperature, '9.001'); + } + if (requestArg.service === 'turn_on') { + return this.writeTelegrams(onOffAddresses, this.booleanPayload(true, entityArg.invert === true), '1.001'); + } + if (requestArg.service === 'turn_off') { + if (onOffAddresses.length) { + return this.writeTelegrams(onOffAddresses, this.booleanPayload(false, entityArg.invert === true), '1.001'); + } + return this.writeTelegrams(modeAddresses, 'off', '20.105'); + } + if (requestArg.service === 'set_hvac_mode') { + const mode = this.stringValue(requestArg.data?.hvac_mode ?? requestArg.data?.hvacMode); + return mode ? this.writeTelegrams(modeAddresses, mode, '20.105') : []; + } + return []; + } + + private static writeTelegrams(addressesArg: string[], payloadArg: unknown, dptArg?: string): IKnxTelegramCommand[] { + return this.uniqueAddresses(addressesArg).map((addressArg) => ({ action: 'write' as const, address: addressArg, payload: payloadArg, dpt: dptArg })); + } + + private static featureForEntity(entityArg: IKnxEntityDescriptor, platformArg: TEntityPlatform): plugins.shxInterfaces.data.IDeviceFeature { + return { + id: this.slug(entityArg.uniqueId || entityArg.unique_id || entityArg.entityId || entityArg.entity_id || this.entityName(entityArg, platformArg)), + capability: this.capabilityForPlatform(platformArg), + name: this.entityName(entityArg, platformArg), + readable: true, + writable: this.entityWritable(entityArg, platformArg), + unit: entityArg.unit || entityArg.unitOfMeasurement || entityArg.unit_of_measurement, + }; + } + + private static entityWritable(entityArg: IKnxEntityDescriptor, platformArg: TEntityPlatform): boolean { + if (typeof entityArg.writable === 'boolean') { + return entityArg.writable; + } + return ['light', 'switch', 'cover', 'climate'].includes(platformArg) && this.addressesFrom(entityArg, ['address', 'groupAddress', 'group_address', 'switchAddress', 'switch_address', 'moveLongAddress', 'move_long_address', 'positionAddress', 'position_address', 'targetTemperatureAddress', 'target_temperature_address', 'onOffAddress', 'on_off_address']).length > 0; + } + + private static entityState(entityArg: IKnxEntityDescriptor, platformArg: TEntityPlatform): unknown { + const rawValue = entityArg.state ?? entityArg.nativeValue ?? entityArg.native_value ?? entityArg.value; + if (platformArg === 'light' || platformArg === 'switch') { + const value = entityArg.isOn ?? entityArg.is_on ?? rawValue; + if (typeof value === 'boolean') { + return value ? 'on' : 'off'; + } + if (typeof value === 'string') { + return value.toLowerCase() === 'true' ? 'on' : value.toLowerCase() === 'false' ? 'off' : value; + } + return value ?? 'unknown'; + } + if (platformArg === 'cover') { + const position = entityArg.currentCoverPosition ?? entityArg.current_cover_position ?? entityArg.position; + if (typeof position === 'number') { + return position; + } + const closed = entityArg.isClosed ?? entityArg.is_closed; + return typeof closed === 'boolean' ? closed ? 'closed' : 'open' : rawValue ?? 'unknown'; + } + if (platformArg === 'climate') { + return entityArg.hvacMode || entityArg.hvac_mode || rawValue || 'unknown'; + } + return rawValue ?? 'unknown'; + } + + private static entityAddresses(entityArg: IKnxEntityDescriptor): Record { + return { + write: this.addressesFrom(entityArg, ['address', 'groupAddress', 'group_address', 'switchAddress', 'switch_address', 'moveLongAddress', 'move_long_address', 'positionAddress', 'position_address', 'targetTemperatureAddress', 'target_temperature_address', 'onOffAddress', 'on_off_address']), + state: this.addressesFrom(entityArg, ['stateAddress', 'state_address', 'switchStateAddress', 'switch_state_address', 'brightnessStateAddress', 'brightness_state_address', 'positionStateAddress', 'position_state_address', 'targetTemperatureStateAddress', 'target_temperature_state_address', 'onOffStateAddress', 'on_off_state_address']), + }; + } + + private static entityDeviceId(entityArg: IKnxEntityDescriptor): string { + const explicit = entityArg.deviceId || entityArg.device_id; + if (explicit) { + return explicit; + } + return `knx.entity.${this.slug(entityArg.uniqueId || entityArg.unique_id || entityArg.entityId || entityArg.entity_id || this.firstEntityAddress(entityArg) || this.entityName(entityArg, this.corePlatform(entityArg.platform || 'sensor')))}`; + } + + private static uniqueIdForEntity(entityArg: IKnxEntityDescriptor, platformArg: TEntityPlatform): string { + return entityArg.uniqueId || entityArg.unique_id || `knx_${platformArg}_${this.slug(this.firstEntityAddress(entityArg) || entityArg.entityId || entityArg.entity_id || this.entityName(entityArg, platformArg))}`; + } + + private static deviceName(entityArg: IKnxEntityDescriptor, platformArg: TEntityPlatform): string { + return entityArg.name || entityArg.originalName || entityArg.original_name || `${this.connectionLabel(platformArg)} ${this.firstEntityAddress(entityArg) || 'entity'}`; + } + + private static entityName(entityArg: IKnxEntityDescriptor, platformArg: TEntityPlatform): string { + return entityArg.name || entityArg.originalName || entityArg.original_name || entityArg.fallbackName || entityArg.fallback_name || entityArg.entityId || entityArg.entity_id || `${this.connectionLabel(platformArg)} ${this.firstEntityAddress(entityArg) || 'entity'}`; + } + + private static interfaceDeviceId(snapshotArg: IKnxSnapshot): string { + const gateway = snapshotArg.gateways[0]; + const gatewayId = gateway ? this.gatewayId(gateway) : snapshotArg.connection.host || snapshotArg.connection.individualAddress || snapshotArg.connection.connectionType; + return `knx.interface.${this.slug(String(gatewayId || 'automatic'))}`; + } + + private static interfaceName(snapshotArg: IKnxSnapshot): string { + const gateway = snapshotArg.gateways[0]; + return gateway?.name || (snapshotArg.connection.host ? `KNX/IP interface ${snapshotArg.connection.host}` : 'KNX interface'); + } + + private static gatewayId(gatewayArg: IKnxGatewayDescriptor): string { + return gatewayArg.id || this.individualAddressString(gatewayArg.individualAddress || gatewayArg.individual_address) || gatewayArg.host || gatewayArg.ipAddr || gatewayArg.ip_addr || gatewayArg.ipAddress || gatewayArg.name || 'gateway'; + } + + private static firstEntityAddress(entityArg: IKnxEntityDescriptor): string | undefined { + return this.addressesFrom(entityArg, ['address', 'groupAddress', 'group_address', 'stateAddress', 'state_address', 'switchAddress', 'switch_address', 'brightnessAddress', 'brightness_address', 'moveLongAddress', 'move_long_address', 'temperatureAddress', 'temperature_address'])[0]; + } + + private static addressesFrom(entityArg: IKnxEntityDescriptor, keysArg: string[]): string[] { + const addresses: string[] = []; + const record = entityArg as Record; + for (const key of keysArg) { + addresses.push(...this.addressValues(record[key])); + } + return this.uniqueAddresses(addresses); + } + + private static addressValues(valueArg: unknown): string[] { + if (typeof valueArg === 'string' && valueArg.trim()) { + return [valueArg.trim()]; + } + if (Array.isArray(valueArg)) { + return valueArg.filter((itemArg): itemArg is string => typeof itemArg === 'string' && Boolean(itemArg.trim())).map((itemArg) => itemArg.trim()); + } + return []; + } + + private static uniqueAddresses(addressesArg: string[]): string[] { + return [...new Set(addressesArg.filter(Boolean))]; + } + + private static booleanPayload(valueArg: boolean, invertArg: boolean): boolean { + return invertArg ? !valueArg : valueArg; + } + + private static capabilityForPlatform(platformArg: TEntityPlatform): plugins.shxInterfaces.data.TDeviceCapability { + if (platformArg === 'light') { + return 'light'; + } + if (platformArg === 'cover') { + return 'cover'; + } + if (platformArg === 'climate') { + return 'climate'; + } + return platformArg === 'switch' ? 'switch' : 'sensor'; + } + + private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue { + if (Array.isArray(valueArg)) { + return JSON.stringify(valueArg); + } + if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null || this.isRecord(valueArg)) { + return valueArg; + } + return valueArg === undefined ? null : String(valueArg); + } + + private static connectionLabel(valueArg: unknown): string { + return String(valueArg || 'KNX').replace(/_/g, ' ').replace(/\b\w/g, (matchArg) => matchArg.toUpperCase()); + } + + private static individualAddressString(valueArg: unknown): string | undefined { + if (typeof valueArg === 'string') { + return valueArg; + } + if (valueArg && typeof valueArg === 'object' && 'toString' in valueArg) { + const rendered = String(valueArg); + return rendered === '[object Object]' ? undefined : rendered; + } + return undefined; + } + + private static numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined; + } + + private static stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private static asArray(valueArg: unknown): unknown[] { + return Array.isArray(valueArg) ? valueArg : []; + } + + private static asRecord(valueArg: unknown): Record | undefined { + return this.isRecord(valueArg) ? valueArg : undefined; + } + + private static isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'knx'; + } +} diff --git a/ts/integrations/knx/knx.types.ts b/ts/integrations/knx/knx.types.ts index c4c6ec2..a69fe64 100644 --- a/ts/integrations/knx/knx.types.ts +++ b/ts/integrations/knx/knx.types.ts @@ -1,4 +1,314 @@ -export interface IHomeAssistantKnxConfig { - // TODO: replace with the TypeScript-native config for knx. +import type { TEntityPlatform } from '../../core/types.js'; + +export type TKnxConnectionType = + | 'automatic' + | 'routing' + | 'routing_secure' + | 'tunneling' + | 'tunneling_tcp' + | 'tunneling_tcp_secure' + | string; + +export type TKnxGroupAddress = string; +export type TKnxTelegramAction = 'read' | 'write' | 'response'; +export type TKnxEntityPlatform = TEntityPlatform | string; + +export interface IKnxConfig extends IKnxConnectionConfig { + gateway?: IKnxGatewayDescriptor; + gateways?: IKnxGatewayDescriptor[]; + tunnels?: IKnxGatewayDescriptor[]; + groupAddresses?: IKnxGroupAddressDescriptor[]; + group_addresses?: IKnxGroupAddressDescriptor[]; + entities?: IKnxEntityDescriptor[]; + light?: IKnxEntityDescriptor[]; + switch?: IKnxEntityDescriptor[]; + sensor?: IKnxEntityDescriptor[]; + cover?: IKnxEntityDescriptor[]; + climate?: IKnxEntityDescriptor[]; + snapshot?: IKnxSnapshot; + events?: IKnxEvent[]; + commandExecutor?: TKnxCommandExecutor; [key: string]: unknown; } + +export interface IHomeAssistantKnxConfig extends IKnxConfig {} + +export interface IKnxConnectionConfig { + connectionType?: TKnxConnectionType; + connection_type?: TKnxConnectionType; + host?: string; + port?: number; + individualAddress?: string; + individual_address?: string; + localIp?: string; + local_ip?: string; + multicastGroup?: string; + multicast_group?: string; + multicastPort?: number; + multicast_port?: number; + routeBack?: boolean; + route_back?: boolean; + tunnelEndpointIa?: string | null; + tunnel_endpoint_ia?: string | null; + userId?: number | null; + user_id?: number | null; + userPassword?: string | null; + user_password?: string | null; + deviceAuthentication?: string | null; + device_authentication?: string | null; + backboneKey?: string | null; + backbone_key?: string | null; + syncLatencyTolerance?: number | null; + sync_latency_tolerance?: number | null; + knxkeysFilename?: string; + knxkeys_filename?: string; + knxkeysPassword?: string; + knxkeys_password?: string; + stateUpdater?: boolean; + state_updater?: boolean; + rateLimit?: number; + rate_limit?: number; + telegramLogSize?: number; + telegram_log_size?: number; + [key: string]: unknown; +} + +export interface IKnxGatewayDescriptor { + id?: string; + name?: string; + host?: string; + ipAddr?: string; + ip_addr?: string; + ipAddress?: string; + port?: number; + localIp?: string; + local_ip?: string; + localInterface?: string; + local_interface?: string; + individualAddress?: unknown; + individual_address?: unknown; + supportsRouting?: boolean; + supports_routing?: boolean; + supportsTunneling?: boolean; + supportsTunnelling?: boolean; + supports_tunnelling?: boolean; + supportsTunnelingTcp?: boolean; + supportsTunnellingTcp?: boolean; + supports_tunnelling_tcp?: boolean; + supportsSecure?: boolean; + supports_secure?: boolean; + routingRequiresSecure?: boolean | null; + routing_requires_secure?: boolean | null; + tunnelingRequiresSecure?: boolean | null; + tunnellingRequiresSecure?: boolean | null; + tunnelling_requires_secure?: boolean | null; + tunnelingSlots?: Record; + tunnellingSlots?: Record; + tunnelling_slots?: Record; + metadata?: Record; + [key: string]: unknown; +} + +export interface IKnxTunnelSlot { + individualAddress?: string; + individual_address?: string; + free?: boolean; + authorized?: boolean; + usable?: boolean; + [key: string]: unknown; +} + +export interface IKnxGroupAddressDescriptor { + address: TKnxGroupAddress; + name?: string; + role?: string; + platform?: TKnxEntityPlatform; + entityId?: string; + entity_id?: string; + uniqueId?: string; + unique_id?: string; + deviceId?: string; + device_id?: string; + dpt?: string; + type?: string; + state?: unknown; + value?: unknown; + writable?: boolean; + unit?: string; + metadata?: Record; + [key: string]: unknown; +} + +export interface IKnxEntityDescriptor { + platform?: TKnxEntityPlatform; + entityId?: string; + entity_id?: string; + uniqueId?: string; + unique_id?: string; + deviceId?: string; + device_id?: string; + name?: string; + originalName?: string; + original_name?: string; + fallbackName?: string; + fallback_name?: string; + address?: TKnxGroupAddress | TKnxGroupAddress[]; + groupAddress?: TKnxGroupAddress | TKnxGroupAddress[]; + group_address?: TKnxGroupAddress | TKnxGroupAddress[]; + stateAddress?: TKnxGroupAddress | TKnxGroupAddress[]; + state_address?: TKnxGroupAddress | TKnxGroupAddress[]; + switchAddress?: TKnxGroupAddress | TKnxGroupAddress[]; + switch_address?: TKnxGroupAddress | TKnxGroupAddress[]; + switchStateAddress?: TKnxGroupAddress | TKnxGroupAddress[]; + switch_state_address?: TKnxGroupAddress | TKnxGroupAddress[]; + brightnessAddress?: TKnxGroupAddress | TKnxGroupAddress[]; + brightness_address?: TKnxGroupAddress | TKnxGroupAddress[]; + brightnessStateAddress?: TKnxGroupAddress | TKnxGroupAddress[]; + brightness_state_address?: TKnxGroupAddress | TKnxGroupAddress[]; + moveLongAddress?: TKnxGroupAddress | TKnxGroupAddress[]; + move_long_address?: TKnxGroupAddress | TKnxGroupAddress[]; + moveShortAddress?: TKnxGroupAddress | TKnxGroupAddress[]; + move_short_address?: TKnxGroupAddress | TKnxGroupAddress[]; + stopAddress?: TKnxGroupAddress | TKnxGroupAddress[]; + stop_address?: TKnxGroupAddress | TKnxGroupAddress[]; + positionAddress?: TKnxGroupAddress | TKnxGroupAddress[]; + position_address?: TKnxGroupAddress | TKnxGroupAddress[]; + positionStateAddress?: TKnxGroupAddress | TKnxGroupAddress[]; + position_state_address?: TKnxGroupAddress | TKnxGroupAddress[]; + temperatureAddress?: TKnxGroupAddress | TKnxGroupAddress[]; + temperature_address?: TKnxGroupAddress | TKnxGroupAddress[]; + targetTemperatureAddress?: TKnxGroupAddress | TKnxGroupAddress[]; + target_temperature_address?: TKnxGroupAddress | TKnxGroupAddress[]; + targetTemperatureStateAddress?: TKnxGroupAddress | TKnxGroupAddress[]; + target_temperature_state_address?: TKnxGroupAddress | TKnxGroupAddress[]; + onOffAddress?: TKnxGroupAddress | TKnxGroupAddress[]; + on_off_address?: TKnxGroupAddress | TKnxGroupAddress[]; + onOffStateAddress?: TKnxGroupAddress | TKnxGroupAddress[]; + on_off_state_address?: TKnxGroupAddress | TKnxGroupAddress[]; + operationModeAddress?: TKnxGroupAddress | TKnxGroupAddress[]; + operation_mode_address?: TKnxGroupAddress | TKnxGroupAddress[]; + controllerModeAddress?: TKnxGroupAddress | TKnxGroupAddress[]; + controller_mode_address?: TKnxGroupAddress | TKnxGroupAddress[]; + dpt?: string; + type?: string; + state?: unknown; + nativeValue?: unknown; + native_value?: unknown; + value?: unknown; + isOn?: boolean; + is_on?: boolean; + isClosed?: boolean; + is_closed?: boolean; + currentCoverPosition?: number; + current_cover_position?: number; + position?: number; + currentTemperature?: number; + current_temperature?: number; + targetTemperature?: number; + target_temperature?: number; + hvacMode?: string; + hvac_mode?: string; + deviceClass?: string; + device_class?: string; + unit?: string; + unitOfMeasurement?: string; + unit_of_measurement?: string; + available?: boolean; + writable?: boolean; + invert?: boolean; + invertUpdown?: boolean; + invert_updown?: boolean; + invertPosition?: boolean; + invert_position?: boolean; + syncState?: boolean; + sync_state?: boolean; + attributes?: Record; + metadata?: Record; + raw?: Record; + [key: string]: unknown; +} + +export interface IKnxConnectionSnapshot extends IKnxConnectionConfig { + connectionType: TKnxConnectionType; + host?: string; + port: number; + multicastGroup: string; + multicastPort: number; + secure?: boolean; +} + +export interface IKnxSnapshot { + connected: boolean; + connection: IKnxConnectionSnapshot; + gateways: IKnxGatewayDescriptor[]; + groupAddresses: IKnxGroupAddressDescriptor[]; + entities: IKnxEntityDescriptor[]; + events: IKnxEvent[]; +} + +export interface IKnxTelegramCommand { + action: TKnxTelegramAction; + address: TKnxGroupAddress; + payload?: unknown; + dpt?: string; +} + +export interface IKnxClientCommand { + type: 'group.write' | 'group.read' | 'entity.command'; + service: string; + platform?: TKnxEntityPlatform; + entityId?: string; + uniqueId?: string; + telegrams: IKnxTelegramCommand[]; + payload: Record; + target?: { + entityId?: string; + deviceId?: string; + }; +} + +export interface IKnxCommandResult { + success: boolean; + error?: string; + data?: unknown; +} + +export interface IKnxEvent { + type: string; + timestamp?: number; + deviceId?: string; + entityId?: string; + uniqueId?: string; + source?: string; + destination?: string; + telegramtype?: string; + payload?: unknown; + value?: unknown; + command?: IKnxClientCommand; + data?: unknown; + [key: string]: unknown; +} + +export type TKnxCommandExecutor = ( + commandArg: IKnxClientCommand +) => Promise | IKnxCommandResult | unknown; + +export interface IKnxManualDiscoveryEntry extends IKnxConnectionConfig { + id?: string; + name?: string; + model?: string; + manufacturer?: string; + gateway?: IKnxGatewayDescriptor; + metadata?: Record; +} + +export interface IKnxMdnsRecord { + type?: string; + name?: string; + host?: string; + hostname?: string; + addresses?: string[]; + port?: number; + txt?: Record; + properties?: Record; +} diff --git a/ts/integrations/modbus/.generated-by-smarthome-exchange b/ts/integrations/modbus/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/modbus/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/modbus/index.ts b/ts/integrations/modbus/index.ts index ebb509d..854d5ec 100644 --- a/ts/integrations/modbus/index.ts +++ b/ts/integrations/modbus/index.ts @@ -1,2 +1,6 @@ +export * from './modbus.classes.client.js'; +export * from './modbus.classes.configflow.js'; export * from './modbus.classes.integration.js'; +export * from './modbus.discovery.js'; +export * from './modbus.mapper.js'; export * from './modbus.types.js'; diff --git a/ts/integrations/modbus/modbus.classes.client.ts b/ts/integrations/modbus/modbus.classes.client.ts new file mode 100644 index 0000000..dc0a310 --- /dev/null +++ b/ts/integrations/modbus/modbus.classes.client.ts @@ -0,0 +1,867 @@ +import * as plugins from '../../plugins.js'; +import { ModbusMapper } from './modbus.mapper.js'; +import type { + IModbusCoilConfig, + IModbusCoilState, + IModbusCommand, + IModbusCommandResult, + IModbusConfig, + IModbusEntityConfigBase, + IModbusEntitySnapshot, + IModbusEvent, + IModbusHubConfig, + IModbusHubSnapshot, + IModbusManualTcpEntry, + IModbusRegisterConfig, + IModbusRegisterState, + IModbusSlaveConfig, + IModbusSlaveSnapshot, + IModbusSnapshot, + IModbusSwitchConfig, + IModbusTcpCommandShape, + IModbusTcpResponse, + TModbusCoilType, + TModbusDataType, + TModbusRegisterType, + TModbusTransport, + TModbusWriteType, +} from './modbus.types.js'; + +const defaultHubName = 'modbus_hub'; +const defaultTimeoutMs = 5000; +const defaultTcpPort = 502; + +type TModbusEventHandler = (eventArg: IModbusEvent) => void; + +export class ModbusUnsupportedError extends Error { + constructor(messageArg: string) { + super(messageArg); + this.name = 'ModbusUnsupportedError'; + } +} + +export class ModbusClient { + private snapshot?: IModbusSnapshot; + private transactionId = 1; + private readonly eventHandlers = new Set(); + + constructor(private readonly config: IModbusConfig) {} + + public async getSnapshot(): Promise { + if (this.config.snapshot) { + this.snapshot = this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot)); + return this.snapshot; + } + if (!this.snapshot) { + this.snapshot = this.snapshotFromConfig(this.config.connected ?? true); + } + return this.snapshot; + } + + public async refresh(): Promise { + this.snapshot = undefined; + const snapshot = await this.getSnapshot(); + this.emit({ type: 'snapshot_refreshed', data: snapshot, timestamp: Date.now() }); + return snapshot; + } + + public onEvent(handlerArg: TModbusEventHandler): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async sendCommand(commandArg: IModbusCommand): Promise { + this.emit({ type: 'command_mapped', command: commandArg, hubName: commandArg.hub, hubId: commandArg.hubId, unitId: this.commandUnitId(commandArg), entityId: commandArg.entityId, deviceId: commandArg.deviceId, uniqueId: commandArg.uniqueId, timestamp: Date.now() }); + try { + const result = await this.executeCommand(commandArg); + this.emit({ + type: result.success ? 'command_executed' : 'command_failed', + command: commandArg, + hubName: commandArg.hub, + hubId: commandArg.hubId, + unitId: this.commandUnitId(commandArg), + entityId: commandArg.entityId, + deviceId: commandArg.deviceId, + uniqueId: commandArg.uniqueId, + data: result, + timestamp: Date.now(), + }); + return result; + } catch (errorArg) { + const result: IModbusCommandResult = { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg), data: { command: commandArg } }; + this.emit({ type: 'command_failed', command: commandArg, data: result, timestamp: Date.now() }); + return result; + } + } + + public tcpCommandShape(commandArg: IModbusCommand, hubArg?: IModbusHubSnapshot): IModbusTcpCommandShape { + const unitId = this.commandUnitId(commandArg); + const transactionId = this.nextTransactionId(); + const commandPdu = this.pduForCommand(commandArg); + const frame = this.tcpFrame(transactionId, unitId, commandPdu.pdu); + return { + transport: 'tcp', + host: hubArg?.host, + port: this.numericPort(hubArg?.port), + unitId, + transactionId, + functionCode: commandPdu.functionCode, + functionName: commandPdu.functionName, + address: commandPdu.address, + quantity: commandPdu.quantity, + values: commandPdu.values, + mbap: { + transactionId, + protocolId: 0, + length: commandPdu.pdu.length + 1, + unitId, + }, + pduHex: commandPdu.pdu.toString('hex'), + requestHex: frame.toString('hex'), + }; + } + + public async destroy(): Promise { + this.eventHandlers.clear(); + } + + private async executeCommand(commandArg: IModbusCommand): Promise { + if (commandArg.type === 'refresh') { + return { success: true, data: await this.refresh() }; + } + + const snapshot = await this.getSnapshot(); + if (commandArg.type === 'stop' || commandArg.type === 'restart') { + this.patchHubOnline(snapshot, commandArg, commandArg.type === 'restart'); + return { success: true, data: { mode: 'snapshot', command: commandArg } }; + } + + if (this.config.commandExecutor) { + return this.commandResult(await this.config.commandExecutor(commandArg)); + } + + const hub = this.resolveHub(snapshot, commandArg); + if (!hub) { + return { success: false, error: `Modbus hub not found: ${commandArg.hub || commandArg.hubId || defaultHubName}` }; + } + + const unsupportedReason = hub.unsupportedReason || this.unsupportedReason(hub.type); + if (unsupportedReason) { + return { success: false, error: unsupportedReason }; + } + + const shape = this.tcpCommandShape(commandArg, hub); + if (!hub.host || !this.numericPort(hub.port)) { + const snapshotResult = this.snapshotCommand(commandArg, snapshot, shape); + return snapshotResult || { success: false, error: 'No live Modbus TCP host is configured for this hub.', data: { shape } }; + } + + const response = await this.executeTcpCommand(hub, shape); + this.patchSnapshotFromResponse(snapshot, commandArg, response); + return { success: true, data: { response, shape } }; + } + + private async executeTcpCommand(hubArg: IModbusHubSnapshot, shapeArg: IModbusTcpCommandShape): Promise { + if (hubArg.type !== 'tcp') { + throw new ModbusUnsupportedError(this.unsupportedReason(hubArg.type) || `Unsupported Modbus transport: ${hubArg.type}`); + } + const host = hubArg.host; + const port = this.numericPort(hubArg.port); + if (!host || !port) { + throw new Error('Modbus TCP command requires host and numeric port.'); + } + const request = Buffer.from(shapeArg.requestHex, 'hex'); + const response = await this.requestTcp(host, port, request, this.timeoutMs(hubArg)); + return this.parseTcpResponse(response, shapeArg); + } + + private async requestTcp(hostArg: string, portArg: number, frameArg: Buffer, timeoutMsArg: number): Promise { + return await new Promise((resolve, reject) => { + const socket = new plugins.net.Socket(); + const chunks: Buffer[] = []; + let settled = false; + const finish = (errorArg?: Error, dataArg?: Buffer) => { + if (settled) { + return; + } + settled = true; + socket.destroy(); + if (errorArg) { + reject(errorArg); + } else { + resolve(dataArg || Buffer.alloc(0)); + } + }; + socket.setTimeout(timeoutMsArg, () => finish(new Error(`Modbus TCP request timed out after ${timeoutMsArg}ms.`))); + socket.once('error', (errorArg) => finish(errorArg)); + socket.once('close', () => { + if (!settled) { + finish(new Error('Modbus TCP connection closed before a full response was received.')); + } + }); + socket.on('data', (dataArg) => { + chunks.push(Buffer.isBuffer(dataArg) ? dataArg : Buffer.from(dataArg)); + const buffer = Buffer.concat(chunks); + if (buffer.length < 7) { + return; + } + const responseLength = buffer.readUInt16BE(4); + const totalLength = 6 + responseLength; + if (buffer.length >= totalLength) { + finish(undefined, buffer.subarray(0, totalLength)); + } + }); + socket.connect(portArg, hostArg, () => socket.write(frameArg)); + }); + } + + private parseTcpResponse(responseArg: Buffer, shapeArg: IModbusTcpCommandShape): IModbusTcpResponse { + if (responseArg.length < 8) { + throw new Error('Modbus TCP response is shorter than the MBAP header.'); + } + const transactionId = responseArg.readUInt16BE(0); + const protocolId = responseArg.readUInt16BE(2); + const unitId = responseArg.readUInt8(6); + const pdu = responseArg.subarray(7); + const functionCode = pdu.readUInt8(0); + if (transactionId !== shapeArg.transactionId) { + throw new Error(`Modbus TCP transaction mismatch: expected ${shapeArg.transactionId}, got ${transactionId}.`); + } + if (protocolId !== 0) { + throw new Error(`Unsupported Modbus TCP protocol id: ${protocolId}.`); + } + if (functionCode & 0x80) { + const exceptionCode = pdu.readUInt8(1); + throw new Error(`Modbus exception response ${exceptionCode} for function ${functionCode & 0x7f}.`); + } + if (functionCode !== shapeArg.functionCode) { + throw new Error(`Modbus function mismatch: expected ${shapeArg.functionCode}, got ${functionCode}.`); + } + + const response: IModbusTcpResponse = { + transactionId, + unitId, + functionCode, + rawHex: responseArg.toString('hex'), + }; + if (functionCode === 1 || functionCode === 2) { + const byteCount = pdu.readUInt8(1); + const bits: boolean[] = []; + const quantity = shapeArg.quantity || byteCount * 8; + for (let index = 0; index < quantity; index++) { + bits.push(Boolean(pdu[2 + Math.floor(index / 8)] & (1 << (index % 8)))); + } + response.bits = bits; + response.quantity = quantity; + } else if (functionCode === 3 || functionCode === 4) { + const byteCount = pdu.readUInt8(1); + const registers: number[] = []; + for (let index = 0; index < byteCount / 2; index++) { + registers.push(pdu.readUInt16BE(2 + index * 2)); + } + response.registers = registers; + response.quantity = registers.length; + } else if (functionCode === 5 || functionCode === 6) { + response.address = pdu.readUInt16BE(1); + response.quantity = 1; + } else if (functionCode === 15 || functionCode === 16) { + response.address = pdu.readUInt16BE(1); + response.quantity = pdu.readUInt16BE(3); + } + return response; + } + + private snapshotCommand(commandArg: IModbusCommand, snapshotArg: IModbusSnapshot, shapeArg: IModbusTcpCommandShape): IModbusCommandResult | undefined { + if (commandArg.type === 'read_register') { + const register = this.findRegister(snapshotArg, commandArg.hub || commandArg.hubId, this.commandUnitId(commandArg), commandArg.address, commandArg.inputType || 'holding'); + return register ? { success: true, data: { source: 'snapshot', shape: shapeArg, registers: register.registers, value: ModbusMapper.decodeRegisterValue(register) } } : undefined; + } + if (commandArg.type === 'read_coil') { + const coil = this.findCoil(snapshotArg, commandArg.hub || commandArg.hubId, this.commandUnitId(commandArg), commandArg.address, commandArg.inputType || 'coil'); + return coil ? { success: true, data: { source: 'snapshot', shape: shapeArg, bits: coil.bits, value: coil.value } } : undefined; + } + if (commandArg.type === 'write_register' || commandArg.type === 'write_coil') { + this.patchSnapshotFromCommand(snapshotArg, commandArg); + return { success: true, data: { source: 'snapshot', shape: shapeArg } }; + } + return undefined; + } + + private pduForCommand(commandArg: IModbusCommand): { pdu: Buffer; functionCode: number; functionName: string; address?: number; quantity?: number; values?: number[] | boolean[] } { + if (commandArg.type === 'read_register') { + const functionCode = commandArg.inputType === 'input' ? 4 : 3; + const quantity = commandArg.count || ModbusMapper.registerCountForDataType(commandArg.dataType || 'int16'); + const pdu = Buffer.alloc(5); + pdu.writeUInt8(functionCode, 0); + pdu.writeUInt16BE(commandArg.address, 1); + pdu.writeUInt16BE(quantity, 3); + return { pdu, functionCode, functionName: functionCode === 4 ? 'read_input_registers' : 'read_holding_registers', address: commandArg.address, quantity }; + } + if (commandArg.type === 'read_coil') { + const functionCode = commandArg.inputType === 'discrete_input' ? 2 : 1; + const quantity = commandArg.count || 1; + const pdu = Buffer.alloc(5); + pdu.writeUInt8(functionCode, 0); + pdu.writeUInt16BE(commandArg.address, 1); + pdu.writeUInt16BE(quantity, 3); + return { pdu, functionCode, functionName: functionCode === 2 ? 'read_discrete_inputs' : 'read_coils', address: commandArg.address, quantity }; + } + if (commandArg.type === 'write_register') { + const values = Array.isArray(commandArg.value) ? commandArg.value : [commandArg.value]; + if (values.length === 1) { + const pdu = Buffer.alloc(5); + pdu.writeUInt8(6, 0); + pdu.writeUInt16BE(commandArg.address, 1); + pdu.writeUInt16BE(values[0] & 0xffff, 3); + return { pdu, functionCode: 6, functionName: 'write_register', address: commandArg.address, quantity: 1, values }; + } + const pdu = Buffer.alloc(6 + values.length * 2); + pdu.writeUInt8(16, 0); + pdu.writeUInt16BE(commandArg.address, 1); + pdu.writeUInt16BE(values.length, 3); + pdu.writeUInt8(values.length * 2, 5); + values.forEach((valueArg, indexArg) => pdu.writeUInt16BE(valueArg & 0xffff, 6 + indexArg * 2)); + return { pdu, functionCode: 16, functionName: 'write_registers', address: commandArg.address, quantity: values.length, values }; + } + if (commandArg.type === 'write_coil') { + const values = this.coilValues(commandArg.value); + if (values.length === 1) { + const pdu = Buffer.alloc(5); + pdu.writeUInt8(5, 0); + pdu.writeUInt16BE(commandArg.address, 1); + pdu.writeUInt16BE(values[0] ? 0xff00 : 0x0000, 3); + return { pdu, functionCode: 5, functionName: 'write_coil', address: commandArg.address, quantity: 1, values }; + } + const byteCount = Math.ceil(values.length / 8); + const pdu = Buffer.alloc(6 + byteCount); + pdu.writeUInt8(15, 0); + pdu.writeUInt16BE(commandArg.address, 1); + pdu.writeUInt16BE(values.length, 3); + pdu.writeUInt8(byteCount, 5); + values.forEach((valueArg, indexArg) => { + if (valueArg) { + pdu[6 + Math.floor(indexArg / 8)] |= 1 << (indexArg % 8); + } + }); + return { pdu, functionCode: 15, functionName: 'write_coils', address: commandArg.address, quantity: values.length, values }; + } + throw new Error(`Modbus command has no TCP PDU shape: ${commandArg.type}`); + } + + private tcpFrame(transactionIdArg: number, unitIdArg: number, pduArg: Buffer): Buffer { + const frame = Buffer.alloc(7 + pduArg.length); + frame.writeUInt16BE(transactionIdArg, 0); + frame.writeUInt16BE(0, 2); + frame.writeUInt16BE(pduArg.length + 1, 4); + frame.writeUInt8(unitIdArg, 6); + pduArg.copy(frame, 7); + return frame; + } + + private snapshotFromConfig(connectedArg: boolean): IModbusSnapshot { + const updatedAt = new Date().toISOString(); + const hubs = this.hubsFromConfig().map((hubArg, indexArg) => this.hubSnapshotFromConfig(hubArg, indexArg, connectedArg, updatedAt)); + return { + hubs, + events: [], + connected: connectedArg && hubs.some((hubArg) => hubArg.online), + updatedAt, + }; + } + + private hubSnapshotFromConfig(hubArg: IModbusHubConfig, indexArg: number, connectedArg: boolean, updatedAtArg: string): IModbusHubSnapshot { + const type = this.transportValue(hubArg.type) || 'tcp'; + const name = hubArg.name || (indexArg ? `${defaultHubName}_${indexArg}` : defaultHubName); + const id = slug(hubArg.id || name || `${hubArg.host || 'modbus'}_${hubArg.port || defaultTcpPort}`); + const unsupportedReason = this.unsupportedReason(type); + const slaveMap = new Map(); + const registers: IModbusRegisterState[] = []; + const coils: IModbusCoilState[] = []; + const entities: IModbusEntitySnapshot[] = []; + const defaultUnitId = this.unitIdFromConfig(hubArg, hubArg); + + for (const slave of hubArg.slaves || []) { + this.ensureSlave(slaveMap, id, this.unitIdFromConfig(slave, hubArg), slave, connectedArg && !unsupportedReason); + } + + const addRegister = (registerArg: IModbusRegisterConfig, platformArg: 'sensor' | 'number') => { + const unitId = this.unitIdFromConfig(registerArg, hubArg); + const register = this.registerStateFromConfig(registerArg, id, unitId, updatedAtArg); + registers.push(register); + this.ensureSlave(slaveMap, id, unitId, undefined, connectedArg && !unsupportedReason).registers.push(register); + entities.push(this.registerEntityFromConfig(registerArg, platformArg, id, name, register, connectedArg && !unsupportedReason)); + }; + const addCoil = (coilArg: IModbusCoilConfig | IModbusSwitchConfig, platformArg: 'binary_sensor' | 'switch') => { + const unitId = this.unitIdFromConfig(coilArg, hubArg); + const writeType = this.writeTypeValue((coilArg as IModbusSwitchConfig).writeType || (coilArg as IModbusSwitchConfig).write_type); + const isRegisterBackedSwitch = platformArg === 'switch' && (writeType === 'holding' || writeType === 'holdings'); + if (isRegisterBackedSwitch) { + const register = this.registerStateFromSwitchConfig(coilArg as IModbusSwitchConfig, id, unitId, updatedAtArg); + registers.push(register); + this.ensureSlave(slaveMap, id, unitId, undefined, connectedArg && !unsupportedReason).registers.push(register); + entities.push(this.switchRegisterEntityFromConfig(coilArg as IModbusSwitchConfig, id, name, register, connectedArg && !unsupportedReason)); + return; + } + const coil = this.coilStateFromConfig(coilArg, id, unitId, updatedAtArg); + coils.push(coil); + this.ensureSlave(slaveMap, id, unitId, undefined, connectedArg && !unsupportedReason).coils.push(coil); + entities.push(this.coilEntityFromConfig(coilArg, platformArg, id, name, coil, connectedArg && !unsupportedReason)); + }; + + for (const register of hubArg.registers || []) { + addRegister(register, register.platform === 'number' || register.writable ? 'number' : 'sensor'); + } + for (const sensor of hubArg.sensors || []) { + addRegister(sensor, 'sensor'); + } + for (const number of hubArg.numbers || []) { + addRegister(number, 'number'); + } + for (const coil of hubArg.coils || []) { + addCoil(coil, coil.platform === 'switch' || coil.writable ? 'switch' : 'binary_sensor'); + } + for (const binarySensor of [...(hubArg.binarySensors || []), ...(hubArg.binary_sensors || [])]) { + addCoil(binarySensor, 'binary_sensor'); + } + for (const switchArg of hubArg.switches || []) { + addCoil(switchArg, 'switch'); + } + + if (!slaveMap.size && defaultUnitId) { + this.ensureSlave(slaveMap, id, defaultUnitId, undefined, connectedArg && !unsupportedReason); + } + + return { + id, + name, + type, + host: hubArg.host, + port: hubArg.port || (type === 'tcp' ? defaultTcpPort : undefined), + online: connectedArg && hubArg.connected !== false && !unsupportedReason, + manufacturer: hubArg.manufacturer || 'Modbus', + model: hubArg.model || `${type.toUpperCase()} hub`, + serialNumber: hubArg.serialNumber || hubArg.serial_number, + unsupportedReason, + slaves: [...slaveMap.values()], + registers, + coils, + entities, + metadata: this.cleanAttributes({ + timeoutMs: this.timeoutMs(hubArg), + messageWaitMilliseconds: hubArg.messageWaitMilliseconds ?? hubArg.message_wait_milliseconds, + delay: hubArg.delay, + ...hubArg.metadata, + }), + }; + } + + private registerStateFromConfig(configArg: IModbusRegisterConfig, hubIdArg: string, unitIdArg: number, updatedAtArg: string): IModbusRegisterState { + const dataType = this.dataTypeValue(configArg.dataType || configArg.data_type); + return { + hubId: hubIdArg, + unitId: unitIdArg, + address: configArg.address, + count: configArg.count || ModbusMapper.registerCountForDataType(dataType), + inputType: this.registerTypeValue(configArg.inputType || configArg.input_type || configArg.registerType || configArg.register_type), + dataType, + registers: configArg.registers, + value: configArg.value, + updatedAt: updatedAtArg, + metadata: this.registerMetadata(configArg), + }; + } + + private registerStateFromSwitchConfig(configArg: IModbusSwitchConfig, hubIdArg: string, unitIdArg: number, updatedAtArg: string): IModbusRegisterState { + return { + hubId: hubIdArg, + unitId: unitIdArg, + address: configArg.address, + count: 1, + inputType: 'holding', + dataType: 'uint16', + value: typeof configArg.value === 'number' ? configArg.value : configArg.value ? 1 : 0, + updatedAt: updatedAtArg, + }; + } + + private coilStateFromConfig(configArg: IModbusCoilConfig | IModbusSwitchConfig, hubIdArg: string, unitIdArg: number, updatedAtArg: string): IModbusCoilState { + const slaveCount = configArg.slaveCount || configArg.slave_count || configArg.virtualCount || configArg.virtual_count || 0; + return { + hubId: hubIdArg, + unitId: unitIdArg, + address: configArg.address, + count: configArg.count || slaveCount + 1, + inputType: this.coilTypeValue(configArg.inputType || configArg.input_type || configArg.coilType || configArg.coil_type), + bits: configArg.bits, + value: configArg.value, + updatedAt: updatedAtArg, + }; + } + + private registerEntityFromConfig(configArg: IModbusRegisterConfig, platformArg: 'sensor' | 'number', hubIdArg: string, hubNameArg: string, registerArg: IModbusRegisterState, availableArg: boolean): IModbusEntitySnapshot { + const uniqueId = configArg.uniqueId || configArg.unique_id || `modbus_${hubIdArg}_slave_${registerArg.unitId}_${platformArg}_${registerArg.address}`; + return { + id: `${platformArg}.${slug(configArg.name)}`, + name: configArg.name, + uniqueId, + platform: platformArg, + hubId: hubIdArg, + hubName: hubNameArg, + unitId: registerArg.unitId, + address: registerArg.address, + available: availableArg && configArg.available !== false, + writable: platformArg === 'number' || Boolean(configArg.writable), + register: registerArg, + attributes: this.cleanAttributes({ + unit: configArg.unitOfMeasurement || configArg.unit_of_measurement, + deviceClass: configArg.deviceClass || configArg.device_class, + stateClass: configArg.stateClass || configArg.state_class, + min: configArg.min ?? configArg.minValue ?? configArg.min_value, + max: configArg.max ?? configArg.maxValue ?? configArg.max_value, + step: configArg.step, + scanInterval: configArg.scanInterval ?? configArg.scan_interval, + scale: configArg.scale, + offset: configArg.offset, + precision: configArg.precision, + swap: configArg.swap, + ...configArg.metadata, + }), + }; + } + + private coilEntityFromConfig(configArg: IModbusCoilConfig | IModbusSwitchConfig, platformArg: 'binary_sensor' | 'switch', hubIdArg: string, hubNameArg: string, coilArg: IModbusCoilState, availableArg: boolean): IModbusEntitySnapshot { + const switchConfig = configArg as IModbusSwitchConfig; + const uniqueId = configArg.uniqueId || configArg.unique_id || `modbus_${hubIdArg}_slave_${coilArg.unitId}_${platformArg}_${coilArg.address}`; + return { + id: `${platformArg}.${slug(configArg.name)}`, + name: configArg.name, + uniqueId, + platform: platformArg, + hubId: hubIdArg, + hubName: hubNameArg, + unitId: coilArg.unitId, + address: coilArg.address, + available: availableArg && configArg.available !== false, + writable: platformArg === 'switch' || Boolean(configArg.writable), + coil: coilArg, + attributes: this.cleanAttributes({ + deviceClass: configArg.deviceClass || configArg.device_class, + scanInterval: configArg.scanInterval ?? configArg.scan_interval, + writeType: this.writeTypeValue(switchConfig.writeType || switchConfig.write_type) || 'coil', + commandOn: switchConfig.commandOn ?? switchConfig.command_on ?? 1, + commandOff: switchConfig.commandOff ?? switchConfig.command_off ?? 0, + verify: switchConfig.verify, + ...configArg.metadata, + }), + }; + } + + private switchRegisterEntityFromConfig(configArg: IModbusSwitchConfig, hubIdArg: string, hubNameArg: string, registerArg: IModbusRegisterState, availableArg: boolean): IModbusEntitySnapshot { + const commandOn = configArg.commandOn ?? configArg.command_on ?? 1; + const commandOff = configArg.commandOff ?? configArg.command_off ?? 0; + const value = ModbusMapper.decodeRegisterValue(registerArg); + return { + id: `switch.${slug(configArg.name)}`, + name: configArg.name, + uniqueId: configArg.uniqueId || configArg.unique_id || `modbus_${hubIdArg}_slave_${registerArg.unitId}_switch_${registerArg.address}`, + platform: 'switch', + hubId: hubIdArg, + hubName: hubNameArg, + unitId: registerArg.unitId, + address: registerArg.address, + available: availableArg && configArg.available !== false, + writable: true, + register: registerArg, + state: value === commandOn ? 'on' : value === commandOff ? 'off' : 'unknown', + attributes: this.cleanAttributes({ + deviceClass: configArg.deviceClass || configArg.device_class, + scanInterval: configArg.scanInterval ?? configArg.scan_interval, + writeType: this.writeTypeValue(configArg.writeType || configArg.write_type) || 'holding', + commandOn, + commandOff, + verify: configArg.verify, + ...configArg.metadata, + }), + }; + } + + private hubsFromConfig(): IModbusHubConfig[] { + if (this.config.hubs?.length) { + return this.config.hubs; + } + if (this.config.modbus?.length) { + return this.config.modbus; + } + if (this.config.manualEntries?.length) { + return this.config.manualEntries.map((entryArg) => this.hubFromManualEntry(entryArg)); + } + return []; + } + + private hubFromManualEntry(entryArg: IModbusManualTcpEntry): IModbusHubConfig { + return { + id: entryArg.id, + name: entryArg.name || defaultHubName, + type: 'tcp', + host: entryArg.host, + port: entryArg.port || defaultTcpPort, + slave: entryArg.unitId || entryArg.slave || 1, + manufacturer: entryArg.manufacturer || 'Modbus', + model: entryArg.model || 'TCP hub', + serialNumber: entryArg.serialNumber, + metadata: entryArg.metadata, + }; + } + + private ensureSlave(slaveMapArg: Map, hubIdArg: string, unitIdArg: number, slaveArg: IModbusSlaveConfig | undefined, onlineArg: boolean): IModbusSlaveSnapshot { + const existing = slaveMapArg.get(unitIdArg); + if (existing) { + return existing; + } + const slave: IModbusSlaveSnapshot = { + id: slug(slaveArg?.id || `${hubIdArg}_${unitIdArg}`), + hubId: hubIdArg, + unitId: unitIdArg, + name: slaveArg?.name || `Modbus unit ${unitIdArg}`, + online: onlineArg && slaveArg?.connected !== false, + manufacturer: slaveArg?.manufacturer, + model: slaveArg?.model, + serialNumber: slaveArg?.serialNumber || slaveArg?.serial_number, + registers: [], + coils: [], + metadata: slaveArg?.metadata, + }; + slaveMapArg.set(unitIdArg, slave); + return slave; + } + + private normalizeSnapshot(snapshotArg: IModbusSnapshot): IModbusSnapshot { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + const hubs = snapshotArg.hubs.map((hubArg) => ({ + ...hubArg, + id: slug(hubArg.id || hubArg.name), + name: hubArg.name || defaultHubName, + type: this.transportValue(hubArg.type) || 'tcp', + online: hubArg.online && !this.unsupportedReason(hubArg.type), + unsupportedReason: hubArg.unsupportedReason || this.unsupportedReason(hubArg.type), + slaves: hubArg.slaves || [], + registers: hubArg.registers || [], + coils: hubArg.coils || [], + entities: hubArg.entities || [], + })); + return { + ...snapshotArg, + hubs, + events: snapshotArg.events || [], + connected: snapshotArg.connected && hubs.some((hubArg) => hubArg.online), + updatedAt, + }; + } + + private resolveHub(snapshotArg: IModbusSnapshot, commandArg: IModbusCommand): IModbusHubSnapshot | undefined { + if (commandArg.hubId) { + return snapshotArg.hubs.find((hubArg) => hubArg.id === commandArg.hubId || hubArg.name === commandArg.hubId); + } + if (commandArg.hub) { + return snapshotArg.hubs.find((hubArg) => hubArg.name === commandArg.hub || hubArg.id === commandArg.hub); + } + return snapshotArg.hubs[0]; + } + + private findRegister(snapshotArg: IModbusSnapshot, hubArg: string | undefined, unitIdArg: number, addressArg: number, inputTypeArg: TModbusRegisterType): IModbusRegisterState | undefined { + const hubs = hubArg ? snapshotArg.hubs.filter((entryArg) => entryArg.name === hubArg || entryArg.id === hubArg) : snapshotArg.hubs; + return hubs.flatMap((entryArg) => entryArg.registers).find((registerArg) => registerArg.unitId === unitIdArg && registerArg.address === addressArg && registerArg.inputType === inputTypeArg); + } + + private findCoil(snapshotArg: IModbusSnapshot, hubArg: string | undefined, unitIdArg: number, addressArg: number, inputTypeArg: TModbusCoilType): IModbusCoilState | undefined { + const hubs = hubArg ? snapshotArg.hubs.filter((entryArg) => entryArg.name === hubArg || entryArg.id === hubArg) : snapshotArg.hubs; + return hubs.flatMap((entryArg) => entryArg.coils).find((coilArg) => coilArg.unitId === unitIdArg && coilArg.address === addressArg && coilArg.inputType === inputTypeArg); + } + + private patchSnapshotFromResponse(snapshotArg: IModbusSnapshot, commandArg: IModbusCommand, responseArg: IModbusTcpResponse): void { + if (commandArg.type === 'read_register' && responseArg.registers) { + this.patchRegister(snapshotArg, commandArg, responseArg.registers); + this.emit({ type: 'register_read', command: commandArg, data: responseArg, timestamp: Date.now() }); + } else if (commandArg.type === 'write_register') { + this.patchRegister(snapshotArg, commandArg, Array.isArray(commandArg.value) ? commandArg.value : [commandArg.value]); + this.emit({ type: 'register_written', command: commandArg, data: responseArg, timestamp: Date.now() }); + } else if (commandArg.type === 'read_coil' && responseArg.bits) { + this.patchCoil(snapshotArg, commandArg, responseArg.bits); + this.emit({ type: 'coil_read', command: commandArg, data: responseArg, timestamp: Date.now() }); + } else if (commandArg.type === 'write_coil') { + this.patchCoil(snapshotArg, commandArg, this.coilValues(commandArg.value)); + this.emit({ type: 'coil_written', command: commandArg, data: responseArg, timestamp: Date.now() }); + } + } + + private patchSnapshotFromCommand(snapshotArg: IModbusSnapshot, commandArg: IModbusCommand): void { + if (commandArg.type === 'write_register') { + this.patchRegister(snapshotArg, commandArg, Array.isArray(commandArg.value) ? commandArg.value : [commandArg.value]); + } else if (commandArg.type === 'write_coil') { + this.patchCoil(snapshotArg, commandArg, this.coilValues(commandArg.value)); + } + } + + private patchRegister(snapshotArg: IModbusSnapshot, commandArg: IModbusCommand, registersArg: number[]): void { + if (commandArg.type !== 'read_register' && commandArg.type !== 'write_register') { + return; + } + const hub = this.resolveHub(snapshotArg, commandArg); + if (!hub) { + return; + } + const inputType = commandArg.type === 'read_register' ? commandArg.inputType || 'holding' : 'holding'; + const register = hub.registers.find((registerArg) => registerArg.unitId === this.commandUnitId(commandArg) && registerArg.address === commandArg.address && registerArg.inputType === inputType); + const updatedAt = new Date().toISOString(); + if (register) { + register.registers = registersArg; + register.value = undefined; + register.updatedAt = updatedAt; + } + } + + private patchCoil(snapshotArg: IModbusSnapshot, commandArg: IModbusCommand, bitsArg: boolean[]): void { + if (commandArg.type !== 'read_coil' && commandArg.type !== 'write_coil') { + return; + } + const hub = this.resolveHub(snapshotArg, commandArg); + if (!hub) { + return; + } + const inputType = commandArg.type === 'read_coil' ? commandArg.inputType || 'coil' : 'coil'; + const coil = hub.coils.find((coilArg) => coilArg.unitId === this.commandUnitId(commandArg) && coilArg.address === commandArg.address && coilArg.inputType === inputType); + const updatedAt = new Date().toISOString(); + if (coil) { + coil.bits = bitsArg; + coil.value = bitsArg[0] ?? false; + coil.updatedAt = updatedAt; + } + } + + private patchHubOnline(snapshotArg: IModbusSnapshot, commandArg: IModbusCommand, onlineArg: boolean): void { + const hub = this.resolveHub(snapshotArg, commandArg); + if (!hub) { + return; + } + hub.online = onlineArg && !hub.unsupportedReason; + for (const slave of hub.slaves) { + slave.online = hub.online; + } + snapshotArg.connected = snapshotArg.hubs.some((hubArg) => hubArg.online); + snapshotArg.updatedAt = new Date().toISOString(); + } + + private commandResult(resultArg: IModbusCommandResult | unknown): IModbusCommandResult { + if (resultArg && typeof resultArg === 'object' && 'success' in resultArg) { + return resultArg as IModbusCommandResult; + } + return { success: true, data: resultArg }; + } + + private emit(eventArg: IModbusEvent): void { + for (const handler of this.eventHandlers) { + handler(eventArg); + } + } + + private commandUnitId(commandArg: IModbusCommand): number { + return commandArg.unitId || commandArg.slave || 1; + } + + private nextTransactionId(): number { + const current = this.transactionId; + this.transactionId = this.transactionId >= 0xffff ? 1 : this.transactionId + 1; + return current; + } + + private coilValues(valueArg: boolean | boolean[] | number | number[]): boolean[] { + const values = Array.isArray(valueArg) ? valueArg : [valueArg]; + return values.map((value) => typeof value === 'boolean' ? value : Boolean(value)); + } + + private registerTypeValue(valueArg: unknown): TModbusRegisterType { + return valueArg === 'input' ? 'input' : 'holding'; + } + + private coilTypeValue(valueArg: unknown): TModbusCoilType | TModbusRegisterType { + if (valueArg === 'discrete_input') { + return 'discrete_input'; + } + if (valueArg === 'holding' || valueArg === 'input') { + return valueArg; + } + return 'coil'; + } + + private dataTypeValue(valueArg: unknown): TModbusDataType { + return typeof valueArg === 'string' && valueArg ? valueArg as TModbusDataType : 'int16'; + } + + private writeTypeValue(valueArg: unknown): TModbusWriteType | undefined { + return valueArg === 'holding' || valueArg === 'holdings' || valueArg === 'coil' || valueArg === 'coils' ? valueArg : undefined; + } + + private transportValue(valueArg: unknown): TModbusTransport | undefined { + return valueArg === 'tcp' || valueArg === 'udp' || valueArg === 'rtuovertcp' || valueArg === 'serial' ? valueArg : undefined; + } + + private unsupportedReason(typeArg: TModbusTransport): string | undefined { + if (typeArg === 'serial') { + return 'Modbus RTU serial is explicitly unsupported in this TypeScript runtime; configure TCP or provide a snapshot/commandExecutor.'; + } + if (typeArg === 'udp') { + return 'Modbus UDP transport is not implemented in this TypeScript runtime.'; + } + if (typeArg === 'rtuovertcp') { + return 'Modbus RTU-over-TCP framer is not implemented in this TypeScript runtime.'; + } + return undefined; + } + + private unitIdFromConfig(configArg: IModbusEntityConfigBase | IModbusSlaveConfig | IModbusHubConfig, hubArg: IModbusHubConfig): number { + return configArg.unitId || configArg.unit || configArg.slave || configArg.deviceAddress || configArg.device_address || hubArg.slave || hubArg.deviceAddress || hubArg.device_address || 1; + } + + private numericPort(valueArg: number | string | undefined): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg)) ? Number(valueArg) : undefined; + } + + private timeoutMs(hubArg: Pick | Pick): number { + const metadataTimeout = 'metadata' in hubArg ? this.numberValue(hubArg.metadata?.timeoutMs) : undefined; + if ('timeoutMs' in hubArg && hubArg.timeoutMs) { + return hubArg.timeoutMs; + } + if ('timeout' in hubArg && hubArg.timeout) { + return hubArg.timeout * 1000; + } + return metadataTimeout || this.config.timeoutMs || defaultTimeoutMs; + } + + private numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg)) ? Number(valueArg) : undefined; + } + + private registerMetadata(configArg: IModbusRegisterConfig): Record { + return this.cleanAttributes({ + scale: configArg.scale, + offset: configArg.offset, + precision: configArg.precision, + swap: configArg.swap, + minValue: configArg.minValue ?? configArg.min_value, + maxValue: configArg.maxValue ?? configArg.max_value, + nanValue: configArg.nanValue ?? configArg.nan_value, + zeroSuppress: configArg.zeroSuppress ?? configArg.zero_suppress, + structure: configArg.structure, + }); + } + + private cloneSnapshot(snapshotArg: IModbusSnapshot): IModbusSnapshot { + return JSON.parse(JSON.stringify(snapshotArg)) as IModbusSnapshot; + } + + private cleanAttributes(attributesArg: Record): Record { + return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)); + } +} + +const slug = (valueArg: string): string => valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'modbus'; diff --git a/ts/integrations/modbus/modbus.classes.configflow.ts b/ts/integrations/modbus/modbus.classes.configflow.ts new file mode 100644 index 0000000..210a226 --- /dev/null +++ b/ts/integrations/modbus/modbus.classes.configflow.ts @@ -0,0 +1,52 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IModbusConfig } from './modbus.types.js'; + +const defaultModbusTcpPort = 502; + +export class ModbusConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect Modbus TCP hub', + description: 'Configure a known Modbus TCP endpoint. Serial RTU ports are intentionally not guessed or probed.', + fields: [ + { name: 'name', label: 'Hub name', type: 'text', required: false }, + { name: 'host', label: 'Host', type: 'text', required: true }, + { name: 'port', label: 'TCP port', type: 'number', required: false }, + { name: 'unitId', label: 'Default unit ID', type: 'number', required: false }, + { name: 'timeoutMs', label: 'Timeout milliseconds', type: 'number', required: false }, + ], + submit: async (valuesArg) => { + const unitId = this.numberValue(valuesArg.unitId) || this.numberValue(candidateArg.metadata?.unitId) || 1; + const port = this.numberValue(valuesArg.port) || candidateArg.port || defaultModbusTcpPort; + return { + kind: 'done', + title: 'Modbus TCP hub configured', + config: { + hubs: [{ + id: candidateArg.id, + name: this.stringValue(valuesArg.name) || candidateArg.name || 'modbus_hub', + type: 'tcp', + host: this.stringValue(valuesArg.host) || candidateArg.host || '', + port, + timeoutMs: this.numberValue(valuesArg.timeoutMs) || 5000, + slave: unitId, + manufacturer: candidateArg.manufacturer, + model: candidateArg.model, + serialNumber: candidateArg.serialNumber, + }], + }, + }; + }, + }; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg)) ? Number(valueArg) : undefined; + } +} diff --git a/ts/integrations/modbus/modbus.classes.integration.ts b/ts/integrations/modbus/modbus.classes.integration.ts index c5716c6..b9ffec2 100644 --- a/ts/integrations/modbus/modbus.classes.integration.ts +++ b/ts/integrations/modbus/modbus.classes.integration.ts @@ -1,23 +1,96 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { ModbusClient } from './modbus.classes.client.js'; +import { ModbusConfigFlow } from './modbus.classes.configflow.js'; +import { createModbusDiscoveryDescriptor } from './modbus.discovery.js'; +import { ModbusMapper } from './modbus.mapper.js'; +import type { IModbusConfig } from './modbus.types.js'; -export class HomeAssistantModbusIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "modbus", - displayName: "Modbus", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/modbus", - "upstreamDomain": "modbus", - "iotClass": "local_polling", - "requirements": [ - "pymodbus==3.11.2" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [] -}, - }); +export class ModbusIntegration extends BaseIntegration { + public readonly domain = 'modbus'; + public readonly displayName = 'Modbus'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createModbusDiscoveryDescriptor(); + public readonly configFlow = new ModbusConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/modbus', + upstreamDomain: 'modbus', + iotClass: 'local_polling', + requirements: ['pymodbus==3.11.2'], + dependencies: [], + afterDependencies: [], + codeowners: [], + documentation: 'https://www.home-assistant.io/integrations/modbus', + configFlow: true, + discovery: { + manual: true, + tcp: true, + serial: false, + note: 'Manual Modbus TCP setup is implemented. Serial RTU ports are not guessed or probed.', + }, + runtime: { + type: 'control-runtime', + polling: 'local snapshot or explicit Modbus TCP requests', + services: ['read_coil', 'read_register', 'write_coil', 'write_register', 'stop', 'restart', 'refresh'], + }, + localApi: { + implemented: [ + 'manual Modbus TCP hub configuration', + 'snapshot/manual configured registers and coils', + 'Modbus TCP MBAP request shape for read coils, read discrete inputs, read holding/input registers, write coil(s), and write register(s)', + 'register/coils mapping to sensor, binary_sensor, switch, and number entities', + ], + explicitUnsupported: [ + 'unsafe serial RTU discovery or serial port guessing', + 'serial RTU runtime transport', + 'RTU-over-TCP framer runtime transport', + 'UDP runtime transport', + 'climate, cover, fan, and light platform behavior beyond configured register/coil entities', + ], + }, + }; + + public async setup(configArg: IModbusConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new ModbusRuntime(new ModbusClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantModbusIntegration extends ModbusIntegration {} + +class ModbusRuntime implements IIntegrationRuntime { + public domain = 'modbus'; + + constructor(private readonly client: ModbusClient) {} + + public async devices(): Promise { + return ModbusMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return ModbusMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => handlerArg(ModbusMapper.toIntegrationEvent(eventArg))); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + const snapshot = await this.client.getSnapshot(); + const command = ModbusMapper.commandForService(snapshot, requestArg); + if (!command) { + return { success: false, error: `Unsupported Modbus service: ${requestArg.domain}.${requestArg.service}` }; + } + const result = await this.client.sendCommand(command); + return { success: result.success, error: result.error, data: result.data }; + } + + public async destroy(): Promise { + await this.client.destroy(); } } diff --git a/ts/integrations/modbus/modbus.discovery.ts b/ts/integrations/modbus/modbus.discovery.ts new file mode 100644 index 0000000..d458896 --- /dev/null +++ b/ts/integrations/modbus/modbus.discovery.ts @@ -0,0 +1,124 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IModbusManualTcpEntry } from './modbus.types.js'; + +const modbusDomain = 'modbus'; +const defaultModbusTcpPort = 502; + +export class ModbusManualTcpMatcher implements IDiscoveryMatcher { + public id = 'modbus-manual-tcp-match'; + public source = 'manual' as const; + public description = 'Recognize manual Modbus TCP setup entries without probing serial ports.'; + + public async matches(inputArg: IModbusManualTcpEntry, contextArg: IDiscoveryContext): Promise { + void contextArg; + if (inputArg.type === 'serial' || this.looksLikeSerialPort(inputArg.host) || this.looksLikeSerialPort(String(inputArg.port || ''))) { + return { + matched: false, + confidence: 'low', + reason: 'Serial RTU setup is not auto-discovered by this integration.', + }; + } + + const haystack = `${inputArg.name || ''} ${inputArg.protocol || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''}`.toLowerCase(); + const hasModbusHint = inputArg.type === 'tcp' || haystack.includes('modbus') || Boolean(inputArg.metadata?.modbus || inputArg.metadata?.modbusTcp); + const matched = Boolean(inputArg.host && (hasModbusHint || inputArg.port === defaultModbusTcpPort)); + if (!matched) { + return { + matched: false, + confidence: 'low', + reason: 'Manual entry does not contain a Modbus TCP host or hint.', + }; + } + + const id = this.normalizedId(inputArg); + return { + matched: true, + confidence: hasModbusHint ? 'high' : 'medium', + reason: 'Manual entry can be configured as a Modbus TCP hub.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: modbusDomain, + id, + host: inputArg.host, + port: inputArg.port || defaultModbusTcpPort, + name: inputArg.name, + manufacturer: inputArg.manufacturer || 'Modbus', + model: inputArg.model || 'TCP hub', + serialNumber: inputArg.serialNumber, + metadata: { + ...inputArg.metadata, + type: 'tcp', + unitId: inputArg.unitId || inputArg.slave || 1, + }, + }, + }; + } + + private normalizedId(inputArg: IModbusManualTcpEntry): string { + return slug(inputArg.id || inputArg.serialNumber || `${inputArg.host || 'modbus'}_${inputArg.port || defaultModbusTcpPort}`); + } + + private looksLikeSerialPort(valueArg: string | undefined): boolean { + return Boolean(valueArg && (/^\/dev\//.test(valueArg) || /^com\d+$/i.test(valueArg))); + } +} + +export class ModbusCandidateValidator implements IDiscoveryValidator { + public id = 'modbus-candidate-validator'; + public description = 'Validate that a candidate has enough Modbus TCP information for setup.'; + + public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise { + void contextArg; + const type = String(candidateArg.metadata?.type || '').toLowerCase(); + if (type === 'serial' || this.looksLikeSerialPort(candidateArg.host)) { + return { + matched: false, + confidence: 'low', + reason: 'Serial RTU candidates require explicit serial configuration and are not probed.', + }; + } + + const haystack = `${candidateArg.name || ''} ${candidateArg.model || ''} ${candidateArg.manufacturer || ''}`.toLowerCase(); + const matched = Boolean(candidateArg.host && ( + candidateArg.integrationDomain === modbusDomain + || candidateArg.port === defaultModbusTcpPort + || type === 'tcp' + || haystack.includes('modbus') + || candidateArg.metadata?.modbus + || candidateArg.metadata?.modbusTcp + )); + const id = slug(candidateArg.id || candidateArg.serialNumber || `${candidateArg.host || 'modbus'}_${candidateArg.port || defaultModbusTcpPort}`); + return { + matched, + confidence: matched && candidateArg.integrationDomain === modbusDomain ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has Modbus TCP setup data.' : 'Candidate is not a Modbus TCP hub.', + normalizedDeviceId: matched ? id : undefined, + candidate: matched ? { + ...candidateArg, + integrationDomain: modbusDomain, + id, + port: candidateArg.port || defaultModbusTcpPort, + manufacturer: candidateArg.manufacturer || 'Modbus', + model: candidateArg.model || 'TCP hub', + metadata: { + ...candidateArg.metadata, + type: 'tcp', + }, + } : undefined, + }; + } + + private looksLikeSerialPort(valueArg: string | undefined): boolean { + return Boolean(valueArg && (/^\/dev\//.test(valueArg) || /^com\d+$/i.test(valueArg))); + } +} + +export const createModbusDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: modbusDomain, displayName: 'Modbus' }) + .addMatcher(new ModbusManualTcpMatcher()) + .addValidator(new ModbusCandidateValidator()); +}; + +const slug = (valueArg: string): string => valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || modbusDomain; diff --git a/ts/integrations/modbus/modbus.mapper.ts b/ts/integrations/modbus/modbus.mapper.ts new file mode 100644 index 0000000..c02b919 --- /dev/null +++ b/ts/integrations/modbus/modbus.mapper.ts @@ -0,0 +1,487 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest } from '../../core/types.js'; +import type { + IModbusCoilState, + IModbusCommand, + IModbusEntitySnapshot, + IModbusEvent, + IModbusHubSnapshot, + IModbusRegisterState, + IModbusSnapshot, + TModbusCoilType, + TModbusDataType, + TModbusEntityPlatform, + TModbusRegisterType, +} from './modbus.types.js'; + +const modbusDomain = 'modbus'; + +export class ModbusMapper { + public static toDevices(snapshotArg: IModbusSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = []; + + for (const hub of snapshotArg.hubs) { + devices.push({ + id: this.hubDeviceId(hub), + integrationDomain: modbusDomain, + name: hub.name, + protocol: 'unknown', + manufacturer: hub.manufacturer || 'Modbus', + model: hub.model || `${hub.type.toUpperCase()} hub`, + online: hub.online && !hub.unsupportedReason, + features: [ + { id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false }, + { id: 'configured_entities', capability: 'sensor', name: 'Configured entities', readable: true, writable: false }, + ], + state: [ + { featureId: 'connectivity', value: hub.online && !hub.unsupportedReason ? 'online' : 'offline', updatedAt }, + { featureId: 'configured_entities', value: hub.entities.length, updatedAt }, + ], + metadata: this.cleanAttributes({ + type: hub.type, + host: hub.host, + port: hub.port, + serialNumber: hub.serialNumber, + unsupportedReason: hub.unsupportedReason, + ...hub.metadata, + }), + }); + + for (const slave of hub.slaves) { + devices.push({ + id: this.slaveDeviceId(hub, slave.unitId), + integrationDomain: modbusDomain, + name: slave.name, + protocol: 'unknown', + manufacturer: slave.manufacturer || hub.manufacturer || 'Modbus', + model: slave.model || 'Modbus slave', + online: hub.online && slave.online && !hub.unsupportedReason, + features: [ + { id: 'registers', capability: 'sensor', name: 'Configured registers', readable: true, writable: false }, + { id: 'coils', capability: 'sensor', name: 'Configured coils', readable: true, writable: false }, + ], + state: [ + { featureId: 'registers', value: slave.registers.length, updatedAt }, + { featureId: 'coils', value: slave.coils.length, updatedAt }, + ], + metadata: this.cleanAttributes({ + hubId: hub.id, + hubName: hub.name, + unitId: slave.unitId, + viaDevice: this.hubDeviceId(hub), + serialNumber: slave.serialNumber, + ...slave.metadata, + }), + }); + } + } + + return devices; + } + + public static toEntities(snapshotArg: IModbusSnapshot): IIntegrationEntity[] { + const usedIds = new Map(); + return snapshotArg.hubs.flatMap((hubArg) => hubArg.entities.map((entityArg) => this.toEntity(hubArg, entityArg, usedIds))); + } + + public static commandForService(snapshotArg: IModbusSnapshot, requestArg: IServiceCallRequest): IModbusCommand | undefined { + const targetEntity = this.findEntity(snapshotArg, requestArg); + if (requestArg.domain === modbusDomain && ['refresh', 'reload'].includes(requestArg.service)) { + return { type: 'refresh', ...this.commandTarget(snapshotArg, requestArg, targetEntity) }; + } + if (requestArg.domain === modbusDomain && requestArg.service === 'stop') { + return { type: 'stop', ...this.commandTarget(snapshotArg, requestArg, targetEntity) }; + } + if (requestArg.domain === modbusDomain && requestArg.service === 'restart') { + return { type: 'restart', ...this.commandTarget(snapshotArg, requestArg, targetEntity) }; + } + + if (requestArg.domain === modbusDomain && ['read_register', 'read_holding_register', 'read_input_register'].includes(requestArg.service)) { + const address = this.addressValue(requestArg, targetEntity); + if (address === undefined) { + return undefined; + } + return { + type: 'read_register', + ...this.commandTarget(snapshotArg, requestArg, targetEntity), + address, + count: this.numberData(requestArg, 'count') || targetEntity?.register?.count || 1, + inputType: this.registerType(requestArg, targetEntity, requestArg.service === 'read_input_register' ? 'input' : undefined), + dataType: this.dataType(requestArg, targetEntity), + }; + } + + if (requestArg.domain === modbusDomain && ['read_coil', 'read_discrete_input'].includes(requestArg.service)) { + const address = this.addressValue(requestArg, targetEntity); + if (address === undefined) { + return undefined; + } + return { + type: 'read_coil', + ...this.commandTarget(snapshotArg, requestArg, targetEntity), + address, + count: this.numberData(requestArg, 'count') || targetEntity?.coil?.count || 1, + inputType: this.coilType(requestArg, targetEntity, requestArg.service === 'read_discrete_input' ? 'discrete_input' : undefined), + }; + } + + if (requestArg.domain === modbusDomain && requestArg.service === 'write_register') { + const address = this.addressValue(requestArg, targetEntity); + const value = this.numberOrArrayData(requestArg, 'value'); + return address !== undefined && value !== undefined ? { + type: 'write_register', + ...this.commandTarget(snapshotArg, requestArg, targetEntity), + address, + value, + } : undefined; + } + + if (requestArg.domain === modbusDomain && requestArg.service === 'write_coil') { + const address = this.addressValue(requestArg, targetEntity); + const value = this.boolOrArrayData(requestArg, 'state') ?? this.boolOrArrayData(requestArg, 'value'); + return address !== undefined && value !== undefined ? { + type: 'write_coil', + ...this.commandTarget(snapshotArg, requestArg, targetEntity), + address, + value, + } : undefined; + } + + if (requestArg.domain === 'switch' && ['turn_on', 'turn_off'].includes(requestArg.service) && targetEntity?.platform === 'switch') { + const commandValue = requestArg.service === 'turn_on' ? this.attributeNumber(targetEntity, 'commandOn') ?? 1 : this.attributeNumber(targetEntity, 'commandOff') ?? 0; + const writeType = String(targetEntity.attributes?.writeType || 'coil'); + if (writeType === 'holding' || writeType === 'holdings' || targetEntity.register) { + return { + type: 'write_register', + ...this.commandTarget(snapshotArg, requestArg, targetEntity), + address: targetEntity.address, + value: commandValue, + }; + } + return { + type: 'write_coil', + ...this.commandTarget(snapshotArg, requestArg, targetEntity), + address: targetEntity.address, + value: commandValue, + }; + } + + if (requestArg.domain === 'number' && requestArg.service === 'set_value' && targetEntity?.platform === 'number') { + const value = this.numberData(requestArg, 'value'); + return value !== undefined ? { + type: 'write_register', + ...this.commandTarget(snapshotArg, requestArg, targetEntity), + address: targetEntity.address, + value, + } : undefined; + } + + return undefined; + } + + public static toIntegrationEvent(eventArg: IModbusEvent): IIntegrationEvent { + return { + type: eventArg.type === 'command_failed' ? 'error' : 'state_changed', + integrationDomain: modbusDomain, + deviceId: eventArg.deviceId, + entityId: eventArg.entityId, + data: eventArg, + timestamp: eventArg.timestamp, + }; + } + + public static hubDeviceId(hubArg: Pick): string { + return `modbus.hub.${this.slug(hubArg.id)}`; + } + + public static slaveDeviceId(hubArg: Pick, unitIdArg: number): string { + return `modbus.slave.${this.slug(hubArg.id)}.${unitIdArg}`; + } + + public static decodeRegisterValue(registerArg: IModbusRegisterState): number | string | number[] | null { + if (registerArg.value !== undefined) { + return registerArg.value; + } + const registers = registerArg.registers || []; + if (!registers.length) { + return null; + } + const dataType = this.normalizedDataType(registerArg.dataType); + if (dataType === 'custom') { + return registers; + } + + const bytes = Buffer.alloc(registers.length * 2); + registers.forEach((registerValueArg, indexArg) => bytes.writeUInt16BE(registerValueArg & 0xffff, indexArg * 2)); + let value: number | string; + if (dataType === 'string') { + value = bytes.toString('utf8').replace(/\0+$/g, ''); + } else if (dataType === 'int16') { + value = bytes.readInt16BE(0); + } else if (dataType === 'int32') { + value = bytes.readInt32BE(0); + } else if (dataType === 'uint32') { + value = bytes.readUInt32BE(0); + } else if (dataType === 'int64') { + value = Number(bytes.readBigInt64BE(0)); + } else if (dataType === 'uint64') { + value = Number(bytes.readBigUInt64BE(0)); + } else if (dataType === 'float16') { + value = this.decodeFloat16(bytes.readUInt16BE(0)); + } else if (dataType === 'float32') { + value = bytes.readFloatBE(0); + } else if (dataType === 'float64') { + value = bytes.readDoubleBE(0); + } else { + value = bytes.readUInt16BE(0); + } + return typeof value === 'number' ? this.processNumericValue(value, registerArg) : value; + } + + public static registerCountForDataType(dataTypeArg: TModbusDataType | undefined): number { + const dataType = this.normalizedDataType(dataTypeArg || 'int16'); + if (dataType === 'int32' || dataType === 'uint32' || dataType === 'float32') { + return 2; + } + if (dataType === 'int64' || dataType === 'uint64' || dataType === 'float64') { + return 4; + } + return 1; + } + + private static toEntity(hubArg: IModbusHubSnapshot, entityArg: IModbusEntitySnapshot, usedIdsArg: Map): IIntegrationEntity { + const platform = entityArg.platform; + const id = entityArg.id.includes('.') ? entityArg.id : this.uniqueEntityId(platform, entityArg.name, usedIdsArg); + const state = this.entityState(entityArg); + return { + id, + uniqueId: entityArg.uniqueId, + integrationDomain: modbusDomain, + deviceId: this.slaveDeviceId(hubArg, entityArg.unitId), + platform, + name: entityArg.name, + state, + attributes: this.cleanAttributes({ + hubId: hubArg.id, + hubName: hubArg.name, + unitId: entityArg.unitId, + address: entityArg.address, + writable: entityArg.writable, + registerType: entityArg.register?.inputType, + coilType: entityArg.coil?.inputType, + dataType: entityArg.register?.dataType, + count: entityArg.register?.count || entityArg.coil?.count, + ...entityArg.attributes, + }), + available: entityArg.available && hubArg.online && !hubArg.unsupportedReason, + }; + } + + private static entityState(entityArg: IModbusEntitySnapshot): unknown { + if (entityArg.state !== undefined) { + return entityArg.state; + } + if (entityArg.platform === 'sensor') { + return entityArg.register ? this.decodeRegisterValue(entityArg.register) : null; + } + if (entityArg.platform === 'number') { + const value = entityArg.register ? this.decodeRegisterValue(entityArg.register) : null; + return typeof value === 'number' && Number.isFinite(value) ? value : null; + } + const boolValue = this.coilBoolean(entityArg.coil); + return boolValue === undefined ? 'unknown' : boolValue ? 'on' : 'off'; + } + + private static coilBoolean(coilArg: IModbusCoilState | undefined): boolean | undefined { + if (!coilArg) { + return undefined; + } + if (typeof coilArg.value === 'boolean') { + return coilArg.value; + } + if (typeof coilArg.value === 'number') { + return Boolean(coilArg.value & 1); + } + if (coilArg.bits?.length) { + return Boolean(coilArg.bits[0]); + } + return undefined; + } + + private static uniqueEntityId(platformArg: TModbusEntityPlatform, nameArg: string, usedIdsArg: Map): string { + const baseId = `${platformArg}.${this.slug(nameArg)}`; + const seen = usedIdsArg.get(baseId) || 0; + usedIdsArg.set(baseId, seen + 1); + return seen ? `${baseId}_${seen + 1}` : baseId; + } + + private static findEntity(snapshotArg: IModbusSnapshot, requestArg: IServiceCallRequest): IModbusEntitySnapshot | undefined { + const target = requestArg.target.entityId || requestArg.target.deviceId; + if (!target) { + return undefined; + } + for (const hub of snapshotArg.hubs) { + const entity = hub.entities.find((entityArg) => entityArg.id === target || entityArg.uniqueId === target || this.slaveDeviceId(hub, entityArg.unitId) === target); + if (entity) { + return entity; + } + } + return undefined; + } + + private static commandTarget(snapshotArg: IModbusSnapshot, requestArg: IServiceCallRequest, entityArg?: IModbusEntitySnapshot): Pick { + const firstHub = snapshotArg.hubs[0]; + return { + hub: this.stringData(requestArg, 'hub') || entityArg?.hubName || firstHub?.name, + hubId: this.stringData(requestArg, 'hubId') || entityArg?.hubId || firstHub?.id, + unitId: this.numberData(requestArg, 'unitId') ?? this.numberData(requestArg, 'unit') ?? this.numberData(requestArg, 'slave') ?? entityArg?.unitId ?? 1, + entityId: requestArg.target.entityId, + deviceId: requestArg.target.deviceId, + uniqueId: entityArg?.uniqueId, + }; + } + + private static addressValue(requestArg: IServiceCallRequest, entityArg?: IModbusEntitySnapshot): number | undefined { + return this.numberData(requestArg, 'address') ?? entityArg?.address; + } + + private static registerType(requestArg: IServiceCallRequest, entityArg?: IModbusEntitySnapshot, fallbackArg?: TModbusRegisterType): TModbusRegisterType { + const value = this.stringData(requestArg, 'inputType') || this.stringData(requestArg, 'input_type') || this.stringData(requestArg, 'registerType') || this.stringData(requestArg, 'register_type') || entityArg?.register?.inputType || fallbackArg || 'holding'; + return value === 'input' ? 'input' : 'holding'; + } + + private static coilType(requestArg: IServiceCallRequest, entityArg?: IModbusEntitySnapshot, fallbackArg?: TModbusCoilType): TModbusCoilType { + const value = this.stringData(requestArg, 'inputType') || this.stringData(requestArg, 'input_type') || this.stringData(requestArg, 'coilType') || this.stringData(requestArg, 'coil_type') || entityArg?.coil?.inputType || fallbackArg || 'coil'; + return value === 'discrete_input' ? 'discrete_input' : 'coil'; + } + + private static dataType(requestArg: IServiceCallRequest, entityArg?: IModbusEntitySnapshot): TModbusDataType | undefined { + const value = this.stringData(requestArg, 'dataType') || this.stringData(requestArg, 'data_type') || entityArg?.register?.dataType; + return value as TModbusDataType | undefined; + } + + private static numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined { + const value = requestArg.data?.[keyArg]; + return this.toNumber(value); + } + + private static attributeNumber(entityArg: IModbusEntitySnapshot, keyArg: string): number | undefined { + return this.toNumber(entityArg.attributes?.[keyArg]); + } + + private static toNumber(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg)) ? Number(valueArg) : undefined; + } + + private static stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined { + const value = requestArg.data?.[keyArg]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; + } + + private static numberOrArrayData(requestArg: IServiceCallRequest, keyArg: string): number | number[] | undefined { + const value = requestArg.data?.[keyArg]; + if (Array.isArray(value)) { + const values = value.map((entryArg) => this.toNumber(entryArg)).filter((entryArg): entryArg is number => entryArg !== undefined); + return values.length === value.length ? values : undefined; + } + return this.toNumber(value); + } + + private static boolOrArrayData(requestArg: IServiceCallRequest, keyArg: string): boolean | boolean[] | number | number[] | undefined { + const value = requestArg.data?.[keyArg]; + if (Array.isArray(value)) { + const values = value.map((entryArg) => this.toBooleanOrNumber(entryArg)).filter((entryArg): entryArg is boolean | number => entryArg !== undefined); + return values.length === value.length ? values.map((entryArg) => typeof entryArg === 'boolean' ? entryArg : Boolean(entryArg)) : undefined; + } + return this.toBooleanOrNumber(value); + } + + private static toBooleanOrNumber(valueArg: unknown): boolean | number | undefined { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string') { + const value = valueArg.trim().toLowerCase(); + if (value === 'true' || value === 'on') { + return true; + } + if (value === 'false' || value === 'off') { + return false; + } + if (value && Number.isFinite(Number(value))) { + return Number(value); + } + } + return undefined; + } + + private static normalizedDataType(dataTypeArg: TModbusDataType): Exclude { + if (dataTypeArg === 'int' || dataTypeArg === 'integer') { + return 'int16'; + } + if (dataTypeArg === 'uint') { + return 'uint16'; + } + if (dataTypeArg === 'float') { + return 'float32'; + } + return dataTypeArg; + } + + private static decodeFloat16(valueArg: number): number { + const sign = (valueArg & 0x8000) ? -1 : 1; + const exponent = (valueArg >> 10) & 0x1f; + const fraction = valueArg & 0x03ff; + if (exponent === 0) { + return sign * Math.pow(2, -14) * (fraction / 1024); + } + if (exponent === 31) { + return fraction ? Number.NaN : sign * Number.POSITIVE_INFINITY; + } + return sign * Math.pow(2, exponent - 15) * (1 + fraction / 1024); + } + + private static processNumericValue(valueArg: number, registerArg: IModbusRegisterState): number | null { + const metadata = registerArg.metadata || {}; + const nanValue = this.toNumberValue(metadata.nanValue) ?? this.toNumberValue(metadata.nan_value); + if (nanValue !== undefined && (valueArg === nanValue || valueArg === -nanValue)) { + return null; + } + if (Number.isNaN(valueArg)) { + return null; + } + const scale = this.toNumberValue(metadata.scale) ?? 1; + const offset = this.toNumberValue(metadata.offset) ?? 0; + let value = valueArg * scale + offset; + const min = this.toNumberValue(metadata.minValue) ?? this.toNumberValue(metadata.min_value); + const max = this.toNumberValue(metadata.maxValue) ?? this.toNumberValue(metadata.max_value); + const zeroSuppress = this.toNumberValue(metadata.zeroSuppress) ?? this.toNumberValue(metadata.zero_suppress); + const precision = this.toNumberValue(metadata.precision); + if (min !== undefined && value < min) { + value = min; + } + if (max !== undefined && value > max) { + value = max; + } + if (zeroSuppress !== undefined && Math.abs(value) <= zeroSuppress) { + value = 0; + } + return precision !== undefined ? Number(value.toFixed(precision)) : value; + } + + private static toNumberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg)) ? Number(valueArg) : undefined; + } + + private static cleanAttributes(attributesArg: Record): Record { + return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)); + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || modbusDomain; + } +} diff --git a/ts/integrations/modbus/modbus.types.ts b/ts/integrations/modbus/modbus.types.ts index 76bd3b0..57e51e9 100644 --- a/ts/integrations/modbus/modbus.types.ts +++ b/ts/integrations/modbus/modbus.types.ts @@ -1,4 +1,407 @@ -export interface IHomeAssistantModbusConfig { - // TODO: replace with the TypeScript-native config for modbus. - [key: string]: unknown; +export type TModbusTransport = 'tcp' | 'udp' | 'rtuovertcp' | 'serial'; + +export type TModbusRegisterType = 'holding' | 'input'; + +export type TModbusCoilType = 'coil' | 'discrete_input'; + +export type TModbusDataType = + | 'custom' + | 'string' + | 'int' + | 'integer' + | 'int16' + | 'int32' + | 'int64' + | 'uint' + | 'uint16' + | 'uint32' + | 'uint64' + | 'float' + | 'float16' + | 'float32' + | 'float64'; + +export type TModbusSwap = 'byte' | 'word' | 'word_byte'; + +export type TModbusEntityPlatform = 'sensor' | 'binary_sensor' | 'switch' | 'number'; + +export type TModbusEventType = + | 'snapshot_refreshed' + | 'command_mapped' + | 'command_executed' + | 'command_failed' + | 'register_read' + | 'register_written' + | 'coil_read' + | 'coil_written'; + +export interface IModbusConfig { + hubs?: IModbusHubConfig[]; + modbus?: IModbusHubConfig[]; + snapshot?: IModbusSnapshot; + manualEntries?: IModbusManualTcpEntry[]; + connected?: boolean; + timeoutMs?: number; + commandExecutor?: (commandArg: IModbusCommand) => Promise; } + +export interface IHomeAssistantModbusConfig extends IModbusConfig {} + +export interface IModbusHubConfig { + id?: string; + name?: string; + type?: TModbusTransport; + host?: string; + port?: number | string; + timeout?: number; + timeoutMs?: number; + delay?: number; + messageWaitMilliseconds?: number; + message_wait_milliseconds?: number; + slave?: number; + unit?: number; + unitId?: number; + deviceAddress?: number; + device_address?: number; + connected?: boolean; + manufacturer?: string; + model?: string; + serialNumber?: string; + serial_number?: string; + metadata?: Record; + slaves?: IModbusSlaveConfig[]; + registers?: IModbusRegisterConfig[]; + coils?: IModbusCoilConfig[]; + sensors?: IModbusRegisterConfig[]; + binarySensors?: IModbusCoilConfig[]; + binary_sensors?: IModbusCoilConfig[]; + switches?: IModbusSwitchConfig[]; + numbers?: IModbusNumberConfig[]; +} + +export interface IModbusSlaveConfig { + id?: string; + unitId?: number; + unit?: number; + slave?: number; + deviceAddress?: number; + device_address?: number; + name?: string; + connected?: boolean; + manufacturer?: string; + model?: string; + serialNumber?: string; + serial_number?: string; + metadata?: Record; +} + +export interface IModbusEntityConfigBase { + id?: string; + name: string; + uniqueId?: string; + unique_id?: string; + platform?: TModbusEntityPlatform; + address: number; + slave?: number; + unit?: number; + unitId?: number; + deviceAddress?: number; + device_address?: number; + scanInterval?: number; + scan_interval?: number; + deviceClass?: string; + device_class?: string; + available?: boolean; + metadata?: Record; +} + +export interface IModbusRegisterConfig extends IModbusEntityConfigBase { + platform?: 'sensor' | 'number'; + inputType?: TModbusRegisterType; + input_type?: TModbusRegisterType; + registerType?: TModbusRegisterType; + register_type?: TModbusRegisterType; + count?: number; + dataType?: TModbusDataType; + data_type?: TModbusDataType; + structure?: string; + scale?: number; + offset?: number; + precision?: number; + swap?: TModbusSwap; + unitOfMeasurement?: string; + unit_of_measurement?: string; + stateClass?: string; + state_class?: string; + minValue?: number; + min_value?: number; + maxValue?: number; + max_value?: number; + nanValue?: number; + nan_value?: number; + zeroSuppress?: number; + zero_suppress?: number; + min?: number; + max?: number; + step?: number; + writable?: boolean; + value?: number | string | null; + registers?: number[]; +} + +export interface IModbusNumberConfig extends IModbusRegisterConfig { + platform?: 'number'; + writable?: true; +} + +export interface IModbusCoilConfig extends IModbusEntityConfigBase { + platform?: 'binary_sensor' | 'switch'; + inputType?: TModbusCoilType | TModbusRegisterType; + input_type?: TModbusCoilType | TModbusRegisterType; + coilType?: TModbusCoilType; + coil_type?: TModbusCoilType; + count?: number; + slaveCount?: number; + slave_count?: number; + virtualCount?: number; + virtual_count?: number; + writable?: boolean; + value?: boolean | number | null; + bits?: boolean[]; +} + +export interface IModbusSwitchConfig extends IModbusCoilConfig { + platform?: 'switch'; + writeType?: TModbusWriteType; + write_type?: TModbusWriteType; + commandOn?: number; + command_on?: number; + commandOff?: number; + command_off?: number; + stateOn?: number[]; + state_on?: number[]; + stateOff?: number[]; + state_off?: number[]; + verify?: IModbusSwitchVerifyConfig | null; +} + +export interface IModbusSwitchVerifyConfig { + address?: number; + inputType?: TModbusCoilType | TModbusRegisterType; + input_type?: TModbusCoilType | TModbusRegisterType; + delay?: number; + stateOn?: number[]; + state_on?: number[]; + stateOff?: number[]; + state_off?: number[]; +} + +export type TModbusWriteType = 'holding' | 'holdings' | 'coil' | 'coils'; + +export interface IModbusRegisterState { + hubId?: string; + unitId: number; + address: number; + count: number; + inputType: TModbusRegisterType; + dataType: TModbusDataType; + registers?: number[]; + value?: number | string | number[] | null; + updatedAt?: string; + metadata?: Record; +} + +export interface IModbusCoilState { + hubId?: string; + unitId: number; + address: number; + count: number; + inputType: TModbusCoilType | TModbusRegisterType; + bits?: boolean[]; + value?: boolean | number | null; + updatedAt?: string; + metadata?: Record; +} + +export interface IModbusEntitySnapshot { + id: string; + name: string; + uniqueId: string; + platform: TModbusEntityPlatform; + hubId: string; + hubName: string; + unitId: number; + address: number; + available: boolean; + writable: boolean; + register?: IModbusRegisterState; + coil?: IModbusCoilState; + state?: unknown; + attributes?: Record; +} + +export interface IModbusSlaveSnapshot { + id: string; + hubId: string; + unitId: number; + name: string; + online: boolean; + manufacturer?: string; + model?: string; + serialNumber?: string; + registers: IModbusRegisterState[]; + coils: IModbusCoilState[]; + metadata?: Record; +} + +export interface IModbusHubSnapshot { + id: string; + name: string; + type: TModbusTransport; + host?: string; + port?: number | string; + online: boolean; + manufacturer?: string; + model?: string; + serialNumber?: string; + unsupportedReason?: string; + slaves: IModbusSlaveSnapshot[]; + registers: IModbusRegisterState[]; + coils: IModbusCoilState[]; + entities: IModbusEntitySnapshot[]; + metadata?: Record; +} + +export interface IModbusSnapshot { + hubs: IModbusHubSnapshot[]; + events: IModbusEvent[]; + connected: boolean; + updatedAt: string; + raw?: Record; +} + +export interface IModbusEvent { + type: TModbusEventType; + command?: IModbusCommand; + hubId?: string; + hubName?: string; + unitId?: number; + entityId?: string; + deviceId?: string; + uniqueId?: string; + data?: unknown; + timestamp: number; +} + +export type IModbusCommand = + | IModbusRefreshCommand + | IModbusReadRegisterCommand + | IModbusWriteRegisterCommand + | IModbusReadCoilCommand + | IModbusWriteCoilCommand + | IModbusStopCommand + | IModbusRestartCommand; + +export interface IModbusCommandBase { + hub?: string; + hubId?: string; + unitId?: number; + slave?: number; + entityId?: string; + deviceId?: string; + uniqueId?: string; +} + +export interface IModbusRefreshCommand extends IModbusCommandBase { + type: 'refresh'; +} + +export interface IModbusStopCommand extends IModbusCommandBase { + type: 'stop'; +} + +export interface IModbusRestartCommand extends IModbusCommandBase { + type: 'restart'; +} + +export interface IModbusReadRegisterCommand extends IModbusCommandBase { + type: 'read_register'; + address: number; + count?: number; + inputType?: TModbusRegisterType; + dataType?: TModbusDataType; +} + +export interface IModbusWriteRegisterCommand extends IModbusCommandBase { + type: 'write_register'; + address: number; + value: number | number[]; +} + +export interface IModbusReadCoilCommand extends IModbusCommandBase { + type: 'read_coil'; + address: number; + count?: number; + inputType?: TModbusCoilType; +} + +export interface IModbusWriteCoilCommand extends IModbusCommandBase { + type: 'write_coil'; + address: number; + value: boolean | boolean[] | number | number[]; +} + +export interface IModbusCommandResult { + success: boolean; + error?: string; + data?: unknown; +} + +export interface IModbusTcpCommandShape { + transport: 'tcp'; + host?: string; + port?: number; + unitId: number; + transactionId: number; + functionCode: number; + functionName: string; + address?: number; + quantity?: number; + values?: number[] | boolean[]; + mbap: { + transactionId: number; + protocolId: 0; + length: number; + unitId: number; + }; + pduHex: string; + requestHex: string; +} + +export interface IModbusTcpResponse { + transactionId: number; + unitId: number; + functionCode: number; + registers?: number[]; + bits?: boolean[]; + address?: number; + quantity?: number; + rawHex: string; +} + +export interface IModbusManualTcpEntry { + host?: string; + port?: number; + type?: TModbusTransport; + protocol?: string; + name?: string; + id?: string; + unitId?: number; + slave?: number; + manufacturer?: string; + model?: string; + serialNumber?: string; + metadata?: Record; +} + +export interface IModbusDiscoveryEntry extends IModbusManualTcpEntry {} diff --git a/ts/integrations/opentherm_gw/.generated-by-smarthome-exchange b/ts/integrations/opentherm_gw/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/opentherm_gw/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/opentherm_gw/index.ts b/ts/integrations/opentherm_gw/index.ts index e83a3eb..70858d2 100644 --- a/ts/integrations/opentherm_gw/index.ts +++ b/ts/integrations/opentherm_gw/index.ts @@ -1,2 +1,6 @@ export * from './opentherm_gw.classes.integration.js'; +export * from './opentherm_gw.classes.client.js'; +export * from './opentherm_gw.classes.configflow.js'; +export * from './opentherm_gw.discovery.js'; +export * from './opentherm_gw.mapper.js'; export * from './opentherm_gw.types.js'; diff --git a/ts/integrations/opentherm_gw/opentherm_gw.classes.client.ts b/ts/integrations/opentherm_gw/opentherm_gw.classes.client.ts new file mode 100644 index 0000000..2eae655 --- /dev/null +++ b/ts/integrations/opentherm_gw/opentherm_gw.classes.client.ts @@ -0,0 +1,596 @@ +import * as plugins from '../../plugins.js'; +import type { + IOpenthermGwCommandRequest, + IOpenthermGwCommandResponse, + IOpenthermGwConfig, + IOpenthermGwEvent, + IOpenthermGwGatewayInfo, + IOpenthermGwGatewayStatus, + IOpenthermGwSnapshot, + IOpenthermGwStatus, + TOpenthermGwCommandCode, + TOpenthermGwCommandValue, + TOpenthermGwStatusValue, +} from './opentherm_gw.types.js'; +import { openthermGwDefaultTcpPort, openthermGwDefaultTimeoutMs } from './opentherm_gw.types.js'; + +const commandErrorCodes = new Set(['NG', 'SE', 'BV', 'OR', 'NS', 'NF', 'OE', 'MPC']); +const reportCodes = ['A', 'B', 'C', 'W', 'G', 'I', 'L', 'M', 'Q', 'S', 'T', 'V', 'P', 'R']; + +export class OpenthermGwCommandError extends Error { + constructor(public readonly code: string, messageArg: string) { + super(`OpenTherm Gateway command ${code} failed: ${messageArg}`); + this.name = 'OpenthermGwCommandError'; + } +} + +export class OpenthermGwClient { + private currentSnapshot?: IOpenthermGwSnapshot; + private commandHistory: IOpenthermGwCommandResponse[] = []; + private handlers = new Set<(eventArg: IOpenthermGwEvent) => void>(); + + constructor(private readonly config: IOpenthermGwConfig) { + if (config.snapshot) { + this.currentSnapshot = this.normalizeSnapshot(this.cloneSnapshot(config.snapshot), config.snapshot.source || 'snapshot'); + } else if (config.status) { + this.currentSnapshot = this.snapshotFromStatus(config.status, false, 'manual'); + } + } + + public async getSnapshot(): Promise { + if (this.isSnapshotMode()) { + return this.normalizeSnapshot(this.cloneSnapshot(this.currentSnapshot || this.emptySnapshot()), this.currentSnapshot?.source || 'manual'); + } + + const status = defaultStatus(); + await this.populateReports(status.gateway); + const summaryStatus = await this.getSummaryStatus(); + status.boiler = { ...status.boiler, ...summaryStatus.boiler }; + status.thermostat = { ...status.thermostat, ...summaryStatus.thermostat }; + this.currentSnapshot = this.snapshotFromStatus(status, true, 'tcp'); + return this.cloneSnapshot(this.currentSnapshot); + } + + public async setRoomSetpoint(temperatureArg: number, temporaryArg = this.config.temporaryOverrideMode ?? true): Promise { + return this.command(temporaryArg ? 'TT' : 'TC', roundCommandNumber(temperatureArg, 1)); + } + + public async setControlSetpoint(temperatureArg: number): Promise { + return this.command('CS', roundCommandNumber(temperatureArg, 1)); + } + + public async setHotWater(valueArg: boolean | string | number): Promise { + if (typeof valueArg === 'boolean') { + return this.command('HW', valueArg ? 1 : 0); + } + return this.command('HW', valueArg); + } + + public async setHotWaterSetpoint(temperatureArg: number): Promise { + return this.command('SW', roundCommandNumber(temperatureArg, 1)); + } + + public async setOutsideTemperature(temperatureArg: number): Promise { + return this.command('OT', roundCommandNumber(temperatureArg, 1)); + } + + public async setCentralHeatingOverride(circuitArg: 1 | 2, enabledArg: boolean): Promise { + return this.command(circuitArg === 2 ? 'H2' : 'CH', enabledArg ? 1 : 0); + } + + public async reset(): Promise { + return this.command('GW', 'R'); + } + + public async command(codeArg: TOpenthermGwCommandCode, valueArg: TOpenthermGwCommandValue): Promise { + const request: IOpenthermGwCommandRequest = { code: codeArg, value: valueArg }; + const response = this.isSnapshotMode() ? this.snapshotCommand(request) : await this.requestTcp(request); + this.commandHistory.push(response); + this.commandHistory = this.commandHistory.slice(-50); + this.applyCommandToSnapshot(response); + this.emit({ type: 'command', gatewayId: this.gatewayId(), command: response, status: this.currentSnapshot?.status, timestamp: Date.now() }); + return response; + } + + public subscribe(handlerArg: (eventArg: IOpenthermGwEvent) => void): () => void { + this.handlers.add(handlerArg); + return () => this.handlers.delete(handlerArg); + } + + public async destroy(): Promise { + this.handlers.clear(); + } + + private async populateReports(statusArg: IOpenthermGwGatewayStatus): Promise { + for (const reportCode of reportCodes) { + try { + const response = await this.requestTcp({ code: 'PR', value: reportCode }); + applyReport(statusArg, response.value); + } catch { + // Report support varies by firmware; keep the snapshot usable with partial data. + } + } + } + + private async getSummaryStatus(): Promise> { + const response = await this.requestTcp({ code: 'PS', value: 1 }); + this.requestTcp({ code: 'PS', value: 0 }).catch(() => undefined); + return parseSummaryLine(response.summaryLine || response.rawLines[response.rawLines.length - 1] || ''); + } + + private requestTcp(requestArg: IOpenthermGwCommandRequest): Promise { + const host = this.config.host; + if (!host) { + throw new Error('OpenTherm Gateway TCP command requires config.host.'); + } + const port = this.config.port || openthermGwDefaultTcpPort; + const timeoutMs = requestArg.timeoutMs || this.config.timeoutMs || openthermGwDefaultTimeoutMs; + const code = requestArg.code.toUpperCase(); + const value = formatCommandValue(requestArg.value); + const commandLine = `${code}=${value}`; + + return new Promise((resolve, reject) => { + let buffer = ''; + let settled = false; + let waitingForSummary = false; + const rawLines: string[] = []; + const socket = plugins.net.createConnection({ host, port }); + const timeout = setTimeout(() => finish(new OpenthermGwCommandError(code, `Timed out after ${timeoutMs}ms.`)), timeoutMs); + + const finish = (errorArg?: Error, responseArg?: IOpenthermGwCommandResponse) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + socket.removeAllListeners(); + socket.destroy(); + if (errorArg) { + reject(errorArg); + return; + } + resolve(responseArg!); + }; + + const response = (responseValueArg: string, summaryLineArg?: string): IOpenthermGwCommandResponse => ({ + code, + value: responseValueArg, + accepted: true, + commandLine, + rawLines: [...rawLines], + summaryLine: summaryLineArg, + updatedAt: new Date().toISOString(), + }); + + const processLine = (lineArg: string) => { + const line = cleanLine(lineArg); + if (!line) { + return; + } + rawLines.push(line); + + if (isOpenThermTrafficLine(line)) { + return; + } + if (commandErrorCodes.has(line)) { + finish(new OpenthermGwCommandError(code, line)); + return; + } + if (waitingForSummary) { + finish(undefined, response('1', line)); + return; + } + if (code === 'GW' && value === 'R' && /^OpenTherm Gateway\s+/i.test(line)) { + finish(undefined, response(line)); + return; + } + + const prefixed = line.match(new RegExp(`^${escapeRegExp(code)}:\\s*(.+)$`, 'i')); + const compatibleBare = (code === 'C2' || code === 'H2') ? line.match(/^(0|1|[0-9]+\.[0-9]{1,2}|[A-Z]{2})$/i) : undefined; + const valueMatch = prefixed?.[1] || compatibleBare?.[1]; + if (!valueMatch) { + return; + } + if (commandErrorCodes.has(valueMatch)) { + finish(new OpenthermGwCommandError(code, valueMatch)); + return; + } + if (code === 'PS' && valueMatch.trim() === '1') { + waitingForSummary = true; + return; + } + finish(undefined, response(valueMatch.trim())); + }; + + socket.on('connect', () => { + socket.write(`${commandLine}\r\n`, 'ascii'); + }); + socket.on('data', (dataArg) => { + buffer += dataArg.toString('ascii'); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() || ''; + for (const line of lines) { + processLine(line); + } + }); + socket.on('error', (errorArg) => finish(errorArg)); + socket.on('close', () => { + if (buffer.trim()) { + processLine(buffer); + } + if (!settled) { + finish(new OpenthermGwCommandError(code, 'Connection closed before a command response was received.')); + } + }); + }); + } + + private snapshotCommand(requestArg: IOpenthermGwCommandRequest): IOpenthermGwCommandResponse { + const code = requestArg.code.toUpperCase(); + const value = formatCommandValue(requestArg.value); + return { + code, + value, + accepted: true, + commandLine: `${code}=${value}`, + rawLines: [`${code}: ${value}`], + updatedAt: new Date().toISOString(), + }; + } + + private applyCommandToSnapshot(responseArg: IOpenthermGwCommandResponse): void { + const snapshot = this.normalizeSnapshot(this.currentSnapshot || this.emptySnapshot(), this.currentSnapshot?.source || 'manual'); + const numericValue = numberValue(responseArg.value); + const value = responseArg.value; + + if (responseArg.code === 'TT' || responseArg.code === 'TC') { + snapshot.status.gateway.otgw_setpoint_ovrd_mode = numericValue === 0 ? 'N' : responseArg.code === 'TT' ? 'T' : 'C'; + snapshot.status.thermostat.room_setpoint_ovrd = numericValue === 0 ? null : numericValue; + } else if (responseArg.code === 'CS') { + snapshot.status.boiler.control_setpoint = numericValue; + } else if (responseArg.code === 'C2') { + snapshot.status.boiler.control_setpoint_2 = numericValue; + } else if (responseArg.code === 'SW') { + snapshot.status.boiler.dhw_setpoint = numericValue; + } else if (responseArg.code === 'HW') { + snapshot.status.gateway.otgw_dhw_ovrd = value === 'A' ? null : value; + } else if (responseArg.code === 'OT') { + snapshot.status.thermostat.outside_temp = value === '-' ? null : numericValue; + } else if (responseArg.code === 'CH') { + snapshot.status.gateway.central_heating_1_override = boolNumber(value); + snapshot.status.boiler.master_ch_enabled = boolNumber(value); + } else if (responseArg.code === 'H2') { + snapshot.status.gateway.central_heating_2_override = boolNumber(value); + snapshot.status.boiler.master_ch2_enabled = boolNumber(value); + } else if (responseArg.code === 'GW' && value === 'R') { + snapshot.status.gateway.otgw_mode = 'R'; + } + + snapshot.commands = [...(snapshot.commands || []), responseArg].slice(-50); + snapshot.online = snapshot.online || Boolean(this.config.host); + snapshot.updatedAt = responseArg.updatedAt; + snapshot.source = snapshot.source === 'tcp' ? 'tcp' : 'runtime'; + this.currentSnapshot = this.normalizeSnapshot(snapshot, snapshot.source); + } + + private normalizeSnapshot(snapshotArg: IOpenthermGwSnapshot, sourceArg: IOpenthermGwSnapshot['source']): IOpenthermGwSnapshot { + const status = { + gateway: { ...(snapshotArg.status?.gateway || {}) }, + boiler: { ...(snapshotArg.status?.boiler || {}) }, + thermostat: { ...(snapshotArg.status?.thermostat || {}) }, + }; + const configuredGateway = this.gatewayInfo(); + const gateway = { + ...configuredGateway, + ...(snapshotArg.gateway || {}), + firmwareVersion: stringValue(snapshotArg.gateway?.firmwareVersion) || stringValue(status.gateway.otgw_about) || configuredGateway.firmwareVersion, + }; + return { + gateway, + status, + climate: snapshotArg.climate, + waterHeater: snapshotArg.waterHeater, + commands: snapshotArg.commands || this.commandHistory, + events: snapshotArg.events || [], + online: Boolean(snapshotArg.online), + source: sourceArg, + updatedAt: snapshotArg.updatedAt || new Date().toISOString(), + }; + } + + private snapshotFromStatus(statusArg: IOpenthermGwStatus, onlineArg: boolean, sourceArg: IOpenthermGwSnapshot['source']): IOpenthermGwSnapshot { + return this.normalizeSnapshot({ + gateway: this.gatewayInfo(), + status: statusArg, + commands: this.commandHistory, + online: onlineArg, + source: sourceArg, + updatedAt: new Date().toISOString(), + }, sourceArg); + } + + private emptySnapshot(): IOpenthermGwSnapshot { + return this.snapshotFromStatus(defaultStatus(), Boolean(this.config.host), this.config.host ? 'tcp' : 'manual'); + } + + private gatewayInfo(): IOpenthermGwGatewayInfo { + const port = this.config.host ? this.config.port || openthermGwDefaultTcpPort : this.config.port; + return { + id: this.gatewayId(), + name: this.config.name || 'OpenTherm Gateway', + host: this.config.host, + port, + device: this.config.device, + }; + } + + private gatewayId(): string { + const port = this.config.host ? this.config.port || openthermGwDefaultTcpPort : this.config.port; + return this.config.id || (this.config.host ? `${this.config.host}:${port}` : this.config.device) || 'opentherm_gateway'; + } + + private isSnapshotMode(): boolean { + return Boolean(this.config.snapshot || this.config.status || !this.config.host); + } + + private emit(eventArg: IOpenthermGwEvent): void { + for (const handler of this.handlers) { + handler(eventArg); + } + } + + private cloneSnapshot(snapshotArg: IOpenthermGwSnapshot): IOpenthermGwSnapshot { + return cloneValue(snapshotArg); + } +} + +export const defaultStatus = (): IOpenthermGwStatus => ({ + gateway: {}, + boiler: {}, + thermostat: {}, +}); + +export const parseSummaryLine = (lineArg: string): Pick => { + const fields = lineArg.split(',').map((fieldArg) => fieldArg.trim()); + if (fields.length >= 34) { + return parseStatusFieldsV5(fields); + } + if (fields.length >= 25) { + return parseStatusFieldsV4(fields); + } + return { boiler: {}, thermostat: {} }; +}; + +const parseStatusFieldsV4 = (fieldsArg: string[]): Pick => { + const deviceStatus = splitField(fieldsArg[0], '/'); + const remoteParams = splitField(fieldsArg[2], '/'); + const capModLimits = splitField(fieldsArg[4], '/'); + const dhwSetpointBounds = splitField(fieldsArg[13], '/'); + const chSetpointBounds = splitField(fieldsArg[14], '/'); + const masterStatus = deviceStatus[0] || ''; + const slaveStatus = deviceStatus[1] || ''; + return { + thermostat: stripUndefined({ + master_ch_enabled: bit(masterStatus, 7), + master_dhw_enabled: bit(masterStatus, 6), + master_cooling_enabled: bit(masterStatus, 5), + master_otc_enabled: bit(masterStatus, 4), + master_ch2_enabled: bit(masterStatus, 3), + control_setpoint: numberValue(fieldsArg[1]), + room_setpoint: numberValue(fieldsArg[5]), + room_temp: numberValue(fieldsArg[8]), + }), + boiler: stripUndefined({ + slave_fault_indication: bit(slaveStatus, 7), + slave_ch_active: bit(slaveStatus, 6), + slave_dhw_active: bit(slaveStatus, 5), + slave_flame_on: bit(slaveStatus, 4), + slave_cooling_active: bit(slaveStatus, 3), + slave_ch2_active: bit(slaveStatus, 2), + slave_diagnostic_indication: bit(slaveStatus, 1), + remote_transfer_dhw: bit(remoteParams[0] || '', 7), + remote_transfer_max_ch: bit(remoteParams[0] || '', 6), + remote_rw_dhw: bit(remoteParams[1] || '', 7), + remote_rw_max_ch: bit(remoteParams[1] || '', 6), + slave_max_relative_modulation: numberValue(fieldsArg[3]), + slave_max_capacity: numberValue(capModLimits[0]), + slave_min_mod_level: numberValue(capModLimits[1]), + relative_mod_level: numberValue(fieldsArg[6]), + ch_water_pressure: numberValue(fieldsArg[7]), + ch_water_temp: numberValue(fieldsArg[9]), + dhw_temp: numberValue(fieldsArg[10]), + outside_temp: numberValue(fieldsArg[11]), + return_water_temp: numberValue(fieldsArg[12]), + slave_dhw_max_setp: numberValue(dhwSetpointBounds[0]), + slave_dhw_min_setp: numberValue(dhwSetpointBounds[1]), + slave_ch_max_setp: numberValue(chSetpointBounds[0]), + slave_ch_min_setp: numberValue(chSetpointBounds[1]), + dhw_setpoint: numberValue(fieldsArg[15]), + max_ch_setpoint: numberValue(fieldsArg[16]), + burner_starts: numberValue(fieldsArg[17]), + ch_pump_starts: numberValue(fieldsArg[18]), + dhw_pump_starts: numberValue(fieldsArg[19]), + dhw_burner_starts: numberValue(fieldsArg[20]), + burner_hours: numberValue(fieldsArg[21]), + ch_pump_hours: numberValue(fieldsArg[22]), + dhw_pump_hours: numberValue(fieldsArg[23]), + dhw_burner_hours: numberValue(fieldsArg[24]), + }), + }; +}; + +const parseStatusFieldsV5 = (fieldsArg: string[]): Pick => { + const deviceStatus = splitField(fieldsArg[0], '/'); + const remoteParams = splitField(fieldsArg[2], '/'); + const capModLimits = splitField(fieldsArg[6], '/'); + const dhwSetpointBounds = splitField(fieldsArg[19], '/'); + const chSetpointBounds = splitField(fieldsArg[20], '/'); + const vhDeviceStatus = splitField(fieldsArg[23], '/'); + const masterStatus = deviceStatus[0] || ''; + const slaveStatus = deviceStatus[1] || ''; + const vhMasterStatus = vhDeviceStatus[0] || ''; + const vhSlaveStatus = vhDeviceStatus[1] || ''; + return { + thermostat: stripUndefined({ + master_ch_enabled: bit(masterStatus, 7), + master_dhw_enabled: bit(masterStatus, 6), + master_cooling_enabled: bit(masterStatus, 5), + master_otc_enabled: bit(masterStatus, 4), + master_ch2_enabled: bit(masterStatus, 3), + control_setpoint: numberValue(fieldsArg[1]), + cooling_control: numberValue(fieldsArg[3]), + control_setpoint_2: numberValue(fieldsArg[4]), + room_setpoint: numberValue(fieldsArg[7]), + room_setpoint_2: numberValue(fieldsArg[11]), + room_temp: numberValue(fieldsArg[12]), + vh_master_vent_enabled: bit(vhMasterStatus, 7), + vh_master_bypass_pos: bit(vhMasterStatus, 6), + vh_master_bypass_mode: bit(vhMasterStatus, 5), + vh_master_free_vent_mode: bit(vhMasterStatus, 4), + vh_control_setpoint: numberValue(fieldsArg[24]), + }), + boiler: stripUndefined({ + slave_fault_indication: bit(slaveStatus, 7), + slave_ch_active: bit(slaveStatus, 6), + slave_dhw_active: bit(slaveStatus, 5), + slave_flame_on: bit(slaveStatus, 4), + slave_cooling_active: bit(slaveStatus, 3), + slave_ch2_active: bit(slaveStatus, 2), + slave_diagnostic_indication: bit(slaveStatus, 1), + remote_transfer_dhw: bit(remoteParams[0] || '', 7), + remote_transfer_max_ch: bit(remoteParams[0] || '', 6), + remote_rw_dhw: bit(remoteParams[1] || '', 7), + remote_rw_max_ch: bit(remoteParams[1] || '', 6), + slave_max_relative_modulation: numberValue(fieldsArg[5]), + slave_max_capacity: numberValue(capModLimits[0]), + slave_min_mod_level: numberValue(capModLimits[1]), + relative_mod_level: numberValue(fieldsArg[8]), + ch_water_pressure: numberValue(fieldsArg[9]), + dhw_flow_rate: numberValue(fieldsArg[10]), + ch_water_temp: numberValue(fieldsArg[13]), + dhw_temp: numberValue(fieldsArg[14]), + outside_temp: numberValue(fieldsArg[15]), + return_water_temp: numberValue(fieldsArg[16]), + ch_water_temp_2: numberValue(fieldsArg[17]), + exhaust_temp: numberValue(fieldsArg[18]), + slave_dhw_max_setp: numberValue(dhwSetpointBounds[0]), + slave_dhw_min_setp: numberValue(dhwSetpointBounds[1]), + slave_ch_max_setp: numberValue(chSetpointBounds[0]), + slave_ch_min_setp: numberValue(chSetpointBounds[1]), + dhw_setpoint: numberValue(fieldsArg[21]), + max_ch_setpoint: numberValue(fieldsArg[22]), + vh_slave_fault_indicate: bit(vhSlaveStatus, 7), + vh_slave_vent_mode: bit(vhSlaveStatus, 6), + vh_slave_bypass_status: bit(vhSlaveStatus, 5), + vh_slave_bypass_auto_status: bit(vhSlaveStatus, 4), + vh_slave_free_vent_status: bit(vhSlaveStatus, 3), + vh_slave_diag_indicate: bit(vhSlaveStatus, 1), + vh_relative_vent: numberValue(fieldsArg[25]), + burner_starts: numberValue(fieldsArg[26]), + ch_pump_starts: numberValue(fieldsArg[27]), + dhw_pump_starts: numberValue(fieldsArg[28]), + dhw_burner_starts: numberValue(fieldsArg[29]), + burner_hours: numberValue(fieldsArg[30]), + ch_pump_hours: numberValue(fieldsArg[31]), + dhw_pump_hours: numberValue(fieldsArg[32]), + dhw_burner_hours: numberValue(fieldsArg[33]), + }), + }; +}; + +const applyReport = (statusArg: IOpenthermGwGatewayStatus, reportArg: string): void => { + const [reportCode, reportValue = ''] = splitField(reportArg, '='); + if (reportCode === 'A') { + statusArg.otgw_about = reportValue; + } else if (reportCode === 'B') { + statusArg.otgw_build = reportValue; + } else if (reportCode === 'C') { + statusArg.otgw_clockmhz = reportValue; + } else if (reportCode === 'W') { + statusArg.otgw_dhw_ovrd = reportValue; + } else if (reportCode === 'M') { + statusArg.otgw_mode = reportValue; + } else if (reportCode === 'Q') { + statusArg.otgw_reset_cause = reportValue; + } else if (reportCode === 'S') { + statusArg.otgw_setback_temp = numberValue(reportValue); + } else if (reportCode === 'P') { + statusArg.otgw_smart_pwr = reportValue; + } else if (reportCode === 'R') { + statusArg.otgw_thermostat_detect = reportValue; + } else if (reportCode === 'V') { + statusArg.otgw_vref = numberValue(reportValue); + } else if (reportCode === 'G') { + statusArg.otgw_gpio_a = numberValue(reportValue[0]); + statusArg.otgw_gpio_b = numberValue(reportValue[1]); + } else if (reportCode === 'I') { + statusArg.otgw_gpio_a_state = numberValue(reportValue[0]); + statusArg.otgw_gpio_b_state = numberValue(reportValue[1]); + } else if (reportCode === 'L') { + statusArg.otgw_led_a = reportValue[0]; + statusArg.otgw_led_b = reportValue[1]; + statusArg.otgw_led_c = reportValue[2]; + statusArg.otgw_led_d = reportValue[3]; + statusArg.otgw_led_e = reportValue[4]; + statusArg.otgw_led_f = reportValue[5]; + } else if (reportCode === 'T') { + statusArg.otgw_ignore_transitions = numberValue(reportValue[0]); + statusArg.otgw_ovrd_high_byte = numberValue(reportValue[1]); + } +}; + +const splitField = (valueArg: string | undefined, separatorArg: string): string[] => (valueArg || '').split(separatorArg); + +const bit = (valueArg: string, indexArg: number): number | undefined => valueArg.length > indexArg ? (valueArg[indexArg] === '1' ? 1 : 0) : undefined; + +const boolNumber = (valueArg: unknown): number | undefined => { + if (valueArg === true || valueArg === 1 || valueArg === '1') { + return 1; + } + if (valueArg === false || valueArg === 0 || valueArg === '0') { + return 0; + } + return undefined; +}; + +const numberValue = (valueArg: unknown): number | undefined => { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim()) { + const parsed = Number(valueArg); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +}; + +const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + +const stripUndefined = >(valueArg: TValue): TValue => { + for (const key of Object.keys(valueArg)) { + if (valueArg[key] === undefined) { + delete valueArg[key]; + } + } + return valueArg; +}; + +const roundCommandNumber = (valueArg: number, digitsArg: number): number => Number(valueArg.toFixed(digitsArg)); + +const formatCommandValue = (valueArg: TOpenthermGwCommandValue): string => { + if (typeof valueArg === 'boolean') { + return valueArg ? '1' : '0'; + } + return String(valueArg); +}; + +const cleanLine = (lineArg: string): string => { + const eotIndex = lineArg.lastIndexOf('\x04'); + return (eotIndex >= 0 ? lineArg.slice(eotIndex + 1) : lineArg).trim(); +}; + +const isOpenThermTrafficLine = (lineArg: string): boolean => /^(T|B|R|A|E)[0-9A-F]{8}$/i.test(lineArg) || /^Error 0[1-4]/.test(lineArg); + +const escapeRegExp = (valueArg: string): string => valueArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const cloneValue = (valueArg: TValue): TValue => JSON.parse(JSON.stringify(valueArg)) as TValue; diff --git a/ts/integrations/opentherm_gw/opentherm_gw.classes.configflow.ts b/ts/integrations/opentherm_gw/opentherm_gw.classes.configflow.ts new file mode 100644 index 0000000..6bf4946 --- /dev/null +++ b/ts/integrations/opentherm_gw/opentherm_gw.classes.configflow.ts @@ -0,0 +1,53 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IOpenthermGwConfig } from './opentherm_gw.types.js'; +import { openthermGwDefaultTcpPort } from './opentherm_gw.types.js'; + +export class OpenthermGwConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect OpenTherm Gateway', + description: 'Use a raw TCP serial bridge host/port, or provide a serial path for metadata-only setup.', + fields: [ + { name: 'name', label: 'Name', type: 'text', required: true }, + { name: 'host', label: 'TCP host', type: 'text' }, + { name: 'port', label: 'TCP port', type: 'number' }, + { name: 'device', label: 'Serial path or URL', type: 'text' }, + { name: 'timeoutMs', label: 'Timeout in milliseconds', type: 'number' }, + ], + submit: async (valuesArg) => { + const host = stringValue(valuesArg.host) || candidateArg.host; + const device = stringValue(valuesArg.device) || stringValue(candidateArg.metadata?.device); + if (!host && !device) { + return { kind: 'error', title: 'OpenTherm Gateway endpoint missing', error: 'Provide a TCP host or serial path.' }; + } + return { + kind: 'done', + title: 'OpenTherm Gateway configured', + config: { + id: stringValue(candidateArg.id) || (host ? `${host}:${numberValue(valuesArg.port) || candidateArg.port || openthermGwDefaultTcpPort}` : device), + name: stringValue(valuesArg.name) || candidateArg.name || 'OpenTherm Gateway', + host, + port: numberValue(valuesArg.port) || candidateArg.port || (host ? openthermGwDefaultTcpPort : undefined), + device, + timeoutMs: numberValue(valuesArg.timeoutMs), + }, + }; + }, + }; + } +} + +const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + +const numberValue = (valueArg: unknown): number | undefined => { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim()) { + const parsed = Number(valueArg); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +}; diff --git a/ts/integrations/opentherm_gw/opentherm_gw.classes.integration.ts b/ts/integrations/opentherm_gw/opentherm_gw.classes.integration.ts index 8708a44..c1f6e14 100644 --- a/ts/integrations/opentherm_gw/opentherm_gw.classes.integration.ts +++ b/ts/integrations/opentherm_gw/opentherm_gw.classes.integration.ts @@ -1,26 +1,178 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { OpenthermGwClient } from './opentherm_gw.classes.client.js'; +import { OpenthermGwConfigFlow } from './opentherm_gw.classes.configflow.js'; +import { createOpenthermGwDiscoveryDescriptor } from './opentherm_gw.discovery.js'; +import { OpenthermGwMapper } from './opentherm_gw.mapper.js'; +import type { IOpenthermGwConfig, TOpenthermGwCommandValue } from './opentherm_gw.types.js'; +import { openthermGwDomain } from './opentherm_gw.types.js'; -export class HomeAssistantOpenthermGwIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "opentherm_gw", - displayName: "OpenTherm Gateway", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/opentherm_gw", - "upstreamDomain": "opentherm_gw", - "integrationType": "device", - "iotClass": "local_push", - "requirements": [ - "pyotgw==2.2.3" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@mvn23" - ] -}, - }); +export class OpenthermGwIntegration extends BaseIntegration { + public readonly domain = openthermGwDomain; + public readonly displayName = 'OpenTherm Gateway'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createOpenthermGwDiscoveryDescriptor(); + public readonly configFlow = new OpenthermGwConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/opentherm_gw', + upstreamDomain: openthermGwDomain, + integrationType: 'device', + iotClass: 'local_push', + requirements: ['pyotgw==2.2.3'], + dependencies: [], + afterDependencies: [], + codeowners: ['@mvn23'], + configFlow: true, + documentation: 'https://www.home-assistant.io/integrations/opentherm_gw', + }; + + public async setup(configArg: IOpenthermGwConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new OpenthermGwRuntime(new OpenthermGwClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantOpenthermGwIntegration extends OpenthermGwIntegration {} + +class OpenthermGwRuntime implements IIntegrationRuntime { + public domain = openthermGwDomain; + + constructor(private readonly client: OpenthermGwClient) {} + + public async devices(): Promise { + return OpenthermGwMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return OpenthermGwMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.subscribe((eventArg) => handlerArg(OpenthermGwMapper.toIntegrationEvent(eventArg))); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + try { + if (requestArg.domain === openthermGwDomain) { + return await this.callGatewayService(requestArg); + } + if (requestArg.domain === 'climate') { + return await this.callClimateService(requestArg); + } + if (requestArg.domain === 'switch') { + return await this.callSwitchService(requestArg); + } + return { success: false, error: `Unsupported OpenTherm Gateway service domain: ${requestArg.domain}` }; + } catch (errorArg) { + return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) }; + } + } + + public async destroy(): Promise { + await this.client.destroy(); + } + + private async callGatewayService(requestArg: IServiceCallRequest): Promise { + if (requestArg.service === 'set_control_setpoint') { + return { success: true, data: await this.client.setControlSetpoint(this.numberValue(requestArg.data?.temperature ?? requestArg.data?.setpoint, 'set_control_setpoint requires data.temperature.')) }; + } + if (requestArg.service === 'set_hot_water') { + if (requestArg.data?.temperature !== undefined) { + return { success: true, data: await this.client.setHotWaterSetpoint(this.numberValue(requestArg.data.temperature, 'set_hot_water temperature must be a number.')) }; + } + return { success: true, data: await this.client.setHotWater(this.hotWaterValue(requestArg.data)) }; + } + if (requestArg.service === 'set_hot_water_ovrd') { + return { success: true, data: await this.client.setHotWater(this.hotWaterValue(requestArg.data)) }; + } + if (requestArg.service === 'set_hot_water_setpoint') { + return { success: true, data: await this.client.setHotWaterSetpoint(this.numberValue(requestArg.data?.temperature, 'set_hot_water_setpoint requires data.temperature.')) }; + } + if (requestArg.service === 'set_outside_temperature') { + return { success: true, data: await this.client.setOutsideTemperature(this.numberValue(requestArg.data?.temperature, 'set_outside_temperature requires data.temperature.')) }; + } + if (requestArg.service === 'set_central_heating_ovrd') { + return { success: true, data: await this.client.setCentralHeatingOverride(1, this.booleanValue(requestArg.data?.ch_override ?? requestArg.data?.enabled, 'set_central_heating_ovrd requires data.ch_override or data.enabled.')) }; + } + if (requestArg.service === 'reset' || requestArg.service === 'reset_gateway') { + return { success: true, data: await this.client.reset() }; + } + if (requestArg.service === 'command' || requestArg.service === 'send_transparent_command') { + const code = this.stringValue(requestArg.data?.command ?? requestArg.data?.transp_cmd, 'OpenTherm Gateway command service requires data.command.'); + const value = this.commandValue(requestArg.data?.value ?? requestArg.data?.transp_arg); + return { success: true, data: await this.client.command(code, value) }; + } + return { success: false, error: `Unsupported OpenTherm Gateway service: ${requestArg.service}` }; + } + + private async callClimateService(requestArg: IServiceCallRequest): Promise { + if (requestArg.service !== 'set_temperature') { + return { success: false, error: `Unsupported OpenTherm Gateway climate service: ${requestArg.service}` }; + } + const temperature = this.numberValue(requestArg.data?.temperature, 'climate.set_temperature requires data.temperature.'); + const temporary = typeof requestArg.data?.temporary === 'boolean' ? requestArg.data.temporary : undefined; + return { success: true, data: await this.client.setRoomSetpoint(temperature, temporary) }; + } + + private async callSwitchService(requestArg: IServiceCallRequest): Promise { + if (requestArg.service !== 'turn_on' && requestArg.service !== 'turn_off') { + return { success: false, error: `Unsupported OpenTherm Gateway switch service: ${requestArg.service}` }; + } + const target = `${requestArg.target.entityId || requestArg.target.deviceId || ''} ${requestArg.data?.key || ''}`; + const circuit = target.includes('2') ? 2 : 1; + return { success: true, data: await this.client.setCentralHeatingOverride(circuit, requestArg.service === 'turn_on') }; + } + + private hotWaterValue(dataArg: Record | undefined): boolean | string | number { + const value = dataArg?.enabled ?? dataArg?.hot_water ?? dataArg?.hotWater ?? dataArg?.dhw_override ?? dataArg?.dhwOverride ?? dataArg?.value; + if (typeof value === 'boolean' || typeof value === 'string' || typeof value === 'number') { + return value; + } + throw new Error('set_hot_water requires data.enabled, data.hot_water, or data.dhw_override unless data.temperature is used.'); + } + + private commandValue(valueArg: unknown): TOpenthermGwCommandValue { + if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean') { + return valueArg; + } + throw new Error('OpenTherm Gateway command service requires data.value.'); + } + + private numberValue(valueArg: unknown, errorArg: string): number { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim()) { + const parsed = Number(valueArg); + if (Number.isFinite(parsed)) { + return parsed; + } + } + throw new Error(errorArg); + } + + private booleanValue(valueArg: unknown, errorArg: string): boolean { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (valueArg === '1' || valueArg === 1 || valueArg === 'true') { + return true; + } + if (valueArg === '0' || valueArg === 0 || valueArg === 'false') { + return false; + } + throw new Error(errorArg); + } + + private stringValue(valueArg: unknown, errorArg: string): string { + if (typeof valueArg === 'string' && valueArg.trim()) { + return valueArg.trim().toUpperCase(); + } + throw new Error(errorArg); } } diff --git a/ts/integrations/opentherm_gw/opentherm_gw.discovery.ts b/ts/integrations/opentherm_gw/opentherm_gw.discovery.ts new file mode 100644 index 0000000..4307db4 --- /dev/null +++ b/ts/integrations/opentherm_gw/opentherm_gw.discovery.ts @@ -0,0 +1,82 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IOpenthermGwManualDiscoveryInput } from './opentherm_gw.types.js'; +import { openthermGwDefaultTcpPort, openthermGwDomain } from './opentherm_gw.types.js'; + +export class OpenthermGwManualMatcher implements IDiscoveryMatcher { + public id = 'opentherm-gw-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual OpenTherm Gateway raw TCP or serial bridge entries.'; + + public async matches(inputArg: IOpenthermGwManualDiscoveryInput): Promise { + const text = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''}`.toLowerCase(); + const hasMetadata = Boolean(inputArg.metadata?.opentherm_gw || inputArg.metadata?.openthermGw || inputArg.metadata?.otgw); + const matched = Boolean(inputArg.host || inputArg.device || hasMetadata || text.includes('opentherm')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain OpenTherm Gateway hints.' }; + } + + const port = inputArg.host ? inputArg.port || openthermGwDefaultTcpPort : inputArg.port; + const id = inputArg.id || (inputArg.host ? `${inputArg.host}:${port}` : inputArg.device); + return { + matched: true, + confidence: inputArg.host || inputArg.device ? 'high' : 'medium', + reason: 'Manual entry can start OpenTherm Gateway setup.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: openthermGwDomain, + id, + host: inputArg.host, + port, + name: inputArg.name || 'OpenTherm Gateway', + manufacturer: inputArg.manufacturer || 'Schelte Bron', + model: inputArg.model || 'OpenTherm Gateway', + metadata: { + ...inputArg.metadata, + device: inputArg.device, + rawTcp: Boolean(inputArg.host), + }, + }, + }; + } +} + +export class OpenthermGwCandidateValidator implements IDiscoveryValidator { + public id = 'opentherm-gw-candidate-validator'; + public description = 'Validate OpenTherm Gateway manual candidates.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const text = `${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''}`.toLowerCase(); + const matched = candidateArg.integrationDomain === openthermGwDomain + || text.includes('opentherm') + || Boolean(candidateArg.metadata?.opentherm_gw || candidateArg.metadata?.openthermGw || candidateArg.metadata?.otgw); + const hasEndpoint = Boolean(candidateArg.host || candidateArg.metadata?.device || candidateArg.metadata?.snapshot); + + if (!matched || !hasEndpoint) { + return { + matched: false, + confidence: matched ? 'medium' : 'low', + reason: matched ? 'OpenTherm Gateway candidate lacks host, serial device, or snapshot metadata.' : 'Candidate is not an OpenTherm Gateway.', + }; + } + + const port = candidateArg.host ? candidateArg.port || openthermGwDefaultTcpPort : candidateArg.port; + return { + matched: true, + confidence: candidateArg.id ? 'certain' : 'high', + reason: 'Candidate has OpenTherm Gateway metadata and a usable endpoint.', + normalizedDeviceId: candidateArg.id || (candidateArg.host ? `${candidateArg.host}:${port}` : String(candidateArg.metadata?.device || 'opentherm_gateway')), + candidate: { + ...candidateArg, + port, + }, + }; + } +} + +export const createOpenthermGwDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: openthermGwDomain, displayName: 'OpenTherm Gateway' }) + .addMatcher(new OpenthermGwManualMatcher()) + .addValidator(new OpenthermGwCandidateValidator()); +}; diff --git a/ts/integrations/opentherm_gw/opentherm_gw.mapper.ts b/ts/integrations/opentherm_gw/opentherm_gw.mapper.ts new file mode 100644 index 0000000..f61fc0f --- /dev/null +++ b/ts/integrations/opentherm_gw/opentherm_gw.mapper.ts @@ -0,0 +1,478 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IIntegrationEvent } from '../../core/types.js'; +import type { + IOpenthermGwBinarySensorDescription, + IOpenthermGwClimateState, + IOpenthermGwEvent, + IOpenthermGwSensorDescription, + IOpenthermGwSnapshot, + IOpenthermGwStatus, + IOpenthermGwSwitchDescription, + IOpenthermGwWaterHeaterState, + TOpenthermGwDataSource, + TOpenthermGwStatusValue, +} from './opentherm_gw.types.js'; +import { openthermGwDomain } from './opentherm_gw.types.js'; + +export const openthermGwSensorDescriptions: IOpenthermGwSensorDescription[] = [ + { source: 'boiler', key: 'control_setpoint', name: 'Control setpoint 1', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { source: 'boiler', key: 'control_setpoint_2', name: 'Control setpoint 2', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { source: 'boiler', key: 'relative_mod_level', name: 'Relative modulation level', unit: '%', stateClass: 'measurement' }, + { source: 'boiler', key: 'ch_water_pressure', name: 'Central heating water pressure', unit: 'bar', deviceClass: 'pressure', stateClass: 'measurement' }, + { source: 'boiler', key: 'dhw_flow_rate', name: 'Hot water flow rate', unit: 'L/min', stateClass: 'measurement' }, + { source: 'boiler', key: 'ch_water_temp', name: 'Central heating 1 water temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { source: 'boiler', key: 'ch_water_temp_2', name: 'Central heating 2 water temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { source: 'boiler', key: 'dhw_temp', name: 'Hot water 1 temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { source: 'boiler', key: 'dhw_temp_2', name: 'Hot water 2 temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { source: 'boiler', key: 'outside_temp', name: 'Outside temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { source: 'boiler', key: 'return_water_temp', name: 'Return water temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { source: 'boiler', key: 'exhaust_temp', name: 'Exhaust temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { source: 'boiler', key: 'dhw_setpoint', name: 'Hot water setpoint', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { source: 'boiler', key: 'max_ch_setpoint', name: 'Max central heating setpoint', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { source: 'boiler', key: 'slave_max_relative_modulation', name: 'Max relative modulation level', unit: '%', stateClass: 'measurement' }, + { source: 'boiler', key: 'slave_max_capacity', name: 'Max capacity', unit: 'kW', stateClass: 'measurement' }, + { source: 'boiler', key: 'slave_min_mod_level', name: 'Min modulation level', unit: '%', stateClass: 'measurement' }, + { source: 'boiler', key: 'burner_starts', name: 'Total burner starts', unit: 'starts', stateClass: 'total' }, + { source: 'boiler', key: 'ch_pump_starts', name: 'Central heating pump starts', unit: 'starts', stateClass: 'total' }, + { source: 'boiler', key: 'dhw_pump_starts', name: 'Hot water pump starts', unit: 'starts', stateClass: 'total' }, + { source: 'boiler', key: 'dhw_burner_starts', name: 'Hot water burner starts', unit: 'starts', stateClass: 'total' }, + { source: 'boiler', key: 'burner_hours', name: 'Total burner hours', unit: 'h', stateClass: 'total' }, + { source: 'boiler', key: 'ch_pump_hours', name: 'Central heating pump hours', unit: 'h', stateClass: 'total' }, + { source: 'boiler', key: 'dhw_pump_hours', name: 'Hot water pump hours', unit: 'h', stateClass: 'total' }, + { source: 'boiler', key: 'dhw_burner_hours', name: 'Hot water burner hours', unit: 'h', stateClass: 'total' }, + { source: 'boiler', key: 'slave_memberid', name: 'Manufacturer ID' }, + { source: 'boiler', key: 'slave_oem_fault', name: 'OEM fault code' }, + { source: 'boiler', key: 'oem_diag', name: 'OEM diagnostic code' }, + { source: 'boiler', key: 'slave_ot_version', name: 'OpenTherm version' }, + { source: 'boiler', key: 'slave_product_type', name: 'Product type' }, + { source: 'boiler', key: 'slave_product_version', name: 'Product version' }, + { source: 'thermostat', key: 'room_temp', name: 'Room temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { source: 'thermostat', key: 'room_setpoint', name: 'Room setpoint 1', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { source: 'thermostat', key: 'room_setpoint_2', name: 'Room setpoint 2', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { source: 'thermostat', key: 'room_setpoint_ovrd', name: 'Room setpoint override', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { source: 'thermostat', key: 'outside_temp', name: 'Outside temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { source: 'thermostat', key: 'control_setpoint', name: 'Control setpoint 1', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { source: 'thermostat', key: 'control_setpoint_2', name: 'Control setpoint 2', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { source: 'thermostat', key: 'master_memberid', name: 'Manufacturer ID' }, + { source: 'thermostat', key: 'master_ot_version', name: 'OpenTherm version' }, + { source: 'thermostat', key: 'master_product_type', name: 'Product type' }, + { source: 'thermostat', key: 'master_product_version', name: 'Product version' }, + { source: 'gateway', key: 'otgw_mode', name: 'Operating mode' }, + { source: 'gateway', key: 'otgw_dhw_ovrd', name: 'Hot water override mode' }, + { source: 'gateway', key: 'otgw_about', name: 'Firmware version' }, + { source: 'gateway', key: 'otgw_build', name: 'Firmware build' }, + { source: 'gateway', key: 'otgw_clockmhz', name: 'Clock speed' }, + { source: 'gateway', key: 'otgw_led_a', name: 'LED A mode' }, + { source: 'gateway', key: 'otgw_led_b', name: 'LED B mode' }, + { source: 'gateway', key: 'otgw_led_c', name: 'LED C mode' }, + { source: 'gateway', key: 'otgw_led_d', name: 'LED D mode' }, + { source: 'gateway', key: 'otgw_led_e', name: 'LED E mode' }, + { source: 'gateway', key: 'otgw_led_f', name: 'LED F mode' }, + { source: 'gateway', key: 'otgw_gpio_a', name: 'GPIO A mode' }, + { source: 'gateway', key: 'otgw_gpio_b', name: 'GPIO B mode' }, + { source: 'gateway', key: 'otgw_reset_cause', name: 'Reset cause' }, + { source: 'gateway', key: 'otgw_setback_temp', name: 'Setback temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' }, + { source: 'gateway', key: 'otgw_setpoint_ovrd_mode', name: 'Room setpoint override mode' }, + { source: 'gateway', key: 'otgw_smart_pwr', name: 'Smart power mode' }, + { source: 'gateway', key: 'otgw_temp_sensor', name: 'Temperature sensor function' }, + { source: 'gateway', key: 'otgw_thermostat_detect', name: 'Thermostat detection mode' }, + { source: 'gateway', key: 'otgw_vref', name: 'Reference voltage' }, +]; + +export const openthermGwBinarySensorDescriptions: IOpenthermGwBinarySensorDescription[] = [ + { source: 'boiler', key: 'slave_fault_indication', name: 'Fault indication', deviceClass: 'problem' }, + { source: 'boiler', key: 'slave_ch_active', name: 'Central heating 1', deviceClass: 'running' }, + { source: 'boiler', key: 'slave_ch2_active', name: 'Central heating 2', deviceClass: 'running' }, + { source: 'boiler', key: 'slave_dhw_active', name: 'Hot water', deviceClass: 'running' }, + { source: 'boiler', key: 'slave_flame_on', name: 'Flame', deviceClass: 'heat' }, + { source: 'boiler', key: 'slave_cooling_active', name: 'Cooling', deviceClass: 'running' }, + { source: 'boiler', key: 'slave_diagnostic_indication', name: 'Diagnostic indication', deviceClass: 'problem' }, + { source: 'boiler', key: 'slave_dhw_present', name: 'Supports hot water' }, + { source: 'boiler', key: 'slave_control_type', name: 'Control type' }, + { source: 'boiler', key: 'slave_cooling_supported', name: 'Supports cooling' }, + { source: 'boiler', key: 'slave_dhw_config', name: 'Hot water config' }, + { source: 'boiler', key: 'slave_master_low_off_pump', name: 'Supports pump control' }, + { source: 'boiler', key: 'slave_ch2_present', name: 'Supports central heating 2' }, + { source: 'boiler', key: 'slave_service_required', name: 'Service required', deviceClass: 'problem' }, + { source: 'boiler', key: 'slave_remote_reset', name: 'Supports remote reset' }, + { source: 'boiler', key: 'slave_low_water_pressure', name: 'Low water pressure', deviceClass: 'problem' }, + { source: 'boiler', key: 'slave_gas_fault', name: 'Gas fault', deviceClass: 'problem' }, + { source: 'boiler', key: 'slave_air_pressure_fault', name: 'Air pressure fault', deviceClass: 'problem' }, + { source: 'boiler', key: 'slave_water_overtemp', name: 'Water overtemperature', deviceClass: 'problem' }, + { source: 'boiler', key: 'remote_transfer_max_ch', name: 'Central heating setpoint transfer support' }, + { source: 'boiler', key: 'remote_rw_max_ch', name: 'Central heating setpoint write support' }, + { source: 'boiler', key: 'remote_transfer_dhw', name: 'Hot water setpoint transfer support' }, + { source: 'boiler', key: 'remote_rw_dhw', name: 'Hot water setpoint write support' }, + { source: 'thermostat', key: 'master_ch_enabled', name: 'Central heating 1' }, + { source: 'thermostat', key: 'master_ch2_enabled', name: 'Central heating 2' }, + { source: 'thermostat', key: 'master_dhw_enabled', name: 'Hot water' }, + { source: 'thermostat', key: 'master_cooling_enabled', name: 'Cooling' }, + { source: 'thermostat', key: 'master_otc_enabled', name: 'Outside temperature correction' }, + { source: 'gateway', key: 'otgw_gpio_a_state', name: 'GPIO A state' }, + { source: 'gateway', key: 'otgw_gpio_b_state', name: 'GPIO B state' }, + { source: 'gateway', key: 'otgw_ignore_transitions', name: 'Ignore transitions' }, + { source: 'gateway', key: 'otgw_ovrd_high_byte', name: 'Override high byte' }, +]; + +export const openthermGwSwitchDescriptions: IOpenthermGwSwitchDescription[] = [ + { key: 'central_heating_1_override', name: 'Central heating 1 override', circuit: 1 }, + { key: 'central_heating_2_override', name: 'Central heating 2 override', circuit: 2 }, +]; + +export class OpenthermGwMapper { + public static toDevices(snapshotArg: IOpenthermGwSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + return [ + this.gatewayDevice(snapshotArg, updatedAt), + this.boilerDevice(snapshotArg, updatedAt), + this.thermostatDevice(snapshotArg, updatedAt), + ]; + } + + public static toEntities(snapshotArg: IOpenthermGwSnapshot): IIntegrationEntity[] { + return [ + this.climateEntity(snapshotArg), + ...this.sensorEntities(snapshotArg), + ...this.binarySensorEntities(snapshotArg), + ...this.switchEntities(snapshotArg), + ]; + } + + public static toClimate(snapshotArg: IOpenthermGwSnapshot): IOpenthermGwClimateState { + const boiler = snapshotArg.status.boiler; + const thermostat = snapshotArg.status.thermostat; + const heating = boolValue(boiler.slave_ch_active) && boolValue(boiler.slave_flame_on); + const cooling = boolValue(boiler.slave_cooling_active); + const hvacAction = cooling ? 'cooling' : heating ? 'heating' : snapshotArg.online ? 'idle' : 'off'; + return { + currentTemperature: numberValue(thermostat.room_temp), + targetTemperature: numberValue(thermostat.room_setpoint_ovrd) ?? numberValue(thermostat.room_setpoint), + hvacMode: cooling ? 'cool' : snapshotArg.online ? 'heat' : 'off', + hvacAction, + presetMode: this.awayMode(snapshotArg) ? 'away' : 'none', + minTemp: 1, + maxTemp: 30, + temperatureUnit: 'C', + }; + } + + public static toWaterHeater(snapshotArg: IOpenthermGwSnapshot): IOpenthermGwWaterHeaterState { + const boiler = snapshotArg.status.boiler; + const gateway = snapshotArg.status.gateway; + const active = boolValue(boiler.slave_dhw_active); + const dhwOverride = gateway.otgw_dhw_ovrd; + return { + currentTemperature: numberValue(boiler.dhw_temp), + targetTemperature: numberValue(boiler.dhw_setpoint), + operationMode: active ? 'heat' : dhwOverride === 0 || dhwOverride === '0' ? 'off' : snapshotArg.online ? 'auto' : 'unknown', + hotWaterActive: active, + temperatureUnit: 'C', + }; + } + + public static toIntegrationEvent(eventArg: IOpenthermGwEvent): IIntegrationEvent { + return { + type: eventArg.type === 'error' ? 'error' : 'state_changed', + integrationDomain: openthermGwDomain, + deviceId: `opentherm_gw.gateway.${this.slug(eventArg.gatewayId)}`, + data: eventArg, + timestamp: eventArg.timestamp, + }; + } + + public static gatewayDeviceId(snapshotArg: IOpenthermGwSnapshot): string { + return `opentherm_gw.gateway.${this.uniqueBase(snapshotArg)}`; + } + + public static boilerDeviceId(snapshotArg: IOpenthermGwSnapshot): string { + return `opentherm_gw.boiler.${this.uniqueBase(snapshotArg)}`; + } + + public static thermostatDeviceId(snapshotArg: IOpenthermGwSnapshot): string { + return `opentherm_gw.thermostat.${this.uniqueBase(snapshotArg)}`; + } + + public static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'opentherm_gateway'; + } + + private static gatewayDevice(snapshotArg: IOpenthermGwSnapshot, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const gateway = snapshotArg.status.gateway; + return { + id: this.gatewayDeviceId(snapshotArg), + integrationDomain: openthermGwDomain, + name: snapshotArg.gateway.name || 'OpenTherm Gateway', + protocol: 'unknown', + manufacturer: 'Schelte Bron', + model: 'OpenTherm Gateway', + online: snapshotArg.online, + features: [ + { id: 'connection', capability: 'sensor', name: 'Connection', readable: true, writable: false }, + { id: 'mode', capability: 'sensor', name: 'Operating mode', readable: true, writable: true }, + { id: 'hot_water_override', capability: 'switch', name: 'Hot water override', readable: true, writable: true }, + { id: 'outside_temperature', capability: 'sensor', name: 'Outside temperature override', readable: true, writable: true, unit: 'C' }, + { id: 'reset', capability: 'switch', name: 'Reset', readable: false, writable: true }, + ], + state: [ + { featureId: 'connection', value: snapshotArg.online ? 'online' : 'offline', updatedAt: updatedAtArg }, + { featureId: 'mode', value: primitiveValue(gateway.otgw_mode) ?? null, updatedAt: updatedAtArg }, + { featureId: 'hot_water_override', value: primitiveValue(gateway.otgw_dhw_ovrd) ?? null, updatedAt: updatedAtArg }, + ], + metadata: { + host: snapshotArg.gateway.host, + port: snapshotArg.gateway.port, + device: snapshotArg.gateway.device, + firmwareVersion: snapshotArg.gateway.firmwareVersion, + source: snapshotArg.source, + }, + }; + } + + private static boilerDevice(snapshotArg: IOpenthermGwSnapshot, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const boiler = snapshotArg.status.boiler; + return { + id: this.boilerDeviceId(snapshotArg), + integrationDomain: openthermGwDomain, + name: `${snapshotArg.gateway.name || 'OpenTherm'} Boiler`, + protocol: 'unknown', + manufacturer: stringValue(boiler.slave_memberid), + model: stringValue(boiler.slave_product_type) || 'OpenTherm Boiler', + online: snapshotArg.online, + features: [ + { id: 'flame', capability: 'sensor', name: 'Flame', readable: true, writable: false }, + { id: 'central_heating', capability: 'climate', name: 'Central heating', readable: true, writable: true }, + { id: 'hot_water', capability: 'climate', name: 'Hot water', readable: true, writable: true }, + { id: 'control_setpoint', capability: 'climate', name: 'Control setpoint', readable: true, writable: true, unit: 'C' }, + { id: 'dhw_setpoint', capability: 'climate', name: 'Hot water setpoint', readable: true, writable: true, unit: 'C' }, + { id: 'water_temperature', capability: 'sensor', name: 'Water temperature', readable: true, writable: false, unit: 'C' }, + ], + state: [ + { featureId: 'flame', value: boolValue(boiler.slave_flame_on), updatedAt: updatedAtArg }, + { featureId: 'central_heating', value: boolValue(boiler.slave_ch_active), updatedAt: updatedAtArg }, + { featureId: 'hot_water', value: boolValue(boiler.slave_dhw_active), updatedAt: updatedAtArg }, + { featureId: 'control_setpoint', value: numberValue(boiler.control_setpoint) ?? null, updatedAt: updatedAtArg }, + { featureId: 'dhw_setpoint', value: numberValue(boiler.dhw_setpoint) ?? null, updatedAt: updatedAtArg }, + { featureId: 'water_temperature', value: numberValue(boiler.ch_water_temp) ?? null, updatedAt: updatedAtArg }, + ], + metadata: { + productVersion: boiler.slave_product_version, + openthermVersion: boiler.slave_ot_version, + }, + }; + } + + private static thermostatDevice(snapshotArg: IOpenthermGwSnapshot, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const thermostat = snapshotArg.status.thermostat; + const climate = this.toClimate(snapshotArg); + return { + id: this.thermostatDeviceId(snapshotArg), + integrationDomain: openthermGwDomain, + name: `${snapshotArg.gateway.name || 'OpenTherm'} Thermostat`, + protocol: 'unknown', + manufacturer: stringValue(thermostat.master_memberid), + model: stringValue(thermostat.master_product_type) || 'OpenTherm Thermostat', + online: snapshotArg.online, + features: [ + { id: 'current_temperature', capability: 'climate', name: 'Current temperature', readable: true, writable: false, unit: 'C' }, + { id: 'target_temperature', capability: 'climate', name: 'Target temperature', readable: true, writable: true, unit: 'C' }, + { id: 'hvac_action', capability: 'climate', name: 'HVAC action', readable: true, writable: false }, + ], + state: [ + { featureId: 'current_temperature', value: climate.currentTemperature ?? null, updatedAt: updatedAtArg }, + { featureId: 'target_temperature', value: climate.targetTemperature ?? null, updatedAt: updatedAtArg }, + { featureId: 'hvac_action', value: climate.hvacAction, updatedAt: updatedAtArg }, + ], + metadata: { + productVersion: thermostat.master_product_version, + openthermVersion: thermostat.master_ot_version, + }, + }; + } + + private static climateEntity(snapshotArg: IOpenthermGwSnapshot): IIntegrationEntity { + const climate = snapshotArg.climate || this.toClimate(snapshotArg); + const name = `${snapshotArg.gateway.name || 'OpenTherm Gateway'} Thermostat`; + return { + id: `climate.${this.slug(name)}`, + uniqueId: `opentherm_gw_${this.uniqueBase(snapshotArg)}_thermostat_climate`, + integrationDomain: openthermGwDomain, + deviceId: this.thermostatDeviceId(snapshotArg), + platform: 'climate', + name, + state: climate.hvacMode, + attributes: { + currentTemperature: climate.currentTemperature, + targetTemperature: climate.targetTemperature, + hvacAction: climate.hvacAction, + presetMode: climate.presetMode, + minTemp: climate.minTemp, + maxTemp: climate.maxTemp, + temperatureUnit: climate.temperatureUnit, + supportedFeatures: ['target_temperature', 'preset_mode'], + }, + available: snapshotArg.online, + }; + } + + private static sensorEntities(snapshotArg: IOpenthermGwSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + const described = new Set(); + for (const description of openthermGwSensorDescriptions) { + described.add(descriptionKey(description.source, description.key)); + const value = statusPart(snapshotArg.status, description.source)[description.key]; + if (value === undefined) { + continue; + } + entities.push(this.sensorEntity(snapshotArg, description, value)); + } + + const binaryKeys = new Set(openthermGwBinarySensorDescriptions.map((descriptionArg) => descriptionKey(descriptionArg.source, descriptionArg.key))); + for (const source of ['gateway', 'boiler', 'thermostat'] as TOpenthermGwDataSource[]) { + for (const [key, value] of Object.entries(statusPart(snapshotArg.status, source))) { + const combinedKey = descriptionKey(source, key); + if (described.has(combinedKey) || binaryKeys.has(combinedKey) || value === undefined || typeof value === 'object') { + continue; + } + entities.push(this.sensorEntity(snapshotArg, { source, key, name: titleCase(key) }, value)); + } + } + return entities; + } + + private static sensorEntity(snapshotArg: IOpenthermGwSnapshot, descriptionArg: IOpenthermGwSensorDescription, valueArg: TOpenthermGwStatusValue): IIntegrationEntity { + const sourceName = sourceLabel(descriptionArg.source); + const entityName = `${snapshotArg.gateway.name || 'OpenTherm Gateway'} ${sourceName} ${descriptionArg.name}`; + return { + id: `sensor.${this.slug(entityName)}`, + uniqueId: `opentherm_gw_${this.uniqueBase(snapshotArg)}_${descriptionArg.source}_${this.slug(descriptionArg.key)}`, + integrationDomain: openthermGwDomain, + deviceId: this.deviceIdForSource(snapshotArg, descriptionArg.source), + platform: 'sensor', + name: entityName, + state: primitiveValue(valueArg) ?? 'unknown', + attributes: { + source: descriptionArg.source, + key: descriptionArg.key, + unitOfMeasurement: descriptionArg.unit, + deviceClass: descriptionArg.deviceClass, + stateClass: descriptionArg.stateClass, + }, + available: snapshotArg.online, + }; + } + + private static binarySensorEntities(snapshotArg: IOpenthermGwSnapshot): IIntegrationEntity[] { + return openthermGwBinarySensorDescriptions.flatMap((descriptionArg) => { + const value = statusPart(snapshotArg.status, descriptionArg.source)[descriptionArg.key]; + if (value === undefined) { + return []; + } + const sourceName = sourceLabel(descriptionArg.source); + const entityName = `${snapshotArg.gateway.name || 'OpenTherm Gateway'} ${sourceName} ${descriptionArg.name}`; + return [{ + id: `binary_sensor.${this.slug(entityName)}`, + uniqueId: `opentherm_gw_${this.uniqueBase(snapshotArg)}_${descriptionArg.source}_${this.slug(descriptionArg.key)}`, + integrationDomain: openthermGwDomain, + deviceId: this.deviceIdForSource(snapshotArg, descriptionArg.source), + platform: 'binary_sensor' as const, + name: entityName, + state: boolValue(value) ? 'on' : 'off', + attributes: { + source: descriptionArg.source, + key: descriptionArg.key, + deviceClass: descriptionArg.deviceClass, + }, + available: snapshotArg.online, + }]; + }); + } + + private static switchEntities(snapshotArg: IOpenthermGwSnapshot): IIntegrationEntity[] { + return openthermGwSwitchDescriptions.map((descriptionArg) => { + const value = this.switchState(snapshotArg, descriptionArg); + const entityName = `${snapshotArg.gateway.name || 'OpenTherm Gateway'} ${descriptionArg.name}`; + return { + id: `switch.${this.slug(entityName)}`, + uniqueId: `opentherm_gw_${this.uniqueBase(snapshotArg)}_${descriptionArg.key}`, + integrationDomain: openthermGwDomain, + deviceId: this.gatewayDeviceId(snapshotArg), + platform: 'switch' as const, + name: entityName, + state: value === undefined ? 'unknown' : value ? 'on' : 'off', + attributes: { + key: descriptionArg.key, + circuit: descriptionArg.circuit, + assumedState: true, + }, + available: snapshotArg.online, + }; + }); + } + + private static switchState(snapshotArg: IOpenthermGwSnapshot, descriptionArg: IOpenthermGwSwitchDescription): boolean | undefined { + const gatewayValue = snapshotArg.status.gateway[descriptionArg.key]; + if (gatewayValue !== undefined) { + return boolValue(gatewayValue); + } + const key = descriptionArg.circuit === 2 ? 'master_ch2_enabled' : 'master_ch_enabled'; + const boilerValue = snapshotArg.status.boiler[key]; + if (boilerValue !== undefined) { + return boolValue(boilerValue); + } + const thermostatValue = snapshotArg.status.thermostat[key]; + return thermostatValue === undefined ? undefined : boolValue(thermostatValue); + } + + private static awayMode(snapshotArg: IOpenthermGwSnapshot): boolean { + const gateway = snapshotArg.status.gateway; + const gpioA = numberValue(gateway.otgw_gpio_a); + const gpioB = numberValue(gateway.otgw_gpio_b); + const gpioAState = numberValue(gateway.otgw_gpio_a_state); + const gpioBState = numberValue(gateway.otgw_gpio_b_state); + return (gpioA === 5 && gpioAState === 0) || (gpioA === 6 && gpioAState === 1) || (gpioB === 5 && gpioBState === 0) || (gpioB === 6 && gpioBState === 1); + } + + private static deviceIdForSource(snapshotArg: IOpenthermGwSnapshot, sourceArg: TOpenthermGwDataSource): string { + if (sourceArg === 'boiler') { + return this.boilerDeviceId(snapshotArg); + } + if (sourceArg === 'thermostat') { + return this.thermostatDeviceId(snapshotArg); + } + return this.gatewayDeviceId(snapshotArg); + } + + private static uniqueBase(snapshotArg: IOpenthermGwSnapshot): string { + return this.slug(snapshotArg.gateway.id || snapshotArg.gateway.host || snapshotArg.gateway.device || snapshotArg.gateway.name || 'opentherm_gateway'); + } +} + +const statusPart = (statusArg: IOpenthermGwStatus, sourceArg: TOpenthermGwDataSource): Record => statusArg[sourceArg]; + +const descriptionKey = (sourceArg: TOpenthermGwDataSource, keyArg: string): string => `${sourceArg}.${keyArg}`; + +const sourceLabel = (sourceArg: TOpenthermGwDataSource): string => sourceArg === 'boiler' ? 'Boiler' : sourceArg === 'thermostat' ? 'Thermostat' : 'Gateway'; + +const boolValue = (valueArg: unknown): boolean => valueArg === true || valueArg === 1 || valueArg === '1' || valueArg === 'true' || valueArg === 'on'; + +const numberValue = (valueArg: unknown): number | undefined => { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim()) { + const parsed = Number(valueArg); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +}; + +const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg : undefined; + +const primitiveValue = (valueArg: TOpenthermGwStatusValue): string | number | boolean | null | undefined => { + if (valueArg === undefined) { + return undefined; + } + return valueArg === null ? null : valueArg; +}; + +const titleCase = (valueArg: string): string => valueArg.replace(/_/g, ' ').replace(/\b\w/g, (matchArg) => matchArg.toUpperCase()); diff --git a/ts/integrations/opentherm_gw/opentherm_gw.types.ts b/ts/integrations/opentherm_gw/opentherm_gw.types.ts index 53b1171..ab9a4e6 100644 --- a/ts/integrations/opentherm_gw/opentherm_gw.types.ts +++ b/ts/integrations/opentherm_gw/opentherm_gw.types.ts @@ -1,4 +1,260 @@ -export interface IHomeAssistantOpenthermGwConfig { - // TODO: replace with the TypeScript-native config for opentherm_gw. - [key: string]: unknown; +export const openthermGwDomain = 'opentherm_gw'; +export const openthermGwDefaultTcpPort = 23; +export const openthermGwDefaultTimeoutMs = 5000; + +export type TOpenthermGwDataSource = 'gateway' | 'boiler' | 'thermostat'; +export type TOpenthermGwSnapshotSource = 'manual' | 'snapshot' | 'tcp' | 'runtime'; +export type TOpenthermGwEventType = 'status' | 'command' | 'error'; +export type TOpenthermGwHvacAction = 'heating' | 'cooling' | 'idle' | 'off'; +export type TOpenthermGwHvacMode = 'heat' | 'cool' | 'off'; +export type TOpenthermGwWaterHeaterMode = 'auto' | 'heat' | 'off' | 'unknown'; +export type TOpenthermGwStatusValue = string | number | boolean | null | undefined; +export type TOpenthermGwCommandValue = string | number | boolean; + +export type TOpenthermGwCommandCode = + | 'TT' + | 'TC' + | 'OT' + | 'SC' + | 'HW' + | 'PR' + | 'PS' + | 'GW' + | 'LA' + | 'LB' + | 'LC' + | 'LD' + | 'LE' + | 'LF' + | 'GA' + | 'GB' + | 'SB' + | 'TS' + | 'SH' + | 'SW' + | 'MM' + | 'CS' + | 'C2' + | 'CH' + | 'H2' + | string; + +export interface IOpenthermGwConfig { + id?: string; + name?: string; + host?: string; + port?: number; + device?: string; + timeoutMs?: number; + temporaryOverrideMode?: boolean; + snapshot?: IOpenthermGwSnapshot; + status?: IOpenthermGwStatus; +} + +export interface IHomeAssistantOpenthermGwConfig extends IOpenthermGwConfig {} + +export interface IOpenthermGwGatewayInfo { + id: string; + name: string; + host?: string; + port?: number; + device?: string; + firmwareVersion?: string; +} + +export interface IOpenthermGwStatus { + gateway: IOpenthermGwGatewayStatus; + boiler: IOpenthermGwDeviceStatus; + thermostat: IOpenthermGwDeviceStatus; +} + +export interface IOpenthermGwGatewayStatus { + otgw_mode?: TOpenthermGwStatusValue; + otgw_dhw_ovrd?: TOpenthermGwStatusValue; + otgw_about?: TOpenthermGwStatusValue; + otgw_build?: TOpenthermGwStatusValue; + otgw_clockmhz?: TOpenthermGwStatusValue; + otgw_led_a?: TOpenthermGwStatusValue; + otgw_led_b?: TOpenthermGwStatusValue; + otgw_led_c?: TOpenthermGwStatusValue; + otgw_led_d?: TOpenthermGwStatusValue; + otgw_led_e?: TOpenthermGwStatusValue; + otgw_led_f?: TOpenthermGwStatusValue; + otgw_gpio_a?: TOpenthermGwStatusValue; + otgw_gpio_b?: TOpenthermGwStatusValue; + otgw_gpio_a_state?: TOpenthermGwStatusValue; + otgw_gpio_b_state?: TOpenthermGwStatusValue; + otgw_reset_cause?: TOpenthermGwStatusValue; + otgw_setback_temp?: TOpenthermGwStatusValue; + otgw_setpoint_ovrd_mode?: TOpenthermGwStatusValue; + otgw_smart_pwr?: TOpenthermGwStatusValue; + otgw_temp_sensor?: TOpenthermGwStatusValue; + otgw_thermostat_detect?: TOpenthermGwStatusValue; + otgw_ignore_transitions?: TOpenthermGwStatusValue; + otgw_ovrd_high_byte?: TOpenthermGwStatusValue; + otgw_vref?: TOpenthermGwStatusValue; + central_heating_1_override?: TOpenthermGwStatusValue; + central_heating_2_override?: TOpenthermGwStatusValue; + [key: string]: TOpenthermGwStatusValue; +} + +export interface IOpenthermGwDeviceStatus { + master_ch_enabled?: TOpenthermGwStatusValue; + master_dhw_enabled?: TOpenthermGwStatusValue; + master_cooling_enabled?: TOpenthermGwStatusValue; + master_otc_enabled?: TOpenthermGwStatusValue; + master_ch2_enabled?: TOpenthermGwStatusValue; + slave_fault_indication?: TOpenthermGwStatusValue; + slave_ch_active?: TOpenthermGwStatusValue; + slave_dhw_active?: TOpenthermGwStatusValue; + slave_flame_on?: TOpenthermGwStatusValue; + slave_cooling_active?: TOpenthermGwStatusValue; + slave_ch2_active?: TOpenthermGwStatusValue; + slave_diagnostic_indication?: TOpenthermGwStatusValue; + control_setpoint?: TOpenthermGwStatusValue; + control_setpoint_2?: TOpenthermGwStatusValue; + room_setpoint?: TOpenthermGwStatusValue; + room_setpoint_2?: TOpenthermGwStatusValue; + room_setpoint_ovrd?: TOpenthermGwStatusValue; + room_temp?: TOpenthermGwStatusValue; + outside_temp?: TOpenthermGwStatusValue; + ch_water_temp?: TOpenthermGwStatusValue; + ch_water_temp_2?: TOpenthermGwStatusValue; + dhw_temp?: TOpenthermGwStatusValue; + dhw_temp_2?: TOpenthermGwStatusValue; + dhw_setpoint?: TOpenthermGwStatusValue; + max_ch_setpoint?: TOpenthermGwStatusValue; + relative_mod_level?: TOpenthermGwStatusValue; + ch_water_pressure?: TOpenthermGwStatusValue; + dhw_flow_rate?: TOpenthermGwStatusValue; + return_water_temp?: TOpenthermGwStatusValue; + exhaust_temp?: TOpenthermGwStatusValue; + slave_max_relative_modulation?: TOpenthermGwStatusValue; + slave_max_capacity?: TOpenthermGwStatusValue; + slave_min_mod_level?: TOpenthermGwStatusValue; + slave_dhw_present?: TOpenthermGwStatusValue; + slave_control_type?: TOpenthermGwStatusValue; + slave_cooling_supported?: TOpenthermGwStatusValue; + slave_dhw_config?: TOpenthermGwStatusValue; + slave_master_low_off_pump?: TOpenthermGwStatusValue; + slave_ch2_present?: TOpenthermGwStatusValue; + slave_service_required?: TOpenthermGwStatusValue; + slave_remote_reset?: TOpenthermGwStatusValue; + slave_low_water_pressure?: TOpenthermGwStatusValue; + slave_gas_fault?: TOpenthermGwStatusValue; + slave_air_pressure_fault?: TOpenthermGwStatusValue; + slave_water_overtemp?: TOpenthermGwStatusValue; + remote_transfer_dhw?: TOpenthermGwStatusValue; + remote_transfer_max_ch?: TOpenthermGwStatusValue; + remote_rw_dhw?: TOpenthermGwStatusValue; + remote_rw_max_ch?: TOpenthermGwStatusValue; + slave_memberid?: TOpenthermGwStatusValue; + master_memberid?: TOpenthermGwStatusValue; + slave_oem_fault?: TOpenthermGwStatusValue; + oem_diag?: TOpenthermGwStatusValue; + burner_starts?: TOpenthermGwStatusValue; + ch_pump_starts?: TOpenthermGwStatusValue; + dhw_pump_starts?: TOpenthermGwStatusValue; + dhw_burner_starts?: TOpenthermGwStatusValue; + burner_hours?: TOpenthermGwStatusValue; + ch_pump_hours?: TOpenthermGwStatusValue; + dhw_pump_hours?: TOpenthermGwStatusValue; + dhw_burner_hours?: TOpenthermGwStatusValue; + master_ot_version?: TOpenthermGwStatusValue; + slave_ot_version?: TOpenthermGwStatusValue; + master_product_type?: TOpenthermGwStatusValue; + master_product_version?: TOpenthermGwStatusValue; + slave_product_type?: TOpenthermGwStatusValue; + slave_product_version?: TOpenthermGwStatusValue; + [key: string]: TOpenthermGwStatusValue; +} + +export interface IOpenthermGwSensorDescription { + key: string; + source: TOpenthermGwDataSource; + name: string; + unit?: string; + deviceClass?: string; + stateClass?: 'measurement' | 'total'; +} + +export interface IOpenthermGwBinarySensorDescription { + key: string; + source: TOpenthermGwDataSource; + name: string; + deviceClass?: string; +} + +export interface IOpenthermGwSwitchDescription { + key: 'central_heating_1_override' | 'central_heating_2_override'; + name: string; + circuit: 1 | 2; +} + +export interface IOpenthermGwClimateState { + currentTemperature?: number; + targetTemperature?: number; + hvacMode: TOpenthermGwHvacMode; + hvacAction: TOpenthermGwHvacAction; + presetMode: 'none' | 'away'; + minTemp: number; + maxTemp: number; + temperatureUnit: 'C'; +} + +export interface IOpenthermGwWaterHeaterState { + currentTemperature?: number; + targetTemperature?: number; + operationMode: TOpenthermGwWaterHeaterMode; + hotWaterActive?: boolean; + temperatureUnit: 'C'; +} + +export interface IOpenthermGwCommandRequest { + code: TOpenthermGwCommandCode; + value: TOpenthermGwCommandValue; + timeoutMs?: number; +} + +export interface IOpenthermGwCommandResponse { + code: TOpenthermGwCommandCode; + value: string; + accepted: boolean; + commandLine: string; + rawLines: string[]; + summaryLine?: string; + errorCode?: string; + updatedAt: string; +} + +export interface IOpenthermGwEvent { + type: TOpenthermGwEventType; + gatewayId: string; + timestamp: number; + status?: IOpenthermGwStatus; + command?: IOpenthermGwCommandResponse; + error?: string; +} + +export interface IOpenthermGwSnapshot { + gateway: IOpenthermGwGatewayInfo; + status: IOpenthermGwStatus; + climate?: IOpenthermGwClimateState; + waterHeater?: IOpenthermGwWaterHeaterState; + commands?: IOpenthermGwCommandResponse[]; + events?: IOpenthermGwEvent[]; + online: boolean; + source: TOpenthermGwSnapshotSource; + updatedAt: string; +} + +export interface IOpenthermGwManualDiscoveryInput { + id?: string; + host?: string; + port?: number; + device?: string; + name?: string; + manufacturer?: string; + model?: string; + metadata?: Record; } diff --git a/ts/integrations/rflink/.generated-by-smarthome-exchange b/ts/integrations/rflink/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/rflink/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/rflink/index.ts b/ts/integrations/rflink/index.ts index 4c74e60..3444196 100644 --- a/ts/integrations/rflink/index.ts +++ b/ts/integrations/rflink/index.ts @@ -1,2 +1,7 @@ +export * from './rflink.classes.client.js'; +export * from './rflink.classes.configflow.js'; export * from './rflink.classes.integration.js'; +export * from './rflink.constants.js'; +export * from './rflink.discovery.js'; +export * from './rflink.mapper.js'; export * from './rflink.types.js'; diff --git a/ts/integrations/rflink/rflink.classes.client.ts b/ts/integrations/rflink/rflink.classes.client.ts new file mode 100644 index 0000000..07fcffb --- /dev/null +++ b/ts/integrations/rflink/rflink.classes.client.ts @@ -0,0 +1,598 @@ +import { + rflinkDefaultBaudRate, + rflinkDefaultCoverTypeByProtocol, + rflinkDefaultLightTypeByProtocol, + rflinkDefaultSignalRepetitions, + rflinkDefaultWaitForAck, + rflinkDomain, + rflinkPacketFieldAliases, + rflinkPacketUnits, +} from './rflink.constants.js'; +import type { + IRflinkCommand, + IRflinkCommandResult, + IRflinkConfig, + IRflinkDevice, + IRflinkEntity, + IRflinkEntityConfig, + IRflinkEvent, + IRflinkGateway, + IRflinkLineProtocolCommandShape, + IRflinkPacket, + IRflinkSnapshot, + TRflinkEntityCollection, + TRflinkEntityPlatform, +} from './rflink.types.js'; + +type TRflinkEventHandler = (eventArg: IRflinkEvent) => void; + +const protocolTranslations = new Map(); +for (const protocol of [ + 'Ikea Koppla', + 'Alecto V1', + 'Alecto V2', + 'UPM/Esic', + 'Oregon TempHygro', + 'Oregon BTHR', + 'Oregon Rain', + 'Oregon Rain2', + 'Oregon Wind', + 'Oregon Wind2', + 'Oregon UVN128/138', + 'Plieger York', + 'Byron SX', + 'CAME-TOP432', +]) { + const serialized = protocol.toLowerCase().replace(/[^a-z0-9_]+/g, ''); + protocolTranslations.set(protocol.toLowerCase(), serialized); + protocolTranslations.set(serialized, protocol.toLowerCase()); +} + +export class RflinkClient { + private snapshot?: IRflinkSnapshot; + private readonly eventHandlers = new Set(); + + constructor(private readonly config: IRflinkConfig) {} + + public async getSnapshot(): Promise { + if (this.config.snapshot) { + this.snapshot = this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot)); + return this.cloneSnapshot(this.snapshot); + } + if (!this.snapshot) { + this.snapshot = this.normalizeSnapshot(this.snapshotFromConfig()); + } + return this.cloneSnapshot(this.snapshot); + } + + public onEvent(handlerArg: TRflinkEventHandler): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async refresh(): Promise { + this.snapshot = this.normalizeSnapshot(this.snapshotFromConfig()); + this.emit({ type: 'snapshot_refreshed', data: this.cloneSnapshot(this.snapshot), timestamp: Date.now() }); + return this.cloneSnapshot(this.snapshot); + } + + public async sendCommand(commandArg: IRflinkCommand): Promise { + if (commandArg.type === 'refresh') { + return { success: true, transmitted: false, data: await this.refresh() }; + } + + const command = this.withLineProtocol(commandArg); + this.emit({ type: command.type === 'learn' ? 'learn' : 'command_mapped', id: command.deviceId, entityId: command.entityId, command, rflinkCommand: command.rflinkCommand, line: command.lineProtocol?.line, timestamp: Date.now() }); + + try { + if (this.config.commandExecutor) { + const executorResult = await this.config.commandExecutor(command); + const result = this.commandResult(executorResult, command, true); + if (result.success) { + await this.patchSnapshot(command); + } + this.emit({ type: result.success ? 'command_executed' : 'command_failed', id: command.deviceId, entityId: command.entityId, command, rflinkCommand: command.rflinkCommand, line: command.lineProtocol?.line, data: result, timestamp: Date.now() }); + return result; + } + + await this.patchSnapshot(command); + const result: IRflinkCommandResult = { + success: true, + transmitted: false, + data: { + lineProtocol: command.lineProtocol, + reason: 'No RFLink serial/TCP transport executor is configured; command was mapped but not transmitted.', + }, + }; + this.emit({ type: 'command_executed', id: command.deviceId, entityId: command.entityId, command, rflinkCommand: command.rflinkCommand, line: command.lineProtocol?.line, data: result, timestamp: Date.now() }); + return result; + } catch (errorArg) { + const result = { success: false, transmitted: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg), data: { command } }; + this.emit({ type: 'command_failed', id: command.deviceId, entityId: command.entityId, command, rflinkCommand: command.rflinkCommand, line: command.lineProtocol?.line, data: result, timestamp: Date.now() }); + return result; + } + } + + public lineProtocolCommandShape(commandArg: IRflinkCommand): IRflinkLineProtocolCommandShape { + return this.withLineProtocol(commandArg).lineProtocol as IRflinkLineProtocolCommandShape; + } + + public static commandShape(deviceIdArg: string, rflinkCommandArg: string, optionsArg: { rawLine?: string; waitForAck?: boolean; repetitions?: number } = {}): IRflinkLineProtocolCommandShape { + if (optionsArg.rawLine) { + const line = this.normalizeRawLine(optionsArg.rawLine); + return { + transport: 'line-protocol', + line, + packet: { node: line.startsWith('20;') ? '20' : '10', raw: line }, + waitForAck: optionsArg.waitForAck, + repetitions: optionsArg.repetitions, + }; + } + + const packet = this.deserializeDeviceId(deviceIdArg); + const command = this.cleanSegment(rflinkCommandArg); + const protocol = this.cleanSegment(packet.protocol || 'unknown'); + + if (protocol.toLowerCase() === 'rfdebug' || protocol.toLowerCase() === 'rfudebug' || protocol.toLowerCase() === 'qrfdebug') { + const debugProtocol = protocol.toUpperCase(); + return { + transport: 'line-protocol', + line: `10;${debugProtocol}=${command.toUpperCase()};`, + packet: { node: '10', protocol: protocol.toLowerCase(), command }, + waitForAck: optionsArg.waitForAck, + repetitions: optionsArg.repetitions, + }; + } + + const segments = ['10', protocol]; + if (packet.id) { + segments.push(this.cleanSegment(packet.id)); + } + if (packet.switch) { + segments.push(this.cleanSegment(packet.switch)); + } + segments.push(command); + + return { + transport: 'line-protocol', + line: `${segments.join(';')};`, + packet: { node: '10', protocol, id: packet.id, switch: packet.switch, command }, + waitForAck: optionsArg.waitForAck, + repetitions: optionsArg.repetitions, + }; + } + + public static decodeLine(lineArg: string): IRflinkPacket | undefined { + const line = this.normalizeRawLine(lineArg); + const parts = line.split(';').filter((partArg) => partArg !== ''); + if (parts.length < 2) { + return undefined; + } + const [node, sequence, protocolRaw] = parts; + const packet: IRflinkPacket = { node, sequence, raw: line, values: {} }; + if (!protocolRaw) { + return packet; + } + + if (protocolRaw.includes('RFLink Gateway')) { + packet.protocol = 'unknown'; + packet.values = { ...packet.values, ...this.parseBanner(protocolRaw) }; + return packet; + } + if (protocolRaw === 'OK') { + packet.values = { ...packet.values, ok: true }; + return packet; + } + if (protocolRaw === 'CMD UNKNOWN') { + packet.values = { ...packet.values, ok: false, response: 'command_unknown' }; + return packet; + } + + packet.protocol = protocolRaw.toLowerCase(); + for (const attr of parts.slice(3)) { + if (!attr.includes('=')) { + continue; + } + const [rawKey, ...rawValueParts] = attr.split('='); + const key = rawKey.toLowerCase(); + const rawValue = rawValueParts.join('=').toLowerCase(); + const field = rflinkPacketFieldAliases[key] || key; + const value = this.decodeValue(key, rawValue); + if (field === 'id') { + packet.id = String(value); + } else if (field === 'switch') { + packet.switch = String(value); + } else if (field === 'command') { + packet.command = String(value).toLowerCase(); + } + packet.values = { ...packet.values, [field]: value }; + const unit = rflinkPacketUnits[key]; + if (unit) { + packet.values[`${field}_unit`] = unit; + } + } + if (packet.protocol === 'kaku' && packet.id && packet.id.length !== 6) { + packet.id = `0000${packet.id}`.slice(-6); + packet.values = { ...packet.values, id: packet.id }; + } + return packet; + } + + public static eventsFromPacket(packetArg: IRflinkPacket): IRflinkEvent[] { + const timestamp = Date.now(); + const values = packetArg.values || {}; + const id = this.serializePacketId(packetArg); + if (packetArg.command) { + return [{ type: 'command', id, rflinkCommand: packetArg.command, packet: packetArg, timestamp }]; + } + if (values.version || values.firmware || values.revision || id === rflinkDomain) { + return [{ type: 'gateway', id: rflinkDomain, packet: packetArg, data: values, timestamp }]; + } + + const reverseAliases = this.reversePacketFieldAliases(); + const events: IRflinkEvent[] = []; + for (const [sensor, abbrev] of Object.entries(reverseAliases)) { + if (!(sensor in values)) { + continue; + } + events.push({ + type: 'sensor', + id: `${id}_${abbrev}`, + sensor, + value: values[sensor], + unit: values[`${sensor}_unit`] as string | undefined, + packet: packetArg, + timestamp, + }); + } + if (events.length && id !== rflinkDomain) { + events.push({ type: 'sensor', id: `${id}_update_time`, sensor: 'update_time', value: Math.round(timestamp / 1000), unit: 's', packet: packetArg, timestamp }); + } + return events; + } + + public async destroy(): Promise { + this.eventHandlers.clear(); + } + + private withLineProtocol(commandArg: IRflinkCommand): IRflinkCommand { + if (commandArg.lineProtocol) { + return commandArg; + } + const rflinkCommand = commandArg.rflinkCommand || this.defaultRflinkCommand(commandArg); + const lineProtocol = RflinkClient.commandShape(commandArg.deviceId || this.deviceIdForEntity(commandArg.entityId) || rflinkDomain, rflinkCommand, { + rawLine: commandArg.rawLine, + waitForAck: this.config.waitForAck ?? rflinkDefaultWaitForAck, + repetitions: commandArg.repetitions || rflinkDefaultSignalRepetitions, + }); + return { ...commandArg, rflinkCommand, lineProtocol }; + } + + private defaultRflinkCommand(commandArg: IRflinkCommand): string { + if (commandArg.type === 'turn_on') { + return 'on'; + } + if (commandArg.type === 'turn_off') { + return 'off'; + } + if (commandArg.type === 'set_value') { + return String(this.brightnessToRflink(commandArg.brightness ?? commandArg.value ?? 255)); + } + if (commandArg.type === 'open_cover') { + return 'UP'; + } + if (commandArg.type === 'close_cover') { + return 'DOWN'; + } + if (commandArg.type === 'stop_cover') { + return 'STOP'; + } + if (commandArg.type === 'learn') { + return 'PAIR'; + } + return 'on'; + } + + private async patchSnapshot(commandArg: IRflinkCommand): Promise { + const snapshot = this.snapshot || this.normalizeSnapshot(this.snapshotFromConfig()); + this.snapshot = snapshot; + const entity = this.findEntity(snapshot, commandArg); + if (!entity || commandArg.type === 'learn' || commandArg.type === 'send_command') { + return; + } + entity.updatedAt = new Date().toISOString(); + entity.command = commandArg.rflinkCommand; + if (commandArg.type === 'turn_on') { + entity.state = 'on'; + } else if (commandArg.type === 'turn_off') { + entity.state = 'off'; + } else if (commandArg.type === 'set_value') { + entity.state = 'on'; + entity.value = commandArg.value ?? commandArg.brightness; + entity.brightness = commandArg.brightness ?? this.rflinkToBrightness(Number(commandArg.rflinkCommand)); + } else if (commandArg.type === 'open_cover') { + entity.state = 'open'; + } else if (commandArg.type === 'close_cover') { + entity.state = 'closed'; + } else if (commandArg.type === 'stop_cover') { + entity.state = 'stopped'; + } + } + + private findEntity(snapshotArg: IRflinkSnapshot, commandArg: IRflinkCommand): IRflinkEntity | undefined { + return snapshotArg.entities.find((entityArg) => entityArg.id === commandArg.deviceId || entityArg.entityId === commandArg.entityId || entityArg.uniqueId === commandArg.entityId || entityArg.aliases?.includes(commandArg.deviceId || '')); + } + + private snapshotFromConfig(): IRflinkSnapshot { + const updatedAt = new Date().toISOString(); + const gateway = this.gatewayFromConfig(); + const events = (this.config.events || []).map((eventArg) => this.cloneEvent(eventArg)); + const entities = this.entitiesFromConfig(events, updatedAt); + const connected = this.config.connected ?? gateway.online ?? false; + return { + gateway: { ...gateway, online: connected }, + devices: entities.map((entityArg) => this.deviceFromEntity(entityArg)), + entities, + events, + connected, + updatedAt, + }; + } + + private gatewayFromConfig(): IRflinkGateway { + const connectionType = this.config.connectionType || (this.config.host ? 'tcp' : this.config.port ? 'serial' : 'manual'); + return { + id: this.config.gateway?.id || this.config.host || String(this.config.port || rflinkDomain), + name: this.config.gateway?.name || 'RFLink Gateway', + connectionType, + host: this.config.gateway?.host || this.config.host, + port: this.config.gateway?.port || this.config.port, + baudRate: this.config.gateway?.baudRate || this.config.baudRate || rflinkDefaultBaudRate, + hardware: this.config.gateway?.hardware, + firmware: this.config.gateway?.firmware || 'RFLink Gateway', + version: this.config.gateway?.version, + revision: this.config.gateway?.revision, + online: this.config.connected ?? this.config.gateway?.online ?? false, + attributes: this.config.gateway?.attributes, + }; + } + + private entitiesFromConfig(eventsArg: IRflinkEvent[], updatedAtArg: string): IRflinkEntity[] { + const entities = [ + ...this.entitiesFromCollection(this.config.entities || [], undefined, updatedAtArg), + ...this.entitiesFromCollection(this.config.devices || [], undefined, updatedAtArg), + ...this.entitiesFromCollection(this.config.lights || [], 'light', updatedAtArg), + ...this.entitiesFromCollection(this.config.switches || [], 'switch', updatedAtArg), + ...this.entitiesFromCollection(this.config.sensors || [], 'sensor', updatedAtArg), + ...this.entitiesFromCollection(this.config.binarySensors || [], 'binary_sensor', updatedAtArg), + ...this.entitiesFromCollection(this.config.covers || [], 'cover', updatedAtArg), + ]; + const knownIds = new Set(entities.flatMap((entityArg) => [entityArg.id, ...(entityArg.aliases || [])])); + for (const event of eventsArg) { + if (!event.id || knownIds.has(event.id) || this.ignored(event.id)) { + continue; + } + const entity = this.entityFromEvent(event, updatedAtArg); + if (entity) { + entities.push(entity); + knownIds.add(entity.id); + } + } + return entities.filter((entityArg) => !this.ignored(entityArg.id)); + } + + private entitiesFromCollection(collectionArg: TRflinkEntityCollection | undefined, platformArg: TRflinkEntityPlatform | undefined, updatedAtArg: string): IRflinkEntity[] { + if (Array.isArray(collectionArg)) { + return collectionArg.map((entityArg) => this.normalizeEntity(entityArg, platformArg, updatedAtArg)); + } + return Object.entries(collectionArg || {}).map(([id, configArg]) => { + const entityConfig = typeof configArg === 'string' ? { id, name: configArg } : { id, ...(configArg || {}) }; + return this.normalizeEntity(entityConfig, platformArg, updatedAtArg); + }); + } + + private normalizeEntity(entityArg: IRflinkEntityConfig, platformArg: TRflinkEntityPlatform | undefined, updatedAtArg: string): IRflinkEntity { + const platform = entityArg.platform || platformArg || 'light'; + const protocol = this.protocolFromDeviceId(entityArg.id); + const type = entityArg.type || (platform === 'light' ? rflinkDefaultLightTypeByProtocol[protocol] || 'switchable' : platform === 'cover' ? rflinkDefaultCoverTypeByProtocol[protocol] || 'standard' : undefined); + const sensorType = entityArg.sensorType || (platform === 'sensor' ? this.sensorTypeFromId(entityArg.id) : undefined); + return { + ...entityArg, + platform, + name: entityArg.name || this.nameFromId(entityArg.id, sensorType), + aliases: [...(entityArg.aliases || [])], + groupAliases: [...(entityArg.groupAliases || [])], + noGroupAliases: [...(entityArg.noGroupAliases || [])], + type, + sensorType, + state: entityArg.state || this.stateFromCommand(entityArg.command), + available: entityArg.available, + updatedAt: updatedAtArg, + }; + } + + private entityFromEvent(eventArg: IRflinkEvent, updatedAtArg: string): IRflinkEntity | undefined { + if (eventArg.type === 'sensor' && eventArg.id) { + return this.normalizeEntity({ id: eventArg.id, platform: 'sensor', sensorType: eventArg.sensor, unitOfMeasurement: eventArg.unit, value: eventArg.value, available: true }, 'sensor', updatedAtArg); + } + if (eventArg.type === 'command' && eventArg.id) { + return this.normalizeEntity({ id: eventArg.id, platform: 'light', command: eventArg.rflinkCommand, state: this.stateFromCommand(eventArg.rflinkCommand), available: true }, 'light', updatedAtArg); + } + return undefined; + } + + private normalizeSnapshot(snapshotArg: IRflinkSnapshot): IRflinkSnapshot { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + const connected = snapshotArg.connected ?? snapshotArg.gateway.online ?? this.config.connected ?? false; + const gateway: IRflinkGateway = { ...this.gatewayFromConfig(), ...snapshotArg.gateway, online: connected }; + const entities = (snapshotArg.entities?.length ? snapshotArg.entities : snapshotArg.devices || []).map((entityArg) => this.normalizeEntity(entityArg, entityArg.platform, updatedAt)).filter((entityArg) => !this.ignored(entityArg.id)); + const devices = (snapshotArg.devices?.length ? snapshotArg.devices : entities.map((entityArg) => this.deviceFromEntity(entityArg))).map((deviceArg) => this.deviceFromEntity(this.normalizeEntity(deviceArg, deviceArg.platform, updatedAt))).filter((deviceArg) => !this.ignored(deviceArg.id)); + return { + gateway, + devices, + entities, + events: (snapshotArg.events || []).map((eventArg) => this.cloneEvent(eventArg)), + connected, + updatedAt, + raw: snapshotArg.raw ? { ...snapshotArg.raw } : undefined, + }; + } + + private deviceFromEntity(entityArg: IRflinkEntity): IRflinkDevice { + const { entityId, uniqueId, updatedAt, ...device } = entityArg; + void entityId; + void uniqueId; + void updatedAt; + return device; + } + + private commandResult(valueArg: IRflinkCommandResult | unknown, commandArg: IRflinkCommand, transmittedArg: boolean): IRflinkCommandResult { + if (valueArg && typeof valueArg === 'object' && 'success' in valueArg) { + return { transmitted: transmittedArg, ...(valueArg as IRflinkCommandResult) }; + } + return { success: true, transmitted: transmittedArg, data: valueArg ?? { lineProtocol: commandArg.lineProtocol } }; + } + + private deviceIdForEntity(entityIdArg: string | undefined): string | undefined { + if (!entityIdArg || !this.snapshot) { + return undefined; + } + return this.snapshot.entities.find((entityArg) => entityArg.entityId === entityIdArg || entityArg.uniqueId === entityIdArg)?.id; + } + + private emit(eventArg: IRflinkEvent): void { + for (const handler of this.eventHandlers) { + handler(eventArg); + } + } + + private ignored(deviceIdArg: string): boolean { + return (this.config.ignoreDevices || []).some((patternArg) => this.wildcardMatch(deviceIdArg, patternArg)); + } + + private wildcardMatch(valueArg: string, patternArg: string): boolean { + const escaped = patternArg.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.'); + return new RegExp(`^${escaped}$`, 'i').test(valueArg); + } + + private protocolFromDeviceId(deviceIdArg: string): string { + const parts = deviceIdArg.split('_'); + return (parts.length > 2 ? parts.slice(0, -2).join('_') : parts[0]).toLowerCase(); + } + + private sensorTypeFromId(deviceIdArg: string): string | undefined { + const suffix = deviceIdArg.split('_').pop() || ''; + return rflinkPacketFieldAliases[suffix] || suffix || undefined; + } + + private stateFromCommand(commandArg: string | undefined): string | undefined { + const command = (commandArg || '').toLowerCase(); + if (command === 'on' || command === 'allon' || command === 'up') { + return 'on'; + } + if (command === 'off' || command === 'alloff' || command === 'down') { + return 'off'; + } + return undefined; + } + + private nameFromId(deviceIdArg: string, sensorTypeArg?: string): string { + const idName = deviceIdArg.replace(/_/g, ' '); + return sensorTypeArg && !idName.toLowerCase().includes(sensorTypeArg.replace(/_/g, ' ')) ? `${idName} ${sensorTypeArg.replace(/_/g, ' ')}` : idName; + } + + private brightnessToRflink(valueArg: number): number { + if (valueArg <= 15) { + return Math.max(0, Math.min(15, Math.round(valueArg))); + } + return Math.max(0, Math.min(15, Math.floor(Math.max(0, Math.min(255, valueArg)) / 17))); + } + + private rflinkToBrightness(valueArg: number): number { + return Math.max(0, Math.min(255, Math.round(valueArg) * 17)); + } + + private cloneSnapshot(snapshotArg: IRflinkSnapshot): IRflinkSnapshot { + return { + gateway: { ...snapshotArg.gateway, attributes: snapshotArg.gateway.attributes ? { ...snapshotArg.gateway.attributes } : undefined }, + devices: snapshotArg.devices.map((deviceArg) => ({ ...deviceArg, aliases: [...(deviceArg.aliases || [])], attributes: deviceArg.attributes ? { ...deviceArg.attributes } : undefined, metadata: deviceArg.metadata ? { ...deviceArg.metadata } : undefined })), + entities: snapshotArg.entities.map((entityArg) => ({ ...entityArg, aliases: [...(entityArg.aliases || [])], attributes: entityArg.attributes ? { ...entityArg.attributes } : undefined, metadata: entityArg.metadata ? { ...entityArg.metadata } : undefined })), + events: snapshotArg.events.map((eventArg) => this.cloneEvent(eventArg)), + connected: snapshotArg.connected, + updatedAt: snapshotArg.updatedAt, + raw: snapshotArg.raw ? { ...snapshotArg.raw } : undefined, + }; + } + + private cloneEvent(eventArg: IRflinkEvent): IRflinkEvent { + return { ...eventArg, command: eventArg.command ? { ...eventArg.command } : undefined, packet: eventArg.packet ? { ...eventArg.packet, values: eventArg.packet.values ? { ...eventArg.packet.values } : undefined } : undefined }; + } + + private static deserializeDeviceId(deviceIdArg: string): IRflinkPacket { + if (deviceIdArg === rflinkDomain) { + return { protocol: 'unknown' }; + } + const parts = deviceIdArg.split('_'); + const switchPart = parts.length > 2 ? parts.pop() : undefined; + const id = parts.length > 1 ? parts.pop() : undefined; + const protocol = protocolTranslations.get(parts.join('_')) || parts.join('_') || 'unknown'; + return { protocol, id, switch: switchPart }; + } + + private static serializePacketId(packetArg: IRflinkPacket): string { + const protocol = packetArg.protocol && packetArg.protocol !== 'unknown' ? protocolTranslations.get(packetArg.protocol) || packetArg.protocol.toLowerCase().replace(/[^a-z0-9_]+/g, '') : rflinkDomain; + return [protocol, packetArg.id, packetArg.switch].filter(Boolean).join('_'); + } + + private static reversePacketFieldAliases(): Record { + const result: Record = {}; + for (const [abbrev, field] of Object.entries(rflinkPacketFieldAliases)) { + if (!result[field]) { + result[field] = abbrev; + } + } + return result; + } + + private static normalizeRawLine(lineArg: string): string { + const line = lineArg.replace(/[\r\n]+/g, '').trim(); + return line.endsWith(';') ? line : `${line};`; + } + + private static cleanSegment(valueArg: string): string { + return valueArg.replace(/[;\r\n]/g, '').trim(); + } + + private static parseBanner(valueArg: string): Record { + const match = valueArg.match(/(?[a-zA-Z\s]+) - (?[a-zA-Z\s]+) V(?[0-9.]+) - R(?[0-9.]+)/); + return match?.groups ? { ...match.groups } : {}; + } + + private static decodeValue(keyArg: string, valueArg: string): unknown { + const hstatus: Record = { '0': 'normal', '1': 'comfortable', '2': 'dry', '3': 'wet' }; + const bforecast: Record = { '0': 'no_info', '1': 'sunny', '2': 'partly_cloudy', '3': 'cloudy', '4': 'rain' }; + if (keyArg === 'hstatus') { + return hstatus[valueArg] || 'Unknown'; + } + if (keyArg === 'bforecast') { + return bforecast[valueArg] || 'Unknown'; + } + if (['temp', 'winchl', 'wintmp'].includes(keyArg)) { + const parsed = Number.parseInt(valueArg, 16); + return parsed & 0x8000 ? -(parsed & 0x7fff) / 10 : parsed / 10; + } + if (['awinsp', 'rain', 'rainrate', 'raintot', 'wings', 'winsp'].includes(keyArg)) { + return Number.parseInt(valueArg, 16) / 10; + } + if (keyArg === 'windir') { + return Number.parseInt(valueArg, 10) * 22.5; + } + if (['baro', 'kwatt', 'lux', 'uv', 'watt'].includes(keyArg)) { + return Number.parseInt(valueArg, 16); + } + if (['chime', 'co2', 'current', 'current2', 'current3', 'dist', 'hum', 'meter', 'sound', 'volt'].includes(keyArg)) { + return Number.parseInt(valueArg, 10); + } + return valueArg; + } +} diff --git a/ts/integrations/rflink/rflink.classes.configflow.ts b/ts/integrations/rflink/rflink.classes.configflow.ts new file mode 100644 index 0000000..4e1245e --- /dev/null +++ b/ts/integrations/rflink/rflink.classes.configflow.ts @@ -0,0 +1,73 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import { rflinkDefaultBaudRate, rflinkDefaultReconnectInterval, rflinkDefaultTcpKeepaliveIdleTimer, rflinkDefaultWaitForAck } from './rflink.constants.js'; +import type { IRflinkConfig, TRflinkConnectionType } from './rflink.types.js'; + +export class RflinkConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect RFLink gateway', + description: 'Configure a local RFLink gateway. Use host and TCP port for a serial bridge, or serial port for a directly attached gateway.', + fields: [ + { name: 'connectionType', label: 'Connection type', type: 'select', required: true, options: [{ label: 'Serial', value: 'serial' }, { label: 'TCP', value: 'tcp' }] }, + { name: 'serialPort', label: 'Serial port', type: 'text' }, + { name: 'host', label: 'TCP host', type: 'text' }, + { name: 'port', label: 'TCP port', type: 'number' }, + { name: 'baudRate', label: 'Serial baud rate', type: 'number' }, + { name: 'waitForAck', label: 'Wait for acknowledgements', type: 'boolean' }, + ], + submit: async (valuesArg) => { + const connectionType = this.connectionType(valuesArg.connectionType) || this.connectionType(candidateArg.metadata?.connectionType) || (candidateArg.host ? 'tcp' : 'serial'); + const serialPort = this.stringValue(valuesArg.serialPort) || this.stringValue(candidateArg.metadata?.serialPort); + const host = this.stringValue(valuesArg.host) || candidateArg.host; + const port = this.numberValue(valuesArg.port) || candidateArg.port; + + if (connectionType === 'tcp' && (!host || !port)) { + return { kind: 'error', title: 'RFLink configuration failed', error: 'TCP RFLink setup requires host and port.' }; + } + if (connectionType === 'serial' && !serialPort) { + return { kind: 'error', title: 'RFLink configuration failed', error: 'Serial RFLink setup requires a serial port path.' }; + } + + return { + kind: 'done', + title: 'RFLink gateway configured', + config: { + connectionType, + host: connectionType === 'tcp' ? host : undefined, + port: connectionType === 'tcp' ? port : serialPort, + baudRate: this.numberValue(valuesArg.baudRate) || this.numberValue(candidateArg.metadata?.baudRate) || rflinkDefaultBaudRate, + waitForAck: this.booleanValue(valuesArg.waitForAck) ?? rflinkDefaultWaitForAck, + tcpKeepaliveIdleTimer: rflinkDefaultTcpKeepaliveIdleTimer, + reconnectInterval: rflinkDefaultReconnectInterval, + gateway: { + id: candidateArg.id || host || serialPort, + name: candidateArg.name || 'RFLink Gateway', + connectionType, + host: connectionType === 'tcp' ? host : undefined, + port: connectionType === 'tcp' ? port : serialPort, + baudRate: this.numberValue(valuesArg.baudRate) || this.numberValue(candidateArg.metadata?.baudRate) || rflinkDefaultBaudRate, + }, + }, + }; + }, + }; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg)) ? Number(valueArg) : undefined; + } + + private booleanValue(valueArg: unknown): boolean | undefined { + return typeof valueArg === 'boolean' ? valueArg : undefined; + } + + private connectionType(valueArg: unknown): TRflinkConnectionType | undefined { + return valueArg === 'serial' || valueArg === 'tcp' || valueArg === 'manual' ? valueArg : undefined; + } +} diff --git a/ts/integrations/rflink/rflink.classes.integration.ts b/ts/integrations/rflink/rflink.classes.integration.ts index b49058a..80633b3 100644 --- a/ts/integrations/rflink/rflink.classes.integration.ts +++ b/ts/integrations/rflink/rflink.classes.integration.ts @@ -1,26 +1,97 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { RflinkClient } from './rflink.classes.client.js'; +import { RflinkConfigFlow } from './rflink.classes.configflow.js'; +import { createRflinkDiscoveryDescriptor } from './rflink.discovery.js'; +import { RflinkMapper } from './rflink.mapper.js'; +import type { IRflinkConfig } from './rflink.types.js'; -export class HomeAssistantRflinkIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "rflink", - displayName: "RFLink", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/rflink", - "upstreamDomain": "rflink", - "iotClass": "assumed_state", - "qualityScale": "legacy", - "requirements": [ - "rflink==0.0.67" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@javicalle" - ] -}, - }); +export class RflinkIntegration extends BaseIntegration { + public readonly domain = 'rflink'; + public readonly displayName = 'RFLink'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createRflinkDiscoveryDescriptor(); + public readonly configFlow = new RflinkConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/rflink', + upstreamDomain: 'rflink', + integrationType: 'hub', + iotClass: 'assumed_state', + qualityScale: 'legacy', + requirements: ['rflink==0.0.67'], + dependencies: [], + afterDependencies: [], + codeowners: ['@javicalle'], + documentation: 'https://www.home-assistant.io/integrations/rflink', + configFlow: true, + discovery: { + manual: true, + serial: true, + tcp: true, + note: 'Home Assistant RFLink is YAML/manual setup. This TypeScript port recognizes explicit manual serial and TCP gateway entries.', + }, + runtime: { + type: 'control-runtime', + polling: 'snapshot/manual', + services: ['turn_on', 'turn_off', 'set_value', 'open', 'close', 'learn', 'send_command', 'refresh'], + lineProtocol: 'RFLink 10;protocol;id;switch;command; command lines with optional commandExecutor transport.', + }, + localApi: { + implemented: [ + 'Manual serial/TCP gateway configuration shape without a serial package dependency', + 'RFLink packet/event typing and basic line packet parsing', + 'RFLink command line shape generation including raw learning lines', + 'Configured/manual snapshot mapping for light, switch, sensor, binary_sensor, and cover entities', + ], + explicitUnsupported: [ + 'Opening serial ports directly from this package', + 'Owning TCP sockets without an injected commandExecutor', + 'RFLink ACK serialization against live hardware without an injected transport', + ], + }, + }; + + public async setup(configArg: IRflinkConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new RflinkRuntime(new RflinkClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantRflinkIntegration extends RflinkIntegration {} + +class RflinkRuntime implements IIntegrationRuntime { + public domain = 'rflink'; + + constructor(private readonly client: RflinkClient) {} + + public async devices(): Promise { + return RflinkMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return RflinkMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => handlerArg(RflinkMapper.toIntegrationEvent(eventArg))); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + const snapshot = await this.client.getSnapshot(); + const command = RflinkMapper.commandForService(snapshot, requestArg); + if (!command) { + return { success: false, error: `Unsupported RFLink service: ${requestArg.domain}.${requestArg.service}` }; + } + const result = await this.client.sendCommand(command); + return { success: result.success, error: result.error, data: result.data }; + } + + public async destroy(): Promise { + await this.client.destroy(); } } diff --git a/ts/integrations/rflink/rflink.constants.ts b/ts/integrations/rflink/rflink.constants.ts new file mode 100644 index 0000000..663b8fe --- /dev/null +++ b/ts/integrations/rflink/rflink.constants.ts @@ -0,0 +1,112 @@ +import type { TRflinkCoverType, TRflinkLightType } from './rflink.types.js'; + +export const rflinkDomain = 'rflink'; +export const rflinkDefaultBaudRate = 57600; +export const rflinkDefaultReconnectInterval = 10; +export const rflinkDefaultTcpKeepaliveIdleTimer = 3600; +export const rflinkDefaultSignalRepetitions = 1; +export const rflinkDefaultWaitForAck = true; + +export const rflinkSensorTypes: Record = { + average_windspeed: { name: 'Average windspeed', unit: 'km/h', deviceClass: 'wind_speed', stateClass: 'measurement' }, + barometric_pressure: { name: 'Barometric pressure', unit: 'hPa', deviceClass: 'pressure', stateClass: 'measurement' }, + battery: { name: 'Battery' }, + co2_air_quality: { name: 'CO2 air quality', unit: 'ppm', deviceClass: 'co2', stateClass: 'measurement' }, + command: { name: 'Command' }, + current_phase_1: { name: 'Current phase 1', unit: 'A', deviceClass: 'current', stateClass: 'measurement' }, + current_phase_2: { name: 'Current phase 2', unit: 'A', deviceClass: 'current', stateClass: 'measurement' }, + current_phase_3: { name: 'Current phase 3', unit: 'A', deviceClass: 'current', stateClass: 'measurement' }, + distance: { name: 'Distance', unit: 'mm', deviceClass: 'distance', stateClass: 'measurement' }, + doorbell_melody: { name: 'Doorbell melody' }, + firmware: { name: 'Firmware' }, + hardware: { name: 'Hardware' }, + humidity: { name: 'Humidity', unit: '%', deviceClass: 'humidity', stateClass: 'measurement' }, + humidity_status: { name: 'Humidity status' }, + kilowatt: { name: 'Kilowatt', unit: 'kW', deviceClass: 'power', stateClass: 'measurement' }, + light_intensity: { name: 'Light intensity', unit: 'lux', deviceClass: 'illuminance', stateClass: 'measurement' }, + meter_value: { name: 'Meter value' }, + noise_level: { name: 'Noise level' }, + rain_rate: { name: 'Rain rate', unit: 'mm/h', deviceClass: 'precipitation_intensity', stateClass: 'measurement' }, + revision: { name: 'Revision' }, + temperature: { name: 'Temperature', unit: '°C', deviceClass: 'temperature', stateClass: 'measurement' }, + timestamp: { name: 'Timestamp', unit: 's' }, + total_rain: { name: 'Total rain', unit: 'mm', deviceClass: 'precipitation', stateClass: 'total_increasing' }, + update_time: { name: 'Update time', unit: 's' }, + uv_intensity: { name: 'UV intensity', stateClass: 'measurement' }, + version: { name: 'Version' }, + voltage: { name: 'Voltage', unit: 'V', deviceClass: 'voltage', stateClass: 'measurement' }, + watt: { name: 'Watt', unit: 'W', deviceClass: 'power', stateClass: 'measurement' }, + weather_forecast: { name: 'Weather forecast' }, + windchill: { name: 'Wind chill', unit: '°C', deviceClass: 'temperature', stateClass: 'measurement' }, + winddirection: { name: 'Wind direction', unit: '°', deviceClass: 'wind_direction', stateClass: 'measurement_angle' }, + windgusts: { name: 'Wind gusts', unit: 'km/h', deviceClass: 'wind_speed', stateClass: 'measurement' }, + windspeed: { name: 'Wind speed', unit: 'km/h', deviceClass: 'wind_speed', stateClass: 'measurement' }, + windtemp: { name: 'Wind temperature', unit: '°C', deviceClass: 'temperature', stateClass: 'measurement' }, +}; + +export const rflinkPacketFieldAliases: Record = { + awinsp: 'average_windspeed', + baro: 'barometric_pressure', + bat: 'battery', + bforecast: 'weather_forecast', + chime: 'doorbell_melody', + cmd: 'command', + co2: 'co2_air_quality', + current: 'current_phase_1', + current2: 'current_phase_2', + current3: 'current_phase_3', + dist: 'distance', + fw: 'firmware', + hstatus: 'humidity_status', + hum: 'humidity', + hw: 'hardware', + kwatt: 'kilowatt', + lux: 'light_intensity', + meter: 'meter_value', + rain: 'total_rain', + rainrate: 'rain_rate', + raintot: 'total_rain', + rev: 'revision', + sound: 'noise_level', + temp: 'temperature', + uv: 'uv_intensity', + ver: 'version', + volt: 'voltage', + watt: 'watt', + winchl: 'windchill', + wind: 'windspeed', + windir: 'winddirection', + wings: 'windgusts', + winsp: 'windspeed', + wintmp: 'windtemp', +}; + +export const rflinkPacketUnits: Record = { + awinsp: 'km/h', + current: 'A', + current2: 'A', + current3: 'A', + hum: '%', + kwatt: 'kW', + lux: 'lux', + rain: 'mm', + rainrate: 'mm', + raintot: 'mm', + temp: '°C', + volt: 'V', + watt: 'W', + winchl: '°C', + wind: 'km/h', + windir: '°', + wings: 'km/h', + winsp: 'km/h', + wintmp: '°C', +}; + +export const rflinkDefaultLightTypeByProtocol: Record = { + newkaku: 'hybrid', +}; + +export const rflinkDefaultCoverTypeByProtocol: Record = { + newkaku: 'inverted', +}; diff --git a/ts/integrations/rflink/rflink.discovery.ts b/ts/integrations/rflink/rflink.discovery.ts new file mode 100644 index 0000000..6ea6bbf --- /dev/null +++ b/ts/integrations/rflink/rflink.discovery.ts @@ -0,0 +1,100 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import { rflinkDefaultBaudRate, rflinkDomain } from './rflink.constants.js'; +import type { IRflinkCandidateMetadata, IRflinkManualEntry } from './rflink.types.js'; + +export class RflinkManualMatcher implements IDiscoveryMatcher { + public id = 'rflink-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual RFLink serial and TCP gateway entries.'; + + public async matches(inputArg: IRflinkManualEntry): Promise { + const metadata = inputArg.metadata || {}; + const connectionType = this.connectionType(inputArg); + const serialPort = this.serialPort(inputArg); + const host = 'host' in inputArg ? inputArg.host : undefined; + const tcpPort = typeof inputArg.port === 'number' ? inputArg.port : undefined; + const baudRate = 'baudRate' in inputArg ? inputArg.baudRate : undefined; + const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''} ${metadata.protocol || ''} ${metadata.service || ''}`.toLowerCase(); + const hasHint = Boolean(metadata.rflink || metadata.rfLink || metadata.protocol === 'rflink' || metadata.service === 'rflink' || haystack.includes('rflink') || haystack.includes('rf link')); + const matched = Boolean(hasHint || connectionType === 'serial' && serialPort || connectionType === 'tcp' && host && tcpPort); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain RFLink setup hints.' }; + } + + const candidateMetadata: IRflinkCandidateMetadata = { + ...metadata, + rflink: true, + connectionType, + serialPort, + baudRate: baudRate || rflinkDefaultBaudRate, + }; + + return { + matched: true, + confidence: hasHint ? 'high' : 'medium', + reason: connectionType === 'tcp' ? 'Manual entry can start RFLink TCP setup.' : 'Manual entry can start RFLink serial setup.', + normalizedDeviceId: inputArg.id || host || serialPort, + candidate: { + source: 'manual', + integrationDomain: rflinkDomain, + id: inputArg.id || host || serialPort, + host, + port: tcpPort, + name: inputArg.name || 'RFLink Gateway', + manufacturer: inputArg.manufacturer || 'Nodo', + model: inputArg.model || 'RFLink Gateway', + metadata: candidateMetadata, + }, + }; + } + + private connectionType(inputArg: IRflinkManualEntry): 'serial' | 'tcp' { + if (inputArg.connectionType === 'tcp' || 'host' in inputArg && inputArg.host) { + return 'tcp'; + } + return 'serial'; + } + + private serialPort(inputArg: IRflinkManualEntry): string | undefined { + if ('path' in inputArg && typeof inputArg.path === 'string' && inputArg.path.trim()) { + return inputArg.path.trim(); + } + return typeof inputArg.port === 'string' && inputArg.port.trim() ? inputArg.port.trim() : undefined; + } +} + +export class RflinkCandidateValidator implements IDiscoveryValidator { + public id = 'rflink-candidate-validator'; + public description = 'Validate that a discovery candidate can configure an RFLink gateway.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const metadata = candidateArg.metadata || {}; + const connectionType = metadata.connectionType === 'tcp' || candidateArg.host ? 'tcp' : metadata.connectionType === 'serial' || metadata.serialPort ? 'serial' : undefined; + const haystack = `${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''} ${metadata.protocol || ''} ${metadata.service || ''}`.toLowerCase(); + const matched = Boolean(candidateArg.integrationDomain === rflinkDomain || metadata.rflink || metadata.rfLink || haystack.includes('rflink') || haystack.includes('rf link') || connectionType === 'tcp' && candidateArg.host && candidateArg.port || connectionType === 'serial' && metadata.serialPort); + return { + matched, + confidence: matched && (candidateArg.host || metadata.serialPort) ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has RFLink gateway metadata.' : 'Candidate is not an RFLink gateway.', + normalizedDeviceId: candidateArg.id || candidateArg.host || String(metadata.serialPort || ''), + candidate: matched ? { + ...candidateArg, + integrationDomain: rflinkDomain, + manufacturer: candidateArg.manufacturer || 'Nodo', + model: candidateArg.model || 'RFLink Gateway', + metadata: { + ...metadata, + rflink: true, + connectionType, + }, + } : undefined, + }; + } +} + +export const createRflinkDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: rflinkDomain, displayName: 'RFLink' }) + .addMatcher(new RflinkManualMatcher()) + .addValidator(new RflinkCandidateValidator()); +}; diff --git a/ts/integrations/rflink/rflink.mapper.ts b/ts/integrations/rflink/rflink.mapper.ts new file mode 100644 index 0000000..a108f4b --- /dev/null +++ b/ts/integrations/rflink/rflink.mapper.ts @@ -0,0 +1,379 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest } from '../../core/types.js'; +import { rflinkDomain, rflinkSensorTypes } from './rflink.constants.js'; +import type { IRflinkCommand, IRflinkEntity, IRflinkEvent, IRflinkSnapshot, TRflinkEntityPlatform } from './rflink.types.js'; + +export class RflinkMapper { + public static toDevices(snapshotArg: IRflinkSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + const gatewayId = this.gatewayDeviceId(snapshotArg); + const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [{ + id: gatewayId, + integrationDomain: rflinkDomain, + name: snapshotArg.gateway.name || 'RFLink Gateway', + protocol: snapshotArg.gateway.connectionType === 'tcp' ? 'http' : 'unknown', + manufacturer: 'Nodo', + model: snapshotArg.gateway.firmware || 'RFLink Gateway', + online: snapshotArg.connected, + features: [ + { id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false }, + { id: 'revision', capability: 'sensor', name: 'Revision', readable: true, writable: false }, + ], + state: [ + { featureId: 'connectivity', value: snapshotArg.connected ? 'online' : 'offline', updatedAt }, + { featureId: 'revision', value: snapshotArg.gateway.revision || null, updatedAt }, + ], + metadata: this.cleanAttributes({ + host: snapshotArg.gateway.host, + port: snapshotArg.gateway.port, + baudRate: snapshotArg.gateway.baudRate, + hardware: snapshotArg.gateway.hardware, + firmware: snapshotArg.gateway.firmware, + version: snapshotArg.gateway.version, + revision: snapshotArg.gateway.revision, + ...snapshotArg.gateway.attributes, + }), + }]; + + for (const entity of snapshotArg.entities) { + devices.push({ + id: this.entityDeviceId(entity), + integrationDomain: rflinkDomain, + name: entity.name || entity.id, + protocol: 'unknown', + manufacturer: 'RFLink', + model: this.modelForEntity(entity), + online: this.available(snapshotArg, entity), + features: this.featuresForEntity(entity), + state: this.deviceStateForEntity(entity, updatedAt), + metadata: this.cleanAttributes({ + rflinkId: entity.id, + aliases: entity.aliases, + groupAliases: entity.groupAliases, + noGroupAliases: entity.noGroupAliases, + viaDevice: gatewayId, + platform: entity.platform, + type: entity.type, + sensorType: entity.sensorType, + unitOfMeasurement: entity.unitOfMeasurement, + deviceClass: entity.deviceClass, + fireEvent: entity.fireEvent, + signalRepetitions: entity.signalRepetitions, + ...entity.metadata, + }), + }); + } + + return devices; + } + + public static toEntities(snapshotArg: IRflinkSnapshot): IIntegrationEntity[] { + const usedIds = new Map(); + return snapshotArg.entities.map((entityArg) => { + const platform = entityArg.platform; + const name = entityArg.name || entityArg.id; + const entityId = entityArg.entityId || this.uniqueEntityId(platform, name, usedIds); + return { + id: entityId, + uniqueId: entityArg.uniqueId || `rflink_${this.slug(entityArg.id)}_${platform}`, + integrationDomain: rflinkDomain, + deviceId: this.entityDeviceId(entityArg), + platform, + name, + state: this.stateForEntity(entityArg), + attributes: this.cleanAttributes({ + rflinkId: entityArg.id, + aliases: entityArg.aliases, + groupAliases: entityArg.groupAliases, + noGroupAliases: entityArg.noGroupAliases, + command: entityArg.command, + brightness: entityArg.brightness, + sensorType: entityArg.sensorType, + unitOfMeasurement: entityArg.unitOfMeasurement || this.sensorMetadata(entityArg).unit, + deviceClass: entityArg.deviceClass || this.sensorMetadata(entityArg).deviceClass, + stateClass: this.sensorMetadata(entityArg).stateClass, + forceUpdate: entityArg.forceUpdate, + offDelaySeconds: entityArg.offDelaySeconds, + type: entityArg.type, + assumedState: ['light', 'switch', 'cover'].includes(entityArg.platform), + ...entityArg.attributes, + }), + available: this.available(snapshotArg, entityArg), + }; + }); + } + + public static commandForService(snapshotArg: IRflinkSnapshot, requestArg: IServiceCallRequest): IRflinkCommand | undefined { + if (requestArg.domain === rflinkDomain && ['refresh', 'reload'].includes(requestArg.service)) { + return { type: 'refresh' }; + } + + if (requestArg.domain === rflinkDomain && requestArg.service === 'learn') { + return this.learnCommand(snapshotArg, requestArg); + } + + if (requestArg.domain === rflinkDomain && requestArg.service === 'send_command') { + const rawLine = this.stringData(requestArg, 'rawLine') || this.stringData(requestArg, 'line'); + const deviceId = this.requestedRflinkDeviceId(snapshotArg, requestArg); + const command = this.stringData(requestArg, 'command') || this.stringData(requestArg, 'rflinkCommand'); + if (rawLine) { + return { type: 'send_command', deviceId, rawLine, rflinkCommand: command || 'raw' }; + } + return deviceId && command ? { type: 'send_command', deviceId, rflinkCommand: command } : undefined; + } + + const entity = this.findRequestedEntity(snapshotArg, requestArg); + const deviceId = this.requestedRflinkDeviceId(snapshotArg, requestArg); + + if (['turn_on', 'turn_off'].includes(requestArg.service) && (requestArg.domain === 'light' || requestArg.domain === 'switch' || requestArg.domain === rflinkDomain)) { + const targetEntity = entity || (deviceId ? this.entityByRflinkId(snapshotArg, deviceId) : undefined); + if (requestArg.service === 'turn_on') { + const dim = this.dimLevel(requestArg); + if (dim !== undefined && (!targetEntity || targetEntity.platform === 'light')) { + return { type: 'set_value', deviceId: targetEntity?.id || deviceId, entityId: requestArg.target.entityId, value: dim, brightness: this.rflinkToBrightness(dim), rflinkCommand: String(dim) }; + } + } + const rflinkCommand = targetEntity?.type === 'toggle' ? 'on' : requestArg.service === 'turn_on' ? 'on' : 'off'; + return targetEntity || deviceId ? { type: requestArg.service === 'turn_on' ? 'turn_on' : 'turn_off', deviceId: targetEntity?.id || deviceId, entityId: requestArg.target.entityId, rflinkCommand } : undefined; + } + + if (requestArg.service === 'set_value' && (requestArg.domain === 'number' || requestArg.domain === 'light' || requestArg.domain === rflinkDomain)) { + const dim = this.dimLevel(requestArg); + return dim !== undefined && (entity || deviceId) ? { type: 'set_value', deviceId: entity?.id || deviceId, entityId: requestArg.target.entityId, value: dim, brightness: this.rflinkToBrightness(dim), rflinkCommand: String(dim) } : undefined; + } + + if (requestArg.domain === 'cover' || requestArg.domain === rflinkDomain) { + if (['open', 'open_cover'].includes(requestArg.service)) { + return entity || deviceId ? { type: 'open_cover', deviceId: entity?.id || deviceId, entityId: requestArg.target.entityId, rflinkCommand: this.coverCommand(entity, 'open') } : undefined; + } + if (['close', 'close_cover'].includes(requestArg.service)) { + return entity || deviceId ? { type: 'close_cover', deviceId: entity?.id || deviceId, entityId: requestArg.target.entityId, rflinkCommand: this.coverCommand(entity, 'close') } : undefined; + } + if (['stop', 'stop_cover'].includes(requestArg.service)) { + return entity || deviceId ? { type: 'stop_cover', deviceId: entity?.id || deviceId, entityId: requestArg.target.entityId, rflinkCommand: 'STOP' } : undefined; + } + } + + return undefined; + } + + public static toIntegrationEvent(eventArg: IRflinkEvent): IIntegrationEvent { + const type = eventArg.type === 'command_failed' ? 'error' : eventArg.type === 'availability' ? 'availability_changed' : 'state_changed'; + return { + type, + integrationDomain: rflinkDomain, + deviceId: eventArg.deviceId || (eventArg.id ? `rflink.device.${this.slug(eventArg.id)}` : undefined), + entityId: eventArg.entityId, + data: eventArg, + timestamp: eventArg.timestamp, + }; + } + + public static gatewayDeviceId(snapshotArg: IRflinkSnapshot): string { + return `rflink.gateway.${this.slug(snapshotArg.gateway.id || snapshotArg.gateway.host || String(snapshotArg.gateway.port || rflinkDomain))}`; + } + + public static entityDeviceId(entityArg: IRflinkEntity): string { + return `rflink.${entityArg.platform}.${this.slug(entityArg.id)}`; + } + + private static featuresForEntity(entityArg: IRflinkEntity): plugins.shxInterfaces.data.IDeviceDefinition['features'] { + if (entityArg.platform === 'light') { + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [{ id: 'power', capability: 'light', name: 'Power', readable: true, writable: true }]; + if (entityArg.type === 'dimmable' || entityArg.type === 'hybrid') { + features.push({ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true, unit: '%' }); + } + return features; + } + if (entityArg.platform === 'switch') { + return [{ id: 'power', capability: 'switch', name: 'Power', readable: true, writable: true }]; + } + if (entityArg.platform === 'binary_sensor') { + return [{ id: 'state', capability: 'sensor', name: 'State', readable: true, writable: false }]; + } + if (entityArg.platform === 'cover') { + return [{ id: 'cover', capability: 'cover', name: 'Cover', readable: true, writable: true }]; + } + return [{ id: 'value', capability: 'sensor', name: this.sensorMetadata(entityArg).name || 'Value', readable: true, writable: false, unit: entityArg.unitOfMeasurement || this.sensorMetadata(entityArg).unit }]; + } + + private static deviceStateForEntity(entityArg: IRflinkEntity, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition['state'] { + if (entityArg.platform === 'light') { + const state: plugins.shxInterfaces.data.IDeviceState[] = [{ featureId: 'power', value: this.onState(entityArg), updatedAt: updatedAtArg }]; + if (entityArg.type === 'dimmable' || entityArg.type === 'hybrid') { + state.push({ featureId: 'brightness', value: entityArg.brightness ?? null, updatedAt: updatedAtArg }); + } + return state; + } + if (entityArg.platform === 'switch' || entityArg.platform === 'binary_sensor') { + return [{ featureId: entityArg.platform === 'switch' ? 'power' : 'state', value: this.onState(entityArg), updatedAt: updatedAtArg }]; + } + if (entityArg.platform === 'cover') { + return [{ featureId: 'cover', value: this.coverState(entityArg), updatedAt: updatedAtArg }]; + } + return [{ featureId: 'value', value: this.deviceStateValue(this.stateForEntity(entityArg)), updatedAt: updatedAtArg }]; + } + + private static stateForEntity(entityArg: IRflinkEntity): unknown { + if (entityArg.platform === 'sensor') { + return entityArg.value ?? entityArg.state ?? null; + } + if (entityArg.platform === 'binary_sensor' || entityArg.platform === 'light' || entityArg.platform === 'switch') { + const on = this.onState(entityArg); + return on === null ? 'unknown' : on ? 'on' : 'off'; + } + if (entityArg.platform === 'cover') { + return this.coverState(entityArg); + } + return entityArg.state ?? null; + } + + private static onState(entityArg: IRflinkEntity): boolean | null { + const state = String(entityArg.state || entityArg.command || '').toLowerCase(); + if (['on', 'allon', 'up', 'open'].includes(state)) { + return true; + } + if (['off', 'alloff', 'down', 'closed', 'close'].includes(state)) { + return false; + } + return null; + } + + private static coverState(entityArg: IRflinkEntity): string { + const state = String(entityArg.state || entityArg.command || '').toLowerCase(); + if (['on', 'allon', 'up', 'open', 'opened'].includes(state)) { + return 'open'; + } + if (['off', 'alloff', 'down', 'close', 'closed'].includes(state)) { + return 'closed'; + } + if (state === 'stop' || state === 'stopped') { + return 'stopped'; + } + return 'unknown'; + } + + private static modelForEntity(entityArg: IRflinkEntity): string { + if (entityArg.platform === 'light') { + return `RFLink ${entityArg.type || 'switchable'} light`; + } + if (entityArg.platform === 'cover') { + return `RFLink ${entityArg.type || 'standard'} cover`; + } + return `RFLink ${entityArg.platform.replace('_', ' ')}`; + } + + private static learnCommand(snapshotArg: IRflinkSnapshot, requestArg: IServiceCallRequest): IRflinkCommand | undefined { + const rawLine = this.stringData(requestArg, 'rawLine') || this.stringData(requestArg, 'line'); + const command = this.stringData(requestArg, 'command') || 'PAIR'; + if (rawLine) { + return { type: 'learn', rawLine, rflinkCommand: command }; + } + const protocol = this.stringData(requestArg, 'protocol'); + const address = this.stringData(requestArg, 'address') || this.stringData(requestArg, 'id'); + const rollingCode = this.stringData(requestArg, 'rollingCode') || this.stringData(requestArg, 'rolling_code'); + const button = this.stringData(requestArg, 'button') || this.stringData(requestArg, 'switch'); + if (protocol && address && rollingCode && button) { + return { type: 'learn', rawLine: `10;${protocol};${address};${rollingCode};${button};${command};`, rflinkCommand: command }; + } + const deviceId = this.requestedRflinkDeviceId(snapshotArg, requestArg); + return deviceId ? { type: 'learn', deviceId, rflinkCommand: command } : undefined; + } + + private static requestedRflinkDeviceId(snapshotArg: IRflinkSnapshot, requestArg: IServiceCallRequest): string | undefined { + const dataDeviceId = this.stringData(requestArg, 'deviceId') || this.stringData(requestArg, 'device_id'); + if (dataDeviceId) { + return dataDeviceId; + } + return this.findRequestedEntity(snapshotArg, requestArg)?.id; + } + + private static findRequestedEntity(snapshotArg: IRflinkSnapshot, requestArg: IServiceCallRequest): IRflinkEntity | undefined { + const entityId = requestArg.target.entityId || this.stringData(requestArg, 'entityId') || this.stringData(requestArg, 'entity_id'); + const deviceId = requestArg.target.deviceId || this.stringData(requestArg, 'targetDeviceId'); + const coreEntities = this.toEntities(snapshotArg); + if (entityId) { + const coreEntity = coreEntities.find((entityArg) => entityArg.id === entityId || entityArg.uniqueId === entityId); + return coreEntity ? snapshotArg.entities.find((entityArg) => this.entityDeviceId(entityArg) === coreEntity.deviceId) : snapshotArg.entities.find((entityArg) => entityArg.id === entityId || entityArg.entityId === entityId || entityArg.uniqueId === entityId); + } + if (deviceId) { + return snapshotArg.entities.find((entityArg) => this.entityDeviceId(entityArg) === deviceId || entityArg.id === deviceId); + } + return undefined; + } + + private static entityByRflinkId(snapshotArg: IRflinkSnapshot, deviceIdArg: string): IRflinkEntity | undefined { + return snapshotArg.entities.find((entityArg) => entityArg.id === deviceIdArg || entityArg.aliases?.includes(deviceIdArg)); + } + + private static coverCommand(entityArg: IRflinkEntity | undefined, directionArg: 'open' | 'close'): string { + const inverted = entityArg?.type === 'inverted'; + if (directionArg === 'open') { + return inverted ? 'DOWN' : 'UP'; + } + return inverted ? 'UP' : 'DOWN'; + } + + private static dimLevel(requestArg: IServiceCallRequest): number | undefined { + const brightness = this.numberData(requestArg, 'brightness'); + if (brightness !== undefined) { + return Math.max(0, Math.min(15, Math.floor(Math.max(0, Math.min(255, brightness)) / 17))); + } + const brightnessPct = this.numberData(requestArg, 'brightness_pct') ?? this.numberData(requestArg, 'brightnessPercent'); + if (brightnessPct !== undefined) { + return Math.max(0, Math.min(15, Math.floor(Math.max(0, Math.min(100, brightnessPct)) * 255 / 100 / 17))); + } + const value = this.numberData(requestArg, 'value'); + return value !== undefined ? Math.max(0, Math.min(15, Math.round(value))) : undefined; + } + + private static rflinkToBrightness(valueArg: number): number { + return Math.max(0, Math.min(255, valueArg * 17)); + } + + private static sensorMetadata(entityArg: IRflinkEntity): { name?: string; unit?: string; deviceClass?: string; stateClass?: string } { + return entityArg.sensorType ? rflinkSensorTypes[entityArg.sensorType] || {} : {}; + } + + private static available(snapshotArg: IRflinkSnapshot, entityArg: IRflinkEntity): boolean { + return entityArg.available === true || snapshotArg.connected && entityArg.available !== false; + } + + private static uniqueEntityId(platformArg: TRflinkEntityPlatform, nameArg: string, usedIdsArg: Map): string { + const base = `${platformArg}.${this.slug(nameArg) || platformArg}`; + const seen = usedIdsArg.get(base) || 0; + usedIdsArg.set(base, seen + 1); + return seen ? `${base}_${seen + 1}` : base; + } + + private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue { + if (valueArg === undefined) { + return null; + } + if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null) { + return valueArg; + } + if (typeof valueArg === 'object' && !Array.isArray(valueArg)) { + return valueArg as Record; + } + return JSON.stringify(valueArg); + } + + private static cleanAttributes(attributesArg: Record): Record { + return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)); + } + + private static stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined { + const value = requestArg.data?.[keyArg]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; + } + + private static numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined { + const value = requestArg.data?.[keyArg]; + return typeof value === 'number' && Number.isFinite(value) ? value : typeof value === 'string' && value.trim() && Number.isFinite(Number(value)) ? Number(value) : undefined; + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'rflink'; + } +} diff --git a/ts/integrations/rflink/rflink.types.ts b/ts/integrations/rflink/rflink.types.ts index 75e7b86..e7607c5 100644 --- a/ts/integrations/rflink/rflink.types.ts +++ b/ts/integrations/rflink/rflink.types.ts @@ -1,4 +1,206 @@ -export interface IHomeAssistantRflinkConfig { - // TODO: replace with the TypeScript-native config for rflink. - [key: string]: unknown; +export type TRflinkConnectionType = 'serial' | 'tcp' | 'manual'; + +export type TRflinkEntityPlatform = 'light' | 'switch' | 'sensor' | 'binary_sensor' | 'cover'; + +export type TRflinkLightType = 'switchable' | 'dimmable' | 'hybrid' | 'toggle'; + +export type TRflinkCoverType = 'standard' | 'inverted'; + +export type TRflinkEventType = + | 'availability' + | 'command' + | 'command_mapped' + | 'command_executed' + | 'command_failed' + | 'gateway' + | 'learn' + | 'raw_line' + | 'sensor' + | 'snapshot_refreshed'; + +export type TRflinkCommandType = + | 'turn_on' + | 'turn_off' + | 'set_value' + | 'open_cover' + | 'close_cover' + | 'stop_cover' + | 'learn' + | 'send_command' + | 'refresh'; + +export interface IRflinkConfig { + host?: string; + port?: string | number; + connectionType?: TRflinkConnectionType; + baudRate?: number; + waitForAck?: boolean; + tcpKeepaliveIdleTimer?: number; + reconnectInterval?: number; + ignoreDevices?: string[]; + automaticAdd?: boolean; + connected?: boolean; + gateway?: IRflinkGateway; + devices?: TRflinkEntityCollection; + entities?: IRflinkEntityConfig[]; + lights?: TRflinkEntityCollection; + switches?: TRflinkEntityCollection; + sensors?: TRflinkEntityCollection; + binarySensors?: TRflinkEntityCollection; + covers?: TRflinkEntityCollection; + events?: IRflinkEvent[]; + snapshot?: IRflinkSnapshot; + commandExecutor?: (commandArg: IRflinkCommand) => Promise; } + +export interface IRflinkGateway { + id?: string; + name?: string; + connectionType?: TRflinkConnectionType; + host?: string; + port?: string | number; + baudRate?: number; + hardware?: string; + firmware?: string; + version?: string; + revision?: string; + online?: boolean; + attributes?: Record; +} + +export type TRflinkEntityCollection = Record | string | undefined> | IRflinkEntityConfig[]; + +export interface IRflinkEntityConfig { + id: string; + platform?: TRflinkEntityPlatform; + name?: string; + aliases?: string[]; + groupAliases?: string[]; + noGroupAliases?: string[]; + group?: boolean; + fireEvent?: boolean; + signalRepetitions?: number; + type?: TRflinkLightType | TRflinkCoverType; + sensorType?: string; + unitOfMeasurement?: string; + deviceClass?: string; + forceUpdate?: boolean; + offDelaySeconds?: number; + state?: string; + value?: unknown; + command?: string; + brightness?: number; + available?: boolean; + attributes?: Record; + metadata?: Record; +} + +export interface IRflinkDevice extends IRflinkEntityConfig { + id: string; + platform: TRflinkEntityPlatform; +} + +export interface IRflinkEntity extends IRflinkDevice { + entityId?: string; + uniqueId?: string; + updatedAt?: string; +} + +export interface IRflinkEvent { + type: TRflinkEventType; + id?: string; + entityId?: string; + deviceId?: string; + command?: IRflinkCommand; + rflinkCommand?: string; + sensor?: string; + value?: unknown; + unit?: string; + line?: string; + packet?: IRflinkPacket; + data?: unknown; + timestamp: number; +} + +export interface IRflinkSnapshot { + gateway: IRflinkGateway; + devices: IRflinkDevice[]; + entities: IRflinkEntity[]; + events: IRflinkEvent[]; + connected: boolean; + updatedAt: string; + raw?: Record; +} + +export interface IRflinkPacket { + node?: '10' | '11' | '20' | string; + sequence?: string; + protocol?: string; + id?: string; + switch?: string; + command?: string; + values?: Record; + raw?: string; +} + +export interface IRflinkLineProtocolCommandShape { + transport: 'line-protocol'; + line: string; + packet: IRflinkPacket; + waitForAck?: boolean; + repetitions?: number; +} + +export interface IRflinkCommand { + type: TRflinkCommandType; + deviceId?: string; + entityId?: string; + rflinkCommand?: string; + value?: number; + brightness?: number; + rawLine?: string; + lineProtocol?: IRflinkLineProtocolCommandShape; + repetitions?: number; + metadata?: Record; +} + +export interface IRflinkCommandResult { + success: boolean; + transmitted?: boolean; + error?: string; + data?: unknown; +} + +export interface IRflinkManualSerialEntry { + connectionType?: 'serial'; + port?: string; + path?: string; + baudRate?: number; + id?: string; + name?: string; + manufacturer?: string; + model?: string; + metadata?: Record; +} + +export interface IRflinkManualTcpEntry { + connectionType?: 'tcp'; + host?: string; + port?: number; + id?: string; + name?: string; + manufacturer?: string; + model?: string; + metadata?: Record; +} + +export type IRflinkManualEntry = IRflinkManualSerialEntry | IRflinkManualTcpEntry; + +export interface IRflinkCandidateMetadata extends Record { + rflink?: boolean; + connectionType?: TRflinkConnectionType; + serialPort?: string; + baudRate?: number; +} + +export type IHomeAssistantRflinkConfig = IRflinkConfig; diff --git a/ts/integrations/velbus/.generated-by-smarthome-exchange b/ts/integrations/velbus/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/velbus/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/velbus/index.ts b/ts/integrations/velbus/index.ts index c37696d..90e4457 100644 --- a/ts/integrations/velbus/index.ts +++ b/ts/integrations/velbus/index.ts @@ -1,2 +1,6 @@ +export * from './velbus.classes.client.js'; +export * from './velbus.classes.configflow.js'; export * from './velbus.classes.integration.js'; +export * from './velbus.discovery.js'; +export * from './velbus.mapper.js'; export * from './velbus.types.js'; diff --git a/ts/integrations/velbus/velbus.classes.client.ts b/ts/integrations/velbus/velbus.classes.client.ts new file mode 100644 index 0000000..a6139f5 --- /dev/null +++ b/ts/integrations/velbus/velbus.classes.client.ts @@ -0,0 +1,157 @@ +import type { + IVelbusChannel, + IVelbusCommand, + IVelbusCommandResult, + IVelbusConfig, + IVelbusEvent, + IVelbusSnapshot, +} from './velbus.types.js'; + +const liveBusUnsupported = 'Velbus live serial/TCP bus control is not implemented; provide config.snapshot for manual snapshot runtime.'; + +export class VelbusClient { + private snapshot?: IVelbusSnapshot; + private readonly eventHandlers = new Set<(eventArg: IVelbusEvent) => void>(); + + constructor(private readonly config: IVelbusConfig) { + this.snapshot = config.snapshot ? cloneSnapshot({ + ...config.snapshot, + gateway: config.snapshot.gateway || config.gateway, + }) : undefined; + } + + public async getSnapshot(): Promise { + if (this.snapshot) { + return cloneSnapshot(this.snapshot); + } + + return { + gateway: this.config.gateway, + modules: [], + connected: false, + updatedAt: new Date().toISOString(), + limitations: [liveBusUnsupported], + }; + } + + public onEvent(handlerArg: (eventArg: IVelbusEvent) => void): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async sendCommand(commandArg: IVelbusCommand): Promise { + if (!this.snapshot || this.config.mutationMode === 'readonly') { + const event = this.createEvent('command_failed', commandArg, liveBusUnsupported); + this.emit(event); + return { success: false, error: liveBusUnsupported }; + } + + const channel = this.findChannel(commandArg); + if (!channel) { + const error = `Velbus channel not found for command ${commandArg.type}.`; + this.emit(this.createEvent('command_failed', commandArg, error)); + return { success: false, error }; + } + + this.applyManualCommand(channel, commandArg); + this.snapshot.updatedAt = new Date().toISOString(); + const event = this.createEvent('command_applied', commandArg, { channel }); + this.snapshot.events = [...(this.snapshot.events || []), event]; + this.emit(event); + return { success: true, data: { snapshot: await this.getSnapshot() } }; + } + + public async destroy(): Promise { + this.eventHandlers.clear(); + } + + private findChannel(commandArg: IVelbusCommand): IVelbusChannel | undefined { + for (const module of this.snapshot?.modules || []) { + if (commandArg.moduleAddress !== undefined && module.address !== commandArg.moduleAddress) { + continue; + } + const channel = module.channels.find((channelArg) => { + if (commandArg.channelId !== undefined && channelArg.id === commandArg.channelId) { + return true; + } + return commandArg.channelNumber !== undefined && channelArg.channelNumber === commandArg.channelNumber; + }); + if (channel) { + return channel; + } + } + return undefined; + } + + private applyManualCommand(channelArg: IVelbusChannel, commandArg: IVelbusCommand): void { + if (commandArg.type === 'turn_on') { + channelArg.state = true; + if (channelArg.kind === 'dimmer') { + channelArg.brightness = typeof commandArg.value === 'number' ? clamp(commandArg.value, 0, 100) : (channelArg.brightness && channelArg.brightness > 0 ? channelArg.brightness : 100); + } + return; + } + + if (commandArg.type === 'turn_off') { + channelArg.state = false; + if (channelArg.kind === 'dimmer') { + channelArg.brightness = 0; + } + return; + } + + if (commandArg.type === 'open') { + channelArg.state = 'open'; + channelArg.position = 100; + return; + } + + if (commandArg.type === 'close') { + channelArg.state = 'closed'; + channelArg.position = 0; + return; + } + + if (commandArg.type === 'stop') { + channelArg.state = channelArg.position === 0 ? 'closed' : 'open'; + return; + } + + if (commandArg.type === 'set_value') { + if (channelArg.kind === 'blind') { + channelArg.position = typeof commandArg.value === 'number' ? clamp(commandArg.value, 0, 100) : channelArg.position; + channelArg.state = channelArg.position === 0 ? 'closed' : 'open'; + } else if (channelArg.kind === 'dimmer' || channelArg.kind === 'led') { + channelArg.brightness = typeof commandArg.value === 'number' ? clamp(commandArg.value, 0, 100) : channelArg.brightness; + channelArg.state = Boolean(channelArg.brightness && channelArg.brightness > 0); + } else if (channelArg.kind === 'climate') { + channelArg.targetTemperature = typeof commandArg.value === 'number' ? commandArg.value : channelArg.targetTemperature; + } else { + channelArg.state = commandArg.value ?? channelArg.state ?? null; + } + } + } + + private createEvent(typeArg: IVelbusEvent['type'], commandArg: IVelbusCommand, dataArg: unknown): IVelbusEvent { + return { + type: typeArg, + moduleAddress: commandArg.moduleAddress, + channelId: commandArg.channelId, + entityId: commandArg.entityId, + deviceId: commandArg.deviceId, + command: commandArg, + data: dataArg, + timestamp: Date.now(), + }; + } + + private emit(eventArg: IVelbusEvent): void { + for (const handler of this.eventHandlers) { + handler(eventArg); + } + } +} + +const cloneSnapshot = (snapshotArg: IVelbusSnapshot): IVelbusSnapshot => JSON.parse(JSON.stringify(snapshotArg)) as IVelbusSnapshot; + +const clamp = (valueArg: number, minArg: number, maxArg: number): number => Math.min(maxArg, Math.max(minArg, valueArg)); diff --git a/ts/integrations/velbus/velbus.classes.configflow.ts b/ts/integrations/velbus/velbus.classes.configflow.ts new file mode 100644 index 0000000..2077929 --- /dev/null +++ b/ts/integrations/velbus/velbus.classes.configflow.ts @@ -0,0 +1,77 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IVelbusConfig, IVelbusGatewayConfig, TVelbusGatewayConnection } from './velbus.types.js'; + +export class VelbusConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + const metadata = candidateArg.metadata || {}; + const connection = metadata.connection === 'serial' || metadata.connection === 'tcp' ? metadata.connection : candidateArg.host ? 'tcp' : 'serial'; + return { + kind: 'form', + title: connection === 'tcp' ? 'Connect Velbus TCP/IP Gateway' : 'Connect Velbus Serial Gateway', + description: 'Configure the Velbus gateway DSN. VLP import is represented as an optional file path; live bus framing is not implemented by this TypeScript runtime.', + fields: [ + { name: 'connection', label: 'Connection', type: 'select', required: true, options: [{ label: 'Serial/USB', value: 'serial' }, { label: 'TCP/IP', value: 'tcp' }] }, + { name: 'serialPath', label: 'Serial path', type: 'text', required: false }, + { name: 'host', label: 'Host', type: 'text', required: false }, + { name: 'port', label: 'Port', type: 'number', required: false }, + { name: 'tls', label: 'Use TLS', type: 'boolean', required: false }, + { name: 'password', label: 'Password', type: 'password', required: false }, + { name: 'vlpFile', label: 'VLP file path', type: 'text', required: false }, + ], + submit: async (valuesArg) => { + const selectedConnection = connectionValue(valuesArg.connection) || connection; + const gateway = gatewayFromValues(selectedConnection, candidateArg, valuesArg); + return { + kind: 'done', + title: 'Velbus gateway configured', + config: { + gateway, + mutationMode: 'manual-snapshot', + }, + }; + }, + }; + } +} + +const gatewayFromValues = (connectionArg: TVelbusGatewayConnection, candidateArg: IDiscoveryCandidate, valuesArg: Record): IVelbusGatewayConfig => { + const metadata = candidateArg.metadata || {}; + const serialPath = stringValue(valuesArg.serialPath) || stringValue(metadata.serialPath) || stringValue(metadata.dsn); + const host = stringValue(valuesArg.host) || candidateArg.host; + const tls = booleanValue(valuesArg.tls, metadata.tls !== false); + const port = numberValue(valuesArg.port) || candidateArg.port || (tls ? 27015 : 6000); + const password = stringValue(valuesArg.password) || stringValue(metadata.password); + const vlpFile = stringValue(valuesArg.vlpFile) || stringValue(metadata.vlpFile); + return { + id: candidateArg.id, + name: candidateArg.name, + connection: connectionArg, + dsn: connectionArg === 'tcp' ? dsnFromTcp(host, port, tls, password) : serialPath, + serialPath: connectionArg === 'serial' ? serialPath : undefined, + host: connectionArg === 'tcp' ? host : undefined, + port: connectionArg === 'tcp' ? port : undefined, + tls: connectionArg === 'tcp' ? tls : undefined, + password: connectionArg === 'tcp' ? password : undefined, + vlpFile, + manufacturer: candidateArg.manufacturer || 'Velleman', + model: candidateArg.model, + serialNumber: candidateArg.serialNumber, + }; +}; + +const connectionValue = (valueArg: unknown): TVelbusGatewayConnection | undefined => valueArg === 'serial' || valueArg === 'tcp' ? valueArg : undefined; + +const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + +const numberValue = (valueArg: unknown): number | undefined => typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg)) ? Number(valueArg) : undefined; + +const booleanValue = (valueArg: unknown, defaultArg: boolean): boolean => typeof valueArg === 'boolean' ? valueArg : defaultArg; + +const dsnFromTcp = (hostArg: string | undefined, portArg: number | undefined, tlsArg: boolean, passwordArg: string | undefined): string | undefined => { + if (!hostArg || !portArg) { + return undefined; + } + const credentials = passwordArg ? `${passwordArg}@` : ''; + return `${tlsArg ? 'tls://' : ''}${credentials}${hostArg}:${portArg}`; +}; diff --git a/ts/integrations/velbus/velbus.classes.integration.ts b/ts/integrations/velbus/velbus.classes.integration.ts index 0fa5b2a..3094dd6 100644 --- a/ts/integrations/velbus/velbus.classes.integration.ts +++ b/ts/integrations/velbus/velbus.classes.integration.ts @@ -1,31 +1,102 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { VelbusClient } from './velbus.classes.client.js'; +import { VelbusConfigFlow } from './velbus.classes.configflow.js'; +import { createVelbusDiscoveryDescriptor } from './velbus.discovery.js'; +import { VelbusMapper } from './velbus.mapper.js'; +import type { IVelbusConfig } from './velbus.types.js'; -export class HomeAssistantVelbusIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "velbus", - displayName: "Velbus", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/velbus", - "upstreamDomain": "velbus", - "integrationType": "hub", - "iotClass": "local_push", - "qualityScale": "silver", - "requirements": [ - "velbus-aio==2026.4.1" - ], - "dependencies": [ - "usb", - "file_upload" - ], - "afterDependencies": [], - "codeowners": [ - "@Cereal2nd", - "@brefra" - ] -}, - }); +export class VelbusIntegration extends BaseIntegration { + public readonly domain = 'velbus'; + public readonly displayName = 'Velbus'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createVelbusDiscoveryDescriptor(); + public readonly configFlow = new VelbusConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/velbus', + upstreamDomain: 'velbus', + integrationType: 'hub', + iotClass: 'local_push', + qualityScale: 'silver', + requirements: ['velbus-aio==2026.4.1'], + dependencies: ['usb', 'file_upload'], + afterDependencies: [], + codeowners: ['@Cereal2nd', '@brefra'], + configFlow: true, + documentation: 'https://www.home-assistant.io/integrations/velbus', + discovery: { + manual: ['serial', 'tcp'], + usb: [ + { vid: '10CF', pid: '0B1B' }, + { vid: '10CF', pid: '0516' }, + { vid: '10CF', pid: '0517' }, + { vid: '10CF', pid: '0518' }, + ], + note: 'Home Assistant Velbus supports config flow for USB/serial and TCP/IP gateways; this runtime exposes manual serial/TCP discovery plus USB VID/PID matching.', + }, + runtime: { + type: 'control-runtime', + mode: 'manual snapshot', + entities: ['light', 'switch', 'sensor', 'binary_sensor', 'cover', 'climate'], + services: ['turn_on', 'turn_off', 'set_value', 'open', 'close'], + }, + localApi: { + implemented: [ + 'Home Assistant Velbus entity semantics from velbus-aio channel collections', + 'manual serial/TCP gateway configuration DSNs', + 'snapshot module/channel mapping', + 'manual snapshot command mutation for supported service calls', + ], + explicitUnsupported: [ + 'live Velbus serial/CAN frame encoding and decoding', + 'live bus scan/sync_clock/set_memo_text/clear_cache services', + 'VLP file parsing', + 'firmware upgrade or module programming', + ], + }, + }; + + public async setup(configArg: IVelbusConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new VelbusRuntime(new VelbusClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantVelbusIntegration extends VelbusIntegration {} + +class VelbusRuntime implements IIntegrationRuntime { + public domain = 'velbus'; + + constructor(private readonly client: VelbusClient) {} + + public async devices(): Promise { + return VelbusMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return VelbusMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => handlerArg(VelbusMapper.toIntegrationEvent(eventArg))); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + const snapshot = await this.client.getSnapshot(); + const command = VelbusMapper.commandForService(snapshot, requestArg); + if (!command) { + return { success: false, error: `Unsupported Velbus service: ${requestArg.domain}.${requestArg.service}` }; + } + const result = await this.client.sendCommand(command); + return { success: result.success, error: result.error, data: result.data }; + } + + public async destroy(): Promise { + await this.client.destroy(); } } diff --git a/ts/integrations/velbus/velbus.discovery.ts b/ts/integrations/velbus/velbus.discovery.ts new file mode 100644 index 0000000..7969706 --- /dev/null +++ b/ts/integrations/velbus/velbus.discovery.ts @@ -0,0 +1,170 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IVelbusManualDiscoveryEntry, IVelbusUsbDiscoveryEntry, TVelbusGatewayConnection } from './velbus.types.js'; + +const velbusUsbVid = '10cf'; +const velbusUsbPids = new Set(['0b1b', '0516', '0517', '0518']); + +export class VelbusManualMatcher implements IDiscoveryMatcher { + public id = 'velbus-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Velbus serial and TCP/IP gateway setup entries.'; + + public async matches(inputArg: IVelbusManualDiscoveryEntry): Promise { + const connection = connectionFromManualEntry(inputArg); + const haystack = `${inputArg.name || ''} ${inputArg.model || ''} ${inputArg.manufacturer || ''}`.toLowerCase(); + const hasVelbusHint = haystack.includes('velbus') || haystack.includes('velleman') || Boolean(inputArg.metadata?.velbus); + const matched = connection === 'manual' ? false : hasVelbusHint || inputArg.connection === connection; + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Velbus serial or TCP/IP hints.' }; + } + + const tcpDsn = parseTcpDsn(inputArg.dsn); + const serialPath = inputArg.serialPath || inputArg.device || inputArg.path || (connection === 'serial' ? inputArg.dsn : undefined); + const tls = inputArg.tls ?? tcpDsn?.tls ?? (inputArg.dsn?.startsWith('tls://') || undefined); + const host = inputArg.host || tcpDsn?.host; + const port = inputArg.port || tcpDsn?.port || (connection === 'tcp' ? (tls === false ? 6000 : 27015) : undefined); + const password = inputArg.password || tcpDsn?.password; + const dsn = inputArg.dsn || (connection === 'serial' ? serialPath : dsnFromTcp(host, port, tls, password)); + const normalizedDeviceId = inputArg.serialNumber || inputArg.id || (connection === 'serial' ? `serial:${serialPath}` : `tcp:${host}:${port}`); + + return { + matched: true, + confidence: connection === 'tcp' && inputArg.host ? 'high' : serialPath ? 'high' : 'medium', + reason: `Manual Velbus ${connection} entry can start configuration.`, + normalizedDeviceId, + candidate: { + source: 'manual', + integrationDomain: 'velbus', + id: normalizedDeviceId, + host, + port, + name: inputArg.name || (connection === 'tcp' ? 'Velbus TCP/IP Gateway' : 'Velbus Serial Gateway'), + manufacturer: inputArg.manufacturer || 'Velleman', + model: inputArg.model, + serialNumber: inputArg.serialNumber, + metadata: { + ...inputArg.metadata, + connection, + dsn, + serialPath, + tls, + password, + vlpFile: inputArg.vlpFile, + }, + }, + }; + } +} + +export class VelbusUsbMatcher implements IDiscoveryMatcher { + public id = 'velbus-usb-match'; + public source = 'usb' as const; + public description = 'Recognize Velbus USB interfaces from Home Assistant manifest VID/PID pairs.'; + + public async matches(inputArg: IVelbusUsbDiscoveryEntry): Promise { + const vid = normalizeUsbId(inputArg.vid); + const pid = normalizeUsbId(inputArg.pid); + const matched = vid === velbusUsbVid && velbusUsbPids.has(pid); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'USB device VID/PID is not a Velbus interface from the Home Assistant manifest.' }; + } + const serialPath = inputArg.device; + const normalizedDeviceId = inputArg.serialNumber || (serialPath ? `serial:${serialPath}` : `usb:${vid}:${pid}`); + return { + matched: true, + confidence: inputArg.serialNumber || serialPath ? 'certain' : 'high', + reason: 'USB VID/PID matches a Velbus interface from the Home Assistant manifest.', + normalizedDeviceId, + candidate: { + source: 'usb', + integrationDomain: 'velbus', + id: normalizedDeviceId, + name: inputArg.product || 'Velbus USB Gateway', + manufacturer: inputArg.manufacturer || 'Velleman', + model: inputArg.product, + serialNumber: inputArg.serialNumber, + metadata: { + ...inputArg.metadata, + connection: 'serial', + dsn: serialPath, + serialPath, + vid, + pid, + }, + }, + }; + } +} + +export class VelbusCandidateValidator implements IDiscoveryValidator { + public id = 'velbus-candidate-validator'; + public description = 'Validate that a discovery candidate can configure a Velbus gateway.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const metadata = candidateArg.metadata || {}; + const connection = metadata.connection === 'serial' || metadata.connection === 'tcp' ? metadata.connection : candidateArg.host ? 'tcp' : metadata.serialPath ? 'serial' : undefined; + const haystack = `${candidateArg.name || ''} ${candidateArg.model || ''} ${candidateArg.manufacturer || ''}`.toLowerCase(); + const matched = candidateArg.integrationDomain === 'velbus' || haystack.includes('velbus') || haystack.includes('velleman') || Boolean(metadata.velbus) || Boolean(connection); + return { + matched, + confidence: matched && (candidateArg.host || metadata.serialPath || metadata.dsn) ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has Velbus gateway metadata.' : 'Candidate is not Velbus.', + normalizedDeviceId: candidateArg.id || candidateArg.serialNumber || (connection === 'tcp' ? `tcp:${candidateArg.host}:${candidateArg.port || 27015}` : metadata.serialPath ? `serial:${metadata.serialPath}` : undefined), + candidate: matched ? { + ...candidateArg, + integrationDomain: 'velbus', + manufacturer: candidateArg.manufacturer || 'Velleman', + metadata: { + ...metadata, + connection, + }, + } : undefined, + }; + } +} + +export const createVelbusDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'velbus', displayName: 'Velbus' }) + .addMatcher(new VelbusManualMatcher()) + .addMatcher(new VelbusUsbMatcher()) + .addValidator(new VelbusCandidateValidator()); +}; + +const connectionFromManualEntry = (entryArg: IVelbusManualDiscoveryEntry): TVelbusGatewayConnection => { + if (entryArg.connection === 'serial' || entryArg.connection === 'tcp') { + return entryArg.connection; + } + if (entryArg.host) { + return 'tcp'; + } + if (parseTcpDsn(entryArg.dsn)) { + return 'tcp'; + } + if (entryArg.serialPath || entryArg.device || entryArg.path || entryArg.dsn?.startsWith('/')) { + return 'serial'; + } + return 'manual'; +}; + +const dsnFromTcp = (hostArg: string | undefined, portArg: number | undefined, tlsArg: boolean | undefined, passwordArg: string | undefined): string | undefined => { + if (!hostArg || !portArg) { + return undefined; + } + const credentials = passwordArg ? `${passwordArg}@` : ''; + return `${tlsArg === false ? '' : 'tls://'}${credentials}${hostArg}:${portArg}`; +}; + +const parseTcpDsn = (dsnArg: string | undefined): { host: string; port: number; tls: boolean; password?: string } | undefined => { + if (!dsnArg || dsnArg.startsWith('/')) { + return undefined; + } + const tls = dsnArg.startsWith('tls://'); + const withoutScheme = dsnArg.replace(/^tls:\/\//, ''); + const [password, hostPort] = withoutScheme.includes('@') ? withoutScheme.split('@', 2) : [undefined, withoutScheme]; + const [host, portText] = hostPort.split(':'); + const port = Number(portText); + return host && Number.isFinite(port) ? { host, port, tls, password } : undefined; +}; + +const normalizeUsbId = (valueArg: string | undefined): string => (valueArg || '').replace(/^0x/i, '').toLowerCase().padStart(4, '0'); diff --git a/ts/integrations/velbus/velbus.mapper.ts b/ts/integrations/velbus/velbus.mapper.ts new file mode 100644 index 0000000..10c46de --- /dev/null +++ b/ts/integrations/velbus/velbus.mapper.ts @@ -0,0 +1,437 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest } from '../../core/types.js'; +import type { + IVelbusChannel, + IVelbusCommand, + IVelbusEntity, + IVelbusEvent, + IVelbusModule, + IVelbusSnapshot, +} from './velbus.types.js'; + +const velbusDomain = 'velbus'; + +export class VelbusMapper { + public static toDevices(snapshotArg: IVelbusSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + const gatewayDeviceId = this.gatewayDeviceId(snapshotArg); + const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [{ + id: gatewayDeviceId, + integrationDomain: velbusDomain, + name: snapshotArg.gateway.name || 'Velbus Gateway', + protocol: 'unknown', + manufacturer: snapshotArg.gateway.manufacturer || 'Velleman', + model: snapshotArg.gateway.model || (snapshotArg.gateway.connection === 'tcp' ? 'Velbus TCP/IP interface' : snapshotArg.gateway.connection === 'serial' ? 'Velbus serial interface' : 'Velbus manual snapshot'), + online: snapshotArg.connected, + features: [ + { id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false }, + { id: 'module_count', capability: 'sensor', name: 'Module count', readable: true, writable: false }, + ], + state: [ + { featureId: 'connectivity', value: snapshotArg.connected ? 'online' : 'offline', updatedAt }, + { featureId: 'module_count', value: snapshotArg.modules.length, updatedAt }, + ], + metadata: this.cleanAttributes({ + connection: snapshotArg.gateway.connection, + dsn: snapshotArg.gateway.dsn, + serialPath: snapshotArg.gateway.serialPath, + host: snapshotArg.gateway.host, + port: snapshotArg.gateway.port, + tls: snapshotArg.gateway.tls, + serialNumber: snapshotArg.gateway.serialNumber, + vlpFile: snapshotArg.gateway.vlpFile, + limitations: snapshotArg.limitations, + ...snapshotArg.gateway.metadata, + }), + }]; + + for (const module of snapshotArg.modules) { + devices.push({ + id: this.moduleDeviceId(module), + integrationDomain: velbusDomain, + name: module.name || `Velbus Module ${module.address}`, + room: module.room, + protocol: 'unknown', + manufacturer: module.manufacturer || 'Velleman', + model: module.typeName || (module.type !== undefined ? String(module.type) : undefined), + online: snapshotArg.connected && module.connected !== false, + features: module.channels.map((channelArg) => ({ + id: this.channelFeatureId(channelArg), + capability: this.capabilityForChannel(channelArg), + name: channelArg.name || `Channel ${channelArg.channelNumber ?? channelArg.id}`, + readable: true, + writable: this.isWritable(channelArg), + unit: channelArg.unit, + })), + state: module.channels.map((channelArg) => ({ + featureId: this.channelFeatureId(channelArg), + value: this.deviceStateValue(channelArg), + updatedAt, + })), + metadata: this.cleanAttributes({ + gatewayDeviceId, + address: module.address, + type: module.type, + typeName: module.typeName, + swVersion: module.swVersion, + serialNumber: module.serialNumber, + ...module.attributes, + }), + }); + } + + return devices; + } + + public static toEntities(snapshotArg: IVelbusSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + const usedIds = new Map(); + + for (const module of snapshotArg.modules) { + for (const channel of module.channels) { + const available = snapshotArg.connected && module.connected !== false && channel.connected !== false; + if (channel.kind === 'dimmer' || channel.kind === 'led') { + const name = channel.kind === 'led' && !(channel.name || '').toLowerCase().startsWith('led') ? `LED ${channel.name || this.channelName(channel)}` : this.channelName(channel); + entities.push(this.entity('light', name, module, channel, usedIds, this.boolState(channel) ? 'on' : 'off', { + brightness: this.brightness(channel), + colorMode: channel.kind === 'dimmer' ? 'brightness' : 'onoff', + transition: channel.kind === 'dimmer', + flash: channel.kind === 'led', + }, available)); + continue; + } + + if (channel.kind === 'relay') { + entities.push(this.entity('switch', this.channelName(channel), module, channel, usedIds, this.boolState(channel) ? 'on' : 'off', {}, available)); + continue; + } + + if (channel.kind === 'button' || channel.kind === 'binary_sensor') { + entities.push(this.entity('binary_sensor', this.channelName(channel), module, channel, usedIds, this.boolState(channel) ? 'on' : 'off', { + deviceClass: channel.deviceClass, + }, available)); + continue; + } + + if (channel.kind === 'blind') { + entities.push(this.entity('cover', this.channelName(channel), module, channel, usedIds, this.coverState(channel), { + currentPosition: channel.position, + supportedFeatures: channel.position === undefined ? ['open', 'close', 'stop'] : ['open', 'close', 'stop', 'set_position'], + }, available)); + continue; + } + + if (channel.kind === 'climate') { + entities.push(this.entity('climate', this.channelName(channel), module, channel, usedIds, channel.hvacMode || 'heat', { + currentTemperature: channel.currentTemperature, + targetTemperature: channel.targetTemperature, + presetMode: channel.presetMode, + hvacModes: ['heat', 'cool'], + presetModes: ['away', 'comfort', 'eco', 'home'], + temperatureUnit: channel.unit || 'C', + }, available)); + continue; + } + + entities.push(this.entity('sensor', this.channelName(channel), module, channel, usedIds, this.sensorValue(channel), { + unit: channel.unit, + deviceClass: channel.deviceClass || (channel.kind === 'temperature' ? 'temperature' : undefined), + stateClass: channel.stateClass, + }, available)); + + if (channel.kind === 'counter' && channel.counterValue !== undefined) { + entities.push(this.entity('sensor', `${this.channelName(channel)} counter`, module, channel, usedIds, channel.counterValue, { + unit: channel.counterUnit, + deviceClass: 'energy', + stateClass: 'total_increasing', + counter: true, + }, available, '-counter')); + } + } + } + + return entities; + } + + public static commandForService(snapshotArg: IVelbusSnapshot, requestArg: IServiceCallRequest): IVelbusCommand | undefined { + if (requestArg.domain === 'light' && requestArg.service === 'turn_on') { + const target = this.findTarget(snapshotArg, requestArg, ['light']); + return target ? this.command('turn_on', target, requestArg, this.brightnessValue(requestArg)) : undefined; + } + + if (requestArg.domain === 'light' && requestArg.service === 'turn_off') { + const target = this.findTarget(snapshotArg, requestArg, ['light']); + return target ? this.command('turn_off', target, requestArg) : undefined; + } + + if (requestArg.domain === 'switch' && requestArg.service === 'turn_on') { + const target = this.findTarget(snapshotArg, requestArg, ['switch']); + return target ? this.command('turn_on', target, requestArg) : undefined; + } + + if (requestArg.domain === 'switch' && requestArg.service === 'turn_off') { + const target = this.findTarget(snapshotArg, requestArg, ['switch']); + return target ? this.command('turn_off', target, requestArg) : undefined; + } + + if (requestArg.domain === velbusDomain && requestArg.service === 'turn_on') { + const target = this.findTarget(snapshotArg, requestArg, ['light', 'switch']); + return target ? this.command('turn_on', target, requestArg, target.entity.platform === 'light' ? this.brightnessValue(requestArg) : undefined) : undefined; + } + + if (requestArg.domain === velbusDomain && requestArg.service === 'turn_off') { + const target = this.findTarget(snapshotArg, requestArg, ['light', 'switch']); + return target ? this.command('turn_off', target, requestArg) : undefined; + } + + if ((requestArg.domain === 'cover' && ['open_cover', 'open'].includes(requestArg.service)) || (requestArg.domain === velbusDomain && requestArg.service === 'open')) { + const target = this.findTarget(snapshotArg, requestArg, ['cover']); + return target ? this.command('open', target, requestArg) : undefined; + } + + if ((requestArg.domain === 'cover' && ['close_cover', 'close'].includes(requestArg.service)) || (requestArg.domain === velbusDomain && requestArg.service === 'close')) { + const target = this.findTarget(snapshotArg, requestArg, ['cover']); + return target ? this.command('close', target, requestArg) : undefined; + } + + if ((requestArg.domain === 'cover' && requestArg.service === 'stop_cover') || (requestArg.domain === velbusDomain && requestArg.service === 'stop')) { + const target = this.findTarget(snapshotArg, requestArg, ['cover']); + return target ? this.command('stop', target, requestArg) : undefined; + } + + if (requestArg.service === 'set_value' || (requestArg.domain === 'cover' && requestArg.service === 'set_cover_position') || (requestArg.domain === 'climate' && requestArg.service === 'set_temperature')) { + const target = this.findTarget(snapshotArg, requestArg, ['light', 'cover', 'climate', 'sensor']); + const value = this.setValue(requestArg, target?.entity.platform); + return target && value !== undefined ? this.command('set_value', target, requestArg, value) : undefined; + } + + return undefined; + } + + public static toIntegrationEvent(eventArg: IVelbusEvent): IIntegrationEvent { + return { + type: eventArg.type === 'command_failed' ? 'error' : 'state_changed', + integrationDomain: velbusDomain, + deviceId: eventArg.deviceId, + entityId: eventArg.entityId, + data: eventArg, + timestamp: eventArg.timestamp, + }; + } + + public static gatewayDeviceId(snapshotArg: IVelbusSnapshot): string { + return `velbus.gateway.${this.slug(snapshotArg.gateway.id || snapshotArg.gateway.serialNumber || snapshotArg.gateway.dsn || snapshotArg.gateway.host || snapshotArg.gateway.serialPath || 'manual')}`; + } + + public static moduleDeviceId(moduleArg: IVelbusModule): string { + return `velbus.module.${this.slug(moduleArg.serialNumber || moduleArg.id || String(moduleArg.address))}`; + } + + private static entity(platformArg: IIntegrationEntity['platform'], nameArg: string, moduleArg: IVelbusModule, channelArg: IVelbusChannel, usedIdsArg: Map, stateArg: unknown, attributesArg: Record, availableArg: boolean, uniqueSuffixArg = ''): IVelbusEntity { + const baseId = `${platformArg}.${this.slug(nameArg) || this.slug(`${moduleArg.address}_${channelArg.id}`)}`; + const seen = usedIdsArg.get(baseId) || 0; + usedIdsArg.set(baseId, seen + 1); + return { + id: seen ? `${baseId}_${seen + 1}` : baseId, + uniqueId: `velbus_${this.slug(moduleArg.serialNumber || String(moduleArg.address))}_${this.slug(String(channelArg.channelNumber ?? channelArg.id))}${uniqueSuffixArg}`, + integrationDomain: velbusDomain, + deviceId: this.moduleDeviceId(moduleArg), + platform: platformArg, + name: nameArg, + state: stateArg, + attributes: this.cleanAttributes({ + moduleAddress: moduleArg.address, + channelId: channelArg.id, + channelNumber: channelArg.channelNumber, + kind: channelArg.kind, + moduleType: moduleArg.type, + moduleTypeName: moduleArg.typeName, + moduleSerialNumber: moduleArg.serialNumber, + ...attributesArg, + ...channelArg.attributes, + }), + available: availableArg, + }; + } + + private static command(typeArg: IVelbusCommand['type'], targetArg: { entity: IIntegrationEntity; module: IVelbusModule; channel: IVelbusChannel }, requestArg: IServiceCallRequest, valueArg?: number | string | boolean): IVelbusCommand { + return this.cleanAttributes({ + type: typeArg, + moduleAddress: targetArg.module.address, + channelId: targetArg.channel.id, + channelNumber: targetArg.channel.channelNumber, + platform: targetArg.entity.platform, + entityId: requestArg.target.entityId, + deviceId: requestArg.target.deviceId, + value: valueArg, + transition: this.numberData(requestArg, 'transition'), + }) as unknown as IVelbusCommand; + } + + private static findTarget(snapshotArg: IVelbusSnapshot, requestArg: IServiceCallRequest, platformsArg: IIntegrationEntity['platform'][]): { entity: IIntegrationEntity; module: IVelbusModule; channel: IVelbusChannel } | undefined { + const entities = this.toEntities(snapshotArg).filter((entityArg) => platformsArg.includes(entityArg.platform)); + const targetId = requestArg.target.entityId || requestArg.target.deviceId; + const entity = targetId ? entities.find((entityArg) => entityArg.id === targetId || entityArg.uniqueId === targetId || entityArg.deviceId === targetId) : entities.length === 1 ? entities[0] : undefined; + if (entity) { + const moduleAddress = typeof entity.attributes?.moduleAddress === 'number' ? entity.attributes.moduleAddress : undefined; + const channelId = typeof entity.attributes?.channelId === 'string' ? entity.attributes.channelId : undefined; + const found = this.findChannel(snapshotArg, moduleAddress, channelId, undefined); + return found ? { entity, ...found } : undefined; + } + + const explicitModuleAddress = this.numberData(requestArg, 'moduleAddress') ?? this.numberData(requestArg, 'address'); + const explicitChannelNumber = this.numberData(requestArg, 'channelNumber') ?? this.numberData(requestArg, 'channel'); + const explicitChannelId = this.stringData(requestArg, 'channelId'); + const found = this.findChannel(snapshotArg, explicitModuleAddress, explicitChannelId, explicitChannelNumber); + if (!found) { + return undefined; + } + const foundEntity = entities.find((entityArg) => entityArg.attributes?.moduleAddress === found.module.address && entityArg.attributes?.channelId === found.channel.id); + return foundEntity ? { entity: foundEntity, ...found } : undefined; + } + + private static findChannel(snapshotArg: IVelbusSnapshot, moduleAddressArg?: number, channelIdArg?: string, channelNumberArg?: number): { module: IVelbusModule; channel: IVelbusChannel } | undefined { + for (const module of snapshotArg.modules) { + if (moduleAddressArg !== undefined && module.address !== moduleAddressArg) { + continue; + } + for (const channel of module.channels) { + if (channelIdArg !== undefined && channel.id === channelIdArg) { + return { module, channel }; + } + if (channelNumberArg !== undefined && channel.channelNumber === channelNumberArg) { + return { module, channel }; + } + } + } + return undefined; + } + + private static channelFeatureId(channelArg: IVelbusChannel): string { + return `channel_${this.slug(String(channelArg.channelNumber ?? channelArg.id))}`; + } + + private static capabilityForChannel(channelArg: IVelbusChannel): plugins.shxInterfaces.data.TDeviceCapability { + if (channelArg.kind === 'relay') { + return 'switch'; + } + if (channelArg.kind === 'dimmer' || channelArg.kind === 'led') { + return 'light'; + } + if (channelArg.kind === 'blind') { + return 'cover'; + } + if (channelArg.kind === 'climate') { + return 'climate'; + } + return 'sensor'; + } + + private static deviceStateValue(channelArg: IVelbusChannel): plugins.shxInterfaces.data.TDeviceStateValue { + if (channelArg.kind === 'blind') { + return this.cleanAttributes({ state: this.coverState(channelArg), position: channelArg.position }); + } + if (channelArg.kind === 'climate') { + return this.cleanAttributes({ currentTemperature: channelArg.currentTemperature, targetTemperature: channelArg.targetTemperature, hvacMode: channelArg.hvacMode, presetMode: channelArg.presetMode }); + } + if (channelArg.kind === 'dimmer' || channelArg.kind === 'led') { + return this.cleanAttributes({ on: this.boolState(channelArg), brightness: this.brightness(channelArg) }); + } + return channelArg.state ?? this.sensorValue(channelArg) ?? null; + } + + private static channelName(channelArg: IVelbusChannel): string { + return channelArg.name || `Channel ${channelArg.channelNumber ?? channelArg.id}`; + } + + private static isWritable(channelArg: IVelbusChannel): boolean { + if (channelArg.writable !== undefined) { + return channelArg.writable; + } + return ['relay', 'dimmer', 'led', 'blind', 'climate'].includes(channelArg.kind); + } + + private static boolState(channelArg: IVelbusChannel): boolean { + if (typeof channelArg.state === 'boolean') { + return channelArg.state; + } + if (typeof channelArg.state === 'number') { + return channelArg.state > 0; + } + if (typeof channelArg.state === 'string') { + return ['on', 'true', 'active', 'closed', 'pressed', 'slow', 'fast'].includes(channelArg.state.toLowerCase()); + } + return Boolean(channelArg.brightness && channelArg.brightness > 0); + } + + private static brightness(channelArg: IVelbusChannel): number | undefined { + if (typeof channelArg.brightness === 'number') { + return Math.min(100, Math.max(0, channelArg.brightness)); + } + if (channelArg.kind === 'dimmer' && typeof channelArg.state === 'number') { + return Math.min(100, Math.max(0, channelArg.state)); + } + return channelArg.kind === 'dimmer' ? (this.boolState(channelArg) ? 100 : 0) : undefined; + } + + private static coverState(channelArg: IVelbusChannel): string { + if (typeof channelArg.state === 'string' && ['open', 'closed', 'opening', 'closing'].includes(channelArg.state.toLowerCase())) { + return channelArg.state.toLowerCase(); + } + if (typeof channelArg.position === 'number') { + return channelArg.position <= 0 ? 'closed' : 'open'; + } + return 'unknown'; + } + + private static sensorValue(channelArg: IVelbusChannel): number | string | boolean | null { + if (channelArg.kind === 'temperature' && typeof channelArg.currentTemperature === 'number') { + return channelArg.currentTemperature; + } + return channelArg.state ?? null; + } + + private static brightnessValue(requestArg: IServiceCallRequest): number | undefined { + const percent = this.numberData(requestArg, 'brightnessPercent') ?? this.numberData(requestArg, 'brightness_pct'); + if (percent !== undefined) { + return Math.min(100, Math.max(0, percent)); + } + const brightness = this.numberData(requestArg, 'brightness'); + if (brightness !== undefined) { + return Math.round(Math.min(255, Math.max(0, brightness)) / 255 * 100); + } + return this.numberData(requestArg, 'value'); + } + + private static setValue(requestArg: IServiceCallRequest, platformArg?: IIntegrationEntity['platform']): number | string | boolean | undefined { + if (platformArg === 'cover') { + return this.numberData(requestArg, 'position') ?? this.numberData(requestArg, 'value'); + } + if (platformArg === 'light') { + return this.brightnessValue(requestArg); + } + if (platformArg === 'climate') { + return this.numberData(requestArg, 'temperature') ?? this.numberData(requestArg, 'value'); + } + const value = requestArg.data?.value; + return typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean' ? value : undefined; + } + + private static numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined { + const value = requestArg.data?.[keyArg]; + return typeof value === 'number' && Number.isFinite(value) ? value : typeof value === 'string' && value.trim() && Number.isFinite(Number(value)) ? Number(value) : undefined; + } + + private static stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined { + const value = requestArg.data?.[keyArg]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; + } + + private static cleanAttributes>(attributesArg: TValue): Record { + return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)); + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'velbus'; + } +} diff --git a/ts/integrations/velbus/velbus.types.ts b/ts/integrations/velbus/velbus.types.ts index 1b5e187..3f49111 100644 --- a/ts/integrations/velbus/velbus.types.ts +++ b/ts/integrations/velbus/velbus.types.ts @@ -1,4 +1,178 @@ -export interface IHomeAssistantVelbusConfig { - // TODO: replace with the TypeScript-native config for velbus. - [key: string]: unknown; +import type { IDiscoveryCandidate, IIntegrationEntity } from '../../core/types.js'; + +export type TVelbusGatewayConnection = 'serial' | 'tcp' | 'manual'; + +export type TVelbusChannelKind = + | 'relay' + | 'dimmer' + | 'button' + | 'led' + | 'binary_sensor' + | 'blind' + | 'sensor' + | 'temperature' + | 'counter' + | 'light_sensor' + | 'climate'; + +export type TVelbusHvacMode = 'heat' | 'cool'; + +export type TVelbusPresetMode = 'away' | 'comfort' | 'eco' | 'home' | 'safe' | 'night' | 'day'; + +export type TVelbusChannelState = string | number | boolean | null; + +export interface IVelbusGatewayConfig { + id?: string; + name?: string; + connection: TVelbusGatewayConnection; + dsn?: string; + serialPath?: string; + host?: string; + port?: number; + tls?: boolean; + password?: string; + vlpFile?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + metadata?: Record; +} + +export interface IVelbusChannel { + id: string; + name?: string; + kind: TVelbusChannelKind; + channelNumber?: number; + moduleAddress?: number; + subDevice?: boolean; + connected?: boolean; + state?: TVelbusChannelState; + brightness?: number; + position?: number; + currentTemperature?: number; + targetTemperature?: number; + hvacMode?: TVelbusHvacMode; + presetMode?: TVelbusPresetMode; + unit?: string; + deviceClass?: string; + stateClass?: string; + counterValue?: number; + counterUnit?: string; + options?: string[]; + selectedOption?: string; + writable?: boolean; + attributes?: Record; +} + +export interface IVelbusModule { + address: number; + id?: string; + name?: string; + type?: number | string; + typeName?: string; + swVersion?: string; + serialNumber?: string; + manufacturer?: string; + room?: string; + connected?: boolean; + channels: IVelbusChannel[]; + attributes?: Record; +} + +export interface IVelbusSnapshot { + gateway: IVelbusGatewayConfig; + modules: IVelbusModule[]; + connected: boolean; + updatedAt?: string; + events?: IVelbusEvent[]; + limitations?: string[]; +} + +export interface IVelbusConfig { + gateway: IVelbusGatewayConfig; + snapshot?: IVelbusSnapshot; + mutationMode?: 'manual-snapshot' | 'readonly'; +} + +export interface IHomeAssistantVelbusConfig extends IVelbusConfig {} + +export interface IVelbusEntity extends IIntegrationEntity { + attributes?: Record & { + moduleAddress?: number; + channelId?: string; + channelNumber?: number; + kind?: TVelbusChannelKind; + }; +} + +export type TVelbusCommandType = 'turn_on' | 'turn_off' | 'set_value' | 'open' | 'close' | 'stop'; + +export interface IVelbusCommand { + type: TVelbusCommandType; + moduleAddress?: number; + channelId?: string; + channelNumber?: number; + platform?: IIntegrationEntity['platform']; + entityId?: string; + deviceId?: string; + value?: number | string | boolean; + transition?: number; + metadata?: Record; +} + +export interface IVelbusCommandResult { + success: boolean; + error?: string; + data?: unknown; +} + +export interface IVelbusEvent { + type: 'state_changed' | 'command_applied' | 'command_failed' | 'gateway_connected' | 'gateway_disconnected'; + moduleAddress?: number; + channelId?: string; + entityId?: string; + deviceId?: string; + command?: IVelbusCommand; + data?: unknown; + timestamp: number; +} + +export interface IVelbusManualDiscoveryEntry { + connection?: TVelbusGatewayConnection; + dsn?: string; + serialPath?: string; + device?: string; + path?: string; + host?: string; + port?: number; + tls?: boolean; + password?: string; + vlpFile?: string; + id?: string; + name?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + metadata?: Record; +} + +export interface IVelbusUsbDiscoveryEntry { + vid?: string; + pid?: string; + device?: string; + serialNumber?: string; + manufacturer?: string; + product?: string; + metadata?: Record; +} + +export interface IVelbusDiscoveryCandidate extends IDiscoveryCandidate { + metadata?: Record & { + connection?: TVelbusGatewayConnection; + dsn?: string; + serialPath?: string; + tls?: boolean; + password?: string; + vlpFile?: string; + }; }