Add native local edge service integrations

This commit is contained in:
2026-05-08 11:23:08 +00:00
parent 23f988eae7
commit d7a332ec60
274 changed files with 9451 additions and 841 deletions
@@ -0,0 +1,71 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { EmoncmsHistoryClient, EmoncmsHistoryConfigFlow, EmoncmsHistoryIntegration, EmoncmsHistoryMapper, HomeAssistantEmoncmsHistoryIntegration, createEmoncmsHistoryDiscoveryDescriptor, emoncmsHistoryProfile, type IEmoncmsHistorySnapshot } from '../../ts/integrations/emoncms_history/index.js';
const rawData = {
url: 'http://emoncms.local',
inputnode: 12,
payload: {
'sensor.grid_power': 421.2,
'sensor.solar_energy': 18.4,
},
units: {
'sensor.grid_power': 'W',
'sensor.solar_energy': 'kWh',
},
};
tap.test('matches manual Emoncms History candidates and creates config flow output', async () => {
const descriptor = createEmoncmsHistoryDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'emoncms_history-manual-match');
const result = await matcher!.matches({ host: 'emoncms.local', name: 'Emoncms History', metadata: { rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('emoncms_history');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new EmoncmsHistoryConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('emoncms.local');
expect(done.config?.path).toEqual('/input/post.json');
});
tap.test('maps Emoncms History raw payload snapshots to runtime devices and entities', async () => {
const client = new EmoncmsHistoryClient({ name: 'History Exporter', rawData, whitelist: ['sensor.grid_power'] });
const snapshot = await client.getSnapshot();
const devices = EmoncmsHistoryMapper.toDevices(snapshot);
const entities = EmoncmsHistoryMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(snapshot.source).toEqual('manual');
expect(devices[0].integrationDomain).toEqual('emoncms_history');
expect(devices[0].manufacturer).toEqual('OpenEnergyMonitor');
expect(entities.length).toEqual(2);
expect(entities[0].state).toEqual(421.2);
expect(entities[0].attributes?.unit).toEqual('W');
});
tap.test('exposes Emoncms History runtime services, HA alias, and executor-gated control', async () => {
const integration = new EmoncmsHistoryIntegration();
const alias = new HomeAssistantEmoncmsHistoryIntegration();
expect(alias.domain).toEqual('emoncms_history');
expect(integration.status).toEqual('control-runtime');
expect(emoncmsHistoryProfile.metadata.configFlow).toEqual(false);
expect(emoncmsHistoryProfile.metadata.requirements).toEqual(['pyemoncms==0.1.3']);
const runtime = await integration.setup({ name: 'History Exporter', rawData }, {});
const status = await runtime.callService!({ domain: 'emoncms_history', service: 'status', target: {} });
const snapshot = status.data as IEmoncmsHistorySnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('History Exporter');
const command = await runtime.callService!({ domain: 'emoncms_history', service: 'input_post', target: {} });
expect(command.success).toBeFalse();
expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue();
await runtime.destroy();
});
export default tap.start();
+75
View File
@@ -0,0 +1,75 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { EmonitorClient, EmonitorConfigFlow, EmonitorIntegration, EmonitorMapper, HomeAssistantEmonitorIntegration, createEmonitorDiscoveryDescriptor, emonitorProfile, type IEmonitorSnapshot } from '../../ts/integrations/emonitor/index.js';
const rawData = {
network: {
mac_address: '00:90:C2:12:34:56',
},
hardware: {
firmware_version: '1.2.3',
serial_number: 'EMON123456',
},
channels: {
1: { active: true, label: 'Mains', inst_power: 100, avg_power: 90, max_power: 130, paired_with_channel: 2 },
2: { active: true, label: 'Mains B', inst_power: 50, avg_power: 45, max_power: 70, paired_with_channel: 1 },
3: { active: false, label: 'Spare', inst_power: 0, avg_power: 0, max_power: 0 },
4: { active: true, label: 'Solar', inst_power: 321, avg_power: 300, max_power: 350 },
},
};
tap.test('matches manual Emonitor candidates and creates config flow output', async () => {
const descriptor = createEmonitorDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'emonitor-manual-match');
const result = await matcher!.matches({ host: 'emonitor.local', name: 'SiteSage Emonitor', metadata: { rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('emonitor');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new EmonitorConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('emonitor.local');
expect(done.config?.rawData).toEqual(rawData);
});
tap.test('maps Emonitor raw status snapshots to power devices and entities', async () => {
const client = new EmonitorClient({ host: 'emonitor.local', rawData });
const snapshot = await client.getSnapshot();
const devices = EmonitorMapper.toDevices(snapshot);
const entities = EmonitorMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(devices[0].integrationDomain).toEqual('emonitor');
expect(devices[0].manufacturer).toEqual('Powerhouse Dynamics, Inc.');
expect(devices[0].name).toEqual('Emonitor 123456');
expect(entities.length).toEqual(6);
expect(entities[0].state).toEqual(150);
expect(entities[0].attributes?.unit).toEqual('W');
expect(entities[0].attributes?.deviceClass).toEqual('power');
});
tap.test('exposes Emonitor read-only runtime, HA alias, and unsupported control without executor', async () => {
const integration = new EmonitorIntegration();
const alias = new HomeAssistantEmonitorIntegration();
expect(alias.domain).toEqual('emonitor');
expect(integration.status).toEqual('read-only-runtime');
expect(emonitorProfile.metadata.configFlow).toEqual(true);
expect(emonitorProfile.metadata.requirements).toEqual(['aioemonitor==1.0.5']);
const runtime = await integration.setup({ rawData }, {});
const status = await runtime.callService!({ domain: 'emonitor', service: 'status', target: {} });
const snapshot = status.data as IEmonitorSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Emonitor 123456');
const command = await runtime.callService!({ domain: 'emonitor', service: 'turn_on', target: {} });
expect(command.success).toBeFalse();
expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue();
await runtime.destroy();
});
export default tap.start();
@@ -0,0 +1,72 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { EmulatedHueClient, EmulatedHueConfigFlow, EmulatedHueIntegration, EmulatedHueMapper, HomeAssistantEmulatedHueIntegration, createEmulatedHueDiscoveryDescriptor, emulatedHueProfile, type IEmulatedHueSnapshot } from '../../ts/integrations/emulated_hue/index.js';
const rawData = {
config: {
name: 'HASS BRIDGE',
ipaddress: '192.0.2.15:8300',
mac: '00:00:00:00:00:00',
},
states: [
{ entity_id: 'light.kitchen', state: 'on', attributes: { friendly_name: 'Kitchen Hue', brightness: 128, supported_color_modes: ['brightness'] } },
{ entity_id: 'media_player.living_room', state: 'off', attributes: { friendly_name: 'Living Room', volume_level: 0.4 } },
],
};
tap.test('matches manual Emulated Hue candidates and creates config flow output', async () => {
const descriptor = createEmulatedHueDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'emulated_hue-manual-match');
const result = await matcher!.matches({ host: '192.0.2.15', name: 'HASS BRIDGE', metadata: { rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('emulated_hue');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new EmulatedHueConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('192.0.2.15');
expect(done.config?.port).toEqual(8300);
});
tap.test('maps Emulated Hue raw HA states to bridge devices and entities', async () => {
const client = new EmulatedHueClient({ rawData });
const snapshot = await client.getSnapshot();
const devices = EmulatedHueMapper.toDevices(snapshot);
const entities = EmulatedHueMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(devices[0].integrationDomain).toEqual('emulated_hue');
expect(devices[0].manufacturer).toEqual('Home Assistant');
expect(devices[0].name).toEqual('HASS BRIDGE');
expect(entities.length).toEqual(2);
expect(entities[0].platform).toEqual('light');
expect(entities[0].state).toEqual(true);
expect(entities[0].attributes?.brightness).toEqual(128);
});
tap.test('exposes Emulated Hue runtime services, HA alias, and executor-gated control', async () => {
const integration = new EmulatedHueIntegration();
const alias = new HomeAssistantEmulatedHueIntegration();
expect(alias.domain).toEqual('emulated_hue');
expect(integration.status).toEqual('control-runtime');
expect(emulatedHueProfile.metadata.configFlow).toEqual(false);
expect(emulatedHueProfile.metadata.dependencies).toEqual(['network']);
expect(emulatedHueProfile.metadata.afterDependencies).toEqual(['http']);
const runtime = await integration.setup({ rawData }, {});
const status = await runtime.callService!({ domain: 'emulated_hue', service: 'status', target: {} });
const snapshot = status.data as IEmulatedHueSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('HASS BRIDGE');
const command = await runtime.callService!({ domain: 'light', service: 'turn_on', target: { entityId: 'light.kitchen' } });
expect(command.success).toBeFalse();
expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue();
await runtime.destroy();
});
export default tap.start();
@@ -0,0 +1,74 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { EmulatedKasaClient, EmulatedKasaConfigFlow, EmulatedKasaIntegration, EmulatedKasaMapper, HomeAssistantEmulatedKasaIntegration, createEmulatedKasaDiscoveryDescriptor, emulatedKasaProfile, type IEmulatedKasaSnapshot } from '../../ts/integrations/emulated_kasa/index.js';
const rawData = {
entities: {
'switch.coffee_maker': {
name: 'Coffee Maker',
unique_id: 'switch.kasa.coffee_maker',
state: 'on',
power: 42.5,
},
'sensor.fridge_power': {
name: 'Fridge Power',
domain: 'sensor',
state: '18.2',
},
},
};
tap.test('matches manual Emulated Kasa candidates and creates config flow output', async () => {
const descriptor = createEmulatedKasaDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'emulated_kasa-manual-match');
const result = await matcher!.matches({ host: 'kasa-emulator.local', name: 'Emulated Kasa', metadata: { rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('emulated_kasa');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new EmulatedKasaConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('kasa-emulator.local');
expect(done.config?.port).toEqual(9999);
});
tap.test('maps Emulated Kasa raw snapshots to devices and entities', async () => {
const client = new EmulatedKasaClient({ name: 'Kasa Bridge', rawData });
const snapshot = await client.getSnapshot();
const devices = EmulatedKasaMapper.toDevices(snapshot);
const entities = EmulatedKasaMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(snapshot.source).toEqual('manual');
expect(devices[0].integrationDomain).toEqual('emulated_kasa');
expect(devices[0].manufacturer).toEqual('Home Assistant');
expect(entities.length).toEqual(3);
expect(entities.some((entityArg) => entityArg.platform === 'sensor' && entityArg.state === 42.5)).toBeTrue();
expect(entities.some((entityArg) => entityArg.platform === 'binary_sensor' && entityArg.state === true)).toBeTrue();
});
tap.test('exposes Emulated Kasa read-only runtime, HA alias, and unsupported control', async () => {
const integration = new EmulatedKasaIntegration();
const alias = new HomeAssistantEmulatedKasaIntegration();
expect(alias.domain).toEqual('emulated_kasa');
expect(integration.status).toEqual('read-only-runtime');
expect(emulatedKasaProfile.metadata.configFlow).toEqual(false);
expect(emulatedKasaProfile.metadata.requirements).toEqual(['sense-energy==0.14.1']);
const runtime = await integration.setup({ name: 'Kasa Runtime', rawData }, {});
const status = await runtime.callService!({ domain: 'emulated_kasa', service: 'status', target: {} });
const snapshot = status.data as IEmulatedKasaSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Kasa Runtime');
const command = await runtime.callService!({ domain: 'emulated_kasa', service: 'turn_on', target: {} });
expect(command.success).toBeFalse();
expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue();
await runtime.destroy();
});
export default tap.start();
@@ -0,0 +1,72 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { EmulatedRokuClient, EmulatedRokuConfigFlow, EmulatedRokuIntegration, EmulatedRokuMapper, HomeAssistantEmulatedRokuIntegration, createEmulatedRokuDiscoveryDescriptor, emulatedRokuProfile, type IEmulatedRokuSnapshot } from '../../ts/integrations/emulated_roku/index.js';
const rawData = {
servers: [
{
name: 'Living Room Remote',
host_ip: '192.168.1.20',
listen_port: 8060,
advertise_ip: '192.168.1.20',
advertise_port: 8060,
upnp_bind_multicast: true,
state: 'listening',
},
],
};
tap.test('matches manual Emulated Roku candidates and creates config flow output', async () => {
const descriptor = createEmulatedRokuDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'emulated_roku-manual-match');
const result = await matcher!.matches({ host: 'roku-emulator.local', name: 'Emulated Roku', metadata: { rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('emulated_roku');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new EmulatedRokuConfigFlow().start(result.candidate!, {})).submit!({ name: 'Living Room Remote' });
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('roku-emulator.local');
expect(done.config?.port).toEqual(8060);
});
tap.test('maps Emulated Roku raw snapshots to devices and entities', async () => {
const client = new EmulatedRokuClient({ name: 'Roku Runtime', rawData });
const snapshot = await client.getSnapshot();
const devices = EmulatedRokuMapper.toDevices(snapshot);
const entities = EmulatedRokuMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(devices[0].integrationDomain).toEqual('emulated_roku');
expect(devices[0].manufacturer).toEqual('Home Assistant');
expect(entities.length).toEqual(1);
expect(entities[0].platform).toEqual('sensor');
expect(entities[0].state).toEqual('listening');
});
tap.test('exposes Emulated Roku read-only runtime, HA alias, and unsupported control', async () => {
const integration = new EmulatedRokuIntegration();
const alias = new HomeAssistantEmulatedRokuIntegration();
expect(alias.domain).toEqual('emulated_roku');
expect(integration.status).toEqual('read-only-runtime');
expect(emulatedRokuProfile.metadata.configFlow).toEqual(true);
expect(emulatedRokuProfile.metadata.requirements).toEqual(['emulated-roku==0.3.0']);
expect(emulatedRokuProfile.metadata.dependencies).toEqual(['network']);
const runtime = await integration.setup({ name: 'Roku Runtime', rawData }, {});
const status = await runtime.callService!({ domain: 'emulated_roku', service: 'status', target: {} });
const snapshot = status.data as IEmulatedRokuSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Roku Runtime');
const command = await runtime.callService!({ domain: 'emulated_roku', service: 'turn_on', target: {} });
expect(command.success).toBeFalse();
expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue();
await runtime.destroy();
});
export default tap.start();
@@ -0,0 +1,68 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { EnergeniePowerSocketsClient, EnergeniePowerSocketsConfigFlow, EnergeniePowerSocketsIntegration, EnergeniePowerSocketsMapper, HomeAssistantEnergeniePowerSocketsIntegration, createEnergeniePowerSocketsDiscoveryDescriptor, energeniePowerSocketsProfile, type IEnergeniePowerSocketsSnapshot } from '../../ts/integrations/energenie_power_sockets/index.js';
const rawData = {
device_id: 'EGPS-1234',
name: 'Desk Power Strip',
manufacturer: 'Energenie',
numberOfSockets: 4,
sockets: [true, false, true, false],
sw_version: '0.2.5',
};
tap.test('matches manual Energenie Power Sockets candidates and creates config flow output', async () => {
const descriptor = createEnergeniePowerSocketsDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'energenie_power_sockets-manual-match');
const result = await matcher!.matches({ name: 'Energenie Power Sockets', metadata: { rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('energenie_power_sockets');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new EnergeniePowerSocketsConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.name).toEqual('Energenie Power Sockets');
expect(done.config?.rawData).toEqual(rawData);
});
tap.test('maps Energenie raw snapshots to switch devices and entities', async () => {
const client = new EnergeniePowerSocketsClient({ deviceApiId: 'EGPS-1234', rawData });
const snapshot = await client.getSnapshot();
const devices = EnergeniePowerSocketsMapper.toDevices(snapshot);
const entities = EnergeniePowerSocketsMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(devices[0].integrationDomain).toEqual('energenie_power_sockets');
expect(devices[0].manufacturer).toEqual('Energenie');
expect(devices[0].features.length).toEqual(4);
expect(devices[0].features[0].writable).toBeTrue();
expect(entities.length).toEqual(4);
expect(entities[0].platform).toEqual('switch');
expect(entities[0].state).toBeTrue();
});
tap.test('exposes Energenie runtime, HA alias, and explicit unsupported control without executor', async () => {
const integration = new EnergeniePowerSocketsIntegration();
const alias = new HomeAssistantEnergeniePowerSocketsIntegration();
expect(alias.domain).toEqual('energenie_power_sockets');
expect(integration.status).toEqual('control-runtime');
expect(energeniePowerSocketsProfile.metadata.configFlow).toEqual(true);
expect(energeniePowerSocketsProfile.metadata.requirements).toEqual(['pyegps==0.2.5']);
const runtime = await integration.setup({ deviceApiId: 'EGPS-1234', rawData }, {});
const status = await runtime.callService!({ domain: 'energenie_power_sockets', service: 'status', target: {} });
const snapshot = status.data as IEnergeniePowerSocketsSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Desk Power Strip');
const command = await runtime.callService!({ domain: 'switch', service: 'turn_off', target: { entityId: 'switch.desk_power_strip_socket_0' } });
expect(command.success).toBeFalse();
expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue();
await runtime.destroy();
});
export default tap.start();
+85
View File
@@ -0,0 +1,85 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { Enigma2Client, Enigma2ConfigFlow, Enigma2Integration, Enigma2Mapper, HomeAssistantEnigma2Integration, createEnigma2DiscoveryDescriptor, enigma2Profile, type IEnigma2Snapshot } from '../../ts/integrations/enigma2/index.js';
const rawData = {
about: {
info: {
brand: 'Dream Property',
model: 'DM920',
ifaces: [{ mac: 'AA:BB:CC:DD:EE:FF' }],
},
},
status: {
in_standby: false,
muted: false,
volume: 35,
is_recording: true,
source_list: ['BBC One HD', 'BBC Two HD'],
currservice: {
station: 'BBC One HD',
name: 'News at Six',
serviceref: '1:0:1:1234:0:0:0:0:0:0:',
fulldescription: 'Evening news',
begin: 1715200000,
end: 1715201800,
},
},
};
tap.test('matches manual Enigma2 candidates and creates config flow output', async () => {
const descriptor = createEnigma2DiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'enigma2-manual-match');
const result = await matcher!.matches({ host: 'enigma2.local', name: 'Living Room Receiver', metadata: { rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('enigma2');
expect(result.candidate?.port).toEqual(80);
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new Enigma2ConfigFlow().start(result.candidate!, {})).submit!({ username: 'root', password: 'dreambox' });
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('enigma2.local');
expect(done.config?.port).toEqual(80);
expect(done.config?.username).toEqual('root');
});
tap.test('maps Enigma2 raw snapshots to media player devices and entities', async () => {
const client = new Enigma2Client({ host: 'enigma2.local', name: 'Living Room Receiver', rawData });
const snapshot = await client.getSnapshot();
const devices = Enigma2Mapper.toDevices(snapshot);
const entities = Enigma2Mapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(devices[0].integrationDomain).toEqual('enigma2');
expect(devices[0].manufacturer).toEqual('Dream Property');
expect(entities.length).toEqual(1);
expect(entities[0].platform).toEqual('media_player');
expect(entities[0].state).toEqual('on');
expect(entities[0].attributes?.volumeLevel).toEqual(0.35);
});
tap.test('exposes Enigma2 runtime, HA alias, and explicit unsupported control without executor', async () => {
const integration = new Enigma2Integration();
const alias = new HomeAssistantEnigma2Integration();
expect(alias.domain).toEqual('enigma2');
expect(integration.status).toEqual('control-runtime');
expect(enigma2Profile.metadata.configFlow).toEqual(true);
expect(enigma2Profile.metadata.requirements).toEqual(['openwebifpy==4.3.1']);
const runtime = await integration.setup({ host: 'enigma2.local', name: 'Living Room Receiver', rawData }, {});
const status = await runtime.callService!({ domain: 'enigma2', service: 'status', target: {} });
const snapshot = status.data as IEnigma2Snapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Living Room Receiver');
const controlCommand = await runtime.callService!({ domain: 'media_player', service: 'turn_off', target: {} });
expect(controlCommand.success).toBeFalse();
expect(controlCommand.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue();
await runtime.destroy();
});
export default tap.start();
+73
View File
@@ -0,0 +1,73 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { EnoceanClient, EnoceanConfigFlow, EnoceanIntegration, EnoceanMapper, HomeAssistantEnoceanIntegration, createEnoceanDiscoveryDescriptor, enoceanProfile, type IEnoceanSnapshot } from '../../ts/integrations/enocean/index.js';
const rawData = {
gateway: {
device: '/dev/ttyUSB0',
manufacturer: 'EnOcean',
model: 'USB 300',
baseAddress: [0, 0, 0, 1],
},
telegrams: [
{ id: [1, 2, 3, 4], name: 'Window Handle', deviceClass: 'windowhandle', state: 'open' },
{ id: [5, 6, 7, 8], name: 'Room Temperature', deviceClass: 'temperature', state: 21.5 },
{ id: [9, 10, 11, 12], name: 'Relay Channel 0', platform: 'switch', state: true, channel: 0 },
],
};
tap.test('matches manual EnOcean candidates and creates config flow output', async () => {
const descriptor = createEnoceanDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'enocean-manual-match');
const result = await matcher!.matches({ name: 'EnOcean USB 300', metadata: { rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('enocean');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new EnoceanConfigFlow().start(result.candidate!, {})).submit!({ name: 'EnOcean USB 300' });
expect(done.kind).toEqual('done');
expect(done.config?.name).toEqual('EnOcean USB 300');
expect(done.config?.transport).toEqual('snapshot');
});
tap.test('maps EnOcean raw snapshots to gateway devices and entities', async () => {
const client = new EnoceanClient({ device: '/dev/ttyUSB0', name: 'EnOcean USB 300', rawData });
const snapshot = await client.getSnapshot();
const devices = EnoceanMapper.toDevices(snapshot);
const entities = EnoceanMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(devices[0].integrationDomain).toEqual('enocean');
expect(devices[0].manufacturer).toEqual('EnOcean');
expect(entities.length).toEqual(3);
expect(entities[0].platform).toEqual('binary_sensor');
expect(entities[0].state).toEqual(true);
expect(entities[0].attributes?.deviceClass).toEqual('opening');
expect(entities[2].platform).toEqual('switch');
});
tap.test('exposes EnOcean runtime, HA alias, and explicit unsupported control without executor', async () => {
const integration = new EnoceanIntegration();
const alias = new HomeAssistantEnoceanIntegration();
expect(alias.domain).toEqual('enocean');
expect(integration.status).toEqual('control-runtime');
expect(enoceanProfile.metadata.dependencies).toEqual(['usb']);
expect(enoceanProfile.metadata.requirements).toEqual(['enocean-async==0.4.2']);
const runtime = await integration.setup({ device: '/dev/ttyUSB0', name: 'EnOcean USB 300', rawData }, {});
const status = await runtime.callService!({ domain: 'enocean', service: 'status', target: {} });
const snapshot = status.data as IEnoceanSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('EnOcean USB 300');
const controlCommand = await runtime.callService!({ domain: 'switch', service: 'turn_on', target: {} });
expect(controlCommand.success).toBeFalse();
expect(controlCommand.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue();
await runtime.destroy();
});
export default tap.start();
@@ -0,0 +1,99 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { EnphaseEnvoyClient, EnphaseEnvoyConfigFlow, EnphaseEnvoyIntegration, EnphaseEnvoyMapper, HomeAssistantEnphaseEnvoyIntegration, createEnphaseEnvoyDiscoveryDescriptor, enphaseEnvoyProfile, type IEnphaseEnvoySnapshot } from '../../ts/integrations/enphase_envoy/index.js';
const rawData = {
serialNumber: '122334455667',
envoyModel: 'IQ Envoy',
firmware: 'D7.6.175',
partNumber: '800-00554-r01',
system_production: {
watts_now: 420,
watt_hours_today: 3100,
watt_hours_lifetime: 9123456,
},
system_consumption: {
watts_now: 275,
watt_hours_today: 2200,
watt_hours_lifetime: 8123456,
},
inverters: {
'INV-1': { last_report_watts: 210 },
},
encharge_aggregate: {
state_of_charge: 82,
reserve_state_of_charge: 30,
},
enpower: {
serial_number: 'ENP-1',
mains_oper_state: 'closed',
mains_admin_state: 'closed',
firmware_version: '7.6.175',
},
dry_contact_status: {
relay1: { status: 'closed', name: 'Load Shed Relay' },
},
tariff: {
storage_settings: {
charge_from_grid: true,
reserved_soc: 30,
mode: 'backup',
},
},
};
tap.test('matches manual Enphase Envoy candidates and creates config flow output', async () => {
const descriptor = createEnphaseEnvoyDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'enphase_envoy-manual-match');
const result = await matcher!.matches({ host: 'envoy.local', name: 'Envoy 122334455667', metadata: { rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('enphase_envoy');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new EnphaseEnvoyConfigFlow().start(result.candidate!, {})).submit!({ username: 'installer', password: '' });
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('envoy.local');
expect(done.config?.path).toEqual('/production.json?details=1');
expect(done.config?.transport).toEqual('http');
});
tap.test('maps Enphase Envoy raw snapshots to devices and entities', async () => {
const client = new EnphaseEnvoyClient({ host: 'envoy.local', name: 'Envoy 122334455667', rawData });
const snapshot = await client.getSnapshot();
const devices = EnphaseEnvoyMapper.toDevices(snapshot);
const entities = EnphaseEnvoyMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(devices[0].integrationDomain).toEqual('enphase_envoy');
expect(devices[0].manufacturer).toEqual('Enphase');
expect(entities.some((entityArg) => entityArg.uniqueId.endsWith('_production') && entityArg.state === 420)).toBeTrue();
expect(entities.some((entityArg) => entityArg.platform === 'number' && entityArg.name === 'Reserve Battery Level')).toBeTrue();
expect(entities.some((entityArg) => entityArg.platform === 'select' && entityArg.state === 'backup')).toBeTrue();
expect(entities.some((entityArg) => entityArg.platform === 'switch' && entityArg.name === 'Grid Enabled')).toBeTrue();
});
tap.test('exposes Enphase Envoy runtime, HA alias, and explicit unsupported control without executor', async () => {
const integration = new EnphaseEnvoyIntegration();
const alias = new HomeAssistantEnphaseEnvoyIntegration();
expect(alias.domain).toEqual('enphase_envoy');
expect(integration.status).toEqual('control-runtime');
expect(enphaseEnvoyProfile.metadata.qualityScale).toEqual('platinum');
expect(enphaseEnvoyProfile.metadata.requirements).toEqual(['pyenphase==2.4.8']);
const runtime = await integration.setup({ host: 'envoy.local', name: 'Envoy 122334455667', rawData }, {});
const status = await runtime.callService!({ domain: 'enphase_envoy', service: 'status', target: {} });
const snapshot = status.data as IEnphaseEnvoySnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Envoy 122334455667');
const controlCommand = await runtime.callService!({ domain: 'switch', service: 'turn_on', target: {} });
expect(controlCommand.success).toBeFalse();
expect(controlCommand.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue();
await runtime.destroy();
});
export default tap.start();
+74
View File
@@ -0,0 +1,74 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { EnvisalinkClient, EnvisalinkConfigFlow, EnvisalinkIntegration, EnvisalinkMapper, HomeAssistantEnvisalinkIntegration, createEnvisalinkDiscoveryDescriptor, envisalinkProfile, type IEnvisalinkSnapshot } from '../../ts/integrations/envisalink/index.js';
const rawData = {
device: {
id: 'envisalink-panel-1',
name: 'Envisalink Alarm Panel',
manufacturer: 'EyezOn',
model: 'EVL-4',
},
entities: [
{ id: 'partition_1_keypad', name: 'Partition 1 Keypad', platform: 'sensor', state: 'Ready', attributes: { armedAway: false, alarm: false } },
{ id: 'front_door', name: 'Front Door', platform: 'binary_sensor', state: false, deviceClass: 'opening', attributes: { zone: 1 } },
{ id: 'front_door_bypass', name: 'Front Door Bypass', platform: 'switch', state: false, writable: true, attributes: { zone: 1 } },
],
};
tap.test('matches manual Envisalink candidates and creates config flow output', async () => {
const descriptor = createEnvisalinkDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'envisalink-manual-match');
const result = await matcher!.matches({ host: 'envisalink.local', name: 'Envisalink', metadata: { rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('envisalink');
expect(result.candidate?.port).toEqual(4025);
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new EnvisalinkConfigFlow().start(result.candidate!, {})).submit!({ username: 'user', password: 'secret' });
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('envisalink.local');
expect(done.config?.port).toEqual(4025);
expect(done.config?.transport).toEqual('tcp');
});
tap.test('maps Envisalink raw snapshots to runtime devices and entities', async () => {
const client = new EnvisalinkClient({ name: 'Envisalink Runtime', rawData });
const snapshot = await client.getSnapshot();
const devices = EnvisalinkMapper.toDevices(snapshot);
const entities = EnvisalinkMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(snapshot.source).toEqual('manual');
expect(devices[0].integrationDomain).toEqual('envisalink');
expect(devices[0].manufacturer).toEqual('EyezOn');
expect(entities.length).toEqual(3);
});
tap.test('exposes Envisalink runtime services, HA alias, and unsupported control without executor', async () => {
const integration = new EnvisalinkIntegration();
const alias = new HomeAssistantEnvisalinkIntegration();
expect(alias instanceof EnvisalinkIntegration).toBeTrue();
expect(alias.domain).toEqual('envisalink');
expect(integration.status).toEqual('control-runtime');
expect(envisalinkProfile.metadata.requirements).toEqual(['pyenvisalink==4.7']);
expect(envisalinkProfile.metadata.configFlow).toEqual(false);
const runtime = await integration.setup({ name: 'Envisalink Runtime', rawData }, {});
const status = await runtime.callService!({ domain: 'envisalink', service: 'status', target: {} });
const snapshot = status.data as IEnvisalinkSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Envisalink Alarm Panel');
expect((await runtime.callService!({ domain: 'envisalink', service: 'refresh', target: {} })).success).toBeTrue();
const command = await runtime.callService!({ domain: 'alarm_control_panel', service: 'alarm_arm_away', target: {}, data: { code: '1234' } });
expect(command.success).toBeFalse();
expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue();
await runtime.destroy();
});
export default tap.start();
+73
View File
@@ -0,0 +1,73 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { EphemberClient, EphemberConfigFlow, EphemberIntegration, EphemberMapper, HomeAssistantEphemberIntegration, createEphemberDiscoveryDescriptor, ephemberProfile, type IEphemberSnapshot } from '../../ts/integrations/ephember/index.js';
const rawData = {
device: {
id: 'eph-ember-home-1',
name: 'EPH Ember Home',
manufacturer: 'EPH Controls',
model: 'Ember',
},
entities: [
{ id: 'living_room', name: 'Living Room', platform: 'climate', state: 'heat_cool', writable: true, attributes: { currentTemperature: 20.5, targetTemperature: 21, hvacAction: 'heating' } },
{ id: 'hot_water', name: 'Hot Water', platform: 'climate', state: 'off', writable: true, attributes: { hotWater: true, hvacAction: 'idle' } },
],
};
tap.test('matches manual Ephember candidates and creates config flow output', async () => {
const descriptor = createEphemberDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'ephember-manual-match');
const result = await matcher!.matches({ name: 'EPH Controls Ember', metadata: { rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('ephember');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new EphemberConfigFlow().start(result.candidate!, {})).submit!({ username: 'user@example.com', password: 'secret' });
expect(done.kind).toEqual('done');
expect(done.config?.username).toEqual('user@example.com');
expect(done.config?.transport).toEqual('snapshot');
expect(done.config?.rawData).toEqual(rawData);
});
tap.test('maps Ephember raw snapshots to runtime devices and entities', async () => {
const client = new EphemberClient({ name: 'EPH Runtime', rawData });
const snapshot = await client.getSnapshot();
const devices = EphemberMapper.toDevices(snapshot);
const entities = EphemberMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(snapshot.source).toEqual('manual');
expect(devices[0].integrationDomain).toEqual('ephember');
expect(devices[0].manufacturer).toEqual('EPH Controls');
expect(entities.length).toEqual(2);
expect(entities[0].platform).toEqual('climate');
});
tap.test('exposes Ephember runtime services, HA alias, and unsupported control without executor', async () => {
const integration = new EphemberIntegration();
const alias = new HomeAssistantEphemberIntegration();
expect(alias instanceof EphemberIntegration).toBeTrue();
expect(alias.domain).toEqual('ephember');
expect(integration.status).toEqual('control-runtime');
expect(ephemberProfile.metadata.requirements).toEqual(['pyephember2==0.4.12']);
expect(ephemberProfile.metadata.configFlow).toEqual(false);
const runtime = await integration.setup({ name: 'EPH Runtime', rawData }, {});
const status = await runtime.callService!({ domain: 'ephember', service: 'status', target: {} });
const snapshot = status.data as IEphemberSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('EPH Ember Home');
expect((await runtime.callService!({ domain: 'ephember', service: 'refresh', target: {} })).success).toBeTrue();
const command = await runtime.callService!({ domain: 'climate', service: 'set_temperature', target: {}, data: { temperature: 21.5 } });
expect(command.success).toBeFalse();
expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue();
await runtime.destroy();
});
export default tap.start();
+73
View File
@@ -0,0 +1,73 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { EpsonClient, EpsonConfigFlow, EpsonIntegration, EpsonMapper, HomeAssistantEpsonIntegration, createEpsonDiscoveryDescriptor, epsonProfile, type IEpsonSnapshot } from '../../ts/integrations/epson/index.js';
const rawData = {
device: {
id: 'epson-projector-1',
name: 'Theater Projector',
manufacturer: 'Epson',
model: 'Home Cinema',
serialNumber: 'EPSON12345',
},
entities: [
{ id: 'projector', name: 'Projector', platform: 'media_player', state: 'on', writable: true, attributes: { source: 'HDMI1', cmode: 'cinema', volumeLevel: 0.35 } },
],
};
tap.test('matches manual Epson candidates and creates config flow output', async () => {
const descriptor = createEpsonDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'epson-manual-match');
const result = await matcher!.matches({ host: 'projector.local', name: 'Epson Projector', metadata: { rawData, connectionType: 'http' } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('epson');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new EpsonConfigFlow().start(result.candidate!, {})).submit!({ name: 'Theater Projector' });
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('projector.local');
expect(done.config?.name).toEqual('Theater Projector');
expect(done.config?.metadata?.connectionType).toEqual('http');
});
tap.test('maps Epson raw snapshots to runtime devices and entities', async () => {
const client = new EpsonClient({ name: 'Epson Runtime', rawData });
const snapshot = await client.getSnapshot();
const devices = EpsonMapper.toDevices(snapshot);
const entities = EpsonMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(snapshot.source).toEqual('manual');
expect(devices[0].integrationDomain).toEqual('epson');
expect(devices[0].manufacturer).toEqual('Epson');
expect(entities.length).toEqual(1);
expect(entities[0].platform).toEqual('media_player');
});
tap.test('exposes Epson runtime services, HA alias, and unsupported control without executor', async () => {
const integration = new EpsonIntegration();
const alias = new HomeAssistantEpsonIntegration();
expect(alias instanceof EpsonIntegration).toBeTrue();
expect(alias.domain).toEqual('epson');
expect(integration.status).toEqual('control-runtime');
expect(epsonProfile.metadata.requirements).toEqual(['epson-projector==0.6.0']);
expect(epsonProfile.metadata.configFlow).toEqual(true);
const runtime = await integration.setup({ name: 'Epson Runtime', rawData }, {});
const status = await runtime.callService!({ domain: 'epson', service: 'status', target: {} });
const snapshot = status.data as IEpsonSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Theater Projector');
expect((await runtime.callService!({ domain: 'epson', service: 'refresh', target: {} })).success).toBeTrue();
const command = await runtime.callService!({ domain: 'epson', service: 'select_cmode', target: {}, data: { cmode: 'cinema' } });
expect(command.success).toBeFalse();
expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue();
await runtime.destroy();
});
export default tap.start();
+79
View File
@@ -0,0 +1,79 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { Eq3btsmartClient, Eq3btsmartConfigFlow, Eq3btsmartIntegration, Eq3btsmartMapper, HomeAssistantEq3btsmartIntegration, createEq3btsmartDiscoveryDescriptor, eq3btsmartProfile, type IEq3btsmartSnapshot } from '../../ts/integrations/eq3btsmart/index.js';
const rawData = {
device: {
id: 'eq3-aa-bb-cc-dd-ee-ff',
name: 'Hall Radiator Valve',
manufacturer: 'eQ-3 AG',
model: 'CC-RT-BLE-EQ',
serialNumber: 'AA:BB:CC:DD:EE:FF',
attributes: {
connection: 'bluetooth',
},
},
entities: [
{ id: 'thermostat', name: 'Thermostat', platform: 'climate', state: { currentTemperature: 20.5, targetTemperature: 21, hvacMode: 'heat', presetMode: 'none' }, writable: true, unit: 'C' },
{ id: 'low_battery', name: 'Low Battery', platform: 'binary_sensor', state: false, deviceClass: 'battery' },
{ id: 'window', name: 'Window', platform: 'binary_sensor', state: false, deviceClass: 'window' },
{ id: 'valve', name: 'Valve', platform: 'sensor', state: 34, unit: '%', stateClass: 'measurement' },
{ id: 'comfort', name: 'Comfort Temperature', platform: 'number', state: 21, unit: 'C', writable: true },
{ id: 'boost', name: 'Boost', platform: 'switch', state: false, writable: true },
],
};
tap.test('matches manual eQ-3 candidates and creates config flow output', async () => {
const descriptor = createEq3btsmartDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'eq3btsmart-manual-match');
const result = await matcher!.matches({ id: 'AA:BB:CC:DD:EE:FF', name: 'CC-RT-BLE Hall', metadata: { rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('eq3btsmart');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new Eq3btsmartConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.uniqueId).toEqual('AA:BB:CC:DD:EE:FF');
expect(done.config?.rawData).toEqual(rawData);
});
tap.test('maps eQ-3 raw snapshots to runtime devices and entities', async () => {
const client = new Eq3btsmartClient({ name: 'Hall Radiator Runtime', rawData });
const snapshot = await client.getSnapshot();
const devices = Eq3btsmartMapper.toDevices(snapshot);
const entities = Eq3btsmartMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(snapshot.source).toEqual('manual');
expect(devices[0].integrationDomain).toEqual('eq3btsmart');
expect(devices[0].manufacturer).toEqual('eQ-3 AG');
expect(entities.some((entityArg) => entityArg.platform === 'climate')).toBeTrue();
expect(entities.some((entityArg) => entityArg.platform === 'number')).toBeTrue();
});
tap.test('exposes eQ-3 runtime services, HA alias, and unsupported control without executor', async () => {
const alias = new HomeAssistantEq3btsmartIntegration();
expect(alias instanceof Eq3btsmartIntegration).toBeTrue();
expect(alias.domain).toEqual('eq3btsmart');
expect(eq3btsmartProfile.metadata.iotClass).toEqual('local_polling');
expect(eq3btsmartProfile.metadata.requirements).toEqual(['eq3btsmart==2.3.0']);
expect(eq3btsmartProfile.metadata.configFlow).toBeTrue();
const runtime = await new Eq3btsmartIntegration().setup({ name: 'Hall Radiator Runtime', rawData }, {});
const status = await runtime.callService!({ domain: 'eq3btsmart', service: 'status', target: {} });
const snapshot = status.data as IEq3btsmartSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.callService!({ domain: 'eq3btsmart', service: 'refresh', target: {} })).success).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Hall Radiator Valve');
const controlCommand = await runtime.callService!({ domain: 'climate', service: 'set_temperature', target: {}, data: { temperature: 20 } });
expect(controlCommand.success).toBeFalse();
expect(controlCommand.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue();
await runtime.destroy();
});
export default tap.start();
+71
View File
@@ -0,0 +1,71 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { EsceaClient, EsceaConfigFlow, EsceaIntegration, EsceaMapper, HomeAssistantEsceaIntegration, createEsceaDiscoveryDescriptor, esceaProfile, type IEsceaSnapshot } from '../../ts/integrations/escea/index.js';
const rawData = {
device: {
id: 'escea-controller-1',
name: 'Living Room Fireplace',
manufacturer: 'Escea',
model: 'Escea Fireplace',
serialNumber: 'ESCEA-001',
},
entities: [
{ id: 'fireplace', name: 'Fireplace', platform: 'climate', state: { hvacMode: 'heat', currentTemperature: 19, targetTemperature: 22, fanMode: 'auto' }, writable: true, unit: 'C' },
],
};
tap.test('matches manual Escea candidates and creates config flow output', async () => {
const descriptor = createEsceaDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'escea-manual-match');
const result = await matcher!.matches({ name: 'Escea Fireplace', metadata: { rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('escea');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new EsceaConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.name).toEqual('Escea Fireplace');
expect(done.config?.rawData).toEqual(rawData);
});
tap.test('maps Escea raw snapshots to runtime devices and entities', async () => {
const client = new EsceaClient({ name: 'Escea Runtime', rawData });
const snapshot = await client.getSnapshot();
const devices = EsceaMapper.toDevices(snapshot);
const entities = EsceaMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(snapshot.source).toEqual('manual');
expect(devices[0].integrationDomain).toEqual('escea');
expect(devices[0].manufacturer).toEqual('Escea');
expect(entities.length).toEqual(1);
expect(entities[0].platform).toEqual('climate');
});
tap.test('exposes Escea runtime services, HA alias, and unsupported control without executor', async () => {
const alias = new HomeAssistantEsceaIntegration();
expect(alias instanceof EsceaIntegration).toBeTrue();
expect(alias.domain).toEqual('escea');
expect(esceaProfile.metadata.iotClass).toEqual('local_push');
expect(esceaProfile.metadata.requirements).toEqual(['pescea==1.0.12']);
expect(esceaProfile.metadata.configFlow).toBeTrue();
const runtime = await new EsceaIntegration().setup({ name: 'Escea Runtime', rawData }, {});
const status = await runtime.callService!({ domain: 'escea', service: 'status', target: {} });
const snapshot = status.data as IEsceaSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.callService!({ domain: 'escea', service: 'snapshot', target: {} })).success).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Living Room Fireplace');
const controlCommand = await runtime.callService!({ domain: 'climate', service: 'set_fan_mode', target: {}, data: { fanMode: 'auto' } });
expect(controlCommand.success).toBeFalse();
expect(controlCommand.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue();
await runtime.destroy();
});
export default tap.start();
+72
View File
@@ -0,0 +1,72 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { EufyClient, EufyConfigFlow, EufyIntegration, EufyMapper, HomeAssistantEufyIntegration, createEufyDiscoveryDescriptor, eufyProfile, type IEufySnapshot } from '../../ts/integrations/eufy/index.js';
const rawData = {
device: {
id: 'eufy-home-1',
name: 'Eufy Home Devices',
manufacturer: 'Eufy',
model: 'EufyHome',
host: '192.0.2.24',
},
entities: [
{ id: 'color_bulb', name: 'Color Bulb', platform: 'light', state: true, writable: true, attributes: { type: 'T1013', brightness: 182, colorTempKelvin: 3200, hsColor: [32, 72] } },
{ id: 'smart_plug', name: 'Smart Plug', platform: 'switch', state: false, writable: true, attributes: { type: 'T1201' } },
],
};
tap.test('matches manual EufyHome candidates and creates config flow output', async () => {
const descriptor = createEufyDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'eufy-manual-match');
const result = await matcher!.matches({ host: '192.0.2.24', name: 'EufyHome Color Bulb', metadata: { rawData, type: 'T1013', accessToken: 'token' } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('eufy');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new EufyConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('192.0.2.24');
expect(done.config?.rawData).toEqual(rawData);
});
tap.test('maps EufyHome raw snapshots to runtime devices and entities', async () => {
const client = new EufyClient({ name: 'Eufy Runtime', rawData });
const snapshot = await client.getSnapshot();
const devices = EufyMapper.toDevices(snapshot);
const entities = EufyMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(snapshot.source).toEqual('manual');
expect(devices[0].integrationDomain).toEqual('eufy');
expect(devices[0].manufacturer).toEqual('Eufy');
expect(entities.some((entityArg) => entityArg.platform === 'light')).toBeTrue();
expect(entities.some((entityArg) => entityArg.platform === 'switch')).toBeTrue();
});
tap.test('exposes EufyHome runtime services, HA alias, and unsupported control without executor', async () => {
const alias = new HomeAssistantEufyIntegration();
expect(alias instanceof EufyIntegration).toBeTrue();
expect(alias.domain).toEqual('eufy');
expect(eufyProfile.metadata.qualityScale).toEqual('legacy');
expect(eufyProfile.metadata.requirements).toEqual(['lakeside==0.13']);
expect(eufyProfile.metadata.configFlow).toEqual(false);
const runtime = await new EufyIntegration().setup({ name: 'Eufy Runtime', rawData }, {});
const status = await runtime.callService!({ domain: 'eufy', service: 'status', target: {} });
const snapshot = status.data as IEufySnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.callService!({ domain: 'eufy', service: 'refresh', target: {} })).success).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Eufy Home Devices');
const controlCommand = await runtime.callService!({ domain: 'light', service: 'turn_on', target: {}, data: { brightness: 128 } });
expect(controlCommand.success).toBeFalse();
expect(controlCommand.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue();
await runtime.destroy();
});
export default tap.start();
@@ -0,0 +1,72 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { EufylifeBleClient, EufylifeBleConfigFlow, EufylifeBleIntegration, EufylifeBleMapper, HomeAssistantEufylifeBleIntegration, createEufylifeBleDiscoveryDescriptor, eufylifeBleProfile, type IEufylifeBleSnapshot } from '../../ts/integrations/eufylife_ble/index.js';
const rawData = {
device: {
id: 'eufy-scale-aabbccddeeff',
name: 'Eufy Smart Scale',
manufacturer: 'Eufy',
model: 'eufy T9148',
serialNumber: 'AA:BB:CC:DD:EE:FF',
protocol: 'local',
},
entities: [
{ id: 'weight', name: 'Weight', platform: 'sensor', state: 72.4, unit: 'kg', deviceClass: 'weight', stateClass: 'measurement' },
{ id: 'real_time_weight', name: 'Real-time weight', platform: 'sensor', state: 72.1, unit: 'kg', deviceClass: 'weight', stateClass: 'measurement' },
{ id: 'heart_rate', name: 'Heart rate', platform: 'sensor', state: 68, unit: 'bpm' },
],
};
tap.test('matches manual EufyLife BLE candidates and creates config flow output', async () => {
const descriptor = createEufylifeBleDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'eufylife_ble-manual-match');
const result = await matcher!.matches({ id: 'AA:BB:CC:DD:EE:FF', name: 'eufy T9148', metadata: { address: 'AA:BB:CC:DD:EE:FF', model: 'eufy T9148', rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('eufylife_ble');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new EufylifeBleConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.uniqueId).toEqual('AA:BB:CC:DD:EE:FF');
expect(done.config?.metadata?.model).toEqual('eufy T9148');
});
tap.test('maps EufyLife BLE raw snapshots to runtime devices and entities', async () => {
const client = new EufylifeBleClient({ name: 'EufyLife Runtime', rawData });
const snapshot = await client.getSnapshot();
const devices = EufylifeBleMapper.toDevices(snapshot);
const entities = EufylifeBleMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(snapshot.source).toEqual('manual');
expect(devices[0].integrationDomain).toEqual('eufylife_ble');
expect(devices[0].manufacturer).toEqual('Eufy');
expect(entities.length).toEqual(3);
});
tap.test('exposes EufyLife BLE read-only runtime, HA alias, and unsupported control', async () => {
const integration = new EufylifeBleIntegration();
const alias = new HomeAssistantEufylifeBleIntegration();
expect(alias.domain).toEqual('eufylife_ble');
expect(integration.status).toEqual('read-only-runtime');
expect(eufylifeBleProfile.metadata.configFlow).toEqual(true);
expect(eufylifeBleProfile.metadata.requirements).toEqual(['eufylife-ble-client==0.1.8']);
const runtime = await integration.setup({ name: 'EufyLife Runtime', rawData }, {});
const status = await runtime.callService!({ domain: 'eufylife_ble', service: 'status', target: {} });
const snapshot = status.data as IEufylifeBleSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Eufy Smart Scale');
const command = await runtime.callService!({ domain: 'eufylife_ble', service: 'turn_on', target: {} });
expect(command.success).toBeFalse();
expect(Boolean(command.error)).toBeTrue();
await runtime.destroy();
});
export default tap.start();
@@ -0,0 +1,72 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { EurotronicCometblueClient, EurotronicCometblueConfigFlow, EurotronicCometblueIntegration, EurotronicCometblueMapper, HomeAssistantEurotronicCometblueIntegration, createEurotronicCometblueDiscoveryDescriptor, eurotronicCometblueProfile, type IEurotronicCometblueSnapshot } from '../../ts/integrations/eurotronic_cometblue/index.js';
const rawData = {
device: {
id: 'cometblue-aabbccddeeff',
name: 'Comet Blue AA:BB',
manufacturer: 'Eurotronic',
model: 'Comet Blue',
serialNumber: 'AA:BB:CC:DD:EE:FF',
protocol: 'local',
},
entities: [
{ id: 'thermostat', name: 'Thermostat', platform: 'climate', state: 'auto', writable: true, attributes: { currentTemperature: 21.5, targetTemperature: 20, hvacModes: ['auto', 'heat', 'off'], presetMode: 'comfort' } },
{ id: 'battery', name: 'Battery', platform: 'sensor', state: 86, unit: '%', deviceClass: 'battery' },
{ id: 'sync_time', name: 'Sync time', platform: 'button', state: 'available', writable: true },
],
};
tap.test('matches manual Eurotronic Comet Blue candidates and creates config flow output', async () => {
const descriptor = createEurotronicCometblueDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'eurotronic_cometblue-manual-match');
const result = await matcher!.matches({ id: 'AA:BB:CC:DD:EE:FF', name: 'Comet Blue AA:BB', metadata: { address: 'AA:BB:CC:DD:EE:FF', pin: '000000', rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('eurotronic_cometblue');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new EurotronicCometblueConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.uniqueId).toEqual('AA:BB:CC:DD:EE:FF');
expect(done.config?.metadata?.pin).toEqual('000000');
});
tap.test('maps Eurotronic Comet Blue raw snapshots to runtime devices and entities', async () => {
const client = new EurotronicCometblueClient({ name: 'Comet Blue Runtime', rawData });
const snapshot = await client.getSnapshot();
const devices = EurotronicCometblueMapper.toDevices(snapshot);
const entities = EurotronicCometblueMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(snapshot.source).toEqual('manual');
expect(devices[0].integrationDomain).toEqual('eurotronic_cometblue');
expect(devices[0].manufacturer).toEqual('Eurotronic');
expect(entities.length).toEqual(3);
});
tap.test('exposes Eurotronic Comet Blue runtime, HA alias, and unsupported control', async () => {
const integration = new EurotronicCometblueIntegration();
const alias = new HomeAssistantEurotronicCometblueIntegration();
expect(alias.domain).toEqual('eurotronic_cometblue');
expect(integration.status).toEqual('control-runtime');
expect(eurotronicCometblueProfile.metadata.qualityScale).toEqual('bronze');
expect(eurotronicCometblueProfile.metadata.requirements).toEqual(['eurotronic-cometblue-ha==1.4.0']);
const runtime = await integration.setup({ name: 'Comet Blue Runtime', rawData }, {});
const status = await runtime.callService!({ domain: 'eurotronic_cometblue', service: 'status', target: {} });
const snapshot = status.data as IEurotronicCometblueSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Comet Blue AA:BB');
const command = await runtime.callService!({ domain: 'climate', service: 'set_temperature', target: {}, data: { temperature: 20.5 } });
expect(command.success).toBeFalse();
expect(Boolean(command.error)).toBeTrue();
await runtime.destroy();
});
export default tap.start();
+72
View File
@@ -0,0 +1,72 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { EverlightsClient, EverlightsConfigFlow, EverlightsIntegration, EverlightsMapper, HomeAssistantEverlightsIntegration, createEverlightsDiscoveryDescriptor, everlightsProfile, type IEverlightsSnapshot } from '../../ts/integrations/everlights/index.js';
const rawData = {
device: {
id: 'everlights-001122334455',
name: 'EverLights 00:11:22:33:44:55',
manufacturer: 'EverLights',
model: 'EverLights controller',
serialNumber: '00:11:22:33:44:55',
host: '192.0.2.20',
protocol: 'http',
},
entities: [
{ id: 'zone_1', name: 'Zone 1', platform: 'light', state: true, writable: true, attributes: { brightness: 255, hsColor: [120, 80], effect: 'Rainbow', effectList: ['Rainbow', 'Solid'] } },
{ id: 'zone_2', name: 'Zone 2', platform: 'light', state: false, writable: true, attributes: { brightness: 0, effectList: ['Rainbow', 'Solid'] } },
],
};
tap.test('matches manual EverLights candidates and creates config flow output', async () => {
const descriptor = createEverlightsDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'everlights-manual-match');
const result = await matcher!.matches({ host: '192.0.2.20', name: 'EverLights Controller', metadata: { rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('everlights');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new EverlightsConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('192.0.2.20');
expect(done.config?.transport).toEqual('snapshot');
});
tap.test('maps EverLights raw snapshots to runtime devices and entities', async () => {
const client = new EverlightsClient({ name: 'EverLights Runtime', rawData });
const snapshot = await client.getSnapshot();
const devices = EverlightsMapper.toDevices(snapshot);
const entities = EverlightsMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(snapshot.source).toEqual('manual');
expect(devices[0].integrationDomain).toEqual('everlights');
expect(devices[0].manufacturer).toEqual('EverLights');
expect(entities.length).toEqual(2);
});
tap.test('exposes EverLights runtime, HA alias, and unsupported control', async () => {
const integration = new EverlightsIntegration();
const alias = new HomeAssistantEverlightsIntegration();
expect(alias.domain).toEqual('everlights');
expect(integration.status).toEqual('control-runtime');
expect(everlightsProfile.metadata.configFlow).toEqual(false);
expect(everlightsProfile.metadata.requirements).toEqual(['pyeverlights==0.1.0']);
const runtime = await integration.setup({ name: 'EverLights Runtime', rawData }, {});
const status = await runtime.callService!({ domain: 'everlights', service: 'status', target: {} });
const snapshot = status.data as IEverlightsSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('EverLights 00:11:22:33:44:55');
const command = await runtime.callService!({ domain: 'light', service: 'turn_on', target: {}, data: { brightness: 255 } });
expect(command.success).toBeFalse();
expect(Boolean(command.error)).toBeTrue();
await runtime.destroy();
});
export default tap.start();
@@ -0,0 +1,80 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { EvilGeniusLabsClient, EvilGeniusLabsConfigFlow, EvilGeniusLabsIntegration, EvilGeniusLabsMapper, HomeAssistantEvilGeniusLabsIntegration, createEvilGeniusLabsDiscoveryDescriptor, evilGeniusLabsProfile, type IEvilGeniusLabsSnapshot } from '../../ts/integrations/evil_genius_labs/index.js';
const rawData = {
device: {
id: 'egl-wifi-123',
name: 'Living Room Fibonacci',
manufacturer: 'Evil Genius Labs',
model: 'Fibonacci64',
},
entities: [
{
id: 'light',
name: 'Living Room Fibonacci',
platform: 'light',
state: true,
attributes: {
brightness: 180,
effect: 'Rainbow',
rgbColor: [12, 34, 56],
},
},
],
};
tap.test('matches manual Evil Genius Labs candidates and creates config flow output', async () => {
const descriptor = createEvilGeniusLabsDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'evil_genius_labs-manual-match');
const result = await matcher!.matches({ host: 'evilgenius.local', name: 'Evil Genius Labs', metadata: { rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('evil_genius_labs');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new EvilGeniusLabsConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('evilgenius.local');
expect(done.config?.path).toEqual('/all');
});
tap.test('maps Evil Genius Labs raw snapshots to runtime devices and entities', async () => {
const client = new EvilGeniusLabsClient({ name: 'Evil Genius Runtime', rawData });
const snapshot = await client.getSnapshot();
const devices = EvilGeniusLabsMapper.toDevices(snapshot);
const entities = EvilGeniusLabsMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(snapshot.source).toEqual('manual');
expect(devices[0].integrationDomain).toEqual('evil_genius_labs');
expect(devices[0].manufacturer).toEqual('Evil Genius Labs');
expect(entities[0].platform).toEqual('light');
});
tap.test('exposes Evil Genius Labs runtime services, HA alias, and executor-gated controls', async () => {
const integration = new EvilGeniusLabsIntegration();
const alias = new HomeAssistantEvilGeniusLabsIntegration();
expect(alias.domain).toEqual('evil_genius_labs');
expect(integration.status).toEqual('control-runtime');
expect(evilGeniusLabsProfile.metadata.configFlow).toEqual(true);
expect(evilGeniusLabsProfile.metadata.requirements).toEqual(['pyevilgenius==2.0.0']);
expect(Object.prototype.hasOwnProperty.call(evilGeniusLabsProfile.metadata, 'qualityScale')).toBeTrue();
expect(evilGeniusLabsProfile.metadata.qualityScale).toBeUndefined();
const runtime = await integration.setup({ name: 'Evil Genius Runtime', rawData }, {});
const status = await runtime.callService!({ domain: 'light', service: 'status', target: {} });
const snapshot = status.data as IEvilGeniusLabsSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Living Room Fibonacci');
const command = await runtime.callService!({ domain: 'light', service: 'turn_on', target: {} });
expect(command.success).toBeFalse();
expect(Boolean(command.error?.includes('requires an injected'))).toBeTrue();
await runtime.destroy();
});
export default tap.start();
+76
View File
@@ -0,0 +1,76 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { Fail2banClient, Fail2banConfigFlow, Fail2banIntegration, Fail2banMapper, HomeAssistantFail2banIntegration, createFail2banDiscoveryDescriptor, fail2banProfile, type IFail2banSnapshot } from '../../ts/integrations/fail2ban/index.js';
const rawData = {
device: {
id: 'fail2ban-host',
name: 'Fail2Ban Host',
},
entities: [
{
id: 'ssh_current_bans',
name: 'fail2ban ssh',
platform: 'sensor',
state: '203.0.113.15',
attributes: {
current_bans: ['203.0.113.15'],
total_bans: ['198.51.100.4', '203.0.113.15'],
jail: 'ssh',
},
},
],
};
tap.test('matches manual Fail2Ban candidates and creates config flow output', async () => {
const descriptor = createFail2banDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'fail2ban-manual-match');
const result = await matcher!.matches({ name: 'Fail2Ban', metadata: { rawData, jails: ['ssh'], filePath: '/var/log/fail2ban.log' } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('fail2ban');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new Fail2banConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.rawData).toEqual(rawData);
});
tap.test('maps Fail2Ban raw snapshots to runtime devices and entities', async () => {
const client = new Fail2banClient({ name: 'Fail2Ban Runtime', rawData });
const snapshot = await client.getSnapshot();
const devices = Fail2banMapper.toDevices(snapshot);
const entities = Fail2banMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(snapshot.source).toEqual('manual');
expect(devices[0].integrationDomain).toEqual('fail2ban');
expect(devices[0].model).toEqual('Fail2Ban log parser');
expect(entities[0].state).toEqual('203.0.113.15');
});
tap.test('exposes Fail2Ban read-only runtime, HA alias, and unsupported control', async () => {
const integration = new Fail2banIntegration();
const alias = new HomeAssistantFail2banIntegration();
expect(alias.domain).toEqual('fail2ban');
expect(integration.status).toEqual('read-only-runtime');
expect(fail2banProfile.metadata.configFlow).toEqual(false);
expect(fail2banProfile.metadata.qualityScale).toEqual('legacy');
expect(fail2banProfile.metadata.requirements).toEqual([]);
const runtime = await integration.setup({ name: 'Fail2Ban Runtime', rawData }, {});
const status = await runtime.callService!({ domain: 'fail2ban', service: 'status', target: {} });
const snapshot = status.data as IFail2banSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Fail2Ban Host');
const command = await runtime.callService!({ domain: 'fail2ban', service: 'turn_on', target: {} });
expect(command.success).toBeFalse();
expect(Boolean(command.error?.includes('requires an injected'))).toBeTrue();
await runtime.destroy();
});
export default tap.start();
+81
View File
@@ -0,0 +1,81 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { FamilyhubClient, FamilyhubConfigFlow, FamilyhubIntegration, FamilyhubMapper, HomeAssistantFamilyhubIntegration, createFamilyhubDiscoveryDescriptor, familyhubProfile, type IFamilyhubSnapshot } from '../../ts/integrations/familyhub/index.js';
const rawData = {
device: {
id: 'familyhub-kitchen',
name: 'Kitchen Family Hub',
manufacturer: 'Samsung',
model: 'Family Hub refrigerator',
host: '192.0.2.44',
port: 17654,
},
entities: [
{
id: 'camera_info',
name: 'Kitchen FamilyHub Camera',
platform: 'sensor',
state: 'available',
attributes: {
glazeUrls: ['/camera1.jpg', '/camera2.jpg'],
stillImagePath: '/.krate/owner/share/scloud/glazeCameraInfo.txt',
},
},
],
};
tap.test('matches manual Family Hub candidates and creates config flow output', async () => {
const descriptor = createFamilyhubDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'familyhub-manual-match');
const result = await matcher!.matches({ host: 'familyhub.local', name: 'Samsung Family Hub', metadata: { rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('familyhub');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new FamilyhubConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('familyhub.local');
expect(done.config?.port).toEqual(17654);
expect(done.config?.path).toEqual('/.krate/owner/share/scloud/glazeCameraInfo.txt');
});
tap.test('maps Family Hub raw snapshots to runtime devices and entities', async () => {
const client = new FamilyhubClient({ name: 'Family Hub Runtime', rawData });
const snapshot = await client.getSnapshot();
const devices = FamilyhubMapper.toDevices(snapshot);
const entities = FamilyhubMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(snapshot.source).toEqual('manual');
expect(devices[0].integrationDomain).toEqual('familyhub');
expect(devices[0].manufacturer).toEqual('Samsung');
expect(entities[0].state).toEqual('available');
});
tap.test('exposes Family Hub read-only runtime, HA alias, and unsupported control', async () => {
const integration = new FamilyhubIntegration();
const alias = new HomeAssistantFamilyhubIntegration();
expect(alias.domain).toEqual('familyhub');
expect(integration.status).toEqual('read-only-runtime');
expect(familyhubProfile.metadata.configFlow).toEqual(false);
expect(familyhubProfile.metadata.qualityScale).toEqual('legacy');
expect(familyhubProfile.metadata.requirements).toEqual(['python-family-hub-local==0.0.2']);
const runtime = await integration.setup({ name: 'Family Hub Runtime', rawData }, {});
const status = await runtime.callService!({ domain: 'camera', service: 'status', target: {} });
const snapshot = status.data as IFamilyhubSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Kitchen Family Hub');
const command = await runtime.callService!({ domain: 'camera', service: 'turn_on', target: {} });
expect(command.success).toBeFalse();
expect(Boolean(command.error?.includes('requires an injected'))).toBeTrue();
await runtime.destroy();
});
export default tap.start();
+80
View File
@@ -0,0 +1,80 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { FibaroClient, FibaroConfigFlow, FibaroIntegration, FibaroMapper, HomeAssistantFibaroIntegration, createFibaroDiscoveryDescriptor, fibaroProfile, type IFibaroSnapshot } from '../../ts/integrations/fibaro/index.js';
const rawData = {
info: {
serial_number: 'HC3-12345',
hc_name: 'Home Center 3',
manufacturer_name: 'Fibaro',
model_name: 'Home Center 3',
current_version: '5.150.18',
mac_address: '00:11:22:33:44:55',
},
devices: [
{ fibaro_id: 11, name: 'Temperature', room_name: 'Kitchen', type: 'com.fibaro.temperatureSensor', value: '21.4', unit: 'C', properties: { value: '21.4' } },
{ fibaro_id: 12, name: 'Motion', room_name: 'Hall', type: 'com.fibaro.motionSensor', value: true, properties: { value: true } },
{ fibaro_id: 13, name: 'Wall Plug', room_name: 'Living', type: 'com.fibaro.binarySwitch', value: false, actions: ['turnOn', 'turnOff'], properties: { value: false, power: '12.5', energy: '1.2' } },
],
scenes: [
{ fibaro_id: 50, name: 'Evening', room_id: 1, visible: true },
],
};
tap.test('matches manual Fibaro candidates and creates config flow output', async () => {
const descriptor = createFibaroDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'fibaro-manual-match');
const result = await matcher!.matches({ host: 'fibaro.local', name: 'Fibaro Home Center', metadata: { rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('fibaro');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new FibaroConfigFlow().start(result.candidate!, {})).submit!({ username: 'admin', password: 'secret' });
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('fibaro.local');
expect(done.config?.path).toEqual('/api/');
expect(done.config?.username).toEqual('admin');
});
tap.test('maps Fibaro raw snapshots to hub devices and entities', async () => {
const client = new FibaroClient({ host: 'fibaro.local', rawData });
const snapshot = await client.getSnapshot();
const devices = FibaroMapper.toDevices(snapshot);
const entities = FibaroMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(snapshot.device.serialNumber).toEqual('HC3-12345');
expect(devices[0].integrationDomain).toEqual('fibaro');
expect(devices[0].manufacturer).toEqual('Fibaro');
expect(entities.length).toEqual(6);
expect(entities.some((entityArg) => entityArg.attributes?.deviceClass === 'temperature')).toBeTrue();
expect(entities.some((entityArg) => entityArg.platform === 'button')).toBeTrue();
});
tap.test('exposes Fibaro read-only runtime, HA alias, and explicit unsupported control without executor', async () => {
const integration = new FibaroIntegration();
const alias = new HomeAssistantFibaroIntegration();
expect(alias.domain).toEqual('fibaro');
expect(integration.status).toEqual('read-only-runtime');
expect(fibaroProfile.metadata.configFlow).toEqual(true);
expect(fibaroProfile.metadata.requirements).toEqual(['pyfibaro==0.8.3']);
expect(Object.prototype.hasOwnProperty.call(fibaroProfile.metadata, 'qualityScale')).toBeTrue();
expect(fibaroProfile.metadata.qualityScale).toBeUndefined();
const runtime = await integration.setup({ host: 'fibaro.local', rawData }, {});
const status = await runtime.callService!({ domain: 'fibaro', service: 'status', target: {} });
const snapshot = status.data as IFibaroSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Home Center 3');
const command = await runtime.callService!({ domain: 'fibaro', service: 'turn_on', target: {} });
expect(command.success).toBeFalse();
expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue();
await runtime.destroy();
});
export default tap.start();
+66
View File
@@ -0,0 +1,66 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { FileClient, FileConfigFlow, FileIntegration, FileMapper, HomeAssistantFileIntegration, createFileDiscoveryDescriptor, fileProfile, type IFileSnapshot } from '../../ts/integrations/file/index.js';
const rawData = {
filePath: 'test/file/sample.log',
latestEntry: '42.5',
contentBytes: 24,
};
tap.test('matches manual File candidates and creates config flow output', async () => {
const descriptor = createFileDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'file-manual-match');
const result = await matcher!.matches({ name: 'Local file sensor', metadata: { rawData, filePath: 'test/file/sample.log', platform: 'sensor' } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('file');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new FileConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.rawData).toEqual(rawData);
expect(done.config?.metadata?.filePath).toEqual('test/file/sample.log');
});
tap.test('maps File raw snapshots to devices and entities', async () => {
const client = new FileClient({ name: 'Temperature Log', rawData, unitOfMeasurement: 'C' });
const snapshot = await client.getSnapshot();
const devices = FileMapper.toDevices(snapshot);
const entities = FileMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(devices[0].integrationDomain).toEqual('file');
expect(devices[0].model).toEqual('Local File');
expect(entities.length).toEqual(1);
expect(entities[0].state).toEqual('42.5');
expect(entities[0].attributes?.unit).toEqual('C');
});
tap.test('reads local File snapshots and exposes read-only runtime with unsupported control', async () => {
const integration = new FileIntegration();
const alias = new HomeAssistantFileIntegration();
expect(alias.domain).toEqual('file');
expect(integration.status).toEqual('read-only-runtime');
expect(fileProfile.metadata.configFlow).toEqual(true);
expect(fileProfile.metadata.requirements).toEqual(['file-read-backwards==2.0.0']);
expect(Object.prototype.hasOwnProperty.call(fileProfile.metadata, 'qualityScale')).toBeTrue();
expect(fileProfile.metadata.qualityScale).toBeUndefined();
const runtime = await integration.setup({ name: 'File Runtime', filePath: 'test/file/sample.log' }, {});
const status = await runtime.callService!({ domain: 'file', service: 'status', target: {} });
const snapshot = status.data as IFileSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect(snapshot.entities[0].state).toEqual('latest value');
expect((await runtime.devices())[0].name).toEqual('File Runtime');
const command = await runtime.callService!({ domain: 'file', service: 'turn_on', target: {} });
expect(command.success).toBeFalse();
expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue();
await runtime.destroy();
});
export default tap.start();
+1
View File
@@ -0,0 +1 @@
1234567890
+69
View File
@@ -0,0 +1,69 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { FilesizeClient, FilesizeConfigFlow, FilesizeIntegration, FilesizeMapper, HomeAssistantFilesizeIntegration, createFilesizeDiscoveryDescriptor, filesizeProfile, type IFilesizeSnapshot } from '../../ts/integrations/filesize/index.js';
const rawData = {
filePath: 'test/filesize/sample.txt',
file: 1.54,
bytes: 1536000,
lastUpdated: '2026-01-02T03:04:05.000Z',
created: '2026-01-01T03:04:05.000Z',
};
tap.test('matches manual File Size candidates and creates config flow output', async () => {
const descriptor = createFilesizeDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'filesize-manual-match');
const result = await matcher!.matches({ name: 'File Size sensor', metadata: { rawData, filePath: 'test/filesize/sample.txt' } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('filesize');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new FilesizeConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.rawData).toEqual(rawData);
expect(done.config?.metadata?.filePath).toEqual('test/filesize/sample.txt');
});
tap.test('maps File Size raw snapshots to devices and entities', async () => {
const client = new FilesizeClient({ rawData });
const snapshot = await client.getSnapshot();
const devices = FilesizeMapper.toDevices(snapshot);
const entities = FilesizeMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(devices[0].integrationDomain).toEqual('filesize');
expect(devices[0].model).toEqual('Local File Statistics');
expect(entities.length).toEqual(4);
expect(entities[0].state).toEqual(1.54);
expect(entities[1].attributes?.unit).toEqual('B');
expect(entities[2].attributes?.deviceClass).toEqual('timestamp');
});
tap.test('reads local File Size snapshots and exposes read-only runtime with unsupported control', async () => {
const integration = new FilesizeIntegration();
const alias = new HomeAssistantFilesizeIntegration();
expect(alias.domain).toEqual('filesize');
expect(integration.status).toEqual('read-only-runtime');
expect(filesizeProfile.metadata.configFlow).toEqual(true);
expect(filesizeProfile.metadata.requirements).toEqual([]);
expect(Object.prototype.hasOwnProperty.call(filesizeProfile.metadata, 'qualityScale')).toBeTrue();
expect(filesizeProfile.metadata.qualityScale).toBeUndefined();
const runtime = await integration.setup({ filePath: 'test/filesize/sample.txt' }, {});
const status = await runtime.callService!({ domain: 'filesize', service: 'status', target: {} });
const snapshot = status.data as IFilesizeSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect(Number(snapshot.entities.find((entityArg) => entityArg.id === 'bytes')?.state) > 0).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('sample.txt');
const command = await runtime.callService!({ domain: 'filesize', service: 'turn_on', target: {} });
expect(command.success).toBeFalse();
expect(command.error?.includes('requires an injected client.execute() or commandExecutor')).toBeTrue();
await runtime.destroy();
});
export default tap.start();
+72
View File
@@ -0,0 +1,72 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { FingClient, FingConfigFlow, FingIntegration, FingMapper, HomeAssistantFingIntegration, createFingDiscoveryDescriptor, fingProfile, type IFingSnapshot } from '../../ts/integrations/fing/index.js';
const rawData = {
device: {
id: 'fing-agent-lab',
name: 'Fing Agent Lab',
manufacturer: 'Fing',
model: 'Fing Agent',
host: '192.168.1.10',
port: 49090,
},
entities: [
{ id: 'phone_connected', name: 'Phone Connected', platform: 'binary_sensor', state: true, attributes: { mac: 'AA:BB:CC:DD:EE:01', ipAddress: '192.168.1.21', type: 'PHONE' } },
{ id: 'printer_connected', name: 'Printer Connected', platform: 'binary_sensor', state: false, attributes: { mac: 'AA:BB:CC:DD:EE:02', ipAddress: '192.168.1.35', type: 'PRINTER' } },
],
};
tap.test('matches manual Fing candidates and creates config flow output', async () => {
const descriptor = createFingDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'fing-manual-match');
const result = await matcher!.matches({ host: '192.168.1.10', name: 'Fing Agent', metadata: { rawData, apiKey: 'local-key' } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('fing');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new FingConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('192.168.1.10');
expect(done.config?.port).toEqual(49090);
expect(done.config?.apiKey).toEqual('local-key');
});
tap.test('maps Fing raw snapshots to runtime devices and entities', async () => {
const client = new FingClient({ rawData });
const snapshot = await client.getSnapshot();
const devices = FingMapper.toDevices(snapshot);
const entities = FingMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(snapshot.source).toEqual('manual');
expect(devices[0].integrationDomain).toEqual('fing');
expect(devices[0].manufacturer).toEqual('Fing');
expect(entities.length).toEqual(2);
expect(entities[0].platform).toEqual('binary_sensor');
});
tap.test('exposes Fing read-only runtime, HA alias, and unsupported control', async () => {
const integration = new FingIntegration();
const alias = new HomeAssistantFingIntegration();
expect(alias.domain).toEqual('fing');
expect(integration.status).toEqual('read-only-runtime');
expect(fingProfile.metadata.configFlow).toEqual(true);
expect(fingProfile.metadata.requirements).toEqual(['fing_agent_api==1.1.0']);
const runtime = await integration.setup({ name: 'Fing Runtime', rawData }, {});
const status = await runtime.callService!({ domain: 'fing', service: 'status', target: {} });
const snapshot = status.data as IFingSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Fing Agent Lab');
const command = await runtime.callService!({ domain: 'fing', service: 'turn_on', target: {} });
expect(command.success).toBeFalse();
await runtime.destroy();
});
export default tap.start();
+70
View File
@@ -0,0 +1,70 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { FireflyIiiClient, FireflyIiiConfigFlow, FireflyIiiIntegration, FireflyIiiMapper, HomeAssistantFireflyIiiIntegration, createFireflyIiiDiscoveryDescriptor, fireflyIiiProfile, type IFireflyIiiSnapshot } from '../../ts/integrations/firefly_iii/index.js';
const rawData = {
device: {
id: 'firefly-local',
name: 'Firefly III Local',
manufacturer: 'Firefly III',
model: 'Firefly III',
configurationUrl: 'https://firefly.local',
},
entities: [
{ id: 'checking_balance', name: 'Checking Balance', platform: 'sensor', state: 1234.56, unit: 'EUR', deviceClass: 'monetary' },
{ id: 'groceries_budget', name: 'Groceries Budget', platform: 'sensor', state: 210.14, unit: 'EUR', deviceClass: 'monetary' },
],
};
tap.test('matches manual Firefly III candidates and creates config flow output', async () => {
const descriptor = createFireflyIiiDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'firefly_iii-manual-match');
const result = await matcher!.matches({ url: 'https://firefly.local', name: 'Firefly III', metadata: { rawData, apiKey: 'token' } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('firefly_iii');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new FireflyIiiConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('firefly.local');
expect(done.config?.apiKey).toEqual('token');
});
tap.test('maps Firefly III raw snapshots to runtime devices and entities', async () => {
const client = new FireflyIiiClient({ rawData });
const snapshot = await client.getSnapshot();
const devices = FireflyIiiMapper.toDevices(snapshot);
const entities = FireflyIiiMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(snapshot.source).toEqual('manual');
expect(devices[0].integrationDomain).toEqual('firefly_iii');
expect(devices[0].manufacturer).toEqual('Firefly III');
expect(entities.length).toEqual(2);
expect(entities[0].attributes?.deviceClass).toEqual('monetary');
});
tap.test('exposes Firefly III read-only runtime, HA alias, and unsupported control', async () => {
const integration = new FireflyIiiIntegration();
const alias = new HomeAssistantFireflyIiiIntegration();
expect(alias.domain).toEqual('firefly_iii');
expect(integration.status).toEqual('read-only-runtime');
expect(fireflyIiiProfile.metadata.configFlow).toEqual(true);
expect(fireflyIiiProfile.metadata.requirements).toEqual(['pyfirefly==0.1.12']);
const runtime = await integration.setup({ name: 'Firefly Runtime', rawData }, {});
const status = await runtime.callService!({ domain: 'firefly_iii', service: 'status', target: {} });
const snapshot = status.data as IFireflyIiiSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Firefly III Local');
const command = await runtime.callService!({ domain: 'firefly_iii', service: 'turn_on', target: {} });
expect(command.success).toBeFalse();
await runtime.destroy();
});
export default tap.start();
+84
View File
@@ -0,0 +1,84 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { FirmataClient, FirmataConfigFlow, FirmataIntegration, FirmataMapper, HomeAssistantFirmataIntegration, createFirmataDiscoveryDescriptor, firmataProfile, type IFirmataSnapshot } from '../../ts/integrations/firmata/index.js';
const rawData = {
device: {
id: 'firmata-usb0',
name: 'Firmata USB0',
manufacturer: 'Firmata',
model: 'Arduino Firmata',
serialNumber: '/dev/ttyUSB0',
},
entities: [
{ id: 'analog_a0', name: 'Analog A0', platform: 'sensor', state: 512, unit: 'raw' },
{ id: 'door_input', name: 'Door Input', platform: 'binary_sensor', state: true },
{ id: 'relay_8', name: 'Relay 8', platform: 'switch', state: false, writable: true },
{ id: 'status_led', name: 'Status LED', platform: 'light', state: 128, writable: true },
],
};
tap.test('matches manual Firmata candidates and creates config flow output', async () => {
const descriptor = createFirmataDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'firmata-manual-match');
const result = await matcher!.matches({ name: 'Firmata USB0', metadata: { rawData, serialPort: '/dev/ttyUSB0' } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('firmata');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new FirmataConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.name).toEqual('Firmata USB0');
expect(done.config?.transport).toEqual('snapshot');
});
tap.test('maps Firmata raw snapshots to runtime devices and entities', async () => {
const client = new FirmataClient({ rawData });
const snapshot = await client.getSnapshot();
const devices = FirmataMapper.toDevices(snapshot);
const entities = FirmataMapper.toEntities(snapshot);
expect(snapshot.online).toBeTrue();
expect(snapshot.source).toEqual('manual');
expect(devices[0].integrationDomain).toEqual('firmata');
expect(devices[0].manufacturer).toEqual('Firmata');
expect(entities.length).toEqual(4);
expect(entities.some((entityArg) => entityArg.platform === 'switch')).toBeTrue();
});
tap.test('exposes Firmata executor-gated runtime, HA alias, and unsupported control', async () => {
const integration = new FirmataIntegration();
const alias = new HomeAssistantFirmataIntegration();
expect(alias.domain).toEqual('firmata');
expect(integration.status).toEqual('control-runtime');
expect(firmataProfile.metadata.configFlow).toEqual(false);
expect(firmataProfile.metadata.requirements).toEqual(['pymata-express==1.19']);
const runtime = await integration.setup({ name: 'Firmata Runtime', rawData }, {});
const status = await runtime.callService!({ domain: 'firmata', service: 'status', target: {} });
const snapshot = status.data as IFirmataSnapshot;
expect(status.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Firmata USB0');
const command = await runtime.callService!({ domain: 'switch', service: 'turn_on', target: { entityId: 'switch.firmata_runtime_relay_8' } });
expect(command.success).toBeFalse();
await runtime.destroy();
const executorRuntime = await integration.setup({
name: 'Firmata Executor',
rawData,
commandExecutor: {
execute: async (requestArg) => ({ success: true, data: { service: requestArg.service, target: requestArg.target } }),
},
}, {});
const executed = await executorRuntime.callService!({ domain: 'switch', service: 'turn_on', target: { entityId: 'switch.firmata_executor_relay_8' } });
expect(executed.success).toBeTrue();
expect((executed.data as { service: string }).service).toEqual('turn_on');
await executorRuntime.destroy();
});
export default tap.start();
+80
View File
@@ -0,0 +1,80 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { FivemClient, FivemConfigFlow, FivemIntegration, FivemMapper, HomeAssistantFivemIntegration, createFivemDiscoveryDescriptor, fivemProfile, type IFivemSnapshot, type TFivemRawData } from '../../ts/integrations/fivem/index.js';
const rawData: TFivemRawData = {
device: {
id: 'fivem-local',
name: 'FiveM Local',
manufacturer: 'Cfx.re',
model: 'FXServer',
port: 30120,
},
entities: [
{ id: 'status', name: 'Status', platform: 'binary_sensor', state: true, deviceClass: 'connectivity' },
{ id: 'players_online', name: 'Players Online', platform: 'sensor', state: 12, unit: 'players', attributes: { players_list: ['Alice', 'Bob'] } },
{ id: 'players_max', name: 'Players Max', platform: 'sensor', state: 64, unit: 'players' },
{ id: 'resources', name: 'Resources', platform: 'sensor', state: 148, unit: 'resources', attributes: { resources_list: ['mapmanager', 'chat'] } },
],
vars: {
gamename: 'gta5',
},
};
tap.test('matches manual FiveM candidates and creates config flow output', async () => {
const descriptor = createFivemDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'fivem-manual-match');
const result = await matcher!.matches({ source: 'manual', host: 'fivem.local', port: 30120, name: 'FiveM Server', metadata: { rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('fivem');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new FivemConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('fivem.local');
expect(done.config?.port).toEqual(30120);
expect(done.config?.path).toEqual('/dynamic.json');
});
tap.test('maps FiveM raw snapshots to runtime devices and entities', async () => {
const client = new FivemClient({ name: 'FiveM Runtime', rawData });
const snapshot = await client.getSnapshot();
const mappedSnapshot = FivemMapper.toSnapshotFromRaw({ name: 'FiveM Runtime' }, rawData);
const devices = FivemMapper.toDevices(mappedSnapshot);
const entities = FivemMapper.toEntities(mappedSnapshot);
expect(snapshot.online).toBeTrue();
expect(mappedSnapshot.source).toEqual('manual');
expect(devices[0].integrationDomain).toEqual('fivem');
expect(devices[0].manufacturer).toEqual('Cfx.re');
expect(entities.some((entityArg) => entityArg.id === 'sensor.fivem_local_players_online')).toBeTrue();
});
tap.test('exposes FiveM read-only runtime, HA alias, and unsupported control', async () => {
const integration = new FivemIntegration();
const alias = new HomeAssistantFivemIntegration();
expect(alias instanceof FivemIntegration).toBeTrue();
expect(alias.domain).toEqual('fivem');
expect(integration.status).toEqual('read-only-runtime');
expect(fivemProfile.metadata.configFlow).toEqual(true);
expect(fivemProfile.metadata.requirements).toEqual(['fivem-api==0.1.2']);
const runtime = await integration.setup({ name: 'FiveM Runtime', rawData }, {});
const status = await runtime.callService!({ domain: 'fivem', service: 'status', target: {} });
const refresh = await runtime.callService!({ domain: 'fivem', service: 'refresh', target: {} });
const snapshot = status.data as IFivemSnapshot;
expect(status.success).toBeTrue();
expect(refresh.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('FiveM Local');
const command = await runtime.callService!({ domain: 'fivem', service: 'turn_on', target: {} });
expect(command.success).toBeFalse();
expect(command.error!).toContain('requires an injected client.execute() or commandExecutor');
await runtime.destroy();
});
export default tap.start();
+85
View File
@@ -0,0 +1,85 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { FjaraskupanClient, FjaraskupanConfigFlow, FjaraskupanIntegration, FjaraskupanMapper, HomeAssistantFjaraskupanIntegration, createFjaraskupanDiscoveryDescriptor, fjaraskupanProfile, type IFjaraskupanSnapshot, type TFjaraskupanRawData } from '../../ts/integrations/fjaraskupan/index.js';
const rawData: TFjaraskupanRawData = {
device: {
id: 'fjaraskupan-aa-bb',
name: 'Fjaraskupan Kitchen',
manufacturer: 'Fjaraskupan',
model: 'Bluetooth cooker hood',
serialNumber: 'AA:BB:CC:DD:EE:FF',
},
entities: [
{ id: 'fan', name: 'Fan', platform: 'fan', state: 50, writable: true, attributes: { presetMode: 'normal', speedCount: 8 } },
{ id: 'light', name: 'Light', platform: 'light', state: true, writable: true, attributes: { brightness: 191 } },
{ id: 'periodic_venting', name: 'Periodic Venting', platform: 'number', state: 15, writable: true, unit: 'min', attributes: { min: 0, max: 59, step: 1 } },
{ id: 'rssi', name: 'Signal Strength', platform: 'sensor', state: -62, unit: 'dBm', deviceClass: 'signal_strength' },
{ id: 'grease_filter', name: 'Grease Filter', platform: 'binary_sensor', state: false, deviceClass: 'problem' },
{ id: 'carbon_filter', name: 'Carbon Filter', platform: 'binary_sensor', state: true, deviceClass: 'problem' },
],
state: {
fan_speed: 4,
light_on: true,
periodic_venting: 15,
},
};
tap.test('matches manual Fjaraskupan candidates and creates config flow output', async () => {
const descriptor = createFjaraskupanDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'fjaraskupan-manual-match');
const result = await matcher!.matches({ source: 'manual', id: 'AA:BB:CC:DD:EE:FF', name: 'Fjaraskupan Kitchen', metadata: { rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('fjaraskupan');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new FjaraskupanConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.name).toEqual('Fjaraskupan Kitchen');
expect(done.config?.rawData).toEqual(rawData);
});
tap.test('maps Fjaraskupan raw snapshots to runtime devices and entities', async () => {
const client = new FjaraskupanClient({ name: 'Fjaraskupan Runtime', rawData });
const snapshot = await client.getSnapshot();
const mappedSnapshot = FjaraskupanMapper.toSnapshotFromRaw({ name: 'Fjaraskupan Runtime' }, rawData);
const devices = FjaraskupanMapper.toDevices(mappedSnapshot);
const entities = FjaraskupanMapper.toEntities(mappedSnapshot);
expect(snapshot.online).toBeTrue();
expect(mappedSnapshot.source).toEqual('manual');
expect(devices[0].integrationDomain).toEqual('fjaraskupan');
expect(devices[0].manufacturer).toEqual('Fjaraskupan');
expect(entities.some((entityArg) => entityArg.id === 'fan.fjaraskupan_kitchen_fan')).toBeTrue();
expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.fjaraskupan_kitchen_carbon_filter')).toBeTrue();
});
tap.test('exposes Fjaraskupan read-only runtime, HA alias, and unsupported control', async () => {
const integration = new FjaraskupanIntegration();
const alias = new HomeAssistantFjaraskupanIntegration();
expect(alias instanceof FjaraskupanIntegration).toBeTrue();
expect(alias.domain).toEqual('fjaraskupan');
expect(integration.status).toEqual('read-only-runtime');
expect(fjaraskupanProfile.metadata.configFlow).toEqual(true);
expect(fjaraskupanProfile.metadata.requirements).toEqual(['fjaraskupan==2.3.3']);
expect(fjaraskupanProfile.metadata.dependencies).toEqual(['bluetooth_adapters']);
const runtime = await integration.setup({ name: 'Fjaraskupan Runtime', rawData }, {});
const status = await runtime.callService!({ domain: 'fjaraskupan', service: 'status', target: {} });
const refresh = await runtime.callService!({ domain: 'fjaraskupan', service: 'refresh', target: {} });
const snapshot = status.data as IFjaraskupanSnapshot;
expect(status.success).toBeTrue();
expect(refresh.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Fjaraskupan Kitchen');
const command = await runtime.callService!({ domain: 'fan', service: 'turn_on', target: { entityId: 'fan.fjaraskupan_runtime_fan' }, data: { percentage: 50 } });
expect(command.success).toBeFalse();
expect(command.error!).toContain('requires an injected client.execute() or commandExecutor');
await runtime.destroy();
});
export default tap.start();
+97
View File
@@ -0,0 +1,97 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { FlexitClient, FlexitConfigFlow, FlexitIntegration, FlexitMapper, HomeAssistantFlexitIntegration, createFlexitDiscoveryDescriptor, flexitProfile, type IFlexitSnapshot, type TFlexitRawData } from '../../ts/integrations/flexit/index.js';
const rawData: TFlexitRawData = {
device: {
id: 'flexit-ci66',
name: 'Flexit CI66',
manufacturer: 'Flexit',
model: 'CI66 Modbus adapter',
},
entities: [
{
id: 'climate',
name: 'Climate',
platform: 'climate',
state: 'cool',
writable: true,
attributes: {
currentTemperature: 21.1,
targetTemperature: 19.5,
fanMode: 'Medium',
fanModes: ['Off', 'Low', 'Medium', 'High'],
hvacAction: 'fan',
hvacModes: ['cool'],
},
},
{ id: 'filter_hours', name: 'Filter Hours', platform: 'sensor', state: 124, unit: 'h' },
{ id: 'filter_alarm', name: 'Filter Alarm', platform: 'binary_sensor', state: false, deviceClass: 'problem' },
{ id: 'heat_recovery', name: 'Heat Recovery', platform: 'sensor', state: 35, unit: '%' },
{ id: 'outdoor_air_temp', name: 'Outdoor Air Temp', platform: 'sensor', state: 8.4, unit: 'C', deviceClass: 'temperature' },
],
registers: {
holding8TargetTemperature: 195,
holding17FanMode: 2,
input9CurrentTemperature: 211,
},
};
tap.test('matches manual Flexit candidates and creates config flow output', async () => {
const descriptor = createFlexitDiscoveryDescriptor();
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'flexit-manual-match');
const result = await matcher!.matches({ source: 'manual', id: 'flexit-ci66', name: 'Flexit CI66', metadata: { rawData } }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('flexit');
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
expect(validation.matched).toBeTrue();
const done = await (await new FlexitConfigFlow().start(result.candidate!, {})).submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.name).toEqual('Flexit CI66');
expect(done.config?.rawData).toEqual(rawData);
});
tap.test('maps Flexit raw snapshots to runtime devices and entities', async () => {
const client = new FlexitClient({ name: 'Flexit Runtime', rawData });
const snapshot = await client.getSnapshot();
const mappedSnapshot = FlexitMapper.toSnapshotFromRaw({ name: 'Flexit Runtime' }, rawData);
const devices = FlexitMapper.toDevices(mappedSnapshot);
const entities = FlexitMapper.toEntities(mappedSnapshot);
expect(snapshot.online).toBeTrue();
expect(mappedSnapshot.source).toEqual('manual');
expect(devices[0].integrationDomain).toEqual('flexit');
expect(devices[0].manufacturer).toEqual('Flexit');
expect(entities.some((entityArg) => entityArg.id === 'climate.flexit_ci66_climate')).toBeTrue();
expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.flexit_ci66_filter_alarm')).toBeTrue();
});
tap.test('exposes Flexit read-only runtime, HA alias, and unsupported control', async () => {
const integration = new FlexitIntegration();
const alias = new HomeAssistantFlexitIntegration();
expect(alias instanceof FlexitIntegration).toBeTrue();
expect(alias.domain).toEqual('flexit');
expect(integration.status).toEqual('read-only-runtime');
expect(flexitProfile.metadata.configFlow).toEqual(false);
expect(flexitProfile.metadata.qualityScale).toEqual('legacy');
expect(flexitProfile.metadata.dependencies).toEqual(['modbus']);
const runtime = await integration.setup({ name: 'Flexit Runtime', rawData }, {});
const status = await runtime.callService!({ domain: 'flexit', service: 'status', target: {} });
const refresh = await runtime.callService!({ domain: 'flexit', service: 'refresh', target: {} });
const snapshot = status.data as IFlexitSnapshot;
expect(status.success).toBeTrue();
expect(refresh.success).toBeTrue();
expect(snapshot.online).toBeTrue();
expect((await runtime.devices())[0].name).toEqual('Flexit CI66');
const command = await runtime.callService!({ domain: 'climate', service: 'set_temperature', target: { entityId: 'climate.flexit_runtime_climate' }, data: { temperature: 20 } });
expect(command.success).toBeFalse();
expect(command.error!).toContain('requires an injected client.execute() or commandExecutor');
await runtime.destroy();
});
export default tap.start();
+60
View File
@@ -131,7 +131,37 @@ import { Elkm1Integration } from './integrations/elkm1/index.js';
import { ElvIntegration } from './integrations/elv/index.js'; import { ElvIntegration } from './integrations/elv/index.js';
import { EmbyIntegration } from './integrations/emby/index.js'; import { EmbyIntegration } from './integrations/emby/index.js';
import { EmoncmsIntegration } from './integrations/emoncms/index.js'; import { EmoncmsIntegration } from './integrations/emoncms/index.js';
import { EmoncmsHistoryIntegration } from './integrations/emoncms_history/index.js';
import { EmonitorIntegration } from './integrations/emonitor/index.js';
import { EmulatedHueIntegration } from './integrations/emulated_hue/index.js';
import { EmulatedKasaIntegration } from './integrations/emulated_kasa/index.js';
import { EmulatedRokuIntegration } from './integrations/emulated_roku/index.js';
import { EnergeniePowerSocketsIntegration } from './integrations/energenie_power_sockets/index.js';
import { Enigma2Integration } from './integrations/enigma2/index.js';
import { EnoceanIntegration } from './integrations/enocean/index.js';
import { EnphaseEnvoyIntegration } from './integrations/enphase_envoy/index.js';
import { EnvisalinkIntegration } from './integrations/envisalink/index.js';
import { EphemberIntegration } from './integrations/ephember/index.js';
import { EpsonIntegration } from './integrations/epson/index.js';
import { Eq3btsmartIntegration } from './integrations/eq3btsmart/index.js';
import { EsceaIntegration } from './integrations/escea/index.js';
import { EsphomeIntegration } from './integrations/esphome/index.js'; import { EsphomeIntegration } from './integrations/esphome/index.js';
import { EufyIntegration } from './integrations/eufy/index.js';
import { EufylifeBleIntegration } from './integrations/eufylife_ble/index.js';
import { EurotronicCometblueIntegration } from './integrations/eurotronic_cometblue/index.js';
import { EverlightsIntegration } from './integrations/everlights/index.js';
import { EvilGeniusLabsIntegration } from './integrations/evil_genius_labs/index.js';
import { Fail2banIntegration } from './integrations/fail2ban/index.js';
import { FamilyhubIntegration } from './integrations/familyhub/index.js';
import { FibaroIntegration } from './integrations/fibaro/index.js';
import { FileIntegration } from './integrations/file/index.js';
import { FilesizeIntegration } from './integrations/filesize/index.js';
import { FingIntegration } from './integrations/fing/index.js';
import { FireflyIiiIntegration } from './integrations/firefly_iii/index.js';
import { FirmataIntegration } from './integrations/firmata/index.js';
import { FivemIntegration } from './integrations/fivem/index.js';
import { FjaraskupanIntegration } from './integrations/fjaraskupan/index.js';
import { FlexitIntegration } from './integrations/flexit/index.js';
import { ForkedDaapdIntegration } from './integrations/forked_daapd/index.js'; import { ForkedDaapdIntegration } from './integrations/forked_daapd/index.js';
import { FoscamIntegration } from './integrations/foscam/index.js'; import { FoscamIntegration } from './integrations/foscam/index.js';
import { FreeboxIntegration } from './integrations/freebox/index.js'; import { FreeboxIntegration } from './integrations/freebox/index.js';
@@ -362,7 +392,37 @@ export const integrations = [
new ElvIntegration(), new ElvIntegration(),
new EmbyIntegration(), new EmbyIntegration(),
new EmoncmsIntegration(), new EmoncmsIntegration(),
new EmoncmsHistoryIntegration(),
new EmonitorIntegration(),
new EmulatedHueIntegration(),
new EmulatedKasaIntegration(),
new EmulatedRokuIntegration(),
new EnergeniePowerSocketsIntegration(),
new Enigma2Integration(),
new EnoceanIntegration(),
new EnphaseEnvoyIntegration(),
new EnvisalinkIntegration(),
new EphemberIntegration(),
new EpsonIntegration(),
new Eq3btsmartIntegration(),
new EsceaIntegration(),
new EsphomeIntegration(), new EsphomeIntegration(),
new EufyIntegration(),
new EufylifeBleIntegration(),
new EurotronicCometblueIntegration(),
new EverlightsIntegration(),
new EvilGeniusLabsIntegration(),
new Fail2banIntegration(),
new FamilyhubIntegration(),
new FibaroIntegration(),
new FileIntegration(),
new FilesizeIntegration(),
new FingIntegration(),
new FireflyIiiIntegration(),
new FirmataIntegration(),
new FivemIntegration(),
new FjaraskupanIntegration(),
new FlexitIntegration(),
new ForkedDaapdIntegration(), new ForkedDaapdIntegration(),
new FoscamIntegration(), new FoscamIntegration(),
new FreeboxIntegration(), new FreeboxIntegration(),
@@ -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,19 @@
import { SimpleLocalClient } from '../../core/index.js';
import { EmoncmsHistoryMapper } from './emoncms_history.mapper.js';
import type { IEmoncmsHistoryConfig, IEmoncmsHistorySnapshot } from './emoncms_history.types.js';
import { emoncmsHistoryProfile } from './emoncms_history.types.js';
export class EmoncmsHistoryClient extends SimpleLocalClient<IEmoncmsHistoryConfig> {
constructor(private readonly configArg: IEmoncmsHistoryConfig) {
super(emoncmsHistoryProfile, configArg);
}
public async getSnapshot(forceRefreshArg = false): Promise<IEmoncmsHistorySnapshot> {
const snapshot = await super.getSnapshot(forceRefreshArg);
if (snapshot.rawData === undefined && snapshot.entities.length) {
return EmoncmsHistoryMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
return EmoncmsHistoryMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEmoncmsHistoryConfig } from './emoncms_history.types.js';
import { emoncmsHistoryProfile } from './emoncms_history.types.js';
export class EmoncmsHistoryConfigFlow extends SimpleLocalConfigFlow<IEmoncmsHistoryConfig> {
constructor() {
super(emoncmsHistoryProfile);
}
}
@@ -1,26 +1,23 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js';
import { EmoncmsHistoryClient } from './emoncms_history.classes.client.js';
import { EmoncmsHistoryConfigFlow } from './emoncms_history.classes.configflow.js';
import { createEmoncmsHistoryDiscoveryDescriptor } from './emoncms_history.discovery.js';
import type { IEmoncmsHistoryConfig } from './emoncms_history.types.js';
import { emoncmsHistoryDomain, emoncmsHistoryProfile } from './emoncms_history.types.js';
export class EmoncmsHistoryIntegration extends SimpleLocalIntegration<IEmoncmsHistoryConfig> {
public readonly domain = emoncmsHistoryDomain;
public readonly discoveryDescriptor = createEmoncmsHistoryDiscoveryDescriptor();
public readonly configFlow = new EmoncmsHistoryConfigFlow();
export class HomeAssistantEmoncmsHistoryIntegration extends DescriptorOnlyIntegration {
constructor() { constructor() {
super({ super(emoncmsHistoryProfile);
domain: "emoncms_history", }
displayName: "Emoncms History",
status: 'descriptor-only', public async setup(configArg: IEmoncmsHistoryConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
metadata: { void contextArg;
"source": "home-assistant/core", return new SimpleLocalRuntime(emoncmsHistoryProfile, new EmoncmsHistoryClient(configArg));
"upstreamPath": "homeassistant/components/emoncms_history",
"upstreamDomain": "emoncms_history",
"iotClass": "local_polling",
"qualityScale": "legacy",
"requirements": [
"pyemoncms==0.1.3"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@alexandrecuer"
]
},
});
} }
} }
export class HomeAssistantEmoncmsHistoryIntegration extends EmoncmsHistoryIntegration {}
@@ -0,0 +1,4 @@
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
import { emoncmsHistoryProfile } from './emoncms_history.types.js';
export const createEmoncmsHistoryDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(emoncmsHistoryProfile);
@@ -0,0 +1,122 @@
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js';
import type { IEmoncmsHistoryConfig } from './emoncms_history.types.js';
import { emoncmsHistoryDefaultName, emoncmsHistoryProfile } from './emoncms_history.types.js';
export class EmoncmsHistoryMapper {
public static toSnapshot(optionsArg: Omit<ISimpleLocalSnapshotOptions<IEmoncmsHistoryConfig>, 'profile'>): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({
...optionsArg,
profile: emoncmsHistoryProfile,
rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData),
});
}
public static toSnapshotFromRaw(configArg: IEmoncmsHistoryConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot {
return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' });
}
public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] {
return SimpleLocalMapper.toDevices(emoncmsHistoryProfile, snapshotArg);
}
public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] {
return SimpleLocalMapper.toEntities(emoncmsHistoryProfile, snapshotArg);
}
public static slug(valueArg: unknown): string {
return SimpleLocalMapper.slug(valueArg);
}
private static normalizeRawData(configArg: IEmoncmsHistoryConfig, rawDataArg: unknown): unknown {
if (!isRecord(rawDataArg) || isSnapshotLike(rawDataArg) || hasSimpleEntities(rawDataArg)) {
return rawDataArg;
}
const payload = recordValue(rawDataArg.payload_dict) || recordValue(rawDataArg.payload) || recordValue(rawDataArg.data) || recordValue(rawDataArg.values) || recordValue(rawDataArg.states) || primitiveRecord(rawDataArg);
const readings = Object.entries(payload).filter((entryArg): entryArg is [string, string | number | boolean | null] => isPrimitive(entryArg[1]));
if (!readings.length) {
return rawDataArg;
}
const endpoint = endpointInfo(configArg, rawDataArg);
const inputNode = configArg.inputNode ?? configArg.inputnode ?? rawDataArg.inputnode ?? rawDataArg.inputNode ?? rawDataArg.node;
const whitelist = stringArray(configArg.whitelist) || stringArray(rawDataArg.whitelist);
const units = recordValue(rawDataArg.units) || {};
const name = configArg.name || stringValue(rawDataArg.name) || emoncmsHistoryDefaultName;
const entities: ISimpleLocalEntitySnapshot[] = readings.map(([keyArg, valueArg]) => ({
id: SimpleLocalMapper.slug(keyArg),
uniqueId: `${emoncmsHistoryProfile.domain}_${SimpleLocalMapper.slug(endpoint.host || name)}_${SimpleLocalMapper.slug(keyArg)}`,
name: titleCase(keyArg),
platform: 'sensor',
state: valueArg,
available: true,
writable: false,
unit: stringValue(units[keyArg]),
attributes: {
entityId: keyArg,
inputNode,
whitelisted: whitelist ? whitelist.includes(keyArg) : undefined,
},
}));
return {
device: {
id: configArg.uniqueId || (endpoint.host ? `${endpoint.host}:${endpoint.port || ''}` : undefined) || stringValue(inputNode) || name,
name,
manufacturer: emoncmsHistoryProfile.manufacturer,
model: emoncmsHistoryProfile.model,
host: endpoint.host,
port: endpoint.port,
protocol: endpoint.useTls ? 'https' : emoncmsHistoryProfile.defaultProtocol,
configurationUrl: endpoint.url,
attributes: {
inputNode,
scanInterval: configArg.scanInterval ?? configArg.scan_interval ?? rawDataArg.scan_interval ?? rawDataArg.scanInterval,
whitelist,
},
},
entities,
online: configArg.online ?? true,
updatedAt: new Date().toISOString(),
source: 'manual',
rawData: rawDataArg,
} satisfies ISimpleLocalSnapshot;
}
}
const ignoredPayloadKeys = new Set(['api_key', 'apiKey', 'client', 'commandExecutor', 'device', 'host', 'inputnode', 'inputNode', 'metadata', 'node', 'password', 'scan_interval', 'scanInterval', 'snapshot', 'snapshotProvider', 'token', 'units', 'url', 'username', 'whitelist']);
const primitiveRecord = (valueArg: Record<string, unknown>): Record<string, unknown> => Object.fromEntries(Object.entries(valueArg).filter(([keyArg, rawValueArg]) => !ignoredPayloadKeys.has(keyArg) && isPrimitive(rawValueArg)));
const endpointInfo = (configArg: IEmoncmsHistoryConfig, rawDataArg: Record<string, unknown>): { host?: string; port?: number; useTls?: boolean; url?: string } => {
const urlValue = configArg.url || stringValue(rawDataArg.url);
const parsed = parseUrl(urlValue);
return {
host: configArg.host || parsed?.host,
port: configArg.port || parsed?.port,
useTls: configArg.useTls ?? parsed?.useTls,
url: parsed?.url || urlValue,
};
};
const parseUrl = (valueArg?: string): { host: string; port?: number; useTls: boolean; url: string } | undefined => {
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
return undefined;
}
try {
const url = new URL(valueArg);
return { host: url.hostname, port: url.port ? Number(url.port) : undefined, useTls: url.protocol === 'https:', url: url.toString() };
} catch {
return undefined;
}
};
const hasSimpleEntities = (valueArg: Record<string, unknown>): boolean => Array.isArray(valueArg.entities) && valueArg.entities.some((entityArg) => isRecord(entityArg) && 'name' in entityArg && 'state' in entityArg);
const isSnapshotLike = (valueArg: Record<string, unknown>): boolean => isRecord(valueArg.device) && Array.isArray(valueArg.entities);
const recordValue = (valueArg: unknown): Record<string, unknown> | undefined => isRecord(valueArg) ? valueArg : undefined;
const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg));
const isPrimitive = (valueArg: unknown): valueArg is string | number | boolean | null => valueArg === null || ['string', 'number', 'boolean'].includes(typeof valueArg);
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
const stringArray = (valueArg: unknown): string[] | undefined => Array.isArray(valueArg) ? valueArg.filter((itemArg): itemArg is string => typeof itemArg === 'string') : undefined;
const titleCase = (valueArg: string): string => valueArg.replace(/[_./-]+/g, ' ').replace(/\b\w/g, (matchArg) => matchArg.toUpperCase());
@@ -1,4 +1,93 @@
export interface IHomeAssistantEmoncmsHistoryConfig { import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js';
// TODO: replace with the TypeScript-native config for emoncms_history.
[key: string]: unknown; export const emoncmsHistoryDomain = 'emoncms_history';
export const emoncmsHistoryDefaultName = 'Emoncms History';
export type TEmoncmsHistoryRawData = TSimpleLocalRawData;
export interface IEmoncmsHistorySnapshot extends ISimpleLocalSnapshot {}
export interface IEmoncmsHistoryConfig extends ISimpleLocalConfig {
url?: string;
inputNode?: string | number;
inputnode?: string | number;
whitelist?: string[];
scanInterval?: number;
scan_interval?: number;
} }
export interface IHomeAssistantEmoncmsHistoryConfig extends IEmoncmsHistoryConfig {}
const emoncmsHistoryControlServices = [
'input_post',
'send',
'send_history',
];
export const emoncmsHistoryProfile: ISimpleLocalIntegrationProfile = {
domain: 'emoncms_history',
displayName: 'Emoncms History',
manufacturer: 'OpenEnergyMonitor',
model: 'Emoncms History',
defaultName: emoncmsHistoryDefaultName,
defaultHttpPath: '/input/post.json',
defaultProtocol: 'http',
status: 'control-runtime',
platforms: [
'sensor',
],
serviceDomains: [
'emoncms_history',
],
controlServices: emoncmsHistoryControlServices,
discoverySources: [
'manual',
'http',
'custom',
],
discoveryKeywords: [
'emoncms',
'emoncms history',
'openenergymonitor',
'inputnode',
'history export',
],
metadata: {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/emoncms_history',
upstreamDomain: 'emoncms_history',
iotClass: 'local_polling',
qualityScale: 'legacy',
requirements: [
'pyemoncms==0.1.3',
],
dependencies: [],
afterDependencies: [],
codeowners: [
'@alexandrecuer',
],
configFlow: false,
runtime: {
type: 'control-runtime',
services: [
'snapshot',
'status',
'refresh',
...emoncmsHistoryControlServices,
],
platforms: [
'sensor',
],
controls: true,
},
localApi: {
implemented: [
'manual local Emoncms URL or host setup, snapshots, raw data, snapshotProvider, and injected native clients',
'mapping configured or raw entity values into outbound history payload snapshots',
'executor-gated Emoncms history/input posting through injected client.execute or commandExecutor',
],
explicitUnsupported: [
'claiming input_post, send, or send_history success without injected client.execute or commandExecutor',
'collecting live Home Assistant state from a Home Assistant state machine',
'performing pyemoncms writes without an injected native client or commandExecutor',
],
},
},
};
+4
View File
@@ -1,2 +1,6 @@
export * from './emoncms_history.classes.client.js';
export * from './emoncms_history.classes.configflow.js';
export * from './emoncms_history.classes.integration.js'; export * from './emoncms_history.classes.integration.js';
export * from './emoncms_history.discovery.js';
export * from './emoncms_history.mapper.js';
export * from './emoncms_history.types.js'; export * from './emoncms_history.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.
@@ -0,0 +1,19 @@
import { SimpleLocalClient } from '../../core/index.js';
import { EmonitorMapper } from './emonitor.mapper.js';
import type { IEmonitorConfig, IEmonitorSnapshot } from './emonitor.types.js';
import { emonitorProfile } from './emonitor.types.js';
export class EmonitorClient extends SimpleLocalClient<IEmonitorConfig> {
constructor(private readonly configArg: IEmonitorConfig) {
super(emonitorProfile, configArg);
}
public async getSnapshot(forceRefreshArg = false): Promise<IEmonitorSnapshot> {
const snapshot = await super.getSnapshot(forceRefreshArg);
if (snapshot.rawData === undefined && snapshot.entities.length) {
return EmonitorMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
return EmonitorMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEmonitorConfig } from './emonitor.types.js';
import { emonitorProfile } from './emonitor.types.js';
export class EmonitorConfigFlow extends SimpleLocalConfigFlow<IEmonitorConfig> {
constructor() {
super(emonitorProfile);
}
}
@@ -1,26 +1,23 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js';
import { EmonitorClient } from './emonitor.classes.client.js';
import { EmonitorConfigFlow } from './emonitor.classes.configflow.js';
import { createEmonitorDiscoveryDescriptor } from './emonitor.discovery.js';
import type { IEmonitorConfig } from './emonitor.types.js';
import { emonitorDomain, emonitorProfile } from './emonitor.types.js';
export class EmonitorIntegration extends SimpleLocalIntegration<IEmonitorConfig> {
public readonly domain = emonitorDomain;
public readonly discoveryDescriptor = createEmonitorDiscoveryDescriptor();
public readonly configFlow = new EmonitorConfigFlow();
export class HomeAssistantEmonitorIntegration extends DescriptorOnlyIntegration {
constructor() { constructor() {
super({ super(emonitorProfile);
domain: "emonitor", }
displayName: "SiteSage Emonitor",
status: 'descriptor-only', public async setup(configArg: IEmonitorConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
metadata: { void contextArg;
"source": "home-assistant/core", return new SimpleLocalRuntime(emonitorProfile, new EmonitorClient(configArg));
"upstreamPath": "homeassistant/components/emonitor",
"upstreamDomain": "emonitor",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"aioemonitor==1.0.5"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@bdraco"
]
},
});
} }
} }
export class HomeAssistantEmonitorIntegration extends EmonitorIntegration {}
@@ -0,0 +1,4 @@
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
import { emonitorProfile } from './emonitor.types.js';
export const createEmonitorDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(emonitorProfile);
+134
View File
@@ -0,0 +1,134 @@
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js';
import type { IEmonitorConfig } from './emonitor.types.js';
import { emonitorDefaultName, emonitorProfile } from './emonitor.types.js';
export class EmonitorMapper {
public static toSnapshot(optionsArg: Omit<ISimpleLocalSnapshotOptions<IEmonitorConfig>, 'profile'>): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({
...optionsArg,
profile: emonitorProfile,
rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData),
});
}
public static toSnapshotFromRaw(configArg: IEmonitorConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot {
return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' });
}
public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] {
return SimpleLocalMapper.toDevices(emonitorProfile, snapshotArg);
}
public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] {
return SimpleLocalMapper.toEntities(emonitorProfile, snapshotArg);
}
public static slug(valueArg: unknown): string {
return SimpleLocalMapper.slug(valueArg);
}
private static normalizeRawData(configArg: IEmonitorConfig, rawDataArg: unknown): unknown {
if (!isRecord(rawDataArg) || isSnapshotLike(rawDataArg) || hasSimpleEntities(rawDataArg)) {
return rawDataArg;
}
const channels = channelRecords(rawDataArg.channels);
if (!channels.length) {
return rawDataArg;
}
const network = recordValue(rawDataArg.network) || {};
const hardware = recordValue(rawDataArg.hardware) || {};
const macAddress = configArg.macAddress || configArg.mac_address || stringValue(network.mac_address) || stringValue(network.macAddress) || stringValue(rawDataArg.mac_address) || stringValue(rawDataArg.macAddress);
const serialNumber = stringValue(hardware.serial_number) || stringValue(hardware.serialNumber) || stringValue(rawDataArg.serial_number) || stringValue(rawDataArg.serialNumber);
const firmwareVersion = stringValue(hardware.firmware_version) || stringValue(hardware.firmwareVersion) || stringValue(rawDataArg.firmware_version) || stringValue(rawDataArg.firmwareVersion);
const deviceName = configArg.name || stringValue(rawDataArg.name) || (macAddress ? `Emonitor ${shortMac(macAddress)}` : emonitorDefaultName);
const entities: ISimpleLocalEntitySnapshot[] = [];
const seenChannels = new Set<string>();
for (const channel of channels) {
seenChannels.add(channel.number);
if (channel.data.active === false) {
continue;
}
const pairedChannel = stringValue(channel.data.paired_with_channel) || stringValue(channel.data.pairedWithChannel) || numberString(channel.data.paired_with_channel) || numberString(channel.data.pairedWithChannel);
if (pairedChannel && seenChannels.has(pairedChannel)) {
continue;
}
const label = stringValue(channel.data.label) || channel.number;
entities.push(...powerEntities({ channelNumber: channel.number, label, data: channel.data, pairedData: pairedChannel ? channels.find((candidateArg) => candidateArg.number === pairedChannel)?.data : undefined, macAddress, deviceName }));
}
return {
device: {
id: configArg.uniqueId || macAddress || serialNumber || configArg.host || deviceName,
name: deviceName,
manufacturer: emonitorProfile.manufacturer,
model: emonitorProfile.model,
serialNumber,
host: configArg.host,
port: configArg.port,
protocol: emonitorProfile.defaultProtocol,
attributes: {
firmwareVersion,
macAddress,
},
},
entities,
online: configArg.online ?? true,
updatedAt: new Date().toISOString(),
source: 'manual',
rawData: rawDataArg,
} satisfies ISimpleLocalSnapshot;
}
}
const powerEntities = (optionsArg: { channelNumber: string; label: string; data: Record<string, unknown>; pairedData?: Record<string, unknown>; macAddress?: string; deviceName: string }): ISimpleLocalEntitySnapshot[] => {
const base = SimpleLocalMapper.slug(optionsArg.macAddress || optionsArg.deviceName);
return [
['inst_power', optionsArg.label],
['avg_power', `${optionsArg.label} average`],
['max_power', `${optionsArg.label} max`],
].map(([keyArg, nameArg]) => ({
id: `channel_${SimpleLocalMapper.slug(optionsArg.channelNumber)}_${keyArg}`,
uniqueId: `${emonitorProfile.domain}_${base}_${SimpleLocalMapper.slug(optionsArg.channelNumber)}_${keyArg}`,
name: nameArg,
platform: 'sensor',
state: sumPower(optionsArg.data, optionsArg.pairedData, keyArg),
available: true,
writable: false,
unit: 'W',
deviceClass: 'power',
stateClass: 'measurement',
attributes: {
channel: Number(optionsArg.channelNumber),
pairedWithChannel: numberValue(optionsArg.data.paired_with_channel) ?? numberValue(optionsArg.data.pairedWithChannel),
},
}));
};
const sumPower = (channelArg: Record<string, unknown>, pairedChannelArg: Record<string, unknown> | undefined, keyArg: string): number => (numberValue(channelArg[keyArg]) || 0) + (pairedChannelArg ? numberValue(pairedChannelArg[keyArg]) || 0 : 0);
const channelRecords = (valueArg: unknown): Array<{ number: string; data: Record<string, unknown> }> => {
if (Array.isArray(valueArg)) {
return valueArg.filter(isRecord).map((channelArg, indexArg) => ({ number: stringValue(channelArg.channel) || stringValue(channelArg.channel_number) || String(indexArg + 1), data: channelArg }));
}
if (isRecord(valueArg)) {
return Object.entries(valueArg).filter((entryArg): entryArg is [string, Record<string, unknown>] => isRecord(entryArg[1])).map(([numberArg, dataArg]) => ({ number: numberArg, data: dataArg }));
}
return [];
};
const shortMac = (valueArg: string): string => valueArg.replace(/[^a-fA-F0-9]/g, '').slice(-6).toUpperCase();
const hasSimpleEntities = (valueArg: Record<string, unknown>): boolean => Array.isArray(valueArg.entities) && valueArg.entities.some((entityArg) => isRecord(entityArg) && 'name' in entityArg && 'state' in entityArg);
const isSnapshotLike = (valueArg: Record<string, unknown>): boolean => isRecord(valueArg.device) && Array.isArray(valueArg.entities);
const recordValue = (valueArg: unknown): Record<string, unknown> | undefined => isRecord(valueArg) ? valueArg : undefined;
const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg));
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
const numberString = (valueArg: unknown): string | undefined => typeof valueArg === 'number' && Number.isFinite(valueArg) ? String(valueArg) : undefined;
const numberValue = (valueArg: unknown): number | undefined => {
const value = typeof valueArg === 'number' ? valueArg : typeof valueArg === 'string' ? Number(valueArg) : undefined;
return value !== undefined && Number.isFinite(value) ? value : undefined;
};
+88 -3
View File
@@ -1,4 +1,89 @@
export interface IHomeAssistantEmonitorConfig { import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js';
// TODO: replace with the TypeScript-native config for emonitor.
[key: string]: unknown; export const emonitorDomain = 'emonitor';
export const emonitorDefaultName = 'SiteSage Emonitor';
export type TEmonitorRawData = TSimpleLocalRawData;
export interface IEmonitorSnapshot extends ISimpleLocalSnapshot {}
export interface IEmonitorConfig extends ISimpleLocalConfig {
macAddress?: string;
mac_address?: string;
} }
export interface IHomeAssistantEmonitorConfig extends IEmonitorConfig {}
export const emonitorProfile: ISimpleLocalIntegrationProfile = {
domain: 'emonitor',
displayName: 'SiteSage Emonitor',
manufacturer: 'Powerhouse Dynamics, Inc.',
model: 'SiteSage Emonitor',
defaultName: emonitorDefaultName,
defaultProtocol: 'http',
status: 'read-only-runtime',
platforms: [
'sensor',
],
serviceDomains: [],
controlServices: [],
discoverySources: [
'manual',
'dhcp',
'http',
'custom',
],
discoveryKeywords: [
'emonitor',
'sitesage',
'powerhouse dynamics',
'0090c2',
'power monitor',
],
metadata: {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/emonitor',
upstreamDomain: 'emonitor',
integrationType: 'device',
iotClass: 'local_polling',
qualityScale: undefined,
requirements: [
'aioemonitor==1.0.5',
],
dependencies: [],
afterDependencies: [],
codeowners: [
'@bdraco',
],
configFlow: true,
dhcp: [
{
hostname: 'emonitor*',
macaddress: '0090C2*',
},
{
registered_devices: true,
},
],
runtime: {
type: 'read-only-runtime',
services: [
'snapshot',
'status',
'refresh',
],
platforms: [
'sensor',
],
controls: false,
},
localApi: {
implemented: [
'manual local host setup, snapshots, raw data, snapshotProvider, and injected native clients',
'SiteSage Emonitor status/channel snapshot mapping compatible with aioemonitor status data',
],
explicitUnsupported: [
'claiming live control success without injected client.execute or commandExecutor',
'guessing undocumented aioemonitor HTTP endpoints for direct polling',
'cloud account flows and remote API polling',
],
},
},
};
+4
View File
@@ -1,2 +1,6 @@
export * from './emonitor.classes.client.js';
export * from './emonitor.classes.configflow.js';
export * from './emonitor.classes.integration.js'; export * from './emonitor.classes.integration.js';
export * from './emonitor.discovery.js';
export * from './emonitor.mapper.js';
export * from './emonitor.types.js'; export * from './emonitor.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.
@@ -0,0 +1,19 @@
import { SimpleLocalClient } from '../../core/index.js';
import { EmulatedHueMapper } from './emulated_hue.mapper.js';
import type { IEmulatedHueConfig, IEmulatedHueSnapshot } from './emulated_hue.types.js';
import { emulatedHueProfile } from './emulated_hue.types.js';
export class EmulatedHueClient extends SimpleLocalClient<IEmulatedHueConfig> {
constructor(private readonly configArg: IEmulatedHueConfig) {
super(emulatedHueProfile, configArg);
}
public async getSnapshot(forceRefreshArg = false): Promise<IEmulatedHueSnapshot> {
const snapshot = await super.getSnapshot(forceRefreshArg);
if (snapshot.rawData === undefined && snapshot.entities.length) {
return EmulatedHueMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
return EmulatedHueMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEmulatedHueConfig } from './emulated_hue.types.js';
import { emulatedHueProfile } from './emulated_hue.types.js';
export class EmulatedHueConfigFlow extends SimpleLocalConfigFlow<IEmulatedHueConfig> {
constructor() {
super(emulatedHueProfile);
}
}
@@ -1,29 +1,23 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js';
import { EmulatedHueClient } from './emulated_hue.classes.client.js';
import { EmulatedHueConfigFlow } from './emulated_hue.classes.configflow.js';
import { createEmulatedHueDiscoveryDescriptor } from './emulated_hue.discovery.js';
import type { IEmulatedHueConfig } from './emulated_hue.types.js';
import { emulatedHueDomain, emulatedHueProfile } from './emulated_hue.types.js';
export class EmulatedHueIntegration extends SimpleLocalIntegration<IEmulatedHueConfig> {
public readonly domain = emulatedHueDomain;
public readonly discoveryDescriptor = createEmulatedHueDiscoveryDescriptor();
public readonly configFlow = new EmulatedHueConfigFlow();
export class HomeAssistantEmulatedHueIntegration extends DescriptorOnlyIntegration {
constructor() { constructor() {
super({ super(emulatedHueProfile);
domain: "emulated_hue", }
displayName: "Emulated Hue",
status: 'descriptor-only', public async setup(configArg: IEmulatedHueConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
metadata: { void contextArg;
"source": "home-assistant/core", return new SimpleLocalRuntime(emulatedHueProfile, new EmulatedHueClient(configArg));
"upstreamPath": "homeassistant/components/emulated_hue",
"upstreamDomain": "emulated_hue",
"iotClass": "local_push",
"qualityScale": "internal",
"requirements": [],
"dependencies": [
"network"
],
"afterDependencies": [
"http"
],
"codeowners": [
"@bdraco",
"@Tho85"
]
},
});
} }
} }
export class HomeAssistantEmulatedHueIntegration extends EmulatedHueIntegration {}
@@ -0,0 +1,4 @@
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
import { emulatedHueProfile } from './emulated_hue.types.js';
export const createEmulatedHueDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(emulatedHueProfile);
@@ -0,0 +1,206 @@
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TEntityPlatform, type TSimpleLocalRawData } from '../../core/index.js';
import type { IEmulatedHueConfig } from './emulated_hue.types.js';
import { emulatedHueDefaultName, emulatedHueDefaultPort, emulatedHueProfile, emulatedHueSerialNumber, emulatedHueUuid } from './emulated_hue.types.js';
export class EmulatedHueMapper {
public static toSnapshot(optionsArg: Omit<ISimpleLocalSnapshotOptions<IEmulatedHueConfig>, 'profile'>): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({
...optionsArg,
profile: emulatedHueProfile,
rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData),
});
}
public static toSnapshotFromRaw(configArg: IEmulatedHueConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot {
return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' });
}
public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] {
return SimpleLocalMapper.toDevices(emulatedHueProfile, snapshotArg);
}
public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] {
return SimpleLocalMapper.toEntities(emulatedHueProfile, snapshotArg);
}
public static slug(valueArg: unknown): string {
return SimpleLocalMapper.slug(valueArg);
}
private static normalizeRawData(configArg: IEmulatedHueConfig, rawDataArg: unknown): unknown {
if (!isRecord(rawDataArg) || isSnapshotLike(rawDataArg) || hasSimpleEntities(rawDataArg)) {
return rawDataArg;
}
const hueLights = hueLightEntities(rawDataArg.lights);
const haStates = stateRecords(rawDataArg.states ?? rawDataArg.hassStates ?? rawDataArg.entities);
const entities = haStates.length ? entitiesFromStates(configArg, rawDataArg, haStates) : hueLights;
if (!entities.length) {
return rawDataArg;
}
const config = recordValue(rawDataArg.config) || {};
const endpoint = endpointInfo(configArg, rawDataArg, config);
const name = configArg.name || stringValue(config.name) || stringValue(rawDataArg.name) || 'HASS BRIDGE';
return {
device: {
id: configArg.uniqueId || stringValue(config.mac) || emulatedHueSerialNumber,
name,
manufacturer: emulatedHueProfile.manufacturer,
model: emulatedHueProfile.model,
serialNumber: emulatedHueSerialNumber,
host: endpoint.host,
port: endpoint.port,
protocol: emulatedHueProfile.defaultProtocol,
configurationUrl: endpoint.host ? `http://${endpoint.host}:${endpoint.port || emulatedHueDefaultPort}` : undefined,
attributes: {
uuid: emulatedHueUuid,
type: configArg.type || stringValue(rawDataArg.type) || 'google_home',
exposeByDefault: configArg.exposeByDefault ?? configArg.expose_by_default ?? rawDataArg.expose_by_default ?? rawDataArg.exposeByDefault,
lightsAllDimmable: configArg.lightsAllDimmable ?? configArg.lights_all_dimmable ?? rawDataArg.lights_all_dimmable ?? rawDataArg.lightsAllDimmable,
},
},
entities,
online: configArg.online ?? true,
updatedAt: new Date().toISOString(),
source: 'manual',
rawData: rawDataArg,
} satisfies ISimpleLocalSnapshot;
}
}
const defaultExposedDomains = ['switch', 'light', 'group', 'input_boolean', 'media_player', 'fan'];
const platformByDomain: Record<string, TEntityPlatform> = {
climate: 'climate',
cover: 'cover',
fan: 'fan',
group: 'switch',
humidifier: 'fan',
input_boolean: 'switch',
light: 'light',
media_player: 'media_player',
scene: 'button',
script: 'button',
switch: 'switch',
};
const offStates = new Set(['closed', 'off', 'unavailable', 'unknown']);
const entitiesFromStates = (configArg: IEmulatedHueConfig, rawDataArg: Record<string, unknown>, stateRecordsArg: Array<Record<string, unknown>>): ISimpleLocalEntitySnapshot[] => {
const exposedDomains = stringArray(configArg.exposedDomains) || stringArray(configArg.exposed_domains) || stringArray(rawDataArg.exposed_domains) || stringArray(rawDataArg.exposedDomains) || defaultExposedDomains;
const exposeByDefault = configArg.exposeByDefault ?? configArg.expose_by_default ?? rawDataArg.expose_by_default ?? rawDataArg.exposeByDefault ?? true;
return stateRecordsArg.map((stateArg, indexArg) => entityFromState(configArg, exposedDomains, Boolean(exposeByDefault), stateArg, indexArg)).filter((entityArg): entityArg is ISimpleLocalEntitySnapshot => Boolean(entityArg));
};
const entityFromState = (configArg: IEmulatedHueConfig, exposedDomainsArg: string[], exposeByDefaultArg: boolean, stateArg: Record<string, unknown>, indexArg: number): ISimpleLocalEntitySnapshot | undefined => {
const entityId = stringValue(stateArg.entity_id) || stringValue(stateArg.entityId) || stringValue(stateArg.id);
if (!entityId) {
return undefined;
}
const domain = entityId.split('.')[0];
const platform = platformByDomain[domain];
if (!platform || (exposeByDefaultArg && !exposedDomainsArg.includes(domain)) || (!exposeByDefaultArg && !configArg.exposedEntities?.[entityId])) {
return undefined;
}
const attributes = recordValue(stateArg.attributes) || {};
const exposedEntityConfig = configArg.exposedEntities?.[entityId];
if (exposedEntityConfig?.hidden) {
return undefined;
}
return {
id: SimpleLocalMapper.slug(entityId),
uniqueId: `${emulatedHueProfile.domain}_${SimpleLocalMapper.slug(entityId)}`,
name: exposedEntityConfig?.name || stringValue(attributes.emulated_hue_name) || stringValue(attributes.friendly_name) || stringValue(stateArg.name) || entityId,
platform,
state: hueStateValue(domain, stateArg.state),
available: stringValue(stateArg.state) !== 'unavailable',
writable: true,
attributes: {
entityId,
hueNumber: indexArg + 1,
hueReachable: stringValue(stateArg.state) !== 'unavailable',
brightness: attributes.brightness,
colorTempKelvin: attributes.color_temp_kelvin,
hsColor: attributes.hs_color,
supportedColorModes: attributes.supported_color_modes,
supportedFeatures: attributes.supported_features,
},
};
};
const hueLightEntities = (valueArg: unknown): ISimpleLocalEntitySnapshot[] => {
if (!isRecord(valueArg)) {
return [];
}
return Object.entries(valueArg).filter((entryArg): entryArg is [string, Record<string, unknown>] => isRecord(entryArg[1])).map(([numberArg, lightArg]) => {
const state = recordValue(lightArg.state) || {};
return {
id: `hue_${SimpleLocalMapper.slug(numberArg)}`,
uniqueId: `${emulatedHueProfile.domain}_${SimpleLocalMapper.slug(stringValue(lightArg.uniqueid) || numberArg)}`,
name: stringValue(lightArg.name) || `Hue ${numberArg}`,
platform: 'light',
state: state.on === true,
available: state.reachable !== false,
writable: true,
attributes: {
hueNumber: numberArg,
hueUniqueId: lightArg.uniqueid,
hueType: lightArg.type,
modelId: lightArg.modelid,
brightness: state.bri,
colorMode: state.colormode,
},
};
});
};
const endpointInfo = (configArg: IEmulatedHueConfig, rawDataArg: Record<string, unknown>, hueConfigArg: Record<string, unknown>): { host?: string; port?: number } => {
const ipAddress = stringValue(hueConfigArg.ipaddress) || stringValue(rawDataArg.ipaddress);
const parsed = parseHostPort(ipAddress);
const host = configArg.host || configArg.hostIp || configArg.host_ip || stringValue(rawDataArg.host_ip) || stringValue(rawDataArg.hostIp) || parsed?.host;
const port = configArg.port || configArg.listenPort || configArg.listen_port || numberValue(rawDataArg.listen_port) || numberValue(rawDataArg.listenPort) || parsed?.port || (host ? emulatedHueDefaultPort : undefined);
return { host, port };
};
const parseHostPort = (valueArg?: string): { host: string; port?: number } | undefined => {
if (!valueArg) {
return undefined;
}
const [host, port] = valueArg.split(':');
return host ? { host, port: port ? Number(port) : undefined } : undefined;
};
const stateRecords = (valueArg: unknown): Array<Record<string, unknown>> => {
if (Array.isArray(valueArg)) {
return valueArg.filter(isRecord);
}
if (isRecord(valueArg)) {
return Object.values(valueArg).filter(isRecord);
}
return [];
};
const hueStateValue = (domainArg: string, valueArg: unknown): boolean | string => {
const value = stringValue(valueArg);
if (!value) {
return false;
}
if (domainArg === 'cover') {
return value !== 'closed';
}
return !offStates.has(value);
};
const hasSimpleEntities = (valueArg: Record<string, unknown>): boolean => Array.isArray(valueArg.entities) && valueArg.entities.some((entityArg) => isRecord(entityArg) && 'name' in entityArg && 'state' in entityArg && !('entity_id' in entityArg));
const isSnapshotLike = (valueArg: Record<string, unknown>): boolean => isRecord(valueArg.device) && Array.isArray(valueArg.entities);
const recordValue = (valueArg: unknown): Record<string, unknown> | undefined => isRecord(valueArg) ? valueArg : undefined;
const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg));
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
const stringArray = (valueArg: unknown): string[] | undefined => Array.isArray(valueArg) ? valueArg.filter((itemArg): itemArg is string => typeof itemArg === 'string') : undefined;
const numberValue = (valueArg: unknown): number | undefined => {
const value = typeof valueArg === 'number' ? valueArg : typeof valueArg === 'string' ? Number(valueArg) : undefined;
return value !== undefined && Number.isFinite(value) ? value : undefined;
};
@@ -1,4 +1,148 @@
export interface IHomeAssistantEmulatedHueConfig { import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js';
// TODO: replace with the TypeScript-native config for emulated_hue.
[key: string]: unknown; export const emulatedHueDomain = 'emulated_hue';
export const emulatedHueDefaultName = 'Emulated Hue';
export const emulatedHueDefaultPort = 8300;
export const emulatedHueSerialNumber = '001788FFFE23BFC2';
export const emulatedHueUuid = '2f402f80-da50-11e1-9b23-001788255acc';
export type TEmulatedHueRawData = TSimpleLocalRawData;
export interface IEmulatedHueSnapshot extends ISimpleLocalSnapshot {}
export interface IEmulatedHueConfig extends ISimpleLocalConfig {
hostIp?: string;
host_ip?: string;
listenPort?: number;
listen_port?: number;
advertiseIp?: string;
advertise_ip?: string;
advertisePort?: number;
advertise_port?: number;
exposeByDefault?: boolean;
expose_by_default?: boolean;
exposedDomains?: string[];
exposed_domains?: string[];
lightsAllDimmable?: boolean;
lights_all_dimmable?: boolean;
type?: 'alexa' | 'google_home' | string;
exposedEntities?: Record<string, { name?: string; hidden?: boolean }>;
} }
export interface IHomeAssistantEmulatedHueConfig extends IEmulatedHueConfig {}
const emulatedHueControlServices = [
'turn_on',
'turn_off',
'toggle',
'set_level',
'set_temperature',
'open_cover',
'close_cover',
'set_value',
'select_source',
'volume_up',
'volume_down',
'volume_mute',
'media_play',
'media_pause',
'media_stop',
];
export const emulatedHueProfile: ISimpleLocalIntegrationProfile = {
domain: 'emulated_hue',
displayName: 'Emulated Hue',
manufacturer: 'Home Assistant',
model: 'Emulated Hue Bridge',
defaultName: emulatedHueDefaultName,
defaultPort: emulatedHueDefaultPort,
defaultProtocol: 'upnp',
status: 'control-runtime',
platforms: [
'light',
'switch',
'media_player',
'fan',
'cover',
'climate',
'button',
],
serviceDomains: [
'light',
'switch',
'media_player',
'fan',
'cover',
'climate',
'button',
],
controlServices: emulatedHueControlServices,
discoverySources: [
'manual',
'ssdp',
'http',
'custom',
],
discoveryKeywords: [
'emulated hue',
'hass bridge',
'philips hue',
'hue bridge',
'upnp',
'alexa',
'google_home',
],
metadata: {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/emulated_hue',
upstreamDomain: 'emulated_hue',
iotClass: 'local_push',
qualityScale: 'internal',
requirements: [],
dependencies: [
'network',
],
afterDependencies: [
'http',
],
codeowners: [
'@bdraco',
'@Tho85',
],
configFlow: false,
hue: {
serialNumber: emulatedHueSerialNumber,
uuid: emulatedHueUuid,
defaultListenPort: emulatedHueDefaultPort,
defaultType: 'google_home',
},
runtime: {
type: 'control-runtime',
services: [
'snapshot',
'status',
'refresh',
...emulatedHueControlServices,
],
platforms: [
'light',
'switch',
'media_player',
'fan',
'cover',
'climate',
'button',
],
controls: true,
},
localApi: {
implemented: [
'manual bridge setup, snapshots, raw data, snapshotProvider, and injected native clients',
'Hue API-compatible state mapping for exposed local Home Assistant entity snapshots',
'executor-gated local control dispatch through injected client.execute or commandExecutor',
],
explicitUnsupported: [
'starting the HTTP and UPNP responder without a host application',
'claiming Hue API command success without injected client.execute or commandExecutor',
'accepting remote non-local Hue API callers',
],
},
},
};
+4
View File
@@ -1,2 +1,6 @@
export * from './emulated_hue.classes.client.js';
export * from './emulated_hue.classes.configflow.js';
export * from './emulated_hue.classes.integration.js'; export * from './emulated_hue.classes.integration.js';
export * from './emulated_hue.discovery.js';
export * from './emulated_hue.mapper.js';
export * from './emulated_hue.types.js'; export * from './emulated_hue.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.
@@ -0,0 +1,19 @@
import { SimpleLocalClient } from '../../core/index.js';
import { EmulatedKasaMapper } from './emulated_kasa.mapper.js';
import type { IEmulatedKasaConfig, IEmulatedKasaSnapshot } from './emulated_kasa.types.js';
import { emulatedKasaProfile } from './emulated_kasa.types.js';
export class EmulatedKasaClient extends SimpleLocalClient<IEmulatedKasaConfig> {
constructor(private readonly configArg: IEmulatedKasaConfig) {
super(emulatedKasaProfile, configArg);
}
public async getSnapshot(forceRefreshArg = false): Promise<IEmulatedKasaSnapshot> {
const snapshot = await super.getSnapshot(forceRefreshArg);
if (snapshot.rawData === undefined && snapshot.entities.length) {
return EmulatedKasaMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
return EmulatedKasaMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEmulatedKasaConfig } from './emulated_kasa.types.js';
import { emulatedKasaProfile } from './emulated_kasa.types.js';
export class EmulatedKasaConfigFlow extends SimpleLocalConfigFlow<IEmulatedKasaConfig> {
constructor() {
super(emulatedKasaProfile);
}
}
@@ -1,26 +1,23 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js';
import { EmulatedKasaClient } from './emulated_kasa.classes.client.js';
import { EmulatedKasaConfigFlow } from './emulated_kasa.classes.configflow.js';
import { createEmulatedKasaDiscoveryDescriptor } from './emulated_kasa.discovery.js';
import type { IEmulatedKasaConfig } from './emulated_kasa.types.js';
import { emulatedKasaDomain, emulatedKasaProfile } from './emulated_kasa.types.js';
export class EmulatedKasaIntegration extends SimpleLocalIntegration<IEmulatedKasaConfig> {
public readonly domain = emulatedKasaDomain;
public readonly discoveryDescriptor = createEmulatedKasaDiscoveryDescriptor();
public readonly configFlow = new EmulatedKasaConfigFlow();
export class HomeAssistantEmulatedKasaIntegration extends DescriptorOnlyIntegration {
constructor() { constructor() {
super({ super(emulatedKasaProfile);
domain: "emulated_kasa", }
displayName: "Emulated Kasa",
status: 'descriptor-only', public async setup(configArg: IEmulatedKasaConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
metadata: { void contextArg;
"source": "home-assistant/core", return new SimpleLocalRuntime(emulatedKasaProfile, new EmulatedKasaClient(configArg));
"upstreamPath": "homeassistant/components/emulated_kasa",
"upstreamDomain": "emulated_kasa",
"iotClass": "local_push",
"qualityScale": "internal",
"requirements": [
"sense-energy==0.14.1"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@kbickar"
]
},
});
} }
} }
export class HomeAssistantEmulatedKasaIntegration extends EmulatedKasaIntegration {}
@@ -0,0 +1,4 @@
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
import { emulatedKasaProfile } from './emulated_kasa.types.js';
export const createEmulatedKasaDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(emulatedKasaProfile);
@@ -0,0 +1,185 @@
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js';
import type { IEmulatedKasaConfig } from './emulated_kasa.types.js';
import { emulatedKasaDefaultName, emulatedKasaProfile } from './emulated_kasa.types.js';
export class EmulatedKasaMapper {
public static toSnapshot(optionsArg: Omit<ISimpleLocalSnapshotOptions<IEmulatedKasaConfig>, 'profile'>): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({
...optionsArg,
profile: emulatedKasaProfile,
rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData),
});
}
public static toSnapshotFromRaw(configArg: IEmulatedKasaConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot {
return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' });
}
public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] {
return SimpleLocalMapper.toDevices(emulatedKasaProfile, snapshotArg);
}
public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] {
return SimpleLocalMapper.toEntities(emulatedKasaProfile, snapshotArg);
}
public static slug(valueArg: unknown): string {
return SimpleLocalMapper.slug(valueArg);
}
private static normalizeRawData(configArg: IEmulatedKasaConfig, rawDataArg: unknown): unknown {
if (isSimpleSnapshot(rawDataArg)) {
return rawDataArg;
}
const plugRecords = plugEntries(rawDataArg);
if (!plugRecords.length) {
return rawDataArg;
}
const rawRecord = recordValue(rawDataArg);
const name = configArg.name || stringValue(rawRecord?.name) || emulatedKasaDefaultName;
const host = configArg.host || stringValue(rawRecord?.host);
const entities = plugRecords.flatMap(({ id, record }) => kasaEntities(id, record));
if (!entities.length) {
return rawDataArg;
}
return {
device: {
id: configArg.uniqueId || stringValue(rawRecord?.id) || host || emulatedKasaProfile.domain,
name,
manufacturer: emulatedKasaProfile.manufacturer,
model: emulatedKasaProfile.model,
host,
port: configArg.port || numberValue(rawRecord?.port),
protocol: emulatedKasaProfile.defaultProtocol,
attributes: {
emulatedPlugCount: plugRecords.length,
},
},
entities,
online: configArg.online ?? true,
updatedAt: new Date().toISOString(),
source: 'manual',
rawData: rawDataArg,
} satisfies ISimpleLocalSnapshot;
}
}
const kasaEntities = (fallbackIdArg: string, recordArg: Record<string, unknown>): ISimpleLocalEntitySnapshot[] => {
const entityId = stringValue(recordArg.entity_id) || stringValue(recordArg.entityId) || fallbackIdArg;
const alias = stringValue(recordArg.name) || stringValue(recordArg.alias) || titleFromEntityId(entityId);
const slug = SimpleLocalMapper.slug(entityId || alias);
const domain = stringValue(recordArg.domain) || entityId.split('.')[0];
const rawState = recordArg.state ?? recordArg.is_on ?? recordArg.isOn;
const onState = switchState(rawState);
const powerEntity = stringValue(recordArg.power_entity) || stringValue(recordArg.powerEntity);
let power = numberValue(recordArg.power ?? recordArg.power_w ?? recordArg.powerW ?? recordArg.watts ?? recordArg.value);
if (power === undefined && (domain === 'sensor' || entityId.startsWith('sensor.'))) {
power = numberValue(rawState);
}
if (power === undefined && onState === false) {
power = 0;
}
const entities: ISimpleLocalEntitySnapshot[] = [];
if (power !== undefined) {
entities.push({
id: `${slug}_power`,
uniqueId: `${emulatedKasaProfile.domain}_${slug}_power`,
name: `${alias} Power`,
platform: 'sensor',
state: power,
available: true,
writable: false,
unit: 'W',
deviceClass: 'power',
attributes: {
entityId,
powerEntity,
},
});
}
if (onState !== undefined && domain !== 'sensor') {
entities.push({
id: `${slug}_state`,
uniqueId: `${emulatedKasaProfile.domain}_${slug}_state`,
name: `${alias} State`,
platform: 'binary_sensor',
state: onState,
available: true,
writable: false,
attributes: {
entityId,
},
});
}
return entities;
};
const plugEntries = (valueArg: unknown): Array<{ id: string; record: Record<string, unknown> }> => {
if (Array.isArray(valueArg)) {
return valueArg.map((entryArg, indexArg) => ({ id: `plug_${indexArg + 1}`, record: recordValue(entryArg) })).filter((entryArg): entryArg is { id: string; record: Record<string, unknown> } => Boolean(entryArg.record));
}
const rawRecord = recordValue(valueArg);
if (!rawRecord) {
return [];
}
const nested = rawRecord.entities ?? rawRecord.plugs ?? rawRecord.devices;
if (Array.isArray(nested)) {
return plugEntries(nested);
}
const nestedRecord = recordValue(nested);
if (nestedRecord) {
return Object.entries(nestedRecord)
.map(([idArg, entryArg]) => ({ id: idArg, record: recordValue(entryArg) }))
.filter((entryArg): entryArg is { id: string; record: Record<string, unknown> } => Boolean(entryArg.record));
}
if ('power' in rawRecord || 'power_w' in rawRecord || 'powerW' in rawRecord || 'state' in rawRecord) {
return [{ id: stringValue(rawRecord.entity_id) || stringValue(rawRecord.entityId) || stringValue(rawRecord.id) || 'plug', record: rawRecord }];
}
return [];
};
const switchState = (valueArg: unknown): boolean | undefined => {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'number') {
return valueArg !== 0;
}
const value = stringValue(valueArg)?.toLowerCase();
if (!value) {
return undefined;
}
if (['on', 'true', '1', 'open', 'home'].includes(value)) {
return true;
}
if (['off', 'false', '0', 'closed', 'not_home', 'unavailable', 'unknown'].includes(value)) {
return false;
}
return undefined;
};
const titleFromEntityId = (valueArg: string): string => {
const [, objectId = valueArg] = valueArg.split('.');
return objectId.replace(/[_-]+/g, ' ').replace(/\b\w/g, (charArg) => charArg.toUpperCase());
};
const isSimpleSnapshot = (valueArg: unknown): valueArg is ISimpleLocalSnapshot => Boolean(recordValue(valueArg)?.device && Array.isArray(recordValue(valueArg)?.entities));
const recordValue = (valueArg: unknown): Record<string, unknown> | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
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() && Number.isFinite(Number(valueArg))) {
return Number(valueArg);
}
return undefined;
};
@@ -1,4 +1,78 @@
export interface IHomeAssistantEmulatedKasaConfig { import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js';
// TODO: replace with the TypeScript-native config for emulated_kasa.
[key: string]: unknown; export const emulatedKasaDomain = 'emulated_kasa';
} export const emulatedKasaDefaultName = 'Emulated Kasa';
export type TEmulatedKasaRawData = TSimpleLocalRawData;
export interface IEmulatedKasaSnapshot extends ISimpleLocalSnapshot {}
export interface IEmulatedKasaConfig extends ISimpleLocalConfig {}
export interface IHomeAssistantEmulatedKasaConfig extends IEmulatedKasaConfig {}
export const emulatedKasaProfile: ISimpleLocalIntegrationProfile = {
domain: 'emulated_kasa',
displayName: 'Emulated Kasa',
manufacturer: 'Home Assistant',
model: 'TP-Link Kasa Emulator',
defaultName: emulatedKasaDefaultName,
defaultPort: 9999,
defaultProtocol: 'local',
status: 'read-only-runtime',
platforms: [
'sensor',
'binary_sensor',
],
serviceDomains: [],
controlServices: [],
discoverySources: [
'manual',
'custom',
],
discoveryKeywords: [
'emulated kasa',
'kasa',
'tp-link',
'tplink',
'sense energy',
'power',
],
metadata: {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/emulated_kasa',
upstreamDomain: 'emulated_kasa',
iotClass: 'local_push',
qualityScale: 'internal',
requirements: [
'sense-energy==0.14.1',
],
dependencies: [],
afterDependencies: [],
codeowners: [
'@kbickar',
],
configFlow: false,
runtime: {
type: 'read-only-runtime',
services: [
'snapshot',
'status',
'refresh',
],
platforms: [
'sensor',
'binary_sensor',
],
controls: false,
},
localApi: {
implemented: [
'manual local setup for configured HA entities exposed as emulated Kasa plug power snapshots',
'snapshot, raw data, snapshotProvider, and injected native client operation',
],
explicitUnsupported: [
'claiming live Kasa emulation server startup or UDP discovery without an injected native client',
'claiming live command success without injected client.execute or commandExecutor',
'Sense Energy cloud account operations',
],
},
},
};
+4
View File
@@ -1,2 +1,6 @@
export * from './emulated_kasa.classes.client.js';
export * from './emulated_kasa.classes.configflow.js';
export * from './emulated_kasa.classes.integration.js'; export * from './emulated_kasa.classes.integration.js';
export * from './emulated_kasa.discovery.js';
export * from './emulated_kasa.mapper.js';
export * from './emulated_kasa.types.js'; export * from './emulated_kasa.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.
@@ -0,0 +1,19 @@
import { SimpleLocalClient } from '../../core/index.js';
import { EmulatedRokuMapper } from './emulated_roku.mapper.js';
import type { IEmulatedRokuConfig, IEmulatedRokuSnapshot } from './emulated_roku.types.js';
import { emulatedRokuProfile } from './emulated_roku.types.js';
export class EmulatedRokuClient extends SimpleLocalClient<IEmulatedRokuConfig> {
constructor(private readonly configArg: IEmulatedRokuConfig) {
super(emulatedRokuProfile, configArg);
}
public async getSnapshot(forceRefreshArg = false): Promise<IEmulatedRokuSnapshot> {
const snapshot = await super.getSnapshot(forceRefreshArg);
if (snapshot.rawData === undefined && snapshot.entities.length) {
return EmulatedRokuMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
return EmulatedRokuMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEmulatedRokuConfig } from './emulated_roku.types.js';
import { emulatedRokuProfile } from './emulated_roku.types.js';
export class EmulatedRokuConfigFlow extends SimpleLocalConfigFlow<IEmulatedRokuConfig> {
constructor() {
super(emulatedRokuProfile);
}
}
@@ -1,25 +1,23 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js';
import { EmulatedRokuClient } from './emulated_roku.classes.client.js';
import { EmulatedRokuConfigFlow } from './emulated_roku.classes.configflow.js';
import { createEmulatedRokuDiscoveryDescriptor } from './emulated_roku.discovery.js';
import type { IEmulatedRokuConfig } from './emulated_roku.types.js';
import { emulatedRokuDomain, emulatedRokuProfile } from './emulated_roku.types.js';
export class EmulatedRokuIntegration extends SimpleLocalIntegration<IEmulatedRokuConfig> {
public readonly domain = emulatedRokuDomain;
public readonly discoveryDescriptor = createEmulatedRokuDiscoveryDescriptor();
public readonly configFlow = new EmulatedRokuConfigFlow();
export class HomeAssistantEmulatedRokuIntegration extends DescriptorOnlyIntegration {
constructor() { constructor() {
super({ super(emulatedRokuProfile);
domain: "emulated_roku", }
displayName: "Emulated Roku",
status: 'descriptor-only', public async setup(configArg: IEmulatedRokuConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
metadata: { void contextArg;
"source": "home-assistant/core", return new SimpleLocalRuntime(emulatedRokuProfile, new EmulatedRokuClient(configArg));
"upstreamPath": "homeassistant/components/emulated_roku",
"upstreamDomain": "emulated_roku",
"iotClass": "local_push",
"requirements": [
"emulated-roku==0.3.0"
],
"dependencies": [
"network"
],
"afterDependencies": [],
"codeowners": []
},
});
} }
} }
export class HomeAssistantEmulatedRokuIntegration extends EmulatedRokuIntegration {}
@@ -0,0 +1,4 @@
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
import { emulatedRokuProfile } from './emulated_roku.types.js';
export const createEmulatedRokuDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(emulatedRokuProfile);
@@ -0,0 +1,158 @@
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js';
import type { IEmulatedRokuConfig } from './emulated_roku.types.js';
import { emulatedRokuDefaultName, emulatedRokuProfile } from './emulated_roku.types.js';
export class EmulatedRokuMapper {
public static toSnapshot(optionsArg: Omit<ISimpleLocalSnapshotOptions<IEmulatedRokuConfig>, 'profile'>): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({
...optionsArg,
profile: emulatedRokuProfile,
rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData),
});
}
public static toSnapshotFromRaw(configArg: IEmulatedRokuConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot {
return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' });
}
public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] {
return SimpleLocalMapper.toDevices(emulatedRokuProfile, snapshotArg);
}
public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] {
return SimpleLocalMapper.toEntities(emulatedRokuProfile, snapshotArg);
}
public static slug(valueArg: unknown): string {
return SimpleLocalMapper.slug(valueArg);
}
private static normalizeRawData(configArg: IEmulatedRokuConfig, rawDataArg: unknown): unknown {
if (isSimpleSnapshot(rawDataArg)) {
return rawDataArg;
}
const servers = serverEntries(configArg, rawDataArg);
if (!servers.length) {
return rawDataArg;
}
const rawRecord = recordValue(rawDataArg);
const firstServer = servers[0];
const host = configArg.host || stringValue(rawRecord?.host_ip) || stringValue(rawRecord?.hostIp) || stringValue(firstServer.hostIp);
const listenPort = configArg.listenPort || configArg.port || firstServer.listenPort || emulatedRokuProfile.defaultPort;
const name = configArg.name || stringValue(rawRecord?.name) || firstServer.name || emulatedRokuDefaultName;
const entities = servers.map((serverArg) => serverEntity(serverArg));
return {
device: {
id: configArg.uniqueId || host || name,
name,
manufacturer: emulatedRokuProfile.manufacturer,
model: emulatedRokuProfile.model,
host,
port: listenPort,
protocol: emulatedRokuProfile.defaultProtocol,
attributes: {
serverCount: servers.length,
eventType: 'roku_command',
},
},
entities,
online: configArg.online ?? true,
updatedAt: new Date().toISOString(),
source: 'manual',
rawData: rawDataArg,
} satisfies ISimpleLocalSnapshot;
}
}
interface IServerRecord {
id: string;
name: string;
hostIp?: string;
listenPort?: number;
advertiseIp?: string;
advertisePort?: number;
upnpBindMulticast?: boolean;
state: string;
}
const serverEntity = (serverArg: IServerRecord): ISimpleLocalEntitySnapshot => {
const slug = SimpleLocalMapper.slug(serverArg.id || serverArg.name);
return {
id: `${slug}_server`,
uniqueId: `${emulatedRokuProfile.domain}_${slug}_server`,
name: `${serverArg.name} Server`,
platform: 'sensor',
state: serverArg.state,
available: true,
writable: false,
attributes: {
hostIp: serverArg.hostIp,
listenPort: serverArg.listenPort,
advertiseIp: serverArg.advertiseIp,
advertisePort: serverArg.advertisePort,
upnpBindMulticast: serverArg.upnpBindMulticast,
eventType: 'roku_command',
},
};
};
const serverEntries = (configArg: IEmulatedRokuConfig, valueArg: unknown): IServerRecord[] => {
const rawRecord = recordValue(valueArg);
const nested = rawRecord?.servers ?? rawRecord?.server;
const records = Array.isArray(nested) ? nested : nested ? [nested] : rawRecord ? [rawRecord] : [];
const servers = records.map((entryArg, indexArg) => toServerRecord(configArg, entryArg, indexArg)).filter((entryArg): entryArg is IServerRecord => Boolean(entryArg));
if (servers.length) {
return servers;
}
if (configArg.name || configArg.host || configArg.listenPort || configArg.port) {
return [{
id: configArg.uniqueId || configArg.name || configArg.host || emulatedRokuDefaultName,
name: configArg.name || emulatedRokuDefaultName,
hostIp: configArg.host,
listenPort: configArg.listenPort || configArg.port || emulatedRokuProfile.defaultPort,
advertiseIp: configArg.advertiseIp,
advertisePort: configArg.advertisePort,
upnpBindMulticast: configArg.upnpBindMulticast,
state: configArg.online === false ? 'stopped' : 'configured',
}];
}
return [];
};
const toServerRecord = (configArg: IEmulatedRokuConfig, valueArg: unknown, indexArg: number): IServerRecord | undefined => {
const record = recordValue(valueArg);
if (!record) {
return undefined;
}
const name = stringValue(record.name) || (indexArg === 0 ? configArg.name : undefined) || `${emulatedRokuDefaultName} ${indexArg + 1}`;
const listenPort = numberValue(record.listen_port) || numberValue(record.listenPort) || numberValue(record.port) || (indexArg === 0 ? configArg.listenPort || configArg.port : undefined) || emulatedRokuProfile.defaultPort;
const hostIp = stringValue(record.host_ip) || stringValue(record.hostIp) || stringValue(record.host) || (indexArg === 0 ? configArg.host : undefined);
return {
id: stringValue(record.id) || `${name}:${listenPort}`,
name,
hostIp,
listenPort,
advertiseIp: stringValue(record.advertise_ip) || stringValue(record.advertiseIp) || (indexArg === 0 ? configArg.advertiseIp : undefined),
advertisePort: numberValue(record.advertise_port) || numberValue(record.advertisePort) || (indexArg === 0 ? configArg.advertisePort : undefined),
upnpBindMulticast: booleanValue(record.upnp_bind_multicast) ?? booleanValue(record.upnpBindMulticast) ?? (indexArg === 0 ? configArg.upnpBindMulticast : undefined),
state: stringValue(record.state) || 'configured',
};
};
const isSimpleSnapshot = (valueArg: unknown): valueArg is ISimpleLocalSnapshot => Boolean(recordValue(valueArg)?.device && Array.isArray(recordValue(valueArg)?.entities));
const recordValue = (valueArg: unknown): Record<string, unknown> | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
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 Math.round(valueArg);
}
if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) {
return Math.round(Number(valueArg));
}
return undefined;
};
const booleanValue = (valueArg: unknown): boolean | undefined => typeof valueArg === 'boolean' ? valueArg : undefined;
@@ -1,4 +1,80 @@
export interface IHomeAssistantEmulatedRokuConfig { import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js';
// TODO: replace with the TypeScript-native config for emulated_roku.
[key: string]: unknown; export const emulatedRokuDomain = 'emulated_roku';
export const emulatedRokuDefaultName = 'Home Assistant';
export type TEmulatedRokuRawData = TSimpleLocalRawData;
export interface IEmulatedRokuSnapshot extends ISimpleLocalSnapshot {}
export interface IEmulatedRokuConfig extends ISimpleLocalConfig {
listenPort?: number;
advertiseIp?: string;
advertisePort?: number;
upnpBindMulticast?: boolean;
} }
export interface IHomeAssistantEmulatedRokuConfig extends IEmulatedRokuConfig {}
export const emulatedRokuProfile: ISimpleLocalIntegrationProfile = {
domain: 'emulated_roku',
displayName: 'Emulated Roku',
manufacturer: 'Home Assistant',
model: 'Roku API Emulator',
defaultName: emulatedRokuDefaultName,
defaultPort: 8060,
defaultProtocol: 'upnp',
status: 'read-only-runtime',
platforms: [
'sensor',
],
serviceDomains: [],
controlServices: [],
discoverySources: [
'manual',
'ssdp',
'custom',
],
discoveryKeywords: [
'emulated roku',
'roku',
'upnp',
'remote',
'roku_command',
],
metadata: {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/emulated_roku',
upstreamDomain: 'emulated_roku',
iotClass: 'local_push',
qualityScale: undefined,
requirements: [
'emulated-roku==0.3.0',
],
dependencies: [
'network',
],
afterDependencies: [],
codeowners: [],
configFlow: true,
runtime: {
type: 'read-only-runtime',
services: [
'snapshot',
'status',
'refresh',
],
platforms: [
'sensor',
],
controls: false,
},
localApi: {
implemented: [
'manual local setup for configured Emulated Roku server snapshots',
'snapshot, raw data, snapshotProvider, and injected native client operation',
],
explicitUnsupported: [
'claiming live Emulated Roku server startup, SSDP advertisement, or HTTP endpoint binding without an injected native client',
'claiming live command success without injected client.execute or commandExecutor',
],
},
},
};
+4
View File
@@ -1,2 +1,6 @@
export * from './emulated_roku.classes.client.js';
export * from './emulated_roku.classes.configflow.js';
export * from './emulated_roku.classes.integration.js'; export * from './emulated_roku.classes.integration.js';
export * from './emulated_roku.discovery.js';
export * from './emulated_roku.mapper.js';
export * from './emulated_roku.types.js'; export * from './emulated_roku.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.
@@ -0,0 +1,19 @@
import { SimpleLocalClient } from '../../core/index.js';
import { EnergeniePowerSocketsMapper } from './energenie_power_sockets.mapper.js';
import type { IEnergeniePowerSocketsConfig, IEnergeniePowerSocketsSnapshot } from './energenie_power_sockets.types.js';
import { energeniePowerSocketsProfile } from './energenie_power_sockets.types.js';
export class EnergeniePowerSocketsClient extends SimpleLocalClient<IEnergeniePowerSocketsConfig> {
constructor(private readonly configArg: IEnergeniePowerSocketsConfig) {
super(energeniePowerSocketsProfile, configArg);
}
public async getSnapshot(forceRefreshArg = false): Promise<IEnergeniePowerSocketsSnapshot> {
const snapshot = await super.getSnapshot(forceRefreshArg);
if (snapshot.rawData === undefined && snapshot.entities.length) {
return EnergeniePowerSocketsMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
return EnergeniePowerSocketsMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEnergeniePowerSocketsConfig } from './energenie_power_sockets.types.js';
import { energeniePowerSocketsProfile } from './energenie_power_sockets.types.js';
export class EnergeniePowerSocketsConfigFlow extends SimpleLocalConfigFlow<IEnergeniePowerSocketsConfig> {
constructor() {
super(energeniePowerSocketsProfile);
}
}
@@ -1,26 +1,23 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js';
import { EnergeniePowerSocketsClient } from './energenie_power_sockets.classes.client.js';
import { EnergeniePowerSocketsConfigFlow } from './energenie_power_sockets.classes.configflow.js';
import { createEnergeniePowerSocketsDiscoveryDescriptor } from './energenie_power_sockets.discovery.js';
import type { IEnergeniePowerSocketsConfig } from './energenie_power_sockets.types.js';
import { energeniePowerSocketsDomain, energeniePowerSocketsProfile } from './energenie_power_sockets.types.js';
export class EnergeniePowerSocketsIntegration extends SimpleLocalIntegration<IEnergeniePowerSocketsConfig> {
public readonly domain = energeniePowerSocketsDomain;
public readonly discoveryDescriptor = createEnergeniePowerSocketsDiscoveryDescriptor();
public readonly configFlow = new EnergeniePowerSocketsConfigFlow();
export class HomeAssistantEnergeniePowerSocketsIntegration extends DescriptorOnlyIntegration {
constructor() { constructor() {
super({ super(energeniePowerSocketsProfile);
domain: "energenie_power_sockets", }
displayName: "Energenie Power Sockets",
status: 'descriptor-only', public async setup(configArg: IEnergeniePowerSocketsConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
metadata: { void contextArg;
"source": "home-assistant/core", return new SimpleLocalRuntime(energeniePowerSocketsProfile, new EnergeniePowerSocketsClient(configArg));
"upstreamPath": "homeassistant/components/energenie_power_sockets",
"upstreamDomain": "energenie_power_sockets",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"pyegps==0.2.5"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@gnumpi"
]
},
});
} }
} }
export class HomeAssistantEnergeniePowerSocketsIntegration extends EnergeniePowerSocketsIntegration {}
@@ -0,0 +1,4 @@
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
import { energeniePowerSocketsProfile } from './energenie_power_sockets.types.js';
export const createEnergeniePowerSocketsDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(energeniePowerSocketsProfile);
@@ -0,0 +1,162 @@
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js';
import type { IEnergeniePowerSocketsConfig } from './energenie_power_sockets.types.js';
import { energeniePowerSocketsDefaultName, energeniePowerSocketsProfile } from './energenie_power_sockets.types.js';
export class EnergeniePowerSocketsMapper {
public static toSnapshot(optionsArg: Omit<ISimpleLocalSnapshotOptions<IEnergeniePowerSocketsConfig>, 'profile'>): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({
...optionsArg,
profile: energeniePowerSocketsProfile,
rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData),
});
}
public static toSnapshotFromRaw(configArg: IEnergeniePowerSocketsConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot {
return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' });
}
public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] {
return SimpleLocalMapper.toDevices(energeniePowerSocketsProfile, snapshotArg);
}
public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] {
return SimpleLocalMapper.toEntities(energeniePowerSocketsProfile, snapshotArg);
}
public static slug(valueArg: unknown): string {
return SimpleLocalMapper.slug(valueArg);
}
private static normalizeRawData(configArg: IEnergeniePowerSocketsConfig, rawDataArg: unknown): unknown {
if (isSimpleSnapshot(rawDataArg)) {
return rawDataArg;
}
const rawRecord = recordValue(rawDataArg);
if (!rawRecord) {
return rawDataArg;
}
const deviceApiId = configArg.deviceApiId || stringValue(configArg['api-device-id']) || stringValue(rawRecord.device_id) || stringValue(rawRecord.deviceId) || stringValue(rawRecord.id);
const numberOfSockets = configArg.numberOfSockets || numberValue(rawRecord.numberOfSockets) || numberValue(rawRecord.number_of_sockets) || socketCount(rawRecord);
const sockets = socketEntries(rawRecord, numberOfSockets);
if (!sockets.length) {
return rawDataArg;
}
const name = configArg.name || stringValue(rawRecord.name) || energeniePowerSocketsDefaultName;
const manufacturer = stringValue(rawRecord.manufacturer) || energeniePowerSocketsProfile.manufacturer;
const entities = sockets.map((socketArg) => socketEntity(deviceApiId || name, socketArg));
return {
device: {
id: configArg.uniqueId || deviceApiId || name,
name,
manufacturer,
model: stringValue(rawRecord.model) || stringValue(rawRecord.name) || energeniePowerSocketsProfile.model,
serialNumber: deviceApiId,
protocol: energeniePowerSocketsProfile.defaultProtocol,
attributes: {
deviceApiId,
numberOfSockets: sockets.length,
swVersion: stringValue(rawRecord.sw_version) || stringValue(rawRecord.swVersion),
},
},
entities,
online: configArg.online ?? true,
updatedAt: new Date().toISOString(),
source: 'manual',
rawData: rawDataArg,
} satisfies ISimpleLocalSnapshot;
}
}
interface ISocketRecord {
socketId: number;
state: boolean | null;
available: boolean;
}
const socketEntity = (deviceIdArg: string, socketArg: ISocketRecord): ISimpleLocalEntitySnapshot => {
const base = `${SimpleLocalMapper.slug(deviceIdArg)}_${socketArg.socketId}`;
return {
id: `socket_${socketArg.socketId}`,
uniqueId: `${energeniePowerSocketsProfile.domain}_${base}`,
name: `Socket ${socketArg.socketId}`,
platform: 'switch',
state: socketArg.state,
available: socketArg.available,
writable: true,
attributes: {
socketId: socketArg.socketId,
},
};
};
const socketEntries = (rawRecordArg: Record<string, unknown>, numberOfSocketsArg: number | undefined): ISocketRecord[] => {
const sockets = rawRecordArg.sockets ?? rawRecordArg.socketStates ?? rawRecordArg.states;
if (Array.isArray(sockets)) {
return sockets.map((valueArg, indexArg) => toSocketRecord(indexArg, valueArg));
}
const socketRecord = recordValue(sockets);
if (socketRecord) {
return Object.entries(socketRecord).map(([socketIdArg, valueArg]) => toSocketRecord(numberValue(socketIdArg) ?? 0, valueArg));
}
if (numberOfSocketsArg && numberOfSocketsArg > 0) {
return Array.from({ length: numberOfSocketsArg }, (_valueArg, indexArg) => ({ socketId: indexArg, state: null, available: false }));
}
return [];
};
const toSocketRecord = (socketIdArg: number, valueArg: unknown): ISocketRecord => {
const record = recordValue(valueArg);
const state = switchState(record ? record.state ?? record.is_on ?? record.isOn ?? record.on ?? record.status : valueArg);
return {
socketId: socketIdArg,
state: state ?? null,
available: state !== undefined,
};
};
const socketCount = (rawRecordArg: Record<string, unknown>): number | undefined => {
const sockets = rawRecordArg.sockets ?? rawRecordArg.socketStates ?? rawRecordArg.states;
if (Array.isArray(sockets)) {
return sockets.length;
}
const socketRecord = recordValue(sockets);
return socketRecord ? Object.keys(socketRecord).length : undefined;
};
const switchState = (valueArg: unknown): boolean | undefined => {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'number') {
return valueArg !== 0;
}
const value = stringValue(valueArg)?.toLowerCase();
if (!value) {
return undefined;
}
if (['on', 'true', '1'].includes(value)) {
return true;
}
if (['off', 'false', '0'].includes(value)) {
return false;
}
return undefined;
};
const isSimpleSnapshot = (valueArg: unknown): valueArg is ISimpleLocalSnapshot => Boolean(recordValue(valueArg)?.device && Array.isArray(recordValue(valueArg)?.entities));
const recordValue = (valueArg: unknown): Record<string, unknown> | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
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 Math.round(valueArg);
}
if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) {
return Math.round(Number(valueArg));
}
return undefined;
};
@@ -1,4 +1,88 @@
export interface IHomeAssistantEnergeniePowerSocketsConfig { import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js';
// TODO: replace with the TypeScript-native config for energenie_power_sockets.
[key: string]: unknown; export const energeniePowerSocketsDomain = 'energenie_power_sockets';
export const energeniePowerSocketsDefaultName = 'Energenie Power Sockets';
export type TEnergeniePowerSocketsRawData = TSimpleLocalRawData;
export interface IEnergeniePowerSocketsSnapshot extends ISimpleLocalSnapshot {}
export interface IEnergeniePowerSocketsConfig extends ISimpleLocalConfig {
deviceApiId?: string;
numberOfSockets?: number;
} }
export interface IHomeAssistantEnergeniePowerSocketsConfig extends IEnergeniePowerSocketsConfig {}
export const energeniePowerSocketsProfile: ISimpleLocalIntegrationProfile = {
domain: 'energenie_power_sockets',
displayName: 'Energenie Power Sockets',
manufacturer: 'Energenie',
model: 'PowerStrip USB',
defaultName: energeniePowerSocketsDefaultName,
defaultProtocol: 'local',
status: 'control-runtime',
platforms: [
'switch',
],
serviceDomains: [
'switch',
],
controlServices: [
'turn_on',
'turn_off',
'toggle',
],
discoverySources: [
'manual',
'usb',
'custom',
],
discoveryKeywords: [
'energenie',
'power sockets',
'powerstrip',
'pyegps',
'usb',
],
metadata: {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/energenie_power_sockets',
upstreamDomain: 'energenie_power_sockets',
integrationType: 'device',
iotClass: 'local_polling',
qualityScale: undefined,
requirements: [
'pyegps==0.2.5',
],
dependencies: [],
afterDependencies: [],
codeowners: [
'@gnumpi',
],
configFlow: true,
runtime: {
type: 'control-runtime',
services: [
'snapshot',
'status',
'refresh',
'turn_on',
'turn_off',
'toggle',
],
platforms: [
'switch',
],
controls: true,
},
localApi: {
implemented: [
'manual local USB power-strip setup from raw pyegps-style snapshots',
'snapshot, raw data, snapshotProvider, and injected native client operation',
'executor-routed socket switch control when client.execute or commandExecutor is supplied',
],
explicitUnsupported: [
'claiming live socket switch success without injected client.execute or commandExecutor',
'direct pyegps USB access without an injected native client',
],
},
},
};
@@ -1,2 +1,6 @@
export * from './energenie_power_sockets.classes.client.js';
export * from './energenie_power_sockets.classes.configflow.js';
export * from './energenie_power_sockets.classes.integration.js'; export * from './energenie_power_sockets.classes.integration.js';
export * from './energenie_power_sockets.discovery.js';
export * from './energenie_power_sockets.mapper.js';
export * from './energenie_power_sockets.types.js'; export * from './energenie_power_sockets.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.
@@ -0,0 +1,19 @@
import { SimpleLocalClient } from '../../core/index.js';
import { Enigma2Mapper } from './enigma2.mapper.js';
import type { IEnigma2Config, IEnigma2Snapshot } from './enigma2.types.js';
import { enigma2Profile } from './enigma2.types.js';
export class Enigma2Client extends SimpleLocalClient<IEnigma2Config> {
constructor(private readonly configArg: IEnigma2Config) {
super(enigma2Profile, configArg);
}
public async getSnapshot(forceRefreshArg = false): Promise<IEnigma2Snapshot> {
const snapshot = await super.getSnapshot(forceRefreshArg);
if (snapshot.rawData === undefined && snapshot.entities.length) {
return Enigma2Mapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
return Enigma2Mapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEnigma2Config } from './enigma2.types.js';
import { enigma2Profile } from './enigma2.types.js';
export class Enigma2ConfigFlow extends SimpleLocalConfigFlow<IEnigma2Config> {
constructor() {
super(enigma2Profile);
}
}
@@ -1,26 +1,23 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js';
import { Enigma2Client } from './enigma2.classes.client.js';
import { Enigma2ConfigFlow } from './enigma2.classes.configflow.js';
import { createEnigma2DiscoveryDescriptor } from './enigma2.discovery.js';
import type { IEnigma2Config } from './enigma2.types.js';
import { enigma2Domain, enigma2Profile } from './enigma2.types.js';
export class Enigma2Integration extends SimpleLocalIntegration<IEnigma2Config> {
public readonly domain = enigma2Domain;
public readonly discoveryDescriptor = createEnigma2DiscoveryDescriptor();
public readonly configFlow = new Enigma2ConfigFlow();
export class HomeAssistantEnigma2Integration extends DescriptorOnlyIntegration {
constructor() { constructor() {
super({ super(enigma2Profile);
domain: "enigma2", }
displayName: "Enigma2 (OpenWebif)",
status: 'descriptor-only', public async setup(configArg: IEnigma2Config, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
metadata: { void contextArg;
"source": "home-assistant/core", return new SimpleLocalRuntime(enigma2Profile, new Enigma2Client(configArg));
"upstreamPath": "homeassistant/components/enigma2",
"upstreamDomain": "enigma2",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"openwebifpy==4.3.1"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@autinerd"
]
},
});
} }
} }
export class HomeAssistantEnigma2Integration extends Enigma2Integration {}
@@ -0,0 +1,4 @@
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
import { enigma2Profile } from './enigma2.types.js';
export const createEnigma2DiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(enigma2Profile);
+138
View File
@@ -0,0 +1,138 @@
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TSimpleLocalRawData } from '../../core/index.js';
import type { IEnigma2Config } from './enigma2.types.js';
import { enigma2DefaultName, enigma2DefaultPort, enigma2Profile } from './enigma2.types.js';
export class Enigma2Mapper {
public static toSnapshot(optionsArg: Omit<ISimpleLocalSnapshotOptions<IEnigma2Config>, 'profile'>): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({
...optionsArg,
profile: enigma2Profile,
rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData),
});
}
public static toSnapshotFromRaw(configArg: IEnigma2Config, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot {
return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' });
}
public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] {
return SimpleLocalMapper.toDevices(enigma2Profile, snapshotArg);
}
public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] {
return SimpleLocalMapper.toEntities(enigma2Profile, snapshotArg);
}
public static slug(valueArg: unknown): string {
return SimpleLocalMapper.slug(valueArg);
}
private static normalizeRawData(configArg: IEnigma2Config, rawDataArg: unknown): unknown {
if (!isRecord(rawDataArg) || ('device' in rawDataArg && 'entities' in rawDataArg)) {
return rawDataArg;
}
const status = recordValue(rawDataArg.status) || recordValue(rawDataArg.data) || rawDataArg;
const currentService = recordValue(status.currservice) || recordValue(status.currentService) || recordValue(status.current_service) || {};
const hasStatus = 'in_standby' in status || 'inStandby' in status || 'volume' in status || 'muted' in status || 'state' in status || Object.keys(currentService).length > 0;
if (!hasStatus) {
return rawDataArg;
}
const about = recordValue(rawDataArg.about) || rawDataArg;
const info = recordValue(about.info) || recordValue(rawDataArg.info) || about;
const iface = firstRecord(info.ifaces) || firstRecord(info.interfaces);
const host = configArg.host || stringValue(rawDataArg.host) || stringValue(info.host);
const port = configArg.port || numberValue(rawDataArg.port) || (host ? enigma2DefaultPort : undefined);
const mac = configArg.macAddress || configArg.mac_address || stringValue(rawDataArg.mac) || stringValue(rawDataArg.macAddress) || stringValue(iface?.mac);
const name = configArg.name || stringValue(rawDataArg.name) || stringValue(status.name) || enigma2DefaultName;
const inStandby = booleanValue(status.in_standby ?? status.inStandby ?? status.standby);
const mediaState = stringValue(status.state) || (inStandby === undefined ? 'unknown' : inStandby ? 'off' : 'on');
const station = stringValue(currentService.station);
const seriesTitle = stringValue(currentService.name);
const volume = numberValue(status.volume);
const entities: ISimpleLocalEntitySnapshot[] = [{
id: 'media_player',
uniqueId: `${enigma2Profile.domain}_${SimpleLocalMapper.slug(mac || host || name)}_media_player`,
name,
platform: 'media_player',
state: mediaState,
available: configArg.online ?? true,
writable: true,
attributes: {
mediaTitle: station || seriesTitle,
mediaSeriesTitle: seriesTitle,
mediaChannel: station,
mediaContentId: stringValue(currentService.serviceref) || stringValue(currentService.serviceRef),
mediaDescription: stringValue(currentService.fulldescription) || stringValue(currentService.description),
mediaStartTime: currentService.begin,
mediaEndTime: currentService.end,
mediaCurrentlyRecording: booleanValue(status.is_recording ?? status.isRecording),
isVolumeMuted: booleanValue(status.muted),
volumeLevel: volume === undefined ? undefined : volume / 100,
sourceList: arrayValue(status.source_list) || arrayValue(status.sourceList) || arrayValue(rawDataArg.source_list) || arrayValue(rawDataArg.sourceList),
sourceBouquet: configArg.sourceBouquet || configArg.source_bouquet,
useChannelIcon: configArg.useChannelIcon ?? configArg.use_channel_icon,
deepStandby: configArg.deepStandby ?? configArg.deep_standby,
},
}];
return {
device: {
id: configArg.uniqueId || mac || (host ? `${host}:${port || ''}` : undefined) || name,
name,
manufacturer: stringValue(info.brand) || enigma2Profile.manufacturer,
model: stringValue(info.model) || enigma2Profile.model,
serialNumber: mac,
host,
port,
protocol: configArg.useTls || configArg.ssl ? 'https' : enigma2Profile.defaultProtocol,
attributes: {
mac,
verifySsl: configArg.verifySsl ?? configArg.verify_ssl,
},
},
entities,
online: configArg.online ?? true,
updatedAt: new Date().toISOString(),
source: 'manual',
rawData: rawDataArg,
} satisfies ISimpleLocalSnapshot;
}
}
const recordValue = (valueArg: unknown): Record<string, unknown> | undefined => isRecord(valueArg) ? valueArg : undefined;
const firstRecord = (valueArg: unknown): Record<string, unknown> | undefined => Array.isArray(valueArg) ? valueArg.find(isRecord) : undefined;
const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg));
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
const numberValue = (valueArg: unknown): number | undefined => {
const value = typeof valueArg === 'number' ? valueArg : typeof valueArg === 'string' ? Number(valueArg) : undefined;
return value !== undefined && Number.isFinite(value) ? value : undefined;
};
const booleanValue = (valueArg: unknown): boolean | undefined => {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'number') {
return valueArg !== 0;
}
const value = stringValue(valueArg)?.toLowerCase();
if (!value) {
return undefined;
}
if (['1', 'true', 'yes', 'on', 'standby'].includes(value)) {
return true;
}
if (['0', 'false', 'no', 'off', 'active'].includes(value)) {
return false;
}
return undefined;
};
const arrayValue = (valueArg: unknown): unknown[] | undefined => Array.isArray(valueArg) ? valueArg : undefined;
+109 -3
View File
@@ -1,4 +1,110 @@
export interface IHomeAssistantEnigma2Config { import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js';
// TODO: replace with the TypeScript-native config for enigma2.
[key: string]: unknown; export const enigma2Domain = 'enigma2';
export const enigma2DefaultName = 'Enigma2 Media Player';
export const enigma2DefaultPort = 80;
export type TEnigma2RawData = TSimpleLocalRawData;
export interface IEnigma2Snapshot extends ISimpleLocalSnapshot {}
export interface IEnigma2Config extends ISimpleLocalConfig {
ssl?: boolean;
verifySsl?: boolean;
verify_ssl?: boolean;
useChannelIcon?: boolean;
use_channel_icon?: boolean;
deepStandby?: boolean;
deep_standby?: boolean;
sourceBouquet?: string;
source_bouquet?: string;
macAddress?: string;
mac_address?: string;
} }
export interface IHomeAssistantEnigma2Config extends IEnigma2Config {}
const enigma2ControlServices = [
'turn_on',
'turn_off',
'volume_set',
'volume_up',
'volume_down',
'volume_mute',
'media_stop',
'media_play',
'media_pause',
'media_next_track',
'media_previous_track',
'select_source',
];
export const enigma2Profile: ISimpleLocalIntegrationProfile = {
domain: 'enigma2',
displayName: 'Enigma2 (OpenWebif)',
manufacturer: 'Enigma2',
model: 'OpenWebif receiver',
defaultName: enigma2DefaultName,
defaultPort: enigma2DefaultPort,
defaultProtocol: 'http',
status: 'control-runtime',
platforms: [
'media_player',
],
serviceDomains: [
'media_player',
],
controlServices: enigma2ControlServices,
discoverySources: [
'manual',
'http',
'custom',
],
discoveryKeywords: [
'enigma2',
'openwebif',
'dreambox',
'receiver',
'media player',
],
metadata: {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/enigma2',
upstreamDomain: 'enigma2',
integrationType: 'device',
iotClass: 'local_polling',
qualityScale: undefined,
requirements: [
'openwebifpy==4.3.1',
],
dependencies: [],
afterDependencies: [],
codeowners: [
'@autinerd',
],
configFlow: true,
runtime: {
type: 'control-runtime',
services: [
'snapshot',
'status',
'refresh',
...enigma2ControlServices,
],
platforms: [
'media_player',
],
controls: true,
},
localApi: {
implemented: [
'manual local OpenWebif host setup with optional credentials and TLS flags',
'OpenWebif about/status raw data mapping for media player snapshots',
'snapshot, rawData, snapshotProvider, and injected native client operation',
'commandExecutor-backed media player controls only when a real executor is injected',
],
explicitUnsupported: [
'claiming live media player command success without injected client.execute or commandExecutor',
'direct OpenWebif control calls from this package without an injected native client',
'automatic bouquet option discovery without an injected native client snapshotProvider',
],
},
},
};
+4
View File
@@ -1,2 +1,6 @@
export * from './enigma2.classes.client.js';
export * from './enigma2.classes.configflow.js';
export * from './enigma2.classes.integration.js'; export * from './enigma2.classes.integration.js';
export * from './enigma2.discovery.js';
export * from './enigma2.mapper.js';
export * from './enigma2.types.js'; export * from './enigma2.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.
@@ -0,0 +1,19 @@
import { SimpleLocalClient } from '../../core/index.js';
import { EnoceanMapper } from './enocean.mapper.js';
import type { IEnoceanConfig, IEnoceanSnapshot } from './enocean.types.js';
import { enoceanProfile } from './enocean.types.js';
export class EnoceanClient extends SimpleLocalClient<IEnoceanConfig> {
constructor(private readonly configArg: IEnoceanConfig) {
super(enoceanProfile, configArg);
}
public async getSnapshot(forceRefreshArg = false): Promise<IEnoceanSnapshot> {
const snapshot = await super.getSnapshot(forceRefreshArg);
if (snapshot.rawData === undefined && snapshot.entities.length) {
return EnoceanMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
return EnoceanMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEnoceanConfig } from './enocean.types.js';
import { enoceanProfile } from './enocean.types.js';
export class EnoceanConfigFlow extends SimpleLocalConfigFlow<IEnoceanConfig> {
constructor() {
super(enoceanProfile);
}
}
@@ -1,26 +1,23 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js';
import { EnoceanClient } from './enocean.classes.client.js';
import { EnoceanConfigFlow } from './enocean.classes.configflow.js';
import { createEnoceanDiscoveryDescriptor } from './enocean.discovery.js';
import type { IEnoceanConfig } from './enocean.types.js';
import { enoceanDomain, enoceanProfile } from './enocean.types.js';
export class EnoceanIntegration extends SimpleLocalIntegration<IEnoceanConfig> {
public readonly domain = enoceanDomain;
public readonly discoveryDescriptor = createEnoceanDiscoveryDescriptor();
public readonly configFlow = new EnoceanConfigFlow();
export class HomeAssistantEnoceanIntegration extends DescriptorOnlyIntegration {
constructor() { constructor() {
super({ super(enoceanProfile);
domain: "enocean", }
displayName: "EnOcean",
status: 'descriptor-only', public async setup(configArg: IEnoceanConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
metadata: { void contextArg;
"source": "home-assistant/core", return new SimpleLocalRuntime(enoceanProfile, new EnoceanClient(configArg));
"upstreamPath": "homeassistant/components/enocean",
"upstreamDomain": "enocean",
"integrationType": "hub",
"iotClass": "local_push",
"requirements": [
"enocean-async==0.4.2"
],
"dependencies": [
"usb"
],
"afterDependencies": [],
"codeowners": []
},
});
} }
} }
export class HomeAssistantEnoceanIntegration extends EnoceanIntegration {}
@@ -0,0 +1,4 @@
import { createSimpleLocalDiscoveryDescriptor } from '../../core/index.js';
import { enoceanProfile } from './enocean.types.js';
export const createEnoceanDiscoveryDescriptor = () => createSimpleLocalDiscoveryDescriptor(enoceanProfile);
+232
View File
@@ -0,0 +1,232 @@
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
import { SimpleLocalMapper, type IIntegrationEntity, type ISimpleLocalEntitySnapshot, type ISimpleLocalSnapshot, type ISimpleLocalSnapshotOptions, type TEntityPlatform, type TSimpleLocalRawData } from '../../core/index.js';
import type { IEnoceanConfig } from './enocean.types.js';
import { enoceanDefaultName, enoceanProfile } from './enocean.types.js';
export class EnoceanMapper {
public static toSnapshot(optionsArg: Omit<ISimpleLocalSnapshotOptions<IEnoceanConfig>, 'profile'>): ISimpleLocalSnapshot {
return SimpleLocalMapper.toSnapshot({
...optionsArg,
profile: enoceanProfile,
rawData: this.normalizeRawData(optionsArg.config, optionsArg.rawData),
});
}
public static toSnapshotFromRaw(configArg: IEnoceanConfig, rawDataArg: TSimpleLocalRawData): ISimpleLocalSnapshot {
return this.toSnapshot({ config: configArg, rawData: rawDataArg, online: true, source: 'manual' });
}
public static toDevices(snapshotArg: ISimpleLocalSnapshot): shxInterfaces.data.IDeviceDefinition[] {
return SimpleLocalMapper.toDevices(enoceanProfile, snapshotArg);
}
public static toEntities(snapshotArg: ISimpleLocalSnapshot): IIntegrationEntity[] {
return SimpleLocalMapper.toEntities(enoceanProfile, snapshotArg);
}
public static slug(valueArg: unknown): string {
return SimpleLocalMapper.slug(valueArg);
}
private static normalizeRawData(configArg: IEnoceanConfig, rawDataArg: unknown): unknown {
if (!isRecord(rawDataArg) || ('device' in rawDataArg && 'entities' in rawDataArg)) {
return rawDataArg;
}
const gateway = recordValue(rawDataArg.gateway) || rawDataArg;
const records = recordsFrom(rawDataArg.telegrams ?? rawDataArg.devices ?? rawDataArg.sensors ?? rawDataArg.values);
if (!records.length) {
return rawDataArg;
}
const devicePath = configArg.device || stringValue(rawDataArg.device) || stringValue(gateway.device) || configArg.host;
const name = configArg.name || stringValue(rawDataArg.name) || stringValue(gateway.name) || enoceanDefaultName;
const entities = records.map((recordArg) => this.entityFromRecord(recordArg, configArg, devicePath || name)).filter((entityArg): entityArg is ISimpleLocalEntitySnapshot => Boolean(entityArg));
return {
device: {
id: configArg.uniqueId || devicePath || name,
name,
manufacturer: stringValue(gateway.manufacturer) || enoceanProfile.manufacturer,
model: stringValue(gateway.model) || enoceanProfile.model,
serialNumber: stringValue(gateway.serialNumber) || stringValue(gateway.serial_number),
host: configArg.host,
protocol: enoceanProfile.defaultProtocol,
attributes: {
device: devicePath,
baseAddress: addressValue(gateway.baseAddress ?? gateway.base_address),
signalReceiveMessage: 'enocean.receive_message',
signalSendMessage: 'enocean.send_message',
},
},
entities,
online: configArg.online ?? true,
updatedAt: new Date().toISOString(),
source: 'manual',
rawData: rawDataArg,
} satisfies ISimpleLocalSnapshot;
}
private static entityFromRecord(recordArg: Record<string, unknown>, configArg: IEnoceanConfig, uniqueBaseArg: string): ISimpleLocalEntitySnapshot | undefined {
const address = addressValue(recordArg.id ?? recordArg.dev_id ?? recordArg.deviceId ?? recordArg.address ?? recordArg.sender);
const id = stringValue(recordArg.entityId) || stringValue(recordArg.entity_id) || address || stringValue(recordArg.name);
if (!id) {
return undefined;
}
const deviceClass = stringValue(recordArg.deviceClass) || stringValue(recordArg.device_class) || stringValue(recordArg.type) || configArg.deviceClass || configArg.device_class;
const platform = platformValue(recordArg.platform) || platformFromDeviceClass(deviceClass, recordArg.state);
const state = stateValue(recordArg, deviceClass);
return {
id: `${platform}_${SimpleLocalMapper.slug(id)}`,
uniqueId: `${enoceanProfile.domain}_${SimpleLocalMapper.slug(uniqueBaseArg)}_${SimpleLocalMapper.slug(id)}`,
name: stringValue(recordArg.name) || titleCase(id),
platform,
state,
available: recordArg.available === undefined ? true : booleanValue(recordArg.available),
writable: platform === 'light' || platform === 'switch',
unit: unitForDeviceClass(deviceClass),
deviceClass: normalizedDeviceClass(deviceClass),
attributes: {
address,
channel: numberValue(recordArg.channel ?? configArg.channel),
eep: stringValue(recordArg.eep),
rorg: recordArg.rorg,
rawAction: recordArg.action,
which: recordArg.which,
onoff: recordArg.onoff,
},
};
}
}
const stateValue = (recordArg: Record<string, unknown>, deviceClassArg: string | undefined): unknown => {
const explicit = recordArg.state ?? recordArg.value ?? recordArg.native_value ?? recordArg.nativeValue;
const normalized = normalizedDeviceClass(deviceClassArg);
if (explicit !== undefined) {
if (['light', 'motion', 'opening', 'switch'].includes(normalized || '')) {
return booleanValue(explicit) ?? explicit;
}
return explicit;
}
const telegramData = Array.isArray(recordArg.telegram_data) ? recordArg.telegram_data : Array.isArray(recordArg.telegramData) ? recordArg.telegramData : undefined;
if (telegramData && normalized === 'temperature') {
return numberValue(telegramData[2]);
}
if (telegramData && normalized === 'humidity') {
const value = numberValue(telegramData[1]);
return value === undefined ? undefined : Math.round((value * 100 / 250) * 10) / 10;
}
if (telegramData && normalized === 'power') {
return numberValue(telegramData[0]);
}
return booleanValue(recordArg.is_on ?? recordArg.isOn ?? recordArg.on) ?? null;
};
const recordsFrom = (valueArg: unknown): Array<Record<string, unknown>> => {
if (Array.isArray(valueArg)) {
return valueArg.filter(isRecord);
}
if (isRecord(valueArg)) {
return Object.entries(valueArg).flatMap(([keyArg, entryArg]) => isRecord(entryArg) ? [{ id: keyArg, ...entryArg }] : []);
}
return [];
};
const platformValue = (valueArg: unknown): TEntityPlatform | undefined => {
const value = stringValue(valueArg);
return value && ['binary_sensor', 'light', 'sensor', 'switch'].includes(value) ? value as TEntityPlatform : undefined;
};
const platformFromDeviceClass = (deviceClassArg: string | undefined, stateArg: unknown): TEntityPlatform => {
const value = normalizedDeviceClass(deviceClassArg);
if (value === 'switch') {
return 'switch';
}
if (value === 'light') {
return 'light';
}
if (value === 'opening' || value === 'motion') {
return 'binary_sensor';
}
return typeof stateArg === 'boolean' ? 'binary_sensor' : 'sensor';
};
const normalizedDeviceClass = (valueArg: unknown): string | undefined => {
const value = stringValue(valueArg)?.toLowerCase();
if (!value) {
return undefined;
}
const map: Record<string, string> = {
humidity: 'humidity',
powersensor: 'power',
power: 'power',
temperature: 'temperature',
windowhandle: 'opening',
window_handle: 'opening',
switch: 'switch',
light: 'light',
contact: 'opening',
motion: 'motion',
};
return map[value] || value;
};
const unitForDeviceClass = (valueArg: unknown): string | undefined => {
const value = normalizedDeviceClass(valueArg);
if (value === 'temperature') {
return 'C';
}
if (value === 'humidity') {
return '%';
}
if (value === 'power') {
return 'W';
}
return undefined;
};
const addressValue = (valueArg: unknown): string | undefined => {
if (Array.isArray(valueArg)) {
return valueArg.map((partArg) => Number(partArg).toString(16).padStart(2, '0')).join('').toUpperCase();
}
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return Math.round(valueArg).toString(16).toUpperCase();
}
return stringValue(valueArg);
};
const recordValue = (valueArg: unknown): Record<string, unknown> | undefined => isRecord(valueArg) ? valueArg : undefined;
const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg));
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
const numberValue = (valueArg: unknown): number | undefined => {
const value = typeof valueArg === 'number' ? valueArg : typeof valueArg === 'string' ? Number(valueArg) : undefined;
return value !== undefined && Number.isFinite(value) ? value : undefined;
};
const booleanValue = (valueArg: unknown): boolean | undefined => {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'number') {
return valueArg !== 0;
}
const value = stringValue(valueArg)?.toLowerCase();
if (!value) {
return undefined;
}
if (['1', 'true', 'yes', 'on', 'open', 'opened', 'active'].includes(value)) {
return true;
}
if (['0', 'false', 'no', 'off', 'closed', 'close', 'inactive'].includes(value)) {
return false;
}
return undefined;
};
const titleCase = (valueArg: string): string => valueArg.replace(/[_-]+/g, ' ').replace(/\b\w/g, (charArg) => charArg.toUpperCase());
+110 -3
View File
@@ -1,4 +1,111 @@
export interface IHomeAssistantEnoceanConfig { import type { ISimpleLocalConfig, ISimpleLocalIntegrationProfile, ISimpleLocalSnapshot, TSimpleLocalRawData } from '../../core/index.js';
// TODO: replace with the TypeScript-native config for enocean.
[key: string]: unknown; export const enoceanDomain = 'enocean';
export const enoceanDefaultName = 'EnOcean Gateway';
export type TEnoceanRawData = TSimpleLocalRawData;
export interface IEnoceanSnapshot extends ISimpleLocalSnapshot {}
export interface IEnoceanConfig extends ISimpleLocalConfig {
device?: string;
id?: number[];
senderId?: number[];
sender_id?: number[];
channel?: number;
deviceClass?: string;
device_class?: string;
minTemp?: number;
min_temp?: number;
maxTemp?: number;
max_temp?: number;
rangeFrom?: number;
range_from?: number;
rangeTo?: number;
range_to?: number;
} }
export interface IHomeAssistantEnoceanConfig extends IEnoceanConfig {}
const enoceanControlServices = [
'turn_on',
'turn_off',
'toggle',
'set_level',
];
export const enoceanProfile: ISimpleLocalIntegrationProfile = {
domain: 'enocean',
displayName: 'EnOcean',
manufacturer: 'EnOcean',
model: 'USB 300 Gateway',
defaultName: enoceanDefaultName,
defaultProtocol: 'local',
status: 'control-runtime',
platforms: [
'binary_sensor',
'light',
'sensor',
'switch',
],
serviceDomains: [
'light',
'switch',
],
controlServices: enoceanControlServices,
discoverySources: [
'manual',
'usb',
'custom',
],
discoveryKeywords: [
'enocean',
'usb 300',
'erp1',
'dongle',
'gateway',
],
metadata: {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/enocean',
upstreamDomain: 'enocean',
integrationType: 'hub',
iotClass: 'local_push',
qualityScale: undefined,
requirements: [
'enocean-async==0.4.2',
],
dependencies: [
'usb',
],
afterDependencies: [],
codeowners: [],
configFlow: true,
runtime: {
type: 'control-runtime',
services: [
'snapshot',
'status',
'refresh',
...enoceanControlServices,
],
platforms: [
'binary_sensor',
'light',
'sensor',
'switch',
],
controls: true,
},
localApi: {
implemented: [
'manual USB dongle path setup and USB discovery record matching',
'EnOcean raw gateway/device/telegram snapshot mapping for sensors, binary sensors, switches, and lights',
'snapshot, rawData, snapshotProvider, and injected native client operation',
'commandExecutor-backed gateway sends only when a real executor is injected',
],
explicitUnsupported: [
'claiming live EnOcean send command success without injected client.execute or commandExecutor',
'opening and managing a serial Gateway event loop inside this package runtime',
'decoding every EEP beyond values represented in supplied snapshots/rawData',
],
},
},
};
+4
View File
@@ -1,2 +1,6 @@
export * from './enocean.classes.client.js';
export * from './enocean.classes.configflow.js';
export * from './enocean.classes.integration.js'; export * from './enocean.classes.integration.js';
export * from './enocean.discovery.js';
export * from './enocean.mapper.js';
export * from './enocean.types.js'; export * from './enocean.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.
@@ -0,0 +1,23 @@
import { SimpleLocalClient } from '../../core/index.js';
import { EnphaseEnvoyMapper } from './enphase_envoy.mapper.js';
import type { IEnphaseEnvoyConfig, IEnphaseEnvoySnapshot } from './enphase_envoy.types.js';
import { enphaseEnvoyProfile } from './enphase_envoy.types.js';
export class EnphaseEnvoyClient extends SimpleLocalClient<IEnphaseEnvoyConfig> {
private readonly configArg: IEnphaseEnvoyConfig;
constructor(configArg: IEnphaseEnvoyConfig) {
const runtimeConfig = configArg.rawData !== undefined || configArg.entities?.length || configArg.snapshot ? { ...configArg, host: undefined, path: undefined, transport: 'snapshot' as const } : configArg;
super(enphaseEnvoyProfile, runtimeConfig);
this.configArg = configArg;
}
public async getSnapshot(forceRefreshArg = false): Promise<IEnphaseEnvoySnapshot> {
const snapshot = await super.getSnapshot(forceRefreshArg);
if (snapshot.rawData === undefined && snapshot.entities.length) {
return EnphaseEnvoyMapper.toSnapshot({ config: { ...this.configArg, snapshot }, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
return EnphaseEnvoyMapper.toSnapshot({ config: this.configArg, rawData: snapshot.rawData, online: snapshot.online, source: snapshot.source, error: snapshot.error });
}
}
@@ -0,0 +1,9 @@
import { SimpleLocalConfigFlow } from '../../core/index.js';
import type { IEnphaseEnvoyConfig } from './enphase_envoy.types.js';
import { enphaseEnvoyProfile } from './enphase_envoy.types.js';
export class EnphaseEnvoyConfigFlow extends SimpleLocalConfigFlow<IEnphaseEnvoyConfig> {
constructor() {
super(enphaseEnvoyProfile);
}
}
@@ -1,29 +1,23 @@
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; import { SimpleLocalIntegration, SimpleLocalRuntime, type IIntegrationRuntime, type IIntegrationSetupContext } from '../../core/index.js';
import { EnphaseEnvoyClient } from './enphase_envoy.classes.client.js';
import { EnphaseEnvoyConfigFlow } from './enphase_envoy.classes.configflow.js';
import { createEnphaseEnvoyDiscoveryDescriptor } from './enphase_envoy.discovery.js';
import type { IEnphaseEnvoyConfig } from './enphase_envoy.types.js';
import { enphaseEnvoyDomain, enphaseEnvoyProfile } from './enphase_envoy.types.js';
export class EnphaseEnvoyIntegration extends SimpleLocalIntegration<IEnphaseEnvoyConfig> {
public readonly domain = enphaseEnvoyDomain;
public readonly discoveryDescriptor = createEnphaseEnvoyDiscoveryDescriptor();
public readonly configFlow = new EnphaseEnvoyConfigFlow();
export class HomeAssistantEnphaseEnvoyIntegration extends DescriptorOnlyIntegration {
constructor() { constructor() {
super({ super(enphaseEnvoyProfile);
domain: "enphase_envoy", }
displayName: "Enphase Envoy",
status: 'descriptor-only', public async setup(configArg: IEnphaseEnvoyConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
metadata: { void contextArg;
"source": "home-assistant/core", return new SimpleLocalRuntime(enphaseEnvoyProfile, new EnphaseEnvoyClient(configArg));
"upstreamPath": "homeassistant/components/enphase_envoy",
"upstreamDomain": "enphase_envoy",
"integrationType": "hub",
"iotClass": "local_polling",
"qualityScale": "platinum",
"requirements": [
"pyenphase==2.4.8"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@bdraco",
"@cgarwood",
"@catsmanac"
]
},
});
} }
} }
export class HomeAssistantEnphaseEnvoyIntegration extends EnphaseEnvoyIntegration {}

Some files were not shown because too many files have changed in this diff Show More