Add native local bus integrations

This commit is contained in:
2026-05-05 18:06:03 +00:00
parent e7441844c9
commit accfa82f36
64 changed files with 10778 additions and 185 deletions
@@ -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();
@@ -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();
+34
View File
@@ -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();
+87
View File
@@ -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();
+44
View File
@@ -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();
+102
View File
@@ -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();
@@ -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();
@@ -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();
+49
View File
@@ -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();
+76
View File
@@ -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();
+64
View File
@@ -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();
+92
View File
@@ -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();
+12
View File
@@ -12,15 +12,20 @@ import { DenonavrIntegration } from './integrations/denonavr/index.js';
import { DlnaDmrIntegration } from './integrations/dlna_dmr/index.js'; import { DlnaDmrIntegration } from './integrations/dlna_dmr/index.js';
import { EsphomeIntegration } from './integrations/esphome/index.js'; import { EsphomeIntegration } from './integrations/esphome/index.js';
import { HomekitControllerIntegration } from './integrations/homekit_controller/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 { JellyfinIntegration } from './integrations/jellyfin/index.js';
import { KnxIntegration } from './integrations/knx/index.js';
import { KodiIntegration } from './integrations/kodi/index.js'; import { KodiIntegration } from './integrations/kodi/index.js';
import { MatterIntegration } from './integrations/matter/index.js'; import { MatterIntegration } from './integrations/matter/index.js';
import { ModbusIntegration } from './integrations/modbus/index.js';
import { MqttIntegration } from './integrations/mqtt/index.js'; import { MqttIntegration } from './integrations/mqtt/index.js';
import { MpdIntegration } from './integrations/mpd/index.js'; import { MpdIntegration } from './integrations/mpd/index.js';
import { NanoleafIntegration } from './integrations/nanoleaf/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 { OnvifIntegration } from './integrations/onvif/index.js';
import { PlexIntegration } from './integrations/plex/index.js'; import { PlexIntegration } from './integrations/plex/index.js';
import { RainbirdIntegration } from './integrations/rainbird/index.js'; import { RainbirdIntegration } from './integrations/rainbird/index.js';
import { RflinkIntegration } from './integrations/rflink/index.js';
import { RokuIntegration } from './integrations/roku/index.js'; import { RokuIntegration } from './integrations/roku/index.js';
import { SamsungtvIntegration } from './integrations/samsungtv/index.js'; import { SamsungtvIntegration } from './integrations/samsungtv/index.js';
import { ShellyIntegration } from './integrations/shelly/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 { TplinkIntegration } from './integrations/tplink/index.js';
import { TradfriIntegration } from './integrations/tradfri/index.js'; import { TradfriIntegration } from './integrations/tradfri/index.js';
import { UnifiIntegration } from './integrations/unifi/index.js'; import { UnifiIntegration } from './integrations/unifi/index.js';
import { VelbusIntegration } from './integrations/velbus/index.js';
import { VolumioIntegration } from './integrations/volumio/index.js'; import { VolumioIntegration } from './integrations/volumio/index.js';
import { WolfSmartsetIntegration } from './integrations/wolf_smartset/index.js'; import { WolfSmartsetIntegration } from './integrations/wolf_smartset/index.js';
import { WizIntegration } from './integrations/wiz/index.js'; import { WizIntegration } from './integrations/wiz/index.js';
@@ -50,16 +56,21 @@ export const integrations = [
new DlnaDmrIntegration(), new DlnaDmrIntegration(),
new EsphomeIntegration(), new EsphomeIntegration(),
new HomekitControllerIntegration(), new HomekitControllerIntegration(),
new HomematicIntegration(),
new HueIntegration(), new HueIntegration(),
new JellyfinIntegration(), new JellyfinIntegration(),
new KnxIntegration(),
new KodiIntegration(), new KodiIntegration(),
new MatterIntegration(), new MatterIntegration(),
new ModbusIntegration(),
new MqttIntegration(), new MqttIntegration(),
new MpdIntegration(), new MpdIntegration(),
new NanoleafIntegration(), new NanoleafIntegration(),
new OpenthermGwIntegration(),
new OnvifIntegration(), new OnvifIntegration(),
new PlexIntegration(), new PlexIntegration(),
new RainbirdIntegration(), new RainbirdIntegration(),
new RflinkIntegration(),
new RokuIntegration(), new RokuIntegration(),
new SamsungtvIntegration(), new SamsungtvIntegration(),
new ShellyIntegration(), new ShellyIntegration(),
@@ -68,6 +79,7 @@ export const integrations = [
new TplinkIntegration(), new TplinkIntegration(),
new TradfriIntegration(), new TradfriIntegration(),
new UnifiIntegration(), new UnifiIntegration(),
new VelbusIntegration(),
new VolumioIntegration(), new VolumioIntegration(),
new WolfSmartsetIntegration(), new WolfSmartsetIntegration(),
new WizIntegration(), new WizIntegration(),
+7 -13
View File
@@ -514,7 +514,6 @@ import { HomeAssistantHomeassistantSkyConnectIntegration } from '../homeassistan
import { HomeAssistantHomeassistantYellowIntegration } from '../homeassistant_yellow/index.js'; import { HomeAssistantHomeassistantYellowIntegration } from '../homeassistant_yellow/index.js';
import { HomeAssistantHomeeIntegration } from '../homee/index.js'; import { HomeAssistantHomeeIntegration } from '../homee/index.js';
import { HomeAssistantHomekitIntegration } from '../homekit/index.js'; import { HomeAssistantHomekitIntegration } from '../homekit/index.js';
import { HomeAssistantHomematicIntegration } from '../homematic/index.js';
import { HomeAssistantHomematicipCloudIntegration } from '../homematicip_cloud/index.js'; import { HomeAssistantHomematicipCloudIntegration } from '../homematicip_cloud/index.js';
import { HomeAssistantHomevoltIntegration } from '../homevolt/index.js'; import { HomeAssistantHomevoltIntegration } from '../homevolt/index.js';
import { HomeAssistantHomewizardIntegration } from '../homewizard/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 { HomeAssistantKiwiIntegration } from '../kiwi/index.js';
import { HomeAssistantKmtronicIntegration } from '../kmtronic/index.js'; import { HomeAssistantKmtronicIntegration } from '../kmtronic/index.js';
import { HomeAssistantKnockiIntegration } from '../knocki/index.js'; import { HomeAssistantKnockiIntegration } from '../knocki/index.js';
import { HomeAssistantKnxIntegration } from '../knx/index.js';
import { HomeAssistantKonnectedIntegration } from '../konnected/index.js'; import { HomeAssistantKonnectedIntegration } from '../konnected/index.js';
import { HomeAssistantKonnectedEsphomeIntegration } from '../konnected_esphome/index.js'; import { HomeAssistantKonnectedEsphomeIntegration } from '../konnected_esphome/index.js';
import { HomeAssistantKostalPlenticoreIntegration } from '../kostal_plenticore/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 { HomeAssistantMoatIntegration } from '../moat/index.js';
import { HomeAssistantMobileAppIntegration } from '../mobile_app/index.js'; import { HomeAssistantMobileAppIntegration } from '../mobile_app/index.js';
import { HomeAssistantMochadIntegration } from '../mochad/index.js'; import { HomeAssistantMochadIntegration } from '../mochad/index.js';
import { HomeAssistantModbusIntegration } from '../modbus/index.js';
import { HomeAssistantModemCalleridIntegration } from '../modem_callerid/index.js'; import { HomeAssistantModemCalleridIntegration } from '../modem_callerid/index.js';
import { HomeAssistantModernFormsIntegration } from '../modern_forms/index.js'; import { HomeAssistantModernFormsIntegration } from '../modern_forms/index.js';
import { HomeAssistantMoehlenhoffAlpha2Integration } from '../moehlenhoff_alpha2/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 { HomeAssistantOpenrgbIntegration } from '../openrgb/index.js';
import { HomeAssistantOpensensemapIntegration } from '../opensensemap/index.js'; import { HomeAssistantOpensensemapIntegration } from '../opensensemap/index.js';
import { HomeAssistantOpenskyIntegration } from '../opensky/index.js'; import { HomeAssistantOpenskyIntegration } from '../opensky/index.js';
import { HomeAssistantOpenthermGwIntegration } from '../opentherm_gw/index.js';
import { HomeAssistantOpenuvIntegration } from '../openuv/index.js'; import { HomeAssistantOpenuvIntegration } from '../openuv/index.js';
import { HomeAssistantOpenweathermapIntegration } from '../openweathermap/index.js'; import { HomeAssistantOpenweathermapIntegration } from '../openweathermap/index.js';
import { HomeAssistantOpnsenseIntegration } from '../opnsense/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 { HomeAssistantRestIntegration } from '../rest/index.js';
import { HomeAssistantRestCommandIntegration } from '../rest_command/index.js'; import { HomeAssistantRestCommandIntegration } from '../rest_command/index.js';
import { HomeAssistantRexelIntegration } from '../rexel/index.js'; import { HomeAssistantRexelIntegration } from '../rexel/index.js';
import { HomeAssistantRflinkIntegration } from '../rflink/index.js';
import { HomeAssistantRfxtrxIntegration } from '../rfxtrx/index.js'; import { HomeAssistantRfxtrxIntegration } from '../rfxtrx/index.js';
import { HomeAssistantRhasspyIntegration } from '../rhasspy/index.js'; import { HomeAssistantRhasspyIntegration } from '../rhasspy/index.js';
import { HomeAssistantRidwellIntegration } from '../ridwell/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 { HomeAssistantValveIntegration } from '../valve/index.js';
import { HomeAssistantVasttrafikIntegration } from '../vasttrafik/index.js'; import { HomeAssistantVasttrafikIntegration } from '../vasttrafik/index.js';
import { HomeAssistantVegehubIntegration } from '../vegehub/index.js'; import { HomeAssistantVegehubIntegration } from '../vegehub/index.js';
import { HomeAssistantVelbusIntegration } from '../velbus/index.js';
import { HomeAssistantVeluxIntegration } from '../velux/index.js'; import { HomeAssistantVeluxIntegration } from '../velux/index.js';
import { HomeAssistantVenstarIntegration } from '../venstar/index.js'; import { HomeAssistantVenstarIntegration } from '../venstar/index.js';
import { HomeAssistantVeraIntegration } from '../vera/index.js'; import { HomeAssistantVeraIntegration } from '../vera/index.js';
@@ -1940,7 +1934,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeassistantSkyCon
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeassistantYellowIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeassistantYellowIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeeIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomekitIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomekitIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomematicIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomematicipCloudIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomematicipCloudIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomevoltIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomevoltIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomewizardIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomewizardIntegration());
@@ -2054,7 +2047,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantKitchenSinkIntegrat
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKiwiIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKiwiIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKmtronicIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKmtronicIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKnockiIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKnockiIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKnxIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKonnectedIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKonnectedIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKonnectedEsphomeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKonnectedEsphomeIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKostalPlenticoreIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantKostalPlenticoreIntegration());
@@ -2182,7 +2174,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantMjpegIntegration())
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMoatIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantMoatIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMobileAppIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantMobileAppIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMochadIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantMochadIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantModbusIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantModemCalleridIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantModemCalleridIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantModernFormsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantModernFormsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMoehlenhoffAlpha2Integration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantMoehlenhoffAlpha2Integration());
@@ -2307,7 +2298,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenhomeIntegration
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenrgbIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenrgbIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpensensemapIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpensensemapIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenskyIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenskyIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenthermGwIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenuvIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenuvIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenweathermapIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenweathermapIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpnsenseIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpnsenseIntegration());
@@ -2448,7 +2438,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantRepetierIntegration
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRestIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantRestIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRestCommandIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantRestCommandIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRexelIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantRexelIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRflinkIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRfxtrxIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantRfxtrxIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRhasspyIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantRhasspyIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRidwellIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantRidwellIntegration());
@@ -2745,7 +2734,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantValloxIntegration()
generatedHomeAssistantPortIntegrations.push(new HomeAssistantValveIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantValveIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVasttrafikIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantVasttrafikIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVegehubIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantVegehubIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVelbusIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVeluxIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantVeluxIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVenstarIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantVenstarIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVeraIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantVeraIntegration());
@@ -2852,7 +2840,7 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
export const generatedHomeAssistantPortCount = 1424; export const generatedHomeAssistantPortCount = 1418;
export const handwrittenHomeAssistantPortDomains = [ export const handwrittenHomeAssistantPortDomains = [
"androidtv", "androidtv",
"axis", "axis",
@@ -2863,16 +2851,21 @@ export const handwrittenHomeAssistantPortDomains = [
"dlna_dmr", "dlna_dmr",
"esphome", "esphome",
"homekit_controller", "homekit_controller",
"homematic",
"hue", "hue",
"jellyfin", "jellyfin",
"knx",
"kodi", "kodi",
"matter", "matter",
"modbus",
"mpd", "mpd",
"mqtt", "mqtt",
"nanoleaf", "nanoleaf",
"onvif", "onvif",
"opentherm_gw",
"plex", "plex",
"rainbird", "rainbird",
"rflink",
"roku", "roku",
"samsungtv", "samsungtv",
"shelly", "shelly",
@@ -2881,6 +2874,7 @@ export const handwrittenHomeAssistantPortDomains = [
"tplink", "tplink",
"tradfri", "tradfri",
"unifi", "unifi",
"velbus",
"volumio", "volumio",
"wiz", "wiz",
"xiaomi_miio", "xiaomi_miio",
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -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<unknown> {
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) => `<param>${this.encodeValue(paramArg)}</param>`).join('');
return `<?xml version="1.0"?><methodCall><methodName>${escapeXml(methodArg)}</methodName><params>${params}</params></methodCall>`;
}
private encodeValue(valueArg: unknown): string {
if (valueArg === null || valueArg === undefined) {
return '<value><nil/></value>';
}
if (typeof valueArg === 'boolean') {
return `<value><boolean>${valueArg ? '1' : '0'}</boolean></value>`;
}
if (typeof valueArg === 'number') {
return Number.isInteger(valueArg) ? `<value><int>${valueArg}</int></value>` : `<value><double>${valueArg}</double></value>`;
}
if (typeof valueArg === 'string') {
return `<value><string>${escapeXml(valueArg)}</string></value>`;
}
if (Array.isArray(valueArg)) {
return `<value><array><data>${valueArg.map((itemArg) => this.encodeValue(itemArg)).join('')}</data></array></value>`;
}
if (typeof valueArg === 'object') {
const members = Object.entries(valueArg as Record<string, unknown>)
.map(([keyArg, memberValueArg]) => `<member><name>${escapeXml(keyArg)}</name>${this.encodeValue(memberValueArg)}</member>`)
.join('');
return `<value><struct>${members}</struct></value>`;
}
return `<value><string>${escapeXml(String(valueArg))}</string></value>`;
}
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<string, unknown> = {};
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<THomematicEventHandler>();
constructor(private readonly config: IHomematicConfig) {
this.snapshot = config.snapshot ? this.normalizeSnapshot(clone(config.snapshot), 'snapshot') : undefined;
}
public async getSnapshot(): Promise<IHomematicSnapshot> {
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<IHomematicCommandResult> {
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<IHomematicCommand, { type: 'refresh' }>): 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<void> {
this.eventHandlers.clear();
}
private xmlRpcParams(commandArg: Exclude<IHomematicCommand, { type: 'refresh' }>): 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<IHomematicCommand, { type: 'refresh' | 'raw_xmlrpc' }>): IHomematicValue {
if (commandArg.type === 'press') {
return commandArg.value ?? true;
}
return commandArg.value;
}
private async fetchSnapshot(): Promise<IHomematicSnapshot> {
const updatedAt = new Date().toISOString();
const interfaces = this.configInterfaces().filter((interfaceArg) => interfaceArg.connect !== false);
const allDevices: IHomematicDevice[] = [];
const raw: Record<string, unknown> = {};
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<string, Record<string, IHomematicValue>> = {};
const paramsetDescriptions: Record<string, Record<string, IHomematicXmlRpcParamsetDescription>> = {};
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<string, IHomematicValue>;
paramsetDescriptions[address] = descriptionSet as Record<string, IHomematicXmlRpcParamsetDescription>;
}
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<IHomematicXmlRpcDeviceDescription[]> {
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<string, Record<string, IHomematicValue>>, paramsetDescriptionsArg: Record<string, Record<string, IHomematicXmlRpcParamsetDescription>>): IHomematicDevice[] {
const parents = descriptionsArg.filter((descriptionArg) => descriptionArg.ADDRESS && !descriptionArg.PARENT);
const channels = descriptionsArg.filter((descriptionArg) => descriptionArg.ADDRESS && descriptionArg.PARENT);
const devices = new Map<string, IHomematicDevice>();
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<string, IHomematicValue>, descriptionsArg: Record<string, IHomematicXmlRpcParamsetDescription>): 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<number, IHomematicChannel>();
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<void> {
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<unknown>): Promise<unknown[]> => {
try {
const value = await factoryArg();
return Array.isArray(value) ? value : [];
} catch {
return [];
}
};
const optionalRecord = async (factoryArg: () => Promise<unknown>): Promise<Record<string, unknown>> => {
try {
const value = await factoryArg();
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
} 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<string, unknown>): Record<string, unknown> => Object.fromEntries(Object.entries(recordArg).filter(([, valueArg]) => valueArg !== undefined));
const clone = <TValue>(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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
const decodeXml = (valueArg: string): string => valueArg
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&');
@@ -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<IHomematicConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IHomematicConfig>> {
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;
}
}
@@ -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 { export class HomematicIntegration extends BaseIntegration<IHomematicConfig> {
constructor() { public readonly domain = 'homematic';
super({ public readonly displayName = 'Homematic';
domain: "homematic", public readonly status = 'control-runtime' as const;
displayName: "Homematic", public readonly discoveryDescriptor = createHomematicDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new HomematicConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/homematic", upstreamPath: 'homeassistant/components/homematic',
"upstreamDomain": "homematic", upstreamDomain: 'homematic',
"iotClass": "local_push", integrationType: 'hub',
"qualityScale": "legacy", iotClass: 'local_push',
"requirements": [ qualityScale: 'legacy',
"pyhomematic==0.1.77" requirements: ['pyhomematic==0.1.77'],
], dependencies: [],
"dependencies": [], afterDependencies: [],
"afterDependencies": [], codeowners: ['@pvizeli'],
"codeowners": [ configFlow: true,
"@pvizeli" 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<IIntegrationRuntime> {
void contextArg;
return new HomematicRuntime(new HomematicClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantHomematicIntegration extends HomematicIntegration {}
class HomematicRuntime implements IIntegrationRuntime {
public domain = 'homematic';
constructor(private readonly client: HomematicClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return HomematicMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return HomematicMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(HomematicMapper.toIntegrationEvent(eventArg)));
return async () => unsubscribe();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<void> {
await this.client.destroy();
} }
} }
@@ -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<string, string[]> = {
ACTUAL_TEMPERATURE: [
'IPAreaThermostat', 'IPWeatherSensor', 'IPWeatherSensorPlus', 'IPWeatherSensorBasic', 'IPThermostatWall',
'IPThermostatWall2', 'ParticulateMatterSensorIP', 'CO2SensorIP', 'TempModuleSTE2',
],
};
export const homematicAttributeSupport: Record<string, { attribute: string; values?: Record<string, string> }> = {
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<string, { unit?: string; deviceClass?: string; stateClass?: string; icon?: string }> = {
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<string, string | undefined> = {
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<string, Record<string, string | null>> = {
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'];
@@ -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<IHomematicManualEntry> {
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<IDiscoveryMatch> {
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<IHomematicMetadataDiscoveryRecord> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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';
};
@@ -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<string, number>();
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<string, number>, 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<string, number>, 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<string, number>, 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<string, number>, 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<string, number>, 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<string, number>, 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<string, number>, 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<string, number>, attributesArg: Record<string, unknown>, 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<string, unknown>;
}
return { value: valueArg };
}
private static cleanAttributes(attributesArg: Record<string, unknown>): Record<string, unknown> {
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';
}
}
+331 -2
View File
@@ -1,4 +1,333 @@
export interface IHomeAssistantHomematicConfig { export const homematicDefaultInterfacePort = 2001;
// TODO: replace with the TypeScript-native config for homematic. 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<string, unknown> | 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<string, IHomematicInterfaceConfig>;
devices?: IHomematicDevice[];
events?: IHomematicEvent[];
snapshot?: IHomematicSnapshot;
commandExecutor?: (commandArg: IHomematicCommand) => Promise<IHomematicCommandResult | unknown>;
}
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<string, unknown>;
}
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<string, IHomematicValue>;
attributes?: Record<string, unknown>;
}
export interface IHomematicServiceMessage {
address?: string;
channel?: number;
parameter?: string;
message?: string;
value?: IHomematicValue;
attributes?: Record<string, unknown>;
}
export interface IHomematicInterface {
id: string;
name: string;
host?: string;
port?: number;
path?: string;
protocol?: THomematicProtocol;
family?: THomematicInterfaceFamily;
online: boolean;
attributes?: Record<string, unknown>;
}
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<string, unknown>;
raw?: Record<string, unknown>;
}
export interface IHomematicChannel {
address: string;
index: number;
name?: string;
type?: string;
parentAddress?: string;
datapoints: IHomematicDatapoint[];
paramsets?: THomematicParamsetKey[];
attributes?: Record<string, unknown>;
raw?: Record<string, unknown>;
}
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<string, unknown>;
}
export interface IHomematicSnapshot {
ccu: IHomematicCcu;
interfaces: IHomematicInterface[];
devices: IHomematicDevice[];
events: IHomematicEvent[];
connected: boolean;
updatedAt: string;
source: THomematicSnapshotSource;
raw?: Record<string, unknown>;
}
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<string, unknown>;
}
export interface IHomematicMetadataDiscoveryRecord {
domain?: string;
integrationDomain?: string;
name?: string;
manufacturer?: string;
model?: string;
host?: string;
port?: number;
metadata?: Record<string, unknown>;
[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; [key: string]: unknown;
} }
+5
View File
@@ -1,2 +1,7 @@
export * from './homematic.classes.integration.js'; 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'; export * from './homematic.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -1,2 +1,6 @@
export * from './knx.classes.integration.js'; 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'; export * from './knx.types.js';
+65
View File
@@ -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<TKnxEventHandler>();
constructor(private readonly config: IKnxConfig) {}
public async getSnapshot(): Promise<IKnxSnapshot> {
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<IKnxCommandResult> {
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<void> {
throw new Error(this.unsupportedMessage());
}
public async destroy(): Promise<void> {
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.`;
}
}
@@ -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<IKnxConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IKnxConfig>> {
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<string, unknown>): 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;
}
}
+106 -30
View File
@@ -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 { export class KnxIntegration extends BaseIntegration<IKnxConfig> {
constructor() { public readonly domain = 'knx';
super({ public readonly displayName = 'KNX';
domain: "knx", public readonly status = 'control-runtime' as const;
displayName: "KNX", public readonly discoveryDescriptor = createKnxDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new KnxConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/knx", upstreamPath: 'homeassistant/components/knx',
"upstreamDomain": "knx", upstreamDomain: 'knx',
"integrationType": "hub", integrationType: 'hub',
"iotClass": "local_push", iotClass: 'local_push',
"qualityScale": "platinum", qualityScale: 'platinum',
"requirements": [ requirements: [
"xknx==3.15.0", 'xknx==3.15.0',
"xknxproject==3.9.0", 'xknxproject==3.9.0',
"knx-frontend==2026.4.30.60856" 'knx-frontend==2026.4.30.60856',
], ],
"dependencies": [ dependencies: [
"file_upload", 'file_upload',
"http", 'http',
"websocket_api" 'websocket_api',
], ],
"afterDependencies": [ afterDependencies: [
"panel_custom" 'panel_custom',
], ],
"codeowners": [ codeowners: [
"@Julius2342", '@Julius2342',
"@farmio", '@farmio',
"@marvin-w" '@marvin-w',
] ],
}, };
public async setup(configArg: IKnxConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new KnxRuntime(new KnxClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantKnxIntegration extends KnxIntegration {}
class KnxRuntime implements IIntegrationRuntime {
public domain = 'knx';
constructor(private readonly client: KnxClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return KnxMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return KnxMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.onEvent((eventArg) => {
handlerArg(KnxMapper.toIntegrationEvent(eventArg));
}); });
await this.client.getSnapshot();
return async () => unsubscribe();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<void> {
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;
} }
} }
+220
View File
@@ -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<IKnxManualDiscoveryEntry> {
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<IDiscoveryMatch> {
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<IKnxGatewayDescriptor> {
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<IDiscoveryMatch> {
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<IKnxMdnsRecord> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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());
};
+559
View File
@@ -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<string, plugins.shxInterfaces.data.IDeviceDefinition>();
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<string, string[]> {
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<string, unknown>;
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<string, unknown> | undefined {
return this.isRecord(valueArg) ? valueArg : undefined;
}
private static isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
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';
}
}
+312 -2
View File
@@ -1,4 +1,314 @@
export interface IHomeAssistantKnxConfig { import type { TEntityPlatform } from '../../core/types.js';
// TODO: replace with the TypeScript-native config for knx.
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; [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<string, IKnxTunnelSlot>;
tunnellingSlots?: Record<string, IKnxTunnelSlot>;
tunnelling_slots?: Record<string, IKnxTunnelSlot>;
metadata?: Record<string, unknown>;
[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<string, unknown>;
[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<string, unknown>;
metadata?: Record<string, unknown>;
raw?: Record<string, unknown>;
[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<string, unknown>;
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> | IKnxCommandResult | unknown;
export interface IKnxManualDiscoveryEntry extends IKnxConnectionConfig {
id?: string;
name?: string;
model?: string;
manufacturer?: string;
gateway?: IKnxGatewayDescriptor;
metadata?: Record<string, unknown>;
}
export interface IKnxMdnsRecord {
type?: string;
name?: string;
host?: string;
hostname?: string;
addresses?: string[];
port?: number;
txt?: Record<string, string | undefined>;
properties?: Record<string, string | undefined>;
}
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -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.classes.integration.js';
export * from './modbus.discovery.js';
export * from './modbus.mapper.js';
export * from './modbus.types.js'; export * from './modbus.types.js';
@@ -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<TModbusEventHandler>();
constructor(private readonly config: IModbusConfig) {}
public async getSnapshot(): Promise<IModbusSnapshot> {
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<IModbusSnapshot> {
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<IModbusCommandResult> {
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<void> {
this.eventHandlers.clear();
}
private async executeCommand(commandArg: IModbusCommand): Promise<IModbusCommandResult> {
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<IModbusTcpResponse> {
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<Buffer> {
return await new Promise<Buffer>((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<number, IModbusSlaveSnapshot>();
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<number, IModbusSlaveSnapshot>, 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<IModbusHubConfig, 'timeout' | 'timeoutMs'> | Pick<IModbusHubSnapshot, 'metadata'>): 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<string, unknown> {
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<string, unknown>): Record<string, unknown> {
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';
@@ -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<IModbusConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IModbusConfig>> {
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;
}
}
@@ -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 { export class ModbusIntegration extends BaseIntegration<IModbusConfig> {
constructor() { public readonly domain = 'modbus';
super({ public readonly displayName = 'Modbus';
domain: "modbus", public readonly status = 'control-runtime' as const;
displayName: "Modbus", public readonly discoveryDescriptor = createModbusDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new ModbusConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/modbus", upstreamPath: 'homeassistant/components/modbus',
"upstreamDomain": "modbus", upstreamDomain: 'modbus',
"iotClass": "local_polling", iotClass: 'local_polling',
"requirements": [ requirements: ['pymodbus==3.11.2'],
"pymodbus==3.11.2" dependencies: [],
], afterDependencies: [],
"dependencies": [], codeowners: [],
"afterDependencies": [], documentation: 'https://www.home-assistant.io/integrations/modbus',
"codeowners": [] 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<IIntegrationRuntime> {
void contextArg;
return new ModbusRuntime(new ModbusClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantModbusIntegration extends ModbusIntegration {}
class ModbusRuntime implements IIntegrationRuntime {
public domain = 'modbus';
constructor(private readonly client: ModbusClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return ModbusMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return ModbusMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(ModbusMapper.toIntegrationEvent(eventArg)));
return async () => unsubscribe();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<void> {
await this.client.destroy();
} }
} }
+124
View File
@@ -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<IModbusManualTcpEntry> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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;
+487
View File
@@ -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<string, number>();
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<IModbusHubSnapshot, 'id'>): string {
return `modbus.hub.${this.slug(hubArg.id)}`;
}
public static slaveDeviceId(hubArg: Pick<IModbusHubSnapshot, 'id'>, 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<string, number>): 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, number>): 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<IModbusCommand, 'hub' | 'hubId' | 'unitId' | 'entityId' | 'deviceId' | 'uniqueId'> {
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<TModbusDataType, 'int' | 'integer' | 'uint' | 'float'> {
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<string, unknown>): Record<string, unknown> {
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;
}
}
+406 -3
View File
@@ -1,4 +1,407 @@
export interface IHomeAssistantModbusConfig { export type TModbusTransport = 'tcp' | 'udp' | 'rtuovertcp' | 'serial';
// TODO: replace with the TypeScript-native config for modbus.
[key: string]: unknown; 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<IModbusCommandResult | unknown>;
} }
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<string, unknown>;
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<string, unknown>;
}
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<string, unknown>;
}
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<string, unknown>;
}
export interface IModbusCoilState {
hubId?: string;
unitId: number;
address: number;
count: number;
inputType: TModbusCoilType | TModbusRegisterType;
bits?: boolean[];
value?: boolean | number | null;
updatedAt?: string;
metadata?: Record<string, unknown>;
}
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<string, unknown>;
}
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<string, unknown>;
}
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<string, unknown>;
}
export interface IModbusSnapshot {
hubs: IModbusHubSnapshot[];
events: IModbusEvent[];
connected: boolean;
updatedAt: string;
raw?: Record<string, unknown>;
}
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<string, unknown>;
}
export interface IModbusDiscoveryEntry extends IModbusManualTcpEntry {}
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -1,2 +1,6 @@
export * from './opentherm_gw.classes.integration.js'; 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'; export * from './opentherm_gw.types.js';
@@ -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<IOpenthermGwSnapshot> {
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<IOpenthermGwCommandResponse> {
return this.command(temporaryArg ? 'TT' : 'TC', roundCommandNumber(temperatureArg, 1));
}
public async setControlSetpoint(temperatureArg: number): Promise<IOpenthermGwCommandResponse> {
return this.command('CS', roundCommandNumber(temperatureArg, 1));
}
public async setHotWater(valueArg: boolean | string | number): Promise<IOpenthermGwCommandResponse> {
if (typeof valueArg === 'boolean') {
return this.command('HW', valueArg ? 1 : 0);
}
return this.command('HW', valueArg);
}
public async setHotWaterSetpoint(temperatureArg: number): Promise<IOpenthermGwCommandResponse> {
return this.command('SW', roundCommandNumber(temperatureArg, 1));
}
public async setOutsideTemperature(temperatureArg: number): Promise<IOpenthermGwCommandResponse> {
return this.command('OT', roundCommandNumber(temperatureArg, 1));
}
public async setCentralHeatingOverride(circuitArg: 1 | 2, enabledArg: boolean): Promise<IOpenthermGwCommandResponse> {
return this.command(circuitArg === 2 ? 'H2' : 'CH', enabledArg ? 1 : 0);
}
public async reset(): Promise<IOpenthermGwCommandResponse> {
return this.command('GW', 'R');
}
public async command(codeArg: TOpenthermGwCommandCode, valueArg: TOpenthermGwCommandValue): Promise<IOpenthermGwCommandResponse> {
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<void> {
this.handlers.clear();
}
private async populateReports(statusArg: IOpenthermGwGatewayStatus): Promise<void> {
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<Pick<IOpenthermGwStatus, 'boiler' | 'thermostat'>> {
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<IOpenthermGwCommandResponse> {
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<IOpenthermGwCommandResponse>((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<IOpenthermGwStatus, 'boiler' | 'thermostat'> => {
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<IOpenthermGwStatus, 'boiler' | 'thermostat'> => {
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<IOpenthermGwStatus, 'boiler' | 'thermostat'> => {
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 = <TValue extends Record<string, TOpenthermGwStatusValue>>(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 = <TValue>(valueArg: TValue): TValue => JSON.parse(JSON.stringify(valueArg)) as TValue;
@@ -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<IOpenthermGwConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IOpenthermGwConfig>> {
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;
};
@@ -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 { export class OpenthermGwIntegration extends BaseIntegration<IOpenthermGwConfig> {
constructor() { public readonly domain = openthermGwDomain;
super({ public readonly displayName = 'OpenTherm Gateway';
domain: "opentherm_gw", public readonly status = 'control-runtime' as const;
displayName: "OpenTherm Gateway", public readonly discoveryDescriptor = createOpenthermGwDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new OpenthermGwConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/opentherm_gw", upstreamPath: 'homeassistant/components/opentherm_gw',
"upstreamDomain": "opentherm_gw", upstreamDomain: openthermGwDomain,
"integrationType": "device", integrationType: 'device',
"iotClass": "local_push", iotClass: 'local_push',
"requirements": [ requirements: ['pyotgw==2.2.3'],
"pyotgw==2.2.3" dependencies: [],
], afterDependencies: [],
"dependencies": [], codeowners: ['@mvn23'],
"afterDependencies": [], configFlow: true,
"codeowners": [ documentation: 'https://www.home-assistant.io/integrations/opentherm_gw',
"@mvn23" };
]
}, public async setup(configArg: IOpenthermGwConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
}); void contextArg;
return new OpenthermGwRuntime(new OpenthermGwClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantOpenthermGwIntegration extends OpenthermGwIntegration {}
class OpenthermGwRuntime implements IIntegrationRuntime {
public domain = openthermGwDomain;
constructor(private readonly client: OpenthermGwClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return OpenthermGwMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return OpenthermGwMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.subscribe((eventArg) => handlerArg(OpenthermGwMapper.toIntegrationEvent(eventArg)));
return async () => unsubscribe();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<void> {
await this.client.destroy();
}
private async callGatewayService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<IServiceCallResult> {
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<IServiceCallResult> {
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<string, unknown> | 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);
} }
} }
@@ -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<IOpenthermGwManualDiscoveryInput> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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());
};
@@ -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<string>();
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<string, TOpenthermGwStatusValue> => 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());
@@ -1,4 +1,260 @@
export interface IHomeAssistantOpenthermGwConfig { export const openthermGwDomain = 'opentherm_gw';
// TODO: replace with the TypeScript-native config for opentherm_gw. export const openthermGwDefaultTcpPort = 23;
[key: string]: unknown; 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<string, unknown>;
} }
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+5
View File
@@ -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.classes.integration.js';
export * from './rflink.constants.js';
export * from './rflink.discovery.js';
export * from './rflink.mapper.js';
export * from './rflink.types.js'; export * from './rflink.types.js';
@@ -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<string, string>();
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<TRflinkEventHandler>();
constructor(private readonly config: IRflinkConfig) {}
public async getSnapshot(): Promise<IRflinkSnapshot> {
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<IRflinkSnapshot> {
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<IRflinkCommandResult> {
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<void> {
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<void> {
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<string, string> {
const result: Record<string, string> = {};
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<string, string> {
const match = valueArg.match(/(?<hardware>[a-zA-Z\s]+) - (?<firmware>[a-zA-Z\s]+) V(?<version>[0-9.]+) - R(?<revision>[0-9.]+)/);
return match?.groups ? { ...match.groups } : {};
}
private static decodeValue(keyArg: string, valueArg: string): unknown {
const hstatus: Record<string, string> = { '0': 'normal', '1': 'comfortable', '2': 'dry', '3': 'wet' };
const bforecast: Record<string, string> = { '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;
}
}
@@ -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<IRflinkConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IRflinkConfig>> {
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;
}
}
@@ -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 { export class RflinkIntegration extends BaseIntegration<IRflinkConfig> {
constructor() { public readonly domain = 'rflink';
super({ public readonly displayName = 'RFLink';
domain: "rflink", public readonly status = 'control-runtime' as const;
displayName: "RFLink", public readonly discoveryDescriptor = createRflinkDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new RflinkConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/rflink", upstreamPath: 'homeassistant/components/rflink',
"upstreamDomain": "rflink", upstreamDomain: 'rflink',
"iotClass": "assumed_state", integrationType: 'hub',
"qualityScale": "legacy", iotClass: 'assumed_state',
"requirements": [ qualityScale: 'legacy',
"rflink==0.0.67" requirements: ['rflink==0.0.67'],
], dependencies: [],
"dependencies": [], afterDependencies: [],
"afterDependencies": [], codeowners: ['@javicalle'],
"codeowners": [ documentation: 'https://www.home-assistant.io/integrations/rflink',
"@javicalle" 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<IIntegrationRuntime> {
void contextArg;
return new RflinkRuntime(new RflinkClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantRflinkIntegration extends RflinkIntegration {}
class RflinkRuntime implements IIntegrationRuntime {
public domain = 'rflink';
constructor(private readonly client: RflinkClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return RflinkMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return RflinkMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(RflinkMapper.toIntegrationEvent(eventArg)));
return async () => unsubscribe();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<void> {
await this.client.destroy();
} }
} }
+112
View File
@@ -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<string, { name: string; unit?: string; deviceClass?: string; stateClass?: string }> = {
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<string, string> = {
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<string, string | undefined> = {
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<string, TRflinkLightType> = {
newkaku: 'hybrid',
};
export const rflinkDefaultCoverTypeByProtocol: Record<string, TRflinkCoverType> = {
newkaku: 'inverted',
};
+100
View File
@@ -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<IRflinkManualEntry> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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());
};
+379
View File
@@ -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<string, number>();
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, number>): 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<string, unknown>;
}
return JSON.stringify(valueArg);
}
private static cleanAttributes(attributesArg: Record<string, unknown>): Record<string, unknown> {
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';
}
}
+205 -3
View File
@@ -1,4 +1,206 @@
export interface IHomeAssistantRflinkConfig { export type TRflinkConnectionType = 'serial' | 'tcp' | 'manual';
// TODO: replace with the TypeScript-native config for rflink.
[key: string]: unknown; 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<IRflinkCommandResult | unknown>;
} }
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<string, unknown>;
}
export type TRflinkEntityCollection = Record<string, Omit<IRflinkEntityConfig, 'id'> | 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<string, unknown>;
metadata?: Record<string, unknown>;
}
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<string, unknown>;
}
export interface IRflinkPacket {
node?: '10' | '11' | '20' | string;
sequence?: string;
protocol?: string;
id?: string;
switch?: string;
command?: string;
values?: Record<string, unknown>;
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<string, unknown>;
}
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<string, unknown>;
}
export interface IRflinkManualTcpEntry {
connectionType?: 'tcp';
host?: string;
port?: number;
id?: string;
name?: string;
manufacturer?: string;
model?: string;
metadata?: Record<string, unknown>;
}
export type IRflinkManualEntry = IRflinkManualSerialEntry | IRflinkManualTcpEntry;
export interface IRflinkCandidateMetadata extends Record<string, unknown> {
rflink?: boolean;
connectionType?: TRflinkConnectionType;
serialPort?: string;
baudRate?: number;
}
export type IHomeAssistantRflinkConfig = IRflinkConfig;
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -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.classes.integration.js';
export * from './velbus.discovery.js';
export * from './velbus.mapper.js';
export * from './velbus.types.js'; export * from './velbus.types.js';
@@ -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<IVelbusSnapshot> {
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<IVelbusCommandResult> {
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<void> {
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));
@@ -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<IVelbusConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IVelbusConfig>> {
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<string, unknown>): 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}`;
};
@@ -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 { export class VelbusIntegration extends BaseIntegration<IVelbusConfig> {
constructor() { public readonly domain = 'velbus';
super({ public readonly displayName = 'Velbus';
domain: "velbus", public readonly status = 'control-runtime' as const;
displayName: "Velbus", public readonly discoveryDescriptor = createVelbusDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new VelbusConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/velbus", upstreamPath: 'homeassistant/components/velbus',
"upstreamDomain": "velbus", upstreamDomain: 'velbus',
"integrationType": "hub", integrationType: 'hub',
"iotClass": "local_push", iotClass: 'local_push',
"qualityScale": "silver", qualityScale: 'silver',
"requirements": [ requirements: ['velbus-aio==2026.4.1'],
"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' },
], ],
"dependencies": [ 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.',
"usb",
"file_upload"
],
"afterDependencies": [],
"codeowners": [
"@Cereal2nd",
"@brefra"
]
}, },
}); 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<IIntegrationRuntime> {
void contextArg;
return new VelbusRuntime(new VelbusClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantVelbusIntegration extends VelbusIntegration {}
class VelbusRuntime implements IIntegrationRuntime {
public domain = 'velbus';
constructor(private readonly client: VelbusClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return VelbusMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return VelbusMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(VelbusMapper.toIntegrationEvent(eventArg)));
return async () => unsubscribe();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<void> {
await this.client.destroy();
} }
} }
+170
View File
@@ -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<IVelbusManualDiscoveryEntry> {
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<IDiscoveryMatch> {
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<IVelbusUsbDiscoveryEntry> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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');
+437
View File
@@ -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<string, number>();
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<string, number>, stateArg: unknown, attributesArg: Record<string, unknown>, 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<TValue extends Record<string, unknown>>(attributesArg: TValue): Record<string, unknown> {
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';
}
}
+177 -3
View File
@@ -1,4 +1,178 @@
export interface IHomeAssistantVelbusConfig { import type { IDiscoveryCandidate, IIntegrationEntity } from '../../core/types.js';
// TODO: replace with the TypeScript-native config for velbus.
[key: string]: unknown; 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<string, unknown>;
}
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<string, unknown>;
}
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<string, unknown>;
}
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<string, unknown> & {
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<string, unknown>;
}
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<string, unknown>;
}
export interface IVelbusUsbDiscoveryEntry {
vid?: string;
pid?: string;
device?: string;
serialNumber?: string;
manufacturer?: string;
product?: string;
metadata?: Record<string, unknown>;
}
export interface IVelbusDiscoveryCandidate extends IDiscoveryCandidate {
metadata?: Record<string, unknown> & {
connection?: TVelbusGatewayConnection;
dsn?: string;
serialPath?: string;
tls?: boolean;
password?: string;
vlpFile?: string;
};
} }