Add native local edge service integrations
This commit is contained in:
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1234567890
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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
@@ -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',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-22
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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());
|
||||||
@@ -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',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user