Add native local bus integrations
This commit is contained in:
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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
@@ -12,15 +12,20 @@ import { DenonavrIntegration } from './integrations/denonavr/index.js';
|
||||
import { DlnaDmrIntegration } from './integrations/dlna_dmr/index.js';
|
||||
import { EsphomeIntegration } from './integrations/esphome/index.js';
|
||||
import { HomekitControllerIntegration } from './integrations/homekit_controller/index.js';
|
||||
import { HomematicIntegration } from './integrations/homematic/index.js';
|
||||
import { JellyfinIntegration } from './integrations/jellyfin/index.js';
|
||||
import { KnxIntegration } from './integrations/knx/index.js';
|
||||
import { KodiIntegration } from './integrations/kodi/index.js';
|
||||
import { MatterIntegration } from './integrations/matter/index.js';
|
||||
import { ModbusIntegration } from './integrations/modbus/index.js';
|
||||
import { MqttIntegration } from './integrations/mqtt/index.js';
|
||||
import { MpdIntegration } from './integrations/mpd/index.js';
|
||||
import { NanoleafIntegration } from './integrations/nanoleaf/index.js';
|
||||
import { OpenthermGwIntegration } from './integrations/opentherm_gw/index.js';
|
||||
import { OnvifIntegration } from './integrations/onvif/index.js';
|
||||
import { PlexIntegration } from './integrations/plex/index.js';
|
||||
import { RainbirdIntegration } from './integrations/rainbird/index.js';
|
||||
import { RflinkIntegration } from './integrations/rflink/index.js';
|
||||
import { RokuIntegration } from './integrations/roku/index.js';
|
||||
import { SamsungtvIntegration } from './integrations/samsungtv/index.js';
|
||||
import { ShellyIntegration } from './integrations/shelly/index.js';
|
||||
@@ -29,6 +34,7 @@ import { SonosIntegration } from './integrations/sonos/index.js';
|
||||
import { TplinkIntegration } from './integrations/tplink/index.js';
|
||||
import { TradfriIntegration } from './integrations/tradfri/index.js';
|
||||
import { UnifiIntegration } from './integrations/unifi/index.js';
|
||||
import { VelbusIntegration } from './integrations/velbus/index.js';
|
||||
import { VolumioIntegration } from './integrations/volumio/index.js';
|
||||
import { WolfSmartsetIntegration } from './integrations/wolf_smartset/index.js';
|
||||
import { WizIntegration } from './integrations/wiz/index.js';
|
||||
@@ -50,16 +56,21 @@ export const integrations = [
|
||||
new DlnaDmrIntegration(),
|
||||
new EsphomeIntegration(),
|
||||
new HomekitControllerIntegration(),
|
||||
new HomematicIntegration(),
|
||||
new HueIntegration(),
|
||||
new JellyfinIntegration(),
|
||||
new KnxIntegration(),
|
||||
new KodiIntegration(),
|
||||
new MatterIntegration(),
|
||||
new ModbusIntegration(),
|
||||
new MqttIntegration(),
|
||||
new MpdIntegration(),
|
||||
new NanoleafIntegration(),
|
||||
new OpenthermGwIntegration(),
|
||||
new OnvifIntegration(),
|
||||
new PlexIntegration(),
|
||||
new RainbirdIntegration(),
|
||||
new RflinkIntegration(),
|
||||
new RokuIntegration(),
|
||||
new SamsungtvIntegration(),
|
||||
new ShellyIntegration(),
|
||||
@@ -68,6 +79,7 @@ export const integrations = [
|
||||
new TplinkIntegration(),
|
||||
new TradfriIntegration(),
|
||||
new UnifiIntegration(),
|
||||
new VelbusIntegration(),
|
||||
new VolumioIntegration(),
|
||||
new WolfSmartsetIntegration(),
|
||||
new WizIntegration(),
|
||||
|
||||
@@ -514,7 +514,6 @@ import { HomeAssistantHomeassistantSkyConnectIntegration } from '../homeassistan
|
||||
import { HomeAssistantHomeassistantYellowIntegration } from '../homeassistant_yellow/index.js';
|
||||
import { HomeAssistantHomeeIntegration } from '../homee/index.js';
|
||||
import { HomeAssistantHomekitIntegration } from '../homekit/index.js';
|
||||
import { HomeAssistantHomematicIntegration } from '../homematic/index.js';
|
||||
import { HomeAssistantHomematicipCloudIntegration } from '../homematicip_cloud/index.js';
|
||||
import { HomeAssistantHomevoltIntegration } from '../homevolt/index.js';
|
||||
import { HomeAssistantHomewizardIntegration } from '../homewizard/index.js';
|
||||
@@ -628,7 +627,6 @@ import { HomeAssistantKitchenSinkIntegration } from '../kitchen_sink/index.js';
|
||||
import { HomeAssistantKiwiIntegration } from '../kiwi/index.js';
|
||||
import { HomeAssistantKmtronicIntegration } from '../kmtronic/index.js';
|
||||
import { HomeAssistantKnockiIntegration } from '../knocki/index.js';
|
||||
import { HomeAssistantKnxIntegration } from '../knx/index.js';
|
||||
import { HomeAssistantKonnectedIntegration } from '../konnected/index.js';
|
||||
import { HomeAssistantKonnectedEsphomeIntegration } from '../konnected_esphome/index.js';
|
||||
import { HomeAssistantKostalPlenticoreIntegration } from '../kostal_plenticore/index.js';
|
||||
@@ -756,7 +754,6 @@ import { HomeAssistantMjpegIntegration } from '../mjpeg/index.js';
|
||||
import { HomeAssistantMoatIntegration } from '../moat/index.js';
|
||||
import { HomeAssistantMobileAppIntegration } from '../mobile_app/index.js';
|
||||
import { HomeAssistantMochadIntegration } from '../mochad/index.js';
|
||||
import { HomeAssistantModbusIntegration } from '../modbus/index.js';
|
||||
import { HomeAssistantModemCalleridIntegration } from '../modem_callerid/index.js';
|
||||
import { HomeAssistantModernFormsIntegration } from '../modern_forms/index.js';
|
||||
import { HomeAssistantMoehlenhoffAlpha2Integration } from '../moehlenhoff_alpha2/index.js';
|
||||
@@ -881,7 +878,6 @@ import { HomeAssistantOpenhomeIntegration } from '../openhome/index.js';
|
||||
import { HomeAssistantOpenrgbIntegration } from '../openrgb/index.js';
|
||||
import { HomeAssistantOpensensemapIntegration } from '../opensensemap/index.js';
|
||||
import { HomeAssistantOpenskyIntegration } from '../opensky/index.js';
|
||||
import { HomeAssistantOpenthermGwIntegration } from '../opentherm_gw/index.js';
|
||||
import { HomeAssistantOpenuvIntegration } from '../openuv/index.js';
|
||||
import { HomeAssistantOpenweathermapIntegration } from '../openweathermap/index.js';
|
||||
import { HomeAssistantOpnsenseIntegration } from '../opnsense/index.js';
|
||||
@@ -1022,7 +1018,6 @@ import { HomeAssistantRepetierIntegration } from '../repetier/index.js';
|
||||
import { HomeAssistantRestIntegration } from '../rest/index.js';
|
||||
import { HomeAssistantRestCommandIntegration } from '../rest_command/index.js';
|
||||
import { HomeAssistantRexelIntegration } from '../rexel/index.js';
|
||||
import { HomeAssistantRflinkIntegration } from '../rflink/index.js';
|
||||
import { HomeAssistantRfxtrxIntegration } from '../rfxtrx/index.js';
|
||||
import { HomeAssistantRhasspyIntegration } from '../rhasspy/index.js';
|
||||
import { HomeAssistantRidwellIntegration } from '../ridwell/index.js';
|
||||
@@ -1319,7 +1314,6 @@ import { HomeAssistantValloxIntegration } from '../vallox/index.js';
|
||||
import { HomeAssistantValveIntegration } from '../valve/index.js';
|
||||
import { HomeAssistantVasttrafikIntegration } from '../vasttrafik/index.js';
|
||||
import { HomeAssistantVegehubIntegration } from '../vegehub/index.js';
|
||||
import { HomeAssistantVelbusIntegration } from '../velbus/index.js';
|
||||
import { HomeAssistantVeluxIntegration } from '../velux/index.js';
|
||||
import { HomeAssistantVenstarIntegration } from '../venstar/index.js';
|
||||
import { HomeAssistantVeraIntegration } from '../vera/index.js';
|
||||
@@ -1940,7 +1934,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeassistantSkyCon
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeassistantYellowIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomeeIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomekitIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomematicIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomematicipCloudIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomevoltIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHomewizardIntegration());
|
||||
@@ -2054,7 +2047,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantKitchenSinkIntegrat
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKiwiIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKmtronicIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKnockiIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKnxIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKonnectedIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKonnectedEsphomeIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantKostalPlenticoreIntegration());
|
||||
@@ -2182,7 +2174,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantMjpegIntegration())
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMoatIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMobileAppIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMochadIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantModbusIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantModemCalleridIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantModernFormsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMoehlenhoffAlpha2Integration());
|
||||
@@ -2307,7 +2298,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenhomeIntegration
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenrgbIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpensensemapIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenskyIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenthermGwIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenuvIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenweathermapIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpnsenseIntegration());
|
||||
@@ -2448,7 +2438,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantRepetierIntegration
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRestIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRestCommandIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRexelIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRflinkIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRfxtrxIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRhasspyIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantRidwellIntegration());
|
||||
@@ -2745,7 +2734,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantValloxIntegration()
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantValveIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVasttrafikIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVegehubIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVelbusIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVeluxIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVenstarIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantVeraIntegration());
|
||||
@@ -2852,7 +2840,7 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
|
||||
|
||||
export const generatedHomeAssistantPortCount = 1424;
|
||||
export const generatedHomeAssistantPortCount = 1418;
|
||||
export const handwrittenHomeAssistantPortDomains = [
|
||||
"androidtv",
|
||||
"axis",
|
||||
@@ -2863,16 +2851,21 @@ export const handwrittenHomeAssistantPortDomains = [
|
||||
"dlna_dmr",
|
||||
"esphome",
|
||||
"homekit_controller",
|
||||
"homematic",
|
||||
"hue",
|
||||
"jellyfin",
|
||||
"knx",
|
||||
"kodi",
|
||||
"matter",
|
||||
"modbus",
|
||||
"mpd",
|
||||
"mqtt",
|
||||
"nanoleaf",
|
||||
"onvif",
|
||||
"opentherm_gw",
|
||||
"plex",
|
||||
"rainbird",
|
||||
"rflink",
|
||||
"roku",
|
||||
"samsungtv",
|
||||
"shelly",
|
||||
@@ -2881,6 +2874,7 @@ export const handwrittenHomeAssistantPortDomains = [
|
||||
"tplink",
|
||||
"tradfri",
|
||||
"unifi",
|
||||
"velbus",
|
||||
"volumio",
|
||||
"wiz",
|
||||
"xiaomi_miio",
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const decodeXml = (valueArg: string): string => valueArg
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "homematic",
|
||||
displayName: "Homematic",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/homematic",
|
||||
"upstreamDomain": "homematic",
|
||||
"iotClass": "local_push",
|
||||
"qualityScale": "legacy",
|
||||
"requirements": [
|
||||
"pyhomematic==0.1.77"
|
||||
export class HomematicIntegration extends BaseIntegration<IHomematicConfig> {
|
||||
public readonly domain = 'homematic';
|
||||
public readonly displayName = 'Homematic';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createHomematicDiscoveryDescriptor();
|
||||
public readonly configFlow = new HomematicConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/homematic',
|
||||
upstreamDomain: 'homematic',
|
||||
integrationType: 'hub',
|
||||
iotClass: 'local_push',
|
||||
qualityScale: 'legacy',
|
||||
requirements: ['pyhomematic==0.1.77'],
|
||||
dependencies: [],
|
||||
afterDependencies: [],
|
||||
codeowners: ['@pvizeli'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/homematic',
|
||||
discovery: {
|
||||
manual: true,
|
||||
metadata: true,
|
||||
dhcp: false,
|
||||
mdns: false,
|
||||
note: 'Home Assistant Homematic is YAML/manual XML-RPC configuration; this runtime provides manual and metadata discovery only.',
|
||||
},
|
||||
runtime: {
|
||||
type: 'control-runtime',
|
||||
polling: 'snapshot/manual or XML-RPC snapshot when a host is configured',
|
||||
services: ['set_value', 'press', 'turn_on', 'turn_off', 'open', 'close', 'stop', 'set_temperature', 'raw_xmlrpc', 'refresh'],
|
||||
eventServer: 'unsupported; subscribe emits local command/runtime events only',
|
||||
},
|
||||
localApi: {
|
||||
implemented: [
|
||||
'manual/snapshot CCU interface/device/channel/datapoint state',
|
||||
'XML-RPC methodCall encoding for setValue and raw calls',
|
||||
'XML-RPC listDevices/getParamset/getParamsetDescription snapshot best effort',
|
||||
'Home Assistant-style set_device_value/virtualkey service aliases',
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@pvizeli"
|
||||
]
|
||||
},
|
||||
});
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,333 @@
|
||||
export interface IHomeAssistantHomematicConfig {
|
||||
// TODO: replace with the TypeScript-native config for homematic.
|
||||
export const homematicDefaultInterfacePort = 2001;
|
||||
export const homematicDefaultJsonPort = 80;
|
||||
|
||||
export type THomematicProtocol = 'http' | 'https';
|
||||
export type THomematicSnapshotSource = 'snapshot' | 'manual' | 'xmlrpc' | 'runtime';
|
||||
export type THomematicValueType = 'boolean' | 'integer' | 'double' | 'string' | 'dateTime.iso8601' | 'array' | 'struct' | 'unknown';
|
||||
export type THomematicDatapointKind = 'sensor' | 'binary' | 'attribute' | 'write' | 'action' | 'event' | 'unknown';
|
||||
export type THomematicParamsetKey = 'VALUES' | 'MASTER' | 'LINK' | (string & {});
|
||||
export type THomematicInterfaceFamily = 'bidcos-rf' | 'hmip-rf' | 'bidcos-wired' | 'virtual' | 'homegear' | 'unknown' | (string & {});
|
||||
|
||||
export type THomematicEventType =
|
||||
| 'snapshot_refreshed'
|
||||
| 'command_mapped'
|
||||
| 'command_executed'
|
||||
| 'command_failed'
|
||||
| 'datapoint_changed'
|
||||
| 'keypress'
|
||||
| 'impulse'
|
||||
| 'error';
|
||||
|
||||
export type IHomematicValue = string | number | boolean | null | Record<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;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
export * from './homematic.classes.integration.js';
|
||||
export * from './homematic.classes.client.js';
|
||||
export * from './homematic.classes.configflow.js';
|
||||
export * from './homematic.constants.js';
|
||||
export * from './homematic.discovery.js';
|
||||
export * from './homematic.mapper.js';
|
||||
export * from './homematic.types.js';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './knx.classes.integration.js';
|
||||
export * from './knx.classes.client.js';
|
||||
export * from './knx.classes.configflow.js';
|
||||
export * from './knx.discovery.js';
|
||||
export * from './knx.mapper.js';
|
||||
export * from './knx.types.js';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,113 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { KnxClient } from './knx.classes.client.js';
|
||||
import { KnxConfigFlow } from './knx.classes.configflow.js';
|
||||
import { createKnxDiscoveryDescriptor } from './knx.discovery.js';
|
||||
import { KnxMapper } from './knx.mapper.js';
|
||||
import type { IKnxClientCommand, IKnxConfig } from './knx.types.js';
|
||||
|
||||
export class HomeAssistantKnxIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "knx",
|
||||
displayName: "KNX",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/knx",
|
||||
"upstreamDomain": "knx",
|
||||
"integrationType": "hub",
|
||||
"iotClass": "local_push",
|
||||
"qualityScale": "platinum",
|
||||
"requirements": [
|
||||
"xknx==3.15.0",
|
||||
"xknxproject==3.9.0",
|
||||
"knx-frontend==2026.4.30.60856"
|
||||
export class KnxIntegration extends BaseIntegration<IKnxConfig> {
|
||||
public readonly domain = 'knx';
|
||||
public readonly displayName = 'KNX';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createKnxDiscoveryDescriptor();
|
||||
public readonly configFlow = new KnxConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/knx',
|
||||
upstreamDomain: 'knx',
|
||||
integrationType: 'hub',
|
||||
iotClass: 'local_push',
|
||||
qualityScale: 'platinum',
|
||||
requirements: [
|
||||
'xknx==3.15.0',
|
||||
'xknxproject==3.9.0',
|
||||
'knx-frontend==2026.4.30.60856',
|
||||
],
|
||||
"dependencies": [
|
||||
"file_upload",
|
||||
"http",
|
||||
"websocket_api"
|
||||
dependencies: [
|
||||
'file_upload',
|
||||
'http',
|
||||
'websocket_api',
|
||||
],
|
||||
"afterDependencies": [
|
||||
"panel_custom"
|
||||
afterDependencies: [
|
||||
'panel_custom',
|
||||
],
|
||||
"codeowners": [
|
||||
"@Julius2342",
|
||||
"@farmio",
|
||||
"@marvin-w"
|
||||
]
|
||||
},
|
||||
codeowners: [
|
||||
'@Julius2342',
|
||||
'@farmio',
|
||||
'@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,314 @@
|
||||
export interface IHomeAssistantKnxConfig {
|
||||
// TODO: replace with the TypeScript-native config for knx.
|
||||
import type { TEntityPlatform } from '../../core/types.js';
|
||||
|
||||
export type TKnxConnectionType =
|
||||
| 'automatic'
|
||||
| 'routing'
|
||||
| 'routing_secure'
|
||||
| 'tunneling'
|
||||
| 'tunneling_tcp'
|
||||
| 'tunneling_tcp_secure'
|
||||
| string;
|
||||
|
||||
export type TKnxGroupAddress = string;
|
||||
export type TKnxTelegramAction = 'read' | 'write' | 'response';
|
||||
export type TKnxEntityPlatform = TEntityPlatform | string;
|
||||
|
||||
export interface IKnxConfig extends IKnxConnectionConfig {
|
||||
gateway?: IKnxGatewayDescriptor;
|
||||
gateways?: IKnxGatewayDescriptor[];
|
||||
tunnels?: IKnxGatewayDescriptor[];
|
||||
groupAddresses?: IKnxGroupAddressDescriptor[];
|
||||
group_addresses?: IKnxGroupAddressDescriptor[];
|
||||
entities?: IKnxEntityDescriptor[];
|
||||
light?: IKnxEntityDescriptor[];
|
||||
switch?: IKnxEntityDescriptor[];
|
||||
sensor?: IKnxEntityDescriptor[];
|
||||
cover?: IKnxEntityDescriptor[];
|
||||
climate?: IKnxEntityDescriptor[];
|
||||
snapshot?: IKnxSnapshot;
|
||||
events?: IKnxEvent[];
|
||||
commandExecutor?: TKnxCommandExecutor;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantKnxConfig extends IKnxConfig {}
|
||||
|
||||
export interface IKnxConnectionConfig {
|
||||
connectionType?: TKnxConnectionType;
|
||||
connection_type?: TKnxConnectionType;
|
||||
host?: string;
|
||||
port?: number;
|
||||
individualAddress?: string;
|
||||
individual_address?: string;
|
||||
localIp?: string;
|
||||
local_ip?: string;
|
||||
multicastGroup?: string;
|
||||
multicast_group?: string;
|
||||
multicastPort?: number;
|
||||
multicast_port?: number;
|
||||
routeBack?: boolean;
|
||||
route_back?: boolean;
|
||||
tunnelEndpointIa?: string | null;
|
||||
tunnel_endpoint_ia?: string | null;
|
||||
userId?: number | null;
|
||||
user_id?: number | null;
|
||||
userPassword?: string | null;
|
||||
user_password?: string | null;
|
||||
deviceAuthentication?: string | null;
|
||||
device_authentication?: string | null;
|
||||
backboneKey?: string | null;
|
||||
backbone_key?: string | null;
|
||||
syncLatencyTolerance?: number | null;
|
||||
sync_latency_tolerance?: number | null;
|
||||
knxkeysFilename?: string;
|
||||
knxkeys_filename?: string;
|
||||
knxkeysPassword?: string;
|
||||
knxkeys_password?: string;
|
||||
stateUpdater?: boolean;
|
||||
state_updater?: boolean;
|
||||
rateLimit?: number;
|
||||
rate_limit?: number;
|
||||
telegramLogSize?: number;
|
||||
telegram_log_size?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IKnxGatewayDescriptor {
|
||||
id?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
ipAddr?: string;
|
||||
ip_addr?: string;
|
||||
ipAddress?: string;
|
||||
port?: number;
|
||||
localIp?: string;
|
||||
local_ip?: string;
|
||||
localInterface?: string;
|
||||
local_interface?: string;
|
||||
individualAddress?: unknown;
|
||||
individual_address?: unknown;
|
||||
supportsRouting?: boolean;
|
||||
supports_routing?: boolean;
|
||||
supportsTunneling?: boolean;
|
||||
supportsTunnelling?: boolean;
|
||||
supports_tunnelling?: boolean;
|
||||
supportsTunnelingTcp?: boolean;
|
||||
supportsTunnellingTcp?: boolean;
|
||||
supports_tunnelling_tcp?: boolean;
|
||||
supportsSecure?: boolean;
|
||||
supports_secure?: boolean;
|
||||
routingRequiresSecure?: boolean | null;
|
||||
routing_requires_secure?: boolean | null;
|
||||
tunnelingRequiresSecure?: boolean | null;
|
||||
tunnellingRequiresSecure?: boolean | null;
|
||||
tunnelling_requires_secure?: boolean | null;
|
||||
tunnelingSlots?: Record<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.
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './modbus.classes.client.js';
|
||||
export * from './modbus.classes.configflow.js';
|
||||
export * from './modbus.classes.integration.js';
|
||||
export * from './modbus.discovery.js';
|
||||
export * from './modbus.mapper.js';
|
||||
export * from './modbus.types.js';
|
||||
|
||||
@@ -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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "modbus",
|
||||
displayName: "Modbus",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/modbus",
|
||||
"upstreamDomain": "modbus",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"pymodbus==3.11.2"
|
||||
export class ModbusIntegration extends BaseIntegration<IModbusConfig> {
|
||||
public readonly domain = 'modbus';
|
||||
public readonly displayName = 'Modbus';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createModbusDiscoveryDescriptor();
|
||||
public readonly configFlow = new ModbusConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/modbus',
|
||||
upstreamDomain: 'modbus',
|
||||
iotClass: 'local_polling',
|
||||
requirements: ['pymodbus==3.11.2'],
|
||||
dependencies: [],
|
||||
afterDependencies: [],
|
||||
codeowners: [],
|
||||
documentation: 'https://www.home-assistant.io/integrations/modbus',
|
||||
configFlow: true,
|
||||
discovery: {
|
||||
manual: true,
|
||||
tcp: true,
|
||||
serial: false,
|
||||
note: 'Manual Modbus TCP setup is implemented. Serial RTU ports are not guessed or probed.',
|
||||
},
|
||||
runtime: {
|
||||
type: 'control-runtime',
|
||||
polling: 'local snapshot or explicit Modbus TCP requests',
|
||||
services: ['read_coil', 'read_register', 'write_coil', 'write_register', 'stop', 'restart', 'refresh'],
|
||||
},
|
||||
localApi: {
|
||||
implemented: [
|
||||
'manual Modbus TCP hub configuration',
|
||||
'snapshot/manual configured registers and coils',
|
||||
'Modbus TCP MBAP request shape for read coils, read discrete inputs, read holding/input registers, write coil(s), and write register(s)',
|
||||
'register/coils mapping to sensor, binary_sensor, switch, and number entities',
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": []
|
||||
},
|
||||
});
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,407 @@
|
||||
export interface IHomeAssistantModbusConfig {
|
||||
// TODO: replace with the TypeScript-native config for modbus.
|
||||
[key: string]: unknown;
|
||||
export type TModbusTransport = 'tcp' | 'udp' | 'rtuovertcp' | 'serial';
|
||||
|
||||
export type TModbusRegisterType = 'holding' | 'input';
|
||||
|
||||
export type TModbusCoilType = 'coil' | 'discrete_input';
|
||||
|
||||
export type TModbusDataType =
|
||||
| 'custom'
|
||||
| 'string'
|
||||
| 'int'
|
||||
| 'integer'
|
||||
| 'int16'
|
||||
| 'int32'
|
||||
| 'int64'
|
||||
| 'uint'
|
||||
| 'uint16'
|
||||
| 'uint32'
|
||||
| 'uint64'
|
||||
| 'float'
|
||||
| 'float16'
|
||||
| 'float32'
|
||||
| 'float64';
|
||||
|
||||
export type TModbusSwap = 'byte' | 'word' | 'word_byte';
|
||||
|
||||
export type TModbusEntityPlatform = 'sensor' | 'binary_sensor' | 'switch' | 'number';
|
||||
|
||||
export type TModbusEventType =
|
||||
| 'snapshot_refreshed'
|
||||
| 'command_mapped'
|
||||
| 'command_executed'
|
||||
| 'command_failed'
|
||||
| 'register_read'
|
||||
| 'register_written'
|
||||
| 'coil_read'
|
||||
| 'coil_written';
|
||||
|
||||
export interface IModbusConfig {
|
||||
hubs?: IModbusHubConfig[];
|
||||
modbus?: IModbusHubConfig[];
|
||||
snapshot?: IModbusSnapshot;
|
||||
manualEntries?: IModbusManualTcpEntry[];
|
||||
connected?: boolean;
|
||||
timeoutMs?: number;
|
||||
commandExecutor?: (commandArg: IModbusCommand) => Promise<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.
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './opentherm_gw.classes.integration.js';
|
||||
export * from './opentherm_gw.classes.client.js';
|
||||
export * from './opentherm_gw.classes.configflow.js';
|
||||
export * from './opentherm_gw.discovery.js';
|
||||
export * from './opentherm_gw.mapper.js';
|
||||
export * from './opentherm_gw.types.js';
|
||||
|
||||
@@ -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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "opentherm_gw",
|
||||
displayName: "OpenTherm Gateway",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/opentherm_gw",
|
||||
"upstreamDomain": "opentherm_gw",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_push",
|
||||
"requirements": [
|
||||
"pyotgw==2.2.3"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@mvn23"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class OpenthermGwIntegration extends BaseIntegration<IOpenthermGwConfig> {
|
||||
public readonly domain = openthermGwDomain;
|
||||
public readonly displayName = 'OpenTherm Gateway';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createOpenthermGwDiscoveryDescriptor();
|
||||
public readonly configFlow = new OpenthermGwConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/opentherm_gw',
|
||||
upstreamDomain: openthermGwDomain,
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_push',
|
||||
requirements: ['pyotgw==2.2.3'],
|
||||
dependencies: [],
|
||||
afterDependencies: [],
|
||||
codeowners: ['@mvn23'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/opentherm_gw',
|
||||
};
|
||||
|
||||
public async setup(configArg: IOpenthermGwConfig, contextArg: IIntegrationSetupContext): Promise<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 {
|
||||
// TODO: replace with the TypeScript-native config for opentherm_gw.
|
||||
[key: string]: unknown;
|
||||
export const openthermGwDomain = 'opentherm_gw';
|
||||
export const openthermGwDefaultTcpPort = 23;
|
||||
export const openthermGwDefaultTimeoutMs = 5000;
|
||||
|
||||
export type TOpenthermGwDataSource = 'gateway' | 'boiler' | 'thermostat';
|
||||
export type TOpenthermGwSnapshotSource = 'manual' | 'snapshot' | 'tcp' | 'runtime';
|
||||
export type TOpenthermGwEventType = 'status' | 'command' | 'error';
|
||||
export type TOpenthermGwHvacAction = 'heating' | 'cooling' | 'idle' | 'off';
|
||||
export type TOpenthermGwHvacMode = 'heat' | 'cool' | 'off';
|
||||
export type TOpenthermGwWaterHeaterMode = 'auto' | 'heat' | 'off' | 'unknown';
|
||||
export type TOpenthermGwStatusValue = string | number | boolean | null | undefined;
|
||||
export type TOpenthermGwCommandValue = string | number | boolean;
|
||||
|
||||
export type TOpenthermGwCommandCode =
|
||||
| 'TT'
|
||||
| 'TC'
|
||||
| 'OT'
|
||||
| 'SC'
|
||||
| 'HW'
|
||||
| 'PR'
|
||||
| 'PS'
|
||||
| 'GW'
|
||||
| 'LA'
|
||||
| 'LB'
|
||||
| 'LC'
|
||||
| 'LD'
|
||||
| 'LE'
|
||||
| 'LF'
|
||||
| 'GA'
|
||||
| 'GB'
|
||||
| 'SB'
|
||||
| 'TS'
|
||||
| 'SH'
|
||||
| 'SW'
|
||||
| 'MM'
|
||||
| 'CS'
|
||||
| 'C2'
|
||||
| 'CH'
|
||||
| 'H2'
|
||||
| string;
|
||||
|
||||
export interface IOpenthermGwConfig {
|
||||
id?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
device?: string;
|
||||
timeoutMs?: number;
|
||||
temporaryOverrideMode?: boolean;
|
||||
snapshot?: IOpenthermGwSnapshot;
|
||||
status?: IOpenthermGwStatus;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantOpenthermGwConfig extends IOpenthermGwConfig {}
|
||||
|
||||
export interface IOpenthermGwGatewayInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
device?: string;
|
||||
firmwareVersion?: string;
|
||||
}
|
||||
|
||||
export interface IOpenthermGwStatus {
|
||||
gateway: IOpenthermGwGatewayStatus;
|
||||
boiler: IOpenthermGwDeviceStatus;
|
||||
thermostat: IOpenthermGwDeviceStatus;
|
||||
}
|
||||
|
||||
export interface IOpenthermGwGatewayStatus {
|
||||
otgw_mode?: TOpenthermGwStatusValue;
|
||||
otgw_dhw_ovrd?: TOpenthermGwStatusValue;
|
||||
otgw_about?: TOpenthermGwStatusValue;
|
||||
otgw_build?: TOpenthermGwStatusValue;
|
||||
otgw_clockmhz?: TOpenthermGwStatusValue;
|
||||
otgw_led_a?: TOpenthermGwStatusValue;
|
||||
otgw_led_b?: TOpenthermGwStatusValue;
|
||||
otgw_led_c?: TOpenthermGwStatusValue;
|
||||
otgw_led_d?: TOpenthermGwStatusValue;
|
||||
otgw_led_e?: TOpenthermGwStatusValue;
|
||||
otgw_led_f?: TOpenthermGwStatusValue;
|
||||
otgw_gpio_a?: TOpenthermGwStatusValue;
|
||||
otgw_gpio_b?: TOpenthermGwStatusValue;
|
||||
otgw_gpio_a_state?: TOpenthermGwStatusValue;
|
||||
otgw_gpio_b_state?: TOpenthermGwStatusValue;
|
||||
otgw_reset_cause?: TOpenthermGwStatusValue;
|
||||
otgw_setback_temp?: TOpenthermGwStatusValue;
|
||||
otgw_setpoint_ovrd_mode?: TOpenthermGwStatusValue;
|
||||
otgw_smart_pwr?: TOpenthermGwStatusValue;
|
||||
otgw_temp_sensor?: TOpenthermGwStatusValue;
|
||||
otgw_thermostat_detect?: TOpenthermGwStatusValue;
|
||||
otgw_ignore_transitions?: TOpenthermGwStatusValue;
|
||||
otgw_ovrd_high_byte?: TOpenthermGwStatusValue;
|
||||
otgw_vref?: TOpenthermGwStatusValue;
|
||||
central_heating_1_override?: TOpenthermGwStatusValue;
|
||||
central_heating_2_override?: TOpenthermGwStatusValue;
|
||||
[key: string]: TOpenthermGwStatusValue;
|
||||
}
|
||||
|
||||
export interface IOpenthermGwDeviceStatus {
|
||||
master_ch_enabled?: TOpenthermGwStatusValue;
|
||||
master_dhw_enabled?: TOpenthermGwStatusValue;
|
||||
master_cooling_enabled?: TOpenthermGwStatusValue;
|
||||
master_otc_enabled?: TOpenthermGwStatusValue;
|
||||
master_ch2_enabled?: TOpenthermGwStatusValue;
|
||||
slave_fault_indication?: TOpenthermGwStatusValue;
|
||||
slave_ch_active?: TOpenthermGwStatusValue;
|
||||
slave_dhw_active?: TOpenthermGwStatusValue;
|
||||
slave_flame_on?: TOpenthermGwStatusValue;
|
||||
slave_cooling_active?: TOpenthermGwStatusValue;
|
||||
slave_ch2_active?: TOpenthermGwStatusValue;
|
||||
slave_diagnostic_indication?: TOpenthermGwStatusValue;
|
||||
control_setpoint?: TOpenthermGwStatusValue;
|
||||
control_setpoint_2?: TOpenthermGwStatusValue;
|
||||
room_setpoint?: TOpenthermGwStatusValue;
|
||||
room_setpoint_2?: TOpenthermGwStatusValue;
|
||||
room_setpoint_ovrd?: TOpenthermGwStatusValue;
|
||||
room_temp?: TOpenthermGwStatusValue;
|
||||
outside_temp?: TOpenthermGwStatusValue;
|
||||
ch_water_temp?: TOpenthermGwStatusValue;
|
||||
ch_water_temp_2?: TOpenthermGwStatusValue;
|
||||
dhw_temp?: TOpenthermGwStatusValue;
|
||||
dhw_temp_2?: TOpenthermGwStatusValue;
|
||||
dhw_setpoint?: TOpenthermGwStatusValue;
|
||||
max_ch_setpoint?: TOpenthermGwStatusValue;
|
||||
relative_mod_level?: TOpenthermGwStatusValue;
|
||||
ch_water_pressure?: TOpenthermGwStatusValue;
|
||||
dhw_flow_rate?: TOpenthermGwStatusValue;
|
||||
return_water_temp?: TOpenthermGwStatusValue;
|
||||
exhaust_temp?: TOpenthermGwStatusValue;
|
||||
slave_max_relative_modulation?: TOpenthermGwStatusValue;
|
||||
slave_max_capacity?: TOpenthermGwStatusValue;
|
||||
slave_min_mod_level?: TOpenthermGwStatusValue;
|
||||
slave_dhw_present?: TOpenthermGwStatusValue;
|
||||
slave_control_type?: TOpenthermGwStatusValue;
|
||||
slave_cooling_supported?: TOpenthermGwStatusValue;
|
||||
slave_dhw_config?: TOpenthermGwStatusValue;
|
||||
slave_master_low_off_pump?: TOpenthermGwStatusValue;
|
||||
slave_ch2_present?: TOpenthermGwStatusValue;
|
||||
slave_service_required?: TOpenthermGwStatusValue;
|
||||
slave_remote_reset?: TOpenthermGwStatusValue;
|
||||
slave_low_water_pressure?: TOpenthermGwStatusValue;
|
||||
slave_gas_fault?: TOpenthermGwStatusValue;
|
||||
slave_air_pressure_fault?: TOpenthermGwStatusValue;
|
||||
slave_water_overtemp?: TOpenthermGwStatusValue;
|
||||
remote_transfer_dhw?: TOpenthermGwStatusValue;
|
||||
remote_transfer_max_ch?: TOpenthermGwStatusValue;
|
||||
remote_rw_dhw?: TOpenthermGwStatusValue;
|
||||
remote_rw_max_ch?: TOpenthermGwStatusValue;
|
||||
slave_memberid?: TOpenthermGwStatusValue;
|
||||
master_memberid?: TOpenthermGwStatusValue;
|
||||
slave_oem_fault?: TOpenthermGwStatusValue;
|
||||
oem_diag?: TOpenthermGwStatusValue;
|
||||
burner_starts?: TOpenthermGwStatusValue;
|
||||
ch_pump_starts?: TOpenthermGwStatusValue;
|
||||
dhw_pump_starts?: TOpenthermGwStatusValue;
|
||||
dhw_burner_starts?: TOpenthermGwStatusValue;
|
||||
burner_hours?: TOpenthermGwStatusValue;
|
||||
ch_pump_hours?: TOpenthermGwStatusValue;
|
||||
dhw_pump_hours?: TOpenthermGwStatusValue;
|
||||
dhw_burner_hours?: TOpenthermGwStatusValue;
|
||||
master_ot_version?: TOpenthermGwStatusValue;
|
||||
slave_ot_version?: TOpenthermGwStatusValue;
|
||||
master_product_type?: TOpenthermGwStatusValue;
|
||||
master_product_version?: TOpenthermGwStatusValue;
|
||||
slave_product_type?: TOpenthermGwStatusValue;
|
||||
slave_product_version?: TOpenthermGwStatusValue;
|
||||
[key: string]: TOpenthermGwStatusValue;
|
||||
}
|
||||
|
||||
export interface IOpenthermGwSensorDescription {
|
||||
key: string;
|
||||
source: TOpenthermGwDataSource;
|
||||
name: string;
|
||||
unit?: string;
|
||||
deviceClass?: string;
|
||||
stateClass?: 'measurement' | 'total';
|
||||
}
|
||||
|
||||
export interface IOpenthermGwBinarySensorDescription {
|
||||
key: string;
|
||||
source: TOpenthermGwDataSource;
|
||||
name: string;
|
||||
deviceClass?: string;
|
||||
}
|
||||
|
||||
export interface IOpenthermGwSwitchDescription {
|
||||
key: 'central_heating_1_override' | 'central_heating_2_override';
|
||||
name: string;
|
||||
circuit: 1 | 2;
|
||||
}
|
||||
|
||||
export interface IOpenthermGwClimateState {
|
||||
currentTemperature?: number;
|
||||
targetTemperature?: number;
|
||||
hvacMode: TOpenthermGwHvacMode;
|
||||
hvacAction: TOpenthermGwHvacAction;
|
||||
presetMode: 'none' | 'away';
|
||||
minTemp: number;
|
||||
maxTemp: number;
|
||||
temperatureUnit: 'C';
|
||||
}
|
||||
|
||||
export interface IOpenthermGwWaterHeaterState {
|
||||
currentTemperature?: number;
|
||||
targetTemperature?: number;
|
||||
operationMode: TOpenthermGwWaterHeaterMode;
|
||||
hotWaterActive?: boolean;
|
||||
temperatureUnit: 'C';
|
||||
}
|
||||
|
||||
export interface IOpenthermGwCommandRequest {
|
||||
code: TOpenthermGwCommandCode;
|
||||
value: TOpenthermGwCommandValue;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface IOpenthermGwCommandResponse {
|
||||
code: TOpenthermGwCommandCode;
|
||||
value: string;
|
||||
accepted: boolean;
|
||||
commandLine: string;
|
||||
rawLines: string[];
|
||||
summaryLine?: string;
|
||||
errorCode?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IOpenthermGwEvent {
|
||||
type: TOpenthermGwEventType;
|
||||
gatewayId: string;
|
||||
timestamp: number;
|
||||
status?: IOpenthermGwStatus;
|
||||
command?: IOpenthermGwCommandResponse;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface IOpenthermGwSnapshot {
|
||||
gateway: IOpenthermGwGatewayInfo;
|
||||
status: IOpenthermGwStatus;
|
||||
climate?: IOpenthermGwClimateState;
|
||||
waterHeater?: IOpenthermGwWaterHeaterState;
|
||||
commands?: IOpenthermGwCommandResponse[];
|
||||
events?: IOpenthermGwEvent[];
|
||||
online: boolean;
|
||||
source: TOpenthermGwSnapshotSource;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IOpenthermGwManualDiscoveryInput {
|
||||
id?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
device?: string;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
metadata?: Record<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.
|
||||
@@ -1,2 +1,7 @@
|
||||
export * from './rflink.classes.client.js';
|
||||
export * from './rflink.classes.configflow.js';
|
||||
export * from './rflink.classes.integration.js';
|
||||
export * from './rflink.constants.js';
|
||||
export * from './rflink.discovery.js';
|
||||
export * from './rflink.mapper.js';
|
||||
export * from './rflink.types.js';
|
||||
|
||||
@@ -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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "rflink",
|
||||
displayName: "RFLink",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/rflink",
|
||||
"upstreamDomain": "rflink",
|
||||
"iotClass": "assumed_state",
|
||||
"qualityScale": "legacy",
|
||||
"requirements": [
|
||||
"rflink==0.0.67"
|
||||
export class RflinkIntegration extends BaseIntegration<IRflinkConfig> {
|
||||
public readonly domain = 'rflink';
|
||||
public readonly displayName = 'RFLink';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createRflinkDiscoveryDescriptor();
|
||||
public readonly configFlow = new RflinkConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/rflink',
|
||||
upstreamDomain: 'rflink',
|
||||
integrationType: 'hub',
|
||||
iotClass: 'assumed_state',
|
||||
qualityScale: 'legacy',
|
||||
requirements: ['rflink==0.0.67'],
|
||||
dependencies: [],
|
||||
afterDependencies: [],
|
||||
codeowners: ['@javicalle'],
|
||||
documentation: 'https://www.home-assistant.io/integrations/rflink',
|
||||
configFlow: true,
|
||||
discovery: {
|
||||
manual: true,
|
||||
serial: true,
|
||||
tcp: true,
|
||||
note: 'Home Assistant RFLink is YAML/manual setup. This TypeScript port recognizes explicit manual serial and TCP gateway entries.',
|
||||
},
|
||||
runtime: {
|
||||
type: 'control-runtime',
|
||||
polling: 'snapshot/manual',
|
||||
services: ['turn_on', 'turn_off', 'set_value', 'open', 'close', 'learn', 'send_command', 'refresh'],
|
||||
lineProtocol: 'RFLink 10;protocol;id;switch;command; command lines with optional commandExecutor transport.',
|
||||
},
|
||||
localApi: {
|
||||
implemented: [
|
||||
'Manual serial/TCP gateway configuration shape without a serial package dependency',
|
||||
'RFLink packet/event typing and basic line packet parsing',
|
||||
'RFLink command line shape generation including raw learning lines',
|
||||
'Configured/manual snapshot mapping for light, switch, sensor, binary_sensor, and cover entities',
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@javicalle"
|
||||
]
|
||||
},
|
||||
});
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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());
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,206 @@
|
||||
export interface IHomeAssistantRflinkConfig {
|
||||
// TODO: replace with the TypeScript-native config for rflink.
|
||||
[key: string]: unknown;
|
||||
export type TRflinkConnectionType = 'serial' | 'tcp' | 'manual';
|
||||
|
||||
export type TRflinkEntityPlatform = 'light' | 'switch' | 'sensor' | 'binary_sensor' | 'cover';
|
||||
|
||||
export type TRflinkLightType = 'switchable' | 'dimmable' | 'hybrid' | 'toggle';
|
||||
|
||||
export type TRflinkCoverType = 'standard' | 'inverted';
|
||||
|
||||
export type TRflinkEventType =
|
||||
| 'availability'
|
||||
| 'command'
|
||||
| 'command_mapped'
|
||||
| 'command_executed'
|
||||
| 'command_failed'
|
||||
| 'gateway'
|
||||
| 'learn'
|
||||
| 'raw_line'
|
||||
| 'sensor'
|
||||
| 'snapshot_refreshed';
|
||||
|
||||
export type TRflinkCommandType =
|
||||
| 'turn_on'
|
||||
| 'turn_off'
|
||||
| 'set_value'
|
||||
| 'open_cover'
|
||||
| 'close_cover'
|
||||
| 'stop_cover'
|
||||
| 'learn'
|
||||
| 'send_command'
|
||||
| 'refresh';
|
||||
|
||||
export interface IRflinkConfig {
|
||||
host?: string;
|
||||
port?: string | number;
|
||||
connectionType?: TRflinkConnectionType;
|
||||
baudRate?: number;
|
||||
waitForAck?: boolean;
|
||||
tcpKeepaliveIdleTimer?: number;
|
||||
reconnectInterval?: number;
|
||||
ignoreDevices?: string[];
|
||||
automaticAdd?: boolean;
|
||||
connected?: boolean;
|
||||
gateway?: IRflinkGateway;
|
||||
devices?: TRflinkEntityCollection;
|
||||
entities?: IRflinkEntityConfig[];
|
||||
lights?: TRflinkEntityCollection;
|
||||
switches?: TRflinkEntityCollection;
|
||||
sensors?: TRflinkEntityCollection;
|
||||
binarySensors?: TRflinkEntityCollection;
|
||||
covers?: TRflinkEntityCollection;
|
||||
events?: IRflinkEvent[];
|
||||
snapshot?: IRflinkSnapshot;
|
||||
commandExecutor?: (commandArg: IRflinkCommand) => Promise<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.
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './velbus.classes.client.js';
|
||||
export * from './velbus.classes.configflow.js';
|
||||
export * from './velbus.classes.integration.js';
|
||||
export * from './velbus.discovery.js';
|
||||
export * from './velbus.mapper.js';
|
||||
export * from './velbus.types.js';
|
||||
|
||||
@@ -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 {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "velbus",
|
||||
displayName: "Velbus",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/velbus",
|
||||
"upstreamDomain": "velbus",
|
||||
"integrationType": "hub",
|
||||
"iotClass": "local_push",
|
||||
"qualityScale": "silver",
|
||||
"requirements": [
|
||||
"velbus-aio==2026.4.1"
|
||||
export class VelbusIntegration extends BaseIntegration<IVelbusConfig> {
|
||||
public readonly domain = 'velbus';
|
||||
public readonly displayName = 'Velbus';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createVelbusDiscoveryDescriptor();
|
||||
public readonly configFlow = new VelbusConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/velbus',
|
||||
upstreamDomain: 'velbus',
|
||||
integrationType: 'hub',
|
||||
iotClass: 'local_push',
|
||||
qualityScale: 'silver',
|
||||
requirements: ['velbus-aio==2026.4.1'],
|
||||
dependencies: ['usb', 'file_upload'],
|
||||
afterDependencies: [],
|
||||
codeowners: ['@Cereal2nd', '@brefra'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/velbus',
|
||||
discovery: {
|
||||
manual: ['serial', 'tcp'],
|
||||
usb: [
|
||||
{ vid: '10CF', pid: '0B1B' },
|
||||
{ vid: '10CF', pid: '0516' },
|
||||
{ vid: '10CF', pid: '0517' },
|
||||
{ vid: '10CF', pid: '0518' },
|
||||
],
|
||||
"dependencies": [
|
||||
"usb",
|
||||
"file_upload"
|
||||
note: 'Home Assistant Velbus supports config flow for USB/serial and TCP/IP gateways; this runtime exposes manual serial/TCP discovery plus USB VID/PID matching.',
|
||||
},
|
||||
runtime: {
|
||||
type: 'control-runtime',
|
||||
mode: 'manual snapshot',
|
||||
entities: ['light', 'switch', 'sensor', 'binary_sensor', 'cover', 'climate'],
|
||||
services: ['turn_on', 'turn_off', 'set_value', 'open', 'close'],
|
||||
},
|
||||
localApi: {
|
||||
implemented: [
|
||||
'Home Assistant Velbus entity semantics from velbus-aio channel collections',
|
||||
'manual serial/TCP gateway configuration DSNs',
|
||||
'snapshot module/channel mapping',
|
||||
'manual snapshot command mutation for supported service calls',
|
||||
],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@Cereal2nd",
|
||||
"@brefra"
|
||||
]
|
||||
},
|
||||
});
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,178 @@
|
||||
export interface IHomeAssistantVelbusConfig {
|
||||
// TODO: replace with the TypeScript-native config for velbus.
|
||||
[key: string]: unknown;
|
||||
import type { IDiscoveryCandidate, IIntegrationEntity } from '../../core/types.js';
|
||||
|
||||
export type TVelbusGatewayConnection = 'serial' | 'tcp' | 'manual';
|
||||
|
||||
export type TVelbusChannelKind =
|
||||
| 'relay'
|
||||
| 'dimmer'
|
||||
| 'button'
|
||||
| 'led'
|
||||
| 'binary_sensor'
|
||||
| 'blind'
|
||||
| 'sensor'
|
||||
| 'temperature'
|
||||
| 'counter'
|
||||
| 'light_sensor'
|
||||
| 'climate';
|
||||
|
||||
export type TVelbusHvacMode = 'heat' | 'cool';
|
||||
|
||||
export type TVelbusPresetMode = 'away' | 'comfort' | 'eco' | 'home' | 'safe' | 'night' | 'day';
|
||||
|
||||
export type TVelbusChannelState = string | number | boolean | null;
|
||||
|
||||
export interface IVelbusGatewayConfig {
|
||||
id?: string;
|
||||
name?: string;
|
||||
connection: TVelbusGatewayConnection;
|
||||
dsn?: string;
|
||||
serialPath?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
tls?: boolean;
|
||||
password?: string;
|
||||
vlpFile?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
metadata?: Record<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;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user