Add native local platform integrations
This commit is contained in:
@@ -0,0 +1,80 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { FlexitBacnetClient, FlexitBacnetConfigFlow, FlexitBacnetIntegration, FlexitBacnetMapper, HomeAssistantFlexitBacnetIntegration, createFlexitBacnetDiscoveryDescriptor, flexitBacnetProfile, type IFlexitBacnetSnapshot, type TFlexitBacnetRawData } from '../../ts/integrations/flexit_bacnet/index.js';
|
||||||
|
|
||||||
|
const rawData: TFlexitBacnetRawData = {
|
||||||
|
host: '192.0.2.10',
|
||||||
|
device_id: 2,
|
||||||
|
device_name: 'Flexit Nordic Test',
|
||||||
|
serial_number: 'FXBACNET123',
|
||||||
|
model: 'Nordic S4',
|
||||||
|
operation_mode: 'home',
|
||||||
|
ventilation_mode: 'home',
|
||||||
|
room_temperature: 21.4,
|
||||||
|
air_temp_setpoint_home: 20,
|
||||||
|
outside_air_temperature: 5.5,
|
||||||
|
supply_air_fan_rpm: 1180,
|
||||||
|
air_filter_polluted: false,
|
||||||
|
electric_heater: true,
|
||||||
|
fan_setpoint_supply_air_home: 55,
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Flexit BACnet candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createFlexitBacnetDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'flexit_bacnet-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'FXBACNET123', host: '192.0.2.10', name: 'Flexit Nordic Test', metadata: { rawData, deviceId: 2 } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('flexit_bacnet');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new FlexitBacnetConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('192.0.2.10');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Flexit BACnet raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new FlexitBacnetClient({ rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = FlexitBacnetMapper.toSnapshotFromRaw({}, rawData);
|
||||||
|
const devices = FlexitBacnetMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = FlexitBacnetMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(devices[0].integrationDomain).toEqual('flexit_bacnet');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Flexit');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'climate.flexit_nordic_test_climate')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.flexit_nordic_test_air_filter_polluted')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'number.flexit_nordic_test_home_supply_fan_setpoint')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Flexit BACnet read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new FlexitBacnetIntegration();
|
||||||
|
const alias = new HomeAssistantFlexitBacnetIntegration();
|
||||||
|
expect(alias instanceof FlexitBacnetIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('flexit_bacnet');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(flexitBacnetProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(flexitBacnetProfile.metadata.qualityScale).toEqual('silver');
|
||||||
|
expect(flexitBacnetProfile.metadata.requirements).toEqual(['flexit_bacnet==2.2.3']);
|
||||||
|
expect(flexitBacnetProfile.metadata.codeowners).toEqual(['@lellky', '@piotrbulinski']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'flexit_bacnet', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'flexit_bacnet', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IFlexitBacnetSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.entities.some((entityArg) => entityArg.id === 'climate')).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Flexit Nordic Test');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'climate', service: 'set_temperature', target: { entityId: 'climate.flexit_nordic_test_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();
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { FlicClient, FlicConfigFlow, FlicIntegration, FlicMapper, HomeAssistantFlicIntegration, createFlicDiscoveryDescriptor, flicProfile, type IFlicSnapshot, type TFlicRawData } from '../../ts/integrations/flic/index.js';
|
||||||
|
|
||||||
|
const rawData: TFlicRawData = {
|
||||||
|
host: 'localhost',
|
||||||
|
port: 5551,
|
||||||
|
buttons: [
|
||||||
|
{ address: '80:E4:DA:70:01:02', name: 'Kitchen Flic', is_on: true, click_type: 'single', queued_time: 0 },
|
||||||
|
{ address: '80:E4:DA:70:03:04', name: 'Hall Flic', is_on: false, click_type: 'hold', queued_time: 1 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Flic candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createFlicDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'flic-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', name: 'Flic buttons', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('flic');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new FlicConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Flic raw snapshots to devices and entities', async () => {
|
||||||
|
const client = new FlicClient({ rawData, ignoredClickTypes: ['double'] });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const devices = FlicMapper.toDevices(snapshot);
|
||||||
|
const entities = FlicMapper.toEntities(snapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(devices[0].integrationDomain).toEqual('flic');
|
||||||
|
expect(devices[0].model).toEqual('Flic Button');
|
||||||
|
expect(entities.length).toEqual(2);
|
||||||
|
expect(entities[0].id).toEqual('binary_sensor.flic_button_80_e4_da_70_01_02');
|
||||||
|
expect(entities[0].attributes?.eventName).toEqual('flic_click');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Flic read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new FlicIntegration();
|
||||||
|
const alias = new HomeAssistantFlicIntegration();
|
||||||
|
expect(alias instanceof FlicIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('flic');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(flicProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(flicProfile.metadata.qualityScale).toEqual('legacy');
|
||||||
|
expect(flicProfile.metadata.requirements).toEqual(['pyflic==2.0.4']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'flic', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'flic', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IFlicSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.entities.some((entityArg) => entityArg.id === 'button_80_e4_da_70_01_02')).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Flic');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'flic', 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 { FluxLedClient, FluxLedConfigFlow, FluxLedIntegration, FluxLedMapper, HomeAssistantFluxLedIntegration, createFluxLedDiscoveryDescriptor, fluxLedProfile, type IFluxLedSnapshot, type TFluxLedRawData } from '../../ts/integrations/flux_led/index.js';
|
||||||
|
|
||||||
|
const rawData: TFluxLedRawData = {
|
||||||
|
ipaddr: '192.0.2.20',
|
||||||
|
id: 'accf23010203',
|
||||||
|
model: 'RGBW',
|
||||||
|
model_description: 'Magic Home RGBW',
|
||||||
|
model_num: 35,
|
||||||
|
version_num: 10,
|
||||||
|
is_on: true,
|
||||||
|
brightness: 128,
|
||||||
|
color_mode: 'rgbw',
|
||||||
|
rgb: [255, 32, 16],
|
||||||
|
effect: 'rainbow',
|
||||||
|
effect_list: ['rainbow', 'jump'],
|
||||||
|
speed: 47,
|
||||||
|
paired_remotes: 2,
|
||||||
|
operating_modes: ['rgb', 'rgbw'],
|
||||||
|
operating_mode: 'rgbw',
|
||||||
|
remote_access_host: 'remote.example',
|
||||||
|
remote_access_enabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Magic Home candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createFluxLedDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'flux_led-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', host: '192.0.2.20', name: 'Magic Home RGBW 010203', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('flux_led');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new FluxLedConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('192.0.2.20');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Magic Home raw snapshots to devices and entities', async () => {
|
||||||
|
const client = new FluxLedClient({ rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = FluxLedMapper.toSnapshotFromRaw({}, rawData);
|
||||||
|
const devices = FluxLedMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = FluxLedMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(devices[0].integrationDomain).toEqual('flux_led');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Zengge');
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'light' && entityArg.state === true)).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'number.magic_home_rgbw_010203_effect_speed')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'button.magic_home_rgbw_010203_restart')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Magic Home read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new FluxLedIntegration();
|
||||||
|
const alias = new HomeAssistantFluxLedIntegration();
|
||||||
|
expect(alias instanceof FluxLedIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('flux_led');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(fluxLedProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(fluxLedProfile.metadata, 'qualityScale')).toBeTrue();
|
||||||
|
expect(fluxLedProfile.metadata.qualityScale).toBeUndefined();
|
||||||
|
expect(fluxLedProfile.metadata.dependencies).toEqual(['network']);
|
||||||
|
expect(fluxLedProfile.metadata.requirements).toEqual(['flux-led==1.2.0']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'flux_led', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'flux_led', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IFluxLedSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.entities.some((entityArg) => entityArg.id === 'light')).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Magic Home RGBW 010203');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'light', service: 'turn_on', target: { entityId: 'light.magic_home_rgbw_010203_light' } });
|
||||||
|
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 @@
|
|||||||
|
hello folder
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { FolderClient, FolderConfigFlow, FolderIntegration, FolderMapper, HomeAssistantFolderIntegration, createFolderDiscoveryDescriptor, folderProfile, type IFolderSnapshot, type TFolderRawData } from '../../ts/integrations/folder/index.js';
|
||||||
|
|
||||||
|
const fixtureFolder = 'test/folder';
|
||||||
|
const rawData: TFolderRawData = {
|
||||||
|
folder: fixtureFolder,
|
||||||
|
filter: '*.txt',
|
||||||
|
number_of_files: 1,
|
||||||
|
bytes: 12,
|
||||||
|
file_list: [`${fixtureFolder}/alpha.txt`],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Folder candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createFolderDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'folder-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', name: 'Folder sensor', metadata: { rawData, folder: fixtureFolder, folderPath: fixtureFolder, filter: '*.txt' } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('folder');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new FolderConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
expect(done.config?.metadata?.folderPath).toEqual(fixtureFolder);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Folder raw snapshots to devices and entities', async () => {
|
||||||
|
const client = new FolderClient({ rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const devices = FolderMapper.toDevices(snapshot);
|
||||||
|
const entities = FolderMapper.toEntities(snapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(devices[0].integrationDomain).toEqual('folder');
|
||||||
|
expect(devices[0].model).toEqual('Local Folder Sensor');
|
||||||
|
expect(entities.length).toEqual(1);
|
||||||
|
expect(entities[0].attributes?.bytes).toEqual(12);
|
||||||
|
expect(entities[0].attributes?.number_of_files).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reads local Folder snapshots and exposes read-only runtime with unsupported control', async () => {
|
||||||
|
const integration = new FolderIntegration();
|
||||||
|
const alias = new HomeAssistantFolderIntegration();
|
||||||
|
expect(alias instanceof FolderIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('folder');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(folderProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(folderProfile.metadata.qualityScale).toEqual('legacy');
|
||||||
|
expect(folderProfile.metadata.requirements).toEqual([]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ folder: fixtureFolder, filter: '*.txt' }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'folder', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'folder', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IFolderSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.entities[0].attributes?.number_of_files).toEqual(1);
|
||||||
|
expect(Number(snapshot.entities[0].attributes?.bytes) > 0).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('folder');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'folder', 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 @@
|
|||||||
|
event: created
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ignore me
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { FolderWatcherClient, FolderWatcherConfigFlow, FolderWatcherIntegration, FolderWatcherMapper, HomeAssistantFolderWatcherIntegration, createFolderWatcherDiscoveryDescriptor, folderWatcherProfile, type IFolderWatcherSnapshot, type TFolderWatcherRawData } from '../../ts/integrations/folder_watcher/index.js';
|
||||||
|
|
||||||
|
const fixtureFolder = 'test/folder_watcher';
|
||||||
|
const rawData: TFolderWatcherRawData = {
|
||||||
|
folder: fixtureFolder,
|
||||||
|
patterns: ['*.yaml'],
|
||||||
|
watched_files: 1,
|
||||||
|
last_event: {
|
||||||
|
event_type: 'created',
|
||||||
|
path: `${fixtureFolder}/event.yaml`,
|
||||||
|
file: 'event.yaml',
|
||||||
|
folder: fixtureFolder,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Folder Watcher candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createFolderWatcherDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'folder_watcher-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', name: 'Folder watcher', metadata: { rawData, folder: fixtureFolder, patterns: ['*.yaml'] } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('folder_watcher');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new FolderWatcherConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
expect(done.config?.metadata?.folder).toEqual(fixtureFolder);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Folder Watcher raw snapshots to devices and entities', async () => {
|
||||||
|
const client = new FolderWatcherClient({ rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const devices = FolderWatcherMapper.toDevices(snapshot);
|
||||||
|
const entities = FolderWatcherMapper.toEntities(snapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(devices[0].integrationDomain).toEqual('folder_watcher');
|
||||||
|
expect(devices[0].model).toEqual('Watchdog folder observer');
|
||||||
|
expect(entities.length).toEqual(1);
|
||||||
|
expect(entities[0].state).toEqual('created');
|
||||||
|
expect(entities[0].attributes?.haPlatform).toEqual('event');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reads local Folder Watcher snapshots and exposes read-only runtime with unsupported control', async () => {
|
||||||
|
const integration = new FolderWatcherIntegration();
|
||||||
|
const alias = new HomeAssistantFolderWatcherIntegration();
|
||||||
|
expect(alias instanceof FolderWatcherIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('folder_watcher');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(folderWatcherProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(folderWatcherProfile.metadata.qualityScale).toEqual('internal');
|
||||||
|
expect(folderWatcherProfile.metadata.requirements).toEqual(['watchdog==6.0.0']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ folder: fixtureFolder, patterns: ['*.yaml'] }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'folder_watcher', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'folder_watcher', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IFolderWatcherSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.entities[0].state).toEqual('idle');
|
||||||
|
expect(snapshot.entities[0].attributes?.watched_files).toEqual(1);
|
||||||
|
expect((await runtime.devices())[0].name).toEqual(`Folder Watcher ${fixtureFolder}`);
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'folder_watcher', 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,90 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { FortiosClient, FortiosConfigFlow, FortiosIntegration, FortiosMapper, HomeAssistantFortiosIntegration, createFortiosDiscoveryDescriptor, fortiosProfile, type IFortiosSnapshot, type TFortiosRawData } from '../../ts/integrations/fortios/index.js';
|
||||||
|
|
||||||
|
const rawData: TFortiosRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'fortigate-60f',
|
||||||
|
name: 'FortiGate 60F',
|
||||||
|
manufacturer: 'Fortinet',
|
||||||
|
model: 'FortiGate 60F',
|
||||||
|
serialNumber: 'FG60FTK000000001',
|
||||||
|
host: '192.0.2.10',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{
|
||||||
|
id: 'aa_bb_cc_dd_ee_ff',
|
||||||
|
name: 'Laptop',
|
||||||
|
platform: 'binary_sensor',
|
||||||
|
state: true,
|
||||||
|
deviceClass: 'presence',
|
||||||
|
attributes: {
|
||||||
|
macAddress: 'AA:BB:CC:DD:EE:FF',
|
||||||
|
hostname: 'laptop',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: 'firmware_version', name: 'Firmware Version', platform: 'sensor', state: '7.2.8' },
|
||||||
|
],
|
||||||
|
clients: [
|
||||||
|
{ master_mac: 'AA:BB:CC:DD:EE:FF', hostname: 'laptop', is_online: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual FortiOS candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createFortiosDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'fortios-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'fortigate-60f', name: 'FortiGate 60F', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('fortios');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new FortiosConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('FortiGate 60F');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps FortiOS raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new FortiosClient({ name: 'FortiGate 60F', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = FortiosMapper.toSnapshotFromRaw({ name: 'FortiGate 60F' }, rawData);
|
||||||
|
const devices = FortiosMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = FortiosMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('fortios');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Fortinet');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.fortigate_60f_aa_bb_cc_dd_ee_ff')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.fortigate_60f_firmware_version')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes FortiOS read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new FortiosIntegration();
|
||||||
|
const alias = new HomeAssistantFortiosIntegration();
|
||||||
|
expect(alias instanceof FortiosIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('fortios');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(fortiosProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(fortiosProfile.metadata.qualityScale).toEqual('legacy');
|
||||||
|
expect(fortiosProfile.metadata.requirements).toEqual(['fortiosapi==1.0.5']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'FortiGate 60F', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'fortios', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'fortios', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IFortiosSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('FortiGate 60F');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'fortios', service: 'turn_on', target: { entityId: 'binary_sensor.fortigate_60f_aa_bb_cc_dd_ee_ff' } });
|
||||||
|
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,82 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { FritzboxClient, FritzboxConfigFlow, FritzboxIntegration, FritzboxMapper, HomeAssistantFritzboxIntegration, createFritzboxDiscoveryDescriptor, fritzboxProfile, type IFritzboxSnapshot, type TFritzboxRawData } from '../../ts/integrations/fritzbox/index.js';
|
||||||
|
|
||||||
|
const rawData: TFritzboxRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'fritz-ain-1',
|
||||||
|
name: 'FRITZ SmartHome Hub',
|
||||||
|
manufacturer: 'AVM',
|
||||||
|
model: 'FRITZ!Box 7590',
|
||||||
|
host: '192.0.2.20',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'outlet', name: 'Outlet', platform: 'switch', state: true, writable: true, attributes: { ain: '08761 0000001' } },
|
||||||
|
{ id: 'temperature', name: 'Temperature', platform: 'sensor', state: 21.5, unit: 'C', deviceClass: 'temperature' },
|
||||||
|
{ id: 'window_open', name: 'Window Open', platform: 'binary_sensor', state: false, deviceClass: 'window' },
|
||||||
|
{ id: 'bulb', name: 'Bulb', platform: 'light', state: true, writable: true, attributes: { brightness: 180, colorMode: 'brightness' } },
|
||||||
|
{ id: 'thermostat', name: 'Thermostat', platform: 'climate', state: 'heat', writable: true, attributes: { currentTemperature: 20.5, targetTemperature: 22, hvacMode: 'heat', presetMode: 'comfort' } },
|
||||||
|
{ id: 'blind', name: 'Blind', platform: 'cover', state: 'open', writable: true, attributes: { position: 75 } },
|
||||||
|
{ id: 'template', name: 'Template', platform: 'button', state: 'ready', writable: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual FRITZ!SmartHome candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createFritzboxDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'fritzbox-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'fritz-ain-1', name: 'FRITZ SmartHome Hub', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('fritzbox');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new FritzboxConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('FRITZ SmartHome Hub');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps FRITZ!SmartHome raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new FritzboxClient({ name: 'FRITZ SmartHome Hub', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = FritzboxMapper.toSnapshotFromRaw({ name: 'FRITZ SmartHome Hub' }, rawData);
|
||||||
|
const devices = FritzboxMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = FritzboxMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('fritzbox');
|
||||||
|
expect(devices[0].manufacturer).toEqual('AVM');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'switch.fritz_smarthome_hub_outlet')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'climate.fritz_smarthome_hub_thermostat')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'button.fritz_smarthome_hub_template')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes FRITZ!SmartHome read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new FritzboxIntegration();
|
||||||
|
const alias = new HomeAssistantFritzboxIntegration();
|
||||||
|
expect(alias instanceof FritzboxIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('fritzbox');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(fritzboxProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(fritzboxProfile.metadata.integrationType).toEqual('hub');
|
||||||
|
expect(fritzboxProfile.metadata.requirements).toEqual(['pyfritzhome==0.6.20']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'FRITZ SmartHome Hub', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'fritzbox', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'fritzbox', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IFritzboxSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('FRITZ SmartHome Hub');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'switch', service: 'turn_on', target: { entityId: 'switch.fritz_smarthome_hub_outlet' } });
|
||||||
|
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,77 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { FuturenowClient, FuturenowConfigFlow, FuturenowIntegration, FuturenowMapper, HomeAssistantFuturenowIntegration, createFuturenowDiscoveryDescriptor, futurenowProfile, type IFuturenowSnapshot, type TFuturenowRawData } from '../../ts/integrations/futurenow/index.js';
|
||||||
|
|
||||||
|
const rawData: TFuturenowRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'fnip-controller',
|
||||||
|
name: 'FutureNow Controller',
|
||||||
|
manufacturer: 'FutureNow',
|
||||||
|
model: 'FNIP8x10a',
|
||||||
|
host: '192.0.2.30',
|
||||||
|
port: 1024,
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'kitchen', name: 'Kitchen', platform: 'light', state: true, writable: true, attributes: { channel: '1', brightness: 204, dimmable: true, driver: 'FNIP8x10a' } },
|
||||||
|
{ id: 'hallway', name: 'Hallway', platform: 'light', state: false, writable: true, attributes: { channel: '2', brightness: 0, dimmable: false, driver: 'FNIP8x10a' } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual FutureNow candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createFuturenowDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'futurenow-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'fnip-controller', name: 'FutureNow Controller', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('futurenow');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new FuturenowConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('FutureNow Controller');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps FutureNow raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new FuturenowClient({ name: 'FutureNow Controller', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = FuturenowMapper.toSnapshotFromRaw({ name: 'FutureNow Controller' }, rawData);
|
||||||
|
const devices = FuturenowMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = FuturenowMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('futurenow');
|
||||||
|
expect(devices[0].manufacturer).toEqual('FutureNow');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'light.futurenow_controller_kitchen')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'light.futurenow_controller_hallway')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes FutureNow read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new FuturenowIntegration();
|
||||||
|
const alias = new HomeAssistantFuturenowIntegration();
|
||||||
|
expect(alias instanceof FuturenowIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('futurenow');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(futurenowProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(futurenowProfile.metadata.qualityScale).toEqual('legacy');
|
||||||
|
expect(futurenowProfile.metadata.requirements).toEqual(['pyfnip==0.2']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'FutureNow Controller', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'futurenow', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'futurenow', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IFuturenowSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('FutureNow Controller');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'light', service: 'turn_on', target: { entityId: 'light.futurenow_controller_kitchen' }, data: { brightness: 255 } });
|
||||||
|
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,82 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { GardenaBluetoothClient, GardenaBluetoothConfigFlow, GardenaBluetoothIntegration, GardenaBluetoothMapper, HomeAssistantGardenaBluetoothIntegration, createGardenaBluetoothDiscoveryDescriptor, gardenaBluetoothProfile, gardenaBluetoothServiceUuid, type IGardenaBluetoothSnapshot, type TGardenaBluetoothRawData } from '../../ts/integrations/gardena_bluetooth/index.js';
|
||||||
|
|
||||||
|
const rawData: TGardenaBluetoothRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'gardena-aa-bb-cc-dd-ee-ff',
|
||||||
|
name: 'Gardena Valve',
|
||||||
|
manufacturer: 'Gardena',
|
||||||
|
model: 'Water Computer Bluetooth',
|
||||||
|
serialNumber: 'AA:BB:CC:DD:EE:FF',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'valve_connected_state', name: 'Valve Connection', platform: 'binary_sensor', state: true, deviceClass: 'connectivity' },
|
||||||
|
{ id: 'battery_level', name: 'Battery Level', platform: 'sensor', state: 87, unit: '%', deviceClass: 'battery' },
|
||||||
|
{ id: 'manual_watering_time', name: 'Manual Watering Time', platform: 'number', state: 900, writable: true, unit: 's', deviceClass: 'duration' },
|
||||||
|
{ id: 'valve', name: 'Valve', platform: 'switch', state: false, writable: true, deviceClass: 'water', attributes: { remainingOpenTime: 0 } },
|
||||||
|
{ id: 'operation_mode', name: 'Operation Mode', platform: 'select', state: 'active', writable: true, attributes: { options: ['active', 'manual_mode', 'deep_sleep'] } },
|
||||||
|
{ id: 'custom_device_name', name: 'Custom Device Name', platform: 'text', state: 'Front Garden', writable: true },
|
||||||
|
{ id: 'factory_reset', name: 'Factory Reset', platform: 'button', state: 'ready', writable: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Gardena Bluetooth candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createGardenaBluetoothDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'gardena_bluetooth-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'AA:BB:CC:DD:EE:FF', name: 'Gardena Valve', metadata: { rawData, serviceUuid: gardenaBluetoothServiceUuid } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('gardena_bluetooth');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new GardenaBluetoothConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('Gardena Valve');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Gardena Bluetooth raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new GardenaBluetoothClient({ name: 'Gardena Valve', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = GardenaBluetoothMapper.toSnapshotFromRaw({ name: 'Gardena Valve' }, rawData);
|
||||||
|
const devices = GardenaBluetoothMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = GardenaBluetoothMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('gardena_bluetooth');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Gardena');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.gardena_valve_valve_connected_state')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'switch.gardena_valve_valve')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'number.gardena_valve_manual_watering_time')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Gardena Bluetooth read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new GardenaBluetoothIntegration();
|
||||||
|
const alias = new HomeAssistantGardenaBluetoothIntegration();
|
||||||
|
expect(alias instanceof GardenaBluetoothIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('gardena_bluetooth');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(gardenaBluetoothProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(gardenaBluetoothProfile.metadata.dependencies).toEqual(['bluetooth_adapters']);
|
||||||
|
expect(gardenaBluetoothProfile.metadata.requirements).toEqual(['gardena-bluetooth==2.4.0']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'Gardena Valve', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'gardena_bluetooth', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'gardena_bluetooth', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IGardenaBluetoothSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Gardena Valve');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'valve', service: 'open_valve', target: { entityId: 'switch.gardena_valve_valve' } });
|
||||||
|
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,77 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { Gc100Client, Gc100ConfigFlow, Gc100Integration, Gc100Mapper, HomeAssistantGc100Integration, createGc100DiscoveryDescriptor, gc100Profile, type IGc100Snapshot, type TGc100RawData } from '../../ts/integrations/gc100/index.js';
|
||||||
|
|
||||||
|
const rawData: TGc100RawData = {
|
||||||
|
device: {
|
||||||
|
id: 'gc100-1',
|
||||||
|
name: 'Global Cache GC-100',
|
||||||
|
manufacturer: 'Global Cache',
|
||||||
|
model: 'GC-100',
|
||||||
|
host: '192.0.2.40',
|
||||||
|
port: 4998,
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'input_1_1', name: 'Input 1:1', platform: 'binary_sensor', state: true, deviceClass: 'opening', attributes: { portAddress: '1:1' } },
|
||||||
|
{ id: 'relay_1_2', name: 'Relay 1:2', platform: 'switch', state: false, writable: true, attributes: { portAddress: '1:2' } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual GC-100 candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createGc100DiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'gc100-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'gc100-1', name: 'Global Cache GC-100', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('gc100');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new Gc100ConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('Global Cache GC-100');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps GC-100 raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new Gc100Client({ name: 'Global Cache GC-100', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = Gc100Mapper.toSnapshotFromRaw({ name: 'Global Cache GC-100' }, rawData);
|
||||||
|
const devices = Gc100Mapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = Gc100Mapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('gc100');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Global Cache');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.global_cache_gc_100_input_1_1')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'switch.global_cache_gc_100_relay_1_2')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes GC-100 read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new Gc100Integration();
|
||||||
|
const alias = new HomeAssistantGc100Integration();
|
||||||
|
expect(alias instanceof Gc100Integration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('gc100');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(gc100Profile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(gc100Profile.metadata.qualityScale).toEqual('legacy');
|
||||||
|
expect(gc100Profile.metadata.requirements).toEqual(['python-gc100==1.0.3a0']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'Global Cache GC-100', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'gc100', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'gc100', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IGc100Snapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Global Cache GC-100');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'switch', service: 'turn_on', target: { entityId: 'switch.global_cache_gc_100_relay_1_2' } });
|
||||||
|
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,74 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { GenericClient, GenericConfigFlow, GenericIntegration, GenericMapper, HomeAssistantGenericIntegration, createGenericDiscoveryDescriptor, genericProfile, type IGenericSnapshot, type TGenericRawData } from '../../ts/integrations/generic/index.js';
|
||||||
|
|
||||||
|
const rawData: TGenericRawData = {
|
||||||
|
name: 'Front Door Camera',
|
||||||
|
still_image_url: 'http://camera.local/still.jpg',
|
||||||
|
stream_source: 'rtsp://camera.local/live',
|
||||||
|
content_type: 'image/jpeg',
|
||||||
|
advanced: {
|
||||||
|
framerate: 2,
|
||||||
|
verify_ssl: true,
|
||||||
|
rtsp_transport: 'tcp',
|
||||||
|
authentication: 'basic',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Generic Camera candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createGenericDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'generic-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'front-camera', name: 'Front Door Camera', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('generic');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new GenericConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('Front Door Camera');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Generic Camera raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new GenericClient({ name: 'Front Door Camera', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = GenericMapper.toSnapshotFromRaw({ name: 'Front Door Camera' }, rawData);
|
||||||
|
const devices = GenericMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = GenericMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.device.manufacturer).toEqual('Generic');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('generic');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.front_door_camera_camera')).toBeTrue();
|
||||||
|
expect(entities[0].attributes?.streamSource).toEqual('rtsp://camera.local/live');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Generic Camera read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new GenericIntegration();
|
||||||
|
const alias = new HomeAssistantGenericIntegration();
|
||||||
|
expect(alias instanceof GenericIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('generic');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(genericProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(genericProfile.metadata.iotClass).toEqual('local_push');
|
||||||
|
expect(genericProfile.metadata.dependencies).toEqual(['http', 'stream']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'Front Door Camera', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'generic', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'generic', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IGenericSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Front Door Camera');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'camera', service: 'turn_on', target: { entityId: 'camera.front_door_camera' } });
|
||||||
|
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,86 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { GeniushubClient, GeniushubConfigFlow, GeniushubIntegration, GeniushubMapper, HomeAssistantGeniushubIntegration, createGeniushubDiscoveryDescriptor, geniushubProfile, type IGeniushubSnapshot, type TGeniushubRawData } from '../../ts/integrations/geniushub/index.js';
|
||||||
|
|
||||||
|
const rawData: TGeniushubRawData = {
|
||||||
|
hubUid: 'hub-001',
|
||||||
|
zones: [
|
||||||
|
{ id: 1, name: 'Kitchen', type: 'radiator', mode: 'timer', temperature: 21.2, setpoint: 20.5, occupied: true, output: 1 },
|
||||||
|
{ id: 2, name: 'Hot Water Pump', type: 'on / off', mode: 'override', setpoint: 1 },
|
||||||
|
],
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
type: 'Receiver',
|
||||||
|
data: {
|
||||||
|
assignedZones: [{ name: 'Kitchen' }],
|
||||||
|
state: {
|
||||||
|
outputOnOff: true,
|
||||||
|
batteryLevel: 88,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
issues: [
|
||||||
|
{ level: 'warning', description: 'Receiver battery low' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Genius Hub candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createGeniushubDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'geniushub-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'hub-001', name: 'Genius Hub', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('geniushub');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new GeniushubConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('Genius Hub');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Genius Hub raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new GeniushubClient({ name: 'Genius Hub', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = GeniushubMapper.toSnapshotFromRaw({ name: 'Genius Hub' }, rawData);
|
||||||
|
const devices = GeniushubMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = GeniushubMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(devices[0].integrationDomain).toEqual('geniushub');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'climate.genius_hub_zone_1')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'switch.genius_hub_zone_2')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.genius_hub_device_11_battery')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.genius_hub_device_11_output')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Genius Hub read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new GeniushubIntegration();
|
||||||
|
const alias = new HomeAssistantGeniushubIntegration();
|
||||||
|
expect(alias instanceof GeniushubIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('geniushub');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(geniushubProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(geniushubProfile.metadata.requirements).toEqual(['geniushub-client==0.7.1']);
|
||||||
|
expect(geniushubProfile.metadata.codeowners).toEqual(['@manzanotti']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'Genius Hub', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'geniushub', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'geniushub', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IGeniushubSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Genius Hub');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'geniushub', service: 'set_zone_mode', target: { entityId: 'climate.genius_hub_zone_1' }, data: { mode: 'off' } });
|
||||||
|
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,89 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { GoalzeroClient, GoalzeroConfigFlow, GoalzeroIntegration, GoalzeroMapper, HomeAssistantGoalzeroIntegration, createGoalzeroDiscoveryDescriptor, goalzeroProfile, type IGoalzeroSnapshot, type TGoalzeroRawData } from '../../ts/integrations/goalzero/index.js';
|
||||||
|
|
||||||
|
const rawData: TGoalzeroRawData = {
|
||||||
|
sysdata: {
|
||||||
|
macAddress: 'AA:BB:CC:DD:EE:FF',
|
||||||
|
model: 'Yeti 1500X',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
firmwareVersion: '1.2.3',
|
||||||
|
wattsIn: 120,
|
||||||
|
wattsOut: 40,
|
||||||
|
whStored: 900,
|
||||||
|
socPercent: 75,
|
||||||
|
timeToEmptyFull: 360,
|
||||||
|
temperature: 31,
|
||||||
|
wifiStrength: -50,
|
||||||
|
ssid: 'camp',
|
||||||
|
ipAddr: '192.168.1.42',
|
||||||
|
timestamp: 1234,
|
||||||
|
app_online: 1,
|
||||||
|
backlight: 0,
|
||||||
|
isCharging: 1,
|
||||||
|
inputDetected: 1,
|
||||||
|
v12PortStatus: 1,
|
||||||
|
usbPortStatus: 0,
|
||||||
|
acPortStatus: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Goal Zero candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createGoalzeroDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'goalzero-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'AA:BB:CC:DD:EE:FF', name: 'Yeti', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('goalzero');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new GoalzeroConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('Yeti');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Goal Zero raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new GoalzeroClient({ name: 'Goal Zero Yeti', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = GoalzeroMapper.toSnapshotFromRaw({ name: 'Goal Zero Yeti' }, rawData);
|
||||||
|
const devices = GoalzeroMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = GoalzeroMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.device.model).toEqual('Yeti 1500X');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Goal Zero');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.goal_zero_yeti_wattsin')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.goal_zero_yeti_ischarging')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'switch.goal_zero_yeti_acportstatus')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Goal Zero read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new GoalzeroIntegration();
|
||||||
|
const alias = new HomeAssistantGoalzeroIntegration();
|
||||||
|
expect(alias instanceof GoalzeroIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('goalzero');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(goalzeroProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(goalzeroProfile.metadata.requirements).toEqual(['goalzero==0.2.2']);
|
||||||
|
expect(goalzeroProfile.metadata.codeowners).toEqual(['@tkdrob']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'Goal Zero Yeti', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'goalzero', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'goalzero', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IGoalzeroSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Goal Zero Yeti');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'switch', service: 'turn_off', target: { entityId: 'switch.goal_zero_yeti_acportstatus' } });
|
||||||
|
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,72 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { Gogogate2Client, Gogogate2ConfigFlow, Gogogate2Integration, Gogogate2Mapper, HomeAssistantGogogate2Integration, createGogogate2DiscoveryDescriptor, gogogate2Profile, type IGogogate2Snapshot, type TGogogate2RawData } from '../../ts/integrations/gogogate2/index.js';
|
||||||
|
|
||||||
|
const rawData: TGogogate2RawData = {
|
||||||
|
model: 'iSmartGate',
|
||||||
|
firmwareversion: '2.1.0',
|
||||||
|
remoteaccessenabled: false,
|
||||||
|
remoteaccess: 'garage.example.com',
|
||||||
|
doors: [
|
||||||
|
{ door_id: 1, name: 'Garage Door', gate: false, status: 'closed', sensorid: 'ABC123', voltage: 92, temperature: 18.5 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Gogogate2 candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createGogogate2DiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'gogogate2-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'garage-hub', name: 'Garage Controller', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('gogogate2');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new Gogogate2ConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('Garage Controller');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Gogogate2 raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new Gogogate2Client({ name: 'Garage Controller', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = Gogogate2Mapper.toSnapshotFromRaw({ name: 'Garage Controller' }, rawData);
|
||||||
|
const devices = Gogogate2Mapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = Gogogate2Mapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.device.model).toEqual('iSmartGate');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Remsol');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'cover.garage_controller_door_1')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.garage_controller_door_1_battery')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.garage_controller_door_1_temperature')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Gogogate2 read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new Gogogate2Integration();
|
||||||
|
const alias = new HomeAssistantGogogate2Integration();
|
||||||
|
expect(alias instanceof Gogogate2Integration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('gogogate2');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(gogogate2Profile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(gogogate2Profile.metadata.requirements).toEqual(['ismartgate==5.0.2']);
|
||||||
|
expect(gogogate2Profile.metadata.codeowners).toEqual(['@vangorra']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'Garage Controller', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'gogogate2', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'gogogate2', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IGogogate2Snapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Garage Controller');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'cover', service: 'open_cover', target: { entityId: 'cover.garage_controller_door_1' } });
|
||||||
|
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,76 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { GoogleWifiClient, GoogleWifiConfigFlow, GoogleWifiIntegration, GoogleWifiMapper, HomeAssistantGoogleWifiIntegration, createGoogleWifiDiscoveryDescriptor, googleWifiProfile, type IGoogleWifiSnapshot, type TGoogleWifiRawData } from '../../ts/integrations/google_wifi/index.js';
|
||||||
|
|
||||||
|
const rawData: TGoogleWifiRawData = {
|
||||||
|
software: {
|
||||||
|
softwareVersion: '14150.43.80',
|
||||||
|
updateNewVersion: '0.0.0.0',
|
||||||
|
},
|
||||||
|
system: {
|
||||||
|
uptime: 86400,
|
||||||
|
},
|
||||||
|
wan: {
|
||||||
|
localIpAddress: '100.64.1.2',
|
||||||
|
online: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Google Wifi candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createGoogleWifiDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'google_wifi-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'google-wifi', name: 'Google Wifi', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('google_wifi');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new GoogleWifiConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('Google Wifi');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Google Wifi raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new GoogleWifiClient({ name: 'Google Wifi', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = GoogleWifiMapper.toSnapshotFromRaw({ name: 'Google Wifi' }, rawData);
|
||||||
|
const devices = GoogleWifiMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = GoogleWifiMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(devices[0].integrationDomain).toEqual('google_wifi');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.google_wifi_current_version')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.google_wifi_new_version' && entityArg.state === 'Latest')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.google_wifi_uptime' && entityArg.state === 1)).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.google_wifi_status' && entityArg.state === 'Online')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Google Wifi read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new GoogleWifiIntegration();
|
||||||
|
const alias = new HomeAssistantGoogleWifiIntegration();
|
||||||
|
expect(alias instanceof GoogleWifiIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('google_wifi');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(googleWifiProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(googleWifiProfile.metadata.qualityScale).toEqual('legacy');
|
||||||
|
expect(googleWifiProfile.metadata.requirements).toEqual([]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'Google Wifi', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'google_wifi', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'google_wifi', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IGoogleWifiSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Google Wifi');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'google_wifi', service: 'turn_on', target: { entityId: 'sensor.google_wifi_status' } });
|
||||||
|
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,76 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { GoveeBleClient, GoveeBleConfigFlow, GoveeBleIntegration, GoveeBleMapper, HomeAssistantGoveeBleIntegration, createGoveeBleDiscoveryDescriptor, goveeBleProfile, type IGoveeBleSnapshot, type TGoveeBleRawData } from '../../ts/integrations/govee_ble/index.js';
|
||||||
|
|
||||||
|
const rawData: TGoveeBleRawData = {
|
||||||
|
address: 'AA:BB:CC:DD:EE:FF',
|
||||||
|
deviceType: 'H5075',
|
||||||
|
name: 'Govee H5075',
|
||||||
|
sensors: {
|
||||||
|
temperature: 21.6,
|
||||||
|
humidity: 48,
|
||||||
|
battery: 91,
|
||||||
|
rssi: -63,
|
||||||
|
},
|
||||||
|
binarySensors: {
|
||||||
|
motion: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Govee BLE candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createGoveeBleDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'govee_ble-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'AA:BB:CC:DD:EE:FF', name: 'Govee H5075', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('govee_ble');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new GoveeBleConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('Govee H5075');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Govee BLE raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new GoveeBleClient({ name: 'Govee H5075', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = GoveeBleMapper.toSnapshotFromRaw({ name: 'Govee H5075' }, rawData);
|
||||||
|
const devices = GoveeBleMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = GoveeBleMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.device.serialNumber).toEqual('AA:BB:CC:DD:EE:FF');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('govee_ble');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Govee');
|
||||||
|
expect(entities.some((entityArg) => entityArg.uniqueId.endsWith('_temperature') && entityArg.state === 21.6)).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'binary_sensor' && entityArg.name === 'Motion')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Govee BLE read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new GoveeBleIntegration();
|
||||||
|
const alias = new HomeAssistantGoveeBleIntegration();
|
||||||
|
expect(alias instanceof GoveeBleIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('govee_ble');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(goveeBleProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(goveeBleProfile.metadata.requirements).toEqual(['govee-ble==1.2.0']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'Govee H5075', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'govee_ble', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'govee_ble', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IGoveeBleSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Govee H5075');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'govee_ble', 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,82 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { GoveeLightLocalClient, GoveeLightLocalConfigFlow, GoveeLightLocalIntegration, GoveeLightLocalMapper, HomeAssistantGoveeLightLocalIntegration, createGoveeLightLocalDiscoveryDescriptor, goveeLightLocalProfile, type IGoveeLightLocalSnapshot, type TGoveeLightLocalRawData } from '../../ts/integrations/govee_light_local/index.js';
|
||||||
|
|
||||||
|
const rawData: TGoveeLightLocalRawData = {
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
fingerprint: 'GOVEE-LAN-001',
|
||||||
|
sku: 'H6008',
|
||||||
|
name: 'Desk Strip',
|
||||||
|
on: true,
|
||||||
|
brightness: 75,
|
||||||
|
temperature_color: 3200,
|
||||||
|
rgb_color: [255, 128, 0],
|
||||||
|
capabilities: {
|
||||||
|
features: 'COLOR_RGB COLOR_KELVIN_TEMPERATURE BRIGHTNESS SCENES',
|
||||||
|
scenes: {
|
||||||
|
sunrise: 1,
|
||||||
|
movie: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Govee light local candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createGoveeLightLocalDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'govee_light_local-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'GOVEE-LAN-001', name: 'Desk Strip', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('govee_light_local');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new GoveeLightLocalConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('Desk Strip');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Govee light local raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new GoveeLightLocalClient({ name: 'Desk Strip', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = GoveeLightLocalMapper.toSnapshotFromRaw({ name: 'Desk Strip' }, rawData);
|
||||||
|
const devices = GoveeLightLocalMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = GoveeLightLocalMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.device.serialNumber).toEqual('GOVEE-LAN-001');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('govee_light_local');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Govee');
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'light' && entityArg.state === true)).toBeTrue();
|
||||||
|
expect(entities[0].attributes?.brightnessPercent).toEqual(75);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Govee light local runtime, HA alias, and unsupported control without executor', async () => {
|
||||||
|
const integration = new GoveeLightLocalIntegration();
|
||||||
|
const alias = new HomeAssistantGoveeLightLocalIntegration();
|
||||||
|
expect(alias instanceof GoveeLightLocalIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('govee_light_local');
|
||||||
|
expect(integration.status).toEqual('control-runtime');
|
||||||
|
expect(goveeLightLocalProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(goveeLightLocalProfile.metadata.dependencies).toEqual(['network']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'Desk Strip', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'govee_light_local', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'govee_light_local', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IGoveeLightLocalSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Desk Strip');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'light', service: 'turn_on', target: {}, data: { brightness: 128 } });
|
||||||
|
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,77 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { GpsdClient, GpsdConfigFlow, GpsdIntegration, GpsdMapper, HomeAssistantGpsdIntegration, createGpsdDiscoveryDescriptor, gpsdProfile, type IGpsdSnapshot, type TGpsdRawData } from '../../ts/integrations/gpsd/index.js';
|
||||||
|
|
||||||
|
const rawData: TGpsdRawData = {
|
||||||
|
mode: 3,
|
||||||
|
lat: 52.52,
|
||||||
|
lon: 13.405,
|
||||||
|
alt: 35.4,
|
||||||
|
time: '2026-05-11T10:00:00Z',
|
||||||
|
speed: 1.4,
|
||||||
|
climb: 0.1,
|
||||||
|
satellites: [
|
||||||
|
{ used: true },
|
||||||
|
{ used: false },
|
||||||
|
{ used: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual GPSD candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createGpsdDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'gpsd-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', host: '127.0.0.1', port: 2947, name: 'GPS 127.0.0.1', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('gpsd');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new GpsdConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('127.0.0.1');
|
||||||
|
expect(done.config?.port).toEqual(2947);
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps GPSD raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new GpsdClient({ host: '127.0.0.1', port: 2947, rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = GpsdMapper.toSnapshotFromRaw({ host: '127.0.0.1', port: 2947 }, rawData);
|
||||||
|
const devices = GpsdMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = GpsdMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.device.port).toEqual(2947);
|
||||||
|
expect(devices[0].integrationDomain).toEqual('gpsd');
|
||||||
|
expect(devices[0].manufacturer).toEqual('GPSD');
|
||||||
|
expect(entities.some((entityArg) => entityArg.uniqueId.endsWith('_mode') && entityArg.state === '3d_fix')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.uniqueId.endsWith('_used_satellites') && entityArg.state === 2)).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes GPSD read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new GpsdIntegration();
|
||||||
|
const alias = new HomeAssistantGpsdIntegration();
|
||||||
|
expect(alias instanceof GpsdIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('gpsd');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(gpsdProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(gpsdProfile.metadata.requirements).toEqual(['gps3==0.33.3']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ host: '127.0.0.1', port: 2947, rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'gpsd', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'gpsd', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IGpsdSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('GPS');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'gpsd', 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,72 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { GraphiteClient, GraphiteConfigFlow, GraphiteIntegration, GraphiteMapper, HomeAssistantGraphiteIntegration, createGraphiteDiscoveryDescriptor, graphiteProfile, type IGraphiteSnapshot, type TGraphiteRawData } from '../../ts/integrations/graphite/index.js';
|
||||||
|
|
||||||
|
const rawData: TGraphiteRawData = {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 2003,
|
||||||
|
protocol: 'tcp',
|
||||||
|
prefix: 'ha',
|
||||||
|
metrics: [
|
||||||
|
'ha.sensor.temperature.state 21.5 1710000000',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Graphite candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createGraphiteDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'graphite-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', host: '127.0.0.1', port: 2003, name: 'Graphite 127.0.0.1', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('graphite');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new GraphiteConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('127.0.0.1');
|
||||||
|
expect(done.config?.port).toEqual(2003);
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Graphite raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new GraphiteClient({ host: '127.0.0.1', port: 2003, rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = GraphiteMapper.toSnapshotFromRaw({ host: '127.0.0.1', port: 2003 }, rawData);
|
||||||
|
const devices = GraphiteMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = GraphiteMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.device.port).toEqual(2003);
|
||||||
|
expect(devices[0].integrationDomain).toEqual('graphite');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Graphite');
|
||||||
|
expect(entities.some((entityArg) => entityArg.uniqueId.endsWith('_metric_lines') && entityArg.state === 1)).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'binary_sensor' && entityArg.name === 'Configured')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Graphite runtime, HA alias, and unsupported metric send without executor', async () => {
|
||||||
|
const integration = new GraphiteIntegration();
|
||||||
|
const alias = new HomeAssistantGraphiteIntegration();
|
||||||
|
expect(alias instanceof GraphiteIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('graphite');
|
||||||
|
expect(integration.status).toEqual('control-runtime');
|
||||||
|
expect(graphiteProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(graphiteProfile.metadata.qualityScale).toEqual('legacy');
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ host: '127.0.0.1', port: 2003, rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'graphite', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'graphite', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IGraphiteSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Graphite 127.0.0.1:2003');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'graphite', service: 'send_metric', target: {}, data: { metric: 'ha.test 1 1710000000' } });
|
||||||
|
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,84 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { GreeClient, GreeConfigFlow, GreeIntegration, GreeMapper, HomeAssistantGreeIntegration, createGreeDiscoveryDescriptor, greeProfile, type IGreeSnapshot, type TGreeRawData } from '../../ts/integrations/gree/index.js';
|
||||||
|
|
||||||
|
const rawData: TGreeRawData = {
|
||||||
|
device_info: {
|
||||||
|
name: 'Bedroom AC',
|
||||||
|
ip: '192.168.1.70',
|
||||||
|
port: 7000,
|
||||||
|
mac: 'AA:BB:CC:DD:EE:70',
|
||||||
|
},
|
||||||
|
raw_properties: {
|
||||||
|
Pow: 1,
|
||||||
|
Mod: 1,
|
||||||
|
SetTem: 23,
|
||||||
|
TemSen: 22,
|
||||||
|
WdSpd: 3,
|
||||||
|
Lig: 1,
|
||||||
|
Quiet: 0,
|
||||||
|
Air: 1,
|
||||||
|
Blo: 0,
|
||||||
|
Health: 1,
|
||||||
|
Tur: 0,
|
||||||
|
SwhSlp: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Gree candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createGreeDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'gree-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', host: '192.168.1.70', port: 7000, name: 'Bedroom AC', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('gree');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new GreeConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('192.168.1.70');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Gree raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new GreeClient({ host: '192.168.1.70', port: 7000, rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = GreeMapper.toSnapshotFromRaw({ host: '192.168.1.70', port: 7000 }, rawData);
|
||||||
|
const devices = GreeMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = GreeMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.device.serialNumber).toEqual('AA:BB:CC:DD:EE:70');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('gree');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Gree');
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'climate' && entityArg.state === 'cool')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'switch' && entityArg.name === 'Panel light' && entityArg.state === true)).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Gree runtime, HA alias, and unsupported control without executor', async () => {
|
||||||
|
const integration = new GreeIntegration();
|
||||||
|
const alias = new HomeAssistantGreeIntegration();
|
||||||
|
expect(alias instanceof GreeIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('gree');
|
||||||
|
expect(integration.status).toEqual('control-runtime');
|
||||||
|
expect(greeProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(greeProfile.metadata.requirements).toEqual(['greeclimate==2.1.1']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ host: '192.168.1.70', port: 7000, rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'gree', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'gree', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IGreeSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Bedroom AC');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'climate', service: 'set_temperature', target: {}, data: { temperature: 24 } });
|
||||||
|
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,82 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { GreeneyeMonitorClient, GreeneyeMonitorConfigFlow, GreeneyeMonitorIntegration, GreeneyeMonitorMapper, HomeAssistantGreeneyeMonitorIntegration, createGreeneyeMonitorDiscoveryDescriptor, greeneyeMonitorProfile, type IGreeneyeMonitorSnapshot, type TGreeneyeMonitorRawData } from '../../ts/integrations/greeneye_monitor/index.js';
|
||||||
|
|
||||||
|
const rawData: TGreeneyeMonitorRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'gem-00012345',
|
||||||
|
name: 'GEM 00012345',
|
||||||
|
manufacturer: 'Brultech',
|
||||||
|
model: 'GreenEye Monitor',
|
||||||
|
serialNumber: '00012345',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'channel_1_power', name: 'Channel 1 Power', platform: 'sensor', state: 512, unit: 'W', deviceClass: 'power', attributes: { watt_seconds: 123456 } },
|
||||||
|
{ id: 'voltage_1', name: 'Voltage 1', platform: 'sensor', state: 120.4, unit: 'V', deviceClass: 'voltage' },
|
||||||
|
{ id: 'temperature_1', name: 'Temperature 1', platform: 'sensor', state: 21.5, unit: 'C', deviceClass: 'temperature' },
|
||||||
|
{ id: 'pulse_counter_1', name: 'Water Meter', platform: 'sensor', state: 12.3, unit: 'L/min', attributes: { pulses: 42 } },
|
||||||
|
],
|
||||||
|
packet: {
|
||||||
|
channels: 48,
|
||||||
|
pulseCounters: 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual GreenEye Monitor candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createGreeneyeMonitorDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'greeneye_monitor-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'gem-00012345', name: 'GEM 00012345', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('greeneye_monitor');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new GreeneyeMonitorConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('GEM 00012345');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps GreenEye Monitor raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new GreeneyeMonitorClient({ name: 'GreenEye Runtime', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = GreeneyeMonitorMapper.toSnapshotFromRaw({ name: 'GreenEye Runtime' }, rawData);
|
||||||
|
const devices = GreeneyeMonitorMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = GreeneyeMonitorMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('greeneye_monitor');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Brultech');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.gem_00012345_channel_1_power')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.gem_00012345_temperature_1')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes GreenEye Monitor read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new GreeneyeMonitorIntegration();
|
||||||
|
const alias = new HomeAssistantGreeneyeMonitorIntegration();
|
||||||
|
expect(alias instanceof GreeneyeMonitorIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('greeneye_monitor');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(greeneyeMonitorProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(greeneyeMonitorProfile.metadata.qualityScale).toEqual('legacy');
|
||||||
|
expect(greeneyeMonitorProfile.metadata.requirements).toEqual(['greeneye_monitor==3.0.3']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'GreenEye Runtime', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'greeneye_monitor', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'greeneye_monitor', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IGreeneyeMonitorSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('GEM 00012345');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'greeneye_monitor', 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,80 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { GreenwaveClient, GreenwaveConfigFlow, GreenwaveIntegration, GreenwaveMapper, HomeAssistantGreenwaveIntegration, createGreenwaveDiscoveryDescriptor, greenwaveProfile, type IGreenwaveSnapshot, type TGreenwaveRawData } from '../../ts/integrations/greenwave/index.js';
|
||||||
|
|
||||||
|
const rawData: TGreenwaveRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'greenwave-gateway',
|
||||||
|
name: 'Greenwave Gateway',
|
||||||
|
manufacturer: 'Greenwave Reality',
|
||||||
|
model: 'TCP Connected gateway',
|
||||||
|
host: '192.0.2.30',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'kitchen_lamp', name: 'Kitchen Lamp', platform: 'light', state: true, writable: true, attributes: { did: 3, brightness: 191, available: true } },
|
||||||
|
{ id: 'hallway_lamp', name: 'Hallway Lamp', platform: 'light', state: false, writable: true, attributes: { did: 4, brightness: 0, available: true } },
|
||||||
|
],
|
||||||
|
bulbs: {
|
||||||
|
'3': { did: '3', name: 'Kitchen Lamp', state: '1', level: '75' },
|
||||||
|
'4': { did: '4', name: 'Hallway Lamp', state: '0', level: '0' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Greenwave candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createGreenwaveDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'greenwave-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', host: '192.0.2.30', name: 'Greenwave Gateway', metadata: { rawData, version: 3 } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('greenwave');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new GreenwaveConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('192.0.2.30');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Greenwave raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new GreenwaveClient({ name: 'Greenwave Runtime', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = GreenwaveMapper.toSnapshotFromRaw({ name: 'Greenwave Runtime' }, rawData);
|
||||||
|
const devices = GreenwaveMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = GreenwaveMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('greenwave');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Greenwave Reality');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'light.greenwave_gateway_kitchen_lamp')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'light.greenwave_gateway_hallway_lamp')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Greenwave read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new GreenwaveIntegration();
|
||||||
|
const alias = new HomeAssistantGreenwaveIntegration();
|
||||||
|
expect(alias instanceof GreenwaveIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('greenwave');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(greenwaveProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(greenwaveProfile.metadata.qualityScale).toEqual('legacy');
|
||||||
|
expect(greenwaveProfile.metadata.requirements).toEqual(['greenwavereality==0.5.1']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'Greenwave Runtime', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'greenwave', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'greenwave', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IGreenwaveSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Greenwave Gateway');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'light', service: 'turn_on', target: { entityId: 'light.greenwave_gateway_kitchen_lamp' }, data: { brightness: 191 } });
|
||||||
|
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,93 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { GtfsClient, GtfsConfigFlow, GtfsIntegration, GtfsMapper, HomeAssistantGtfsIntegration, createGtfsDiscoveryDescriptor, gtfsProfile, type IGtfsSnapshot, type TGtfsRawData } from '../../ts/integrations/gtfs/index.js';
|
||||||
|
|
||||||
|
const rawData: TGtfsRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'gtfs-downtown-route',
|
||||||
|
name: 'GTFS Downtown Route',
|
||||||
|
manufacturer: 'GTFS',
|
||||||
|
model: 'Transit schedule feed',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{
|
||||||
|
id: 'next_departure',
|
||||||
|
name: 'Next Departure',
|
||||||
|
platform: 'sensor',
|
||||||
|
state: '2026-05-11T14:35:00Z',
|
||||||
|
deviceClass: 'timestamp',
|
||||||
|
attributes: {
|
||||||
|
arrival: '2026-05-11T14:58:00Z',
|
||||||
|
day: 'today',
|
||||||
|
destination: 'DOWNTOWN',
|
||||||
|
origin: 'CENTRAL',
|
||||||
|
route_id: '10',
|
||||||
|
trip_id: 'weekday-10-1435',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
query: {
|
||||||
|
data: 'city.zip',
|
||||||
|
destination: 'DOWNTOWN',
|
||||||
|
origin: 'CENTRAL',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual GTFS candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createGtfsDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'gtfs-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'gtfs-downtown-route', name: 'Downtown GTFS', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('gtfs');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new GtfsConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('Downtown GTFS');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps GTFS raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new GtfsClient({ name: 'GTFS Runtime', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = GtfsMapper.toSnapshotFromRaw({ name: 'GTFS Runtime' }, rawData);
|
||||||
|
const devices = GtfsMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = GtfsMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('gtfs');
|
||||||
|
expect(devices[0].manufacturer).toEqual('GTFS');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.gtfs_downtown_route_next_departure')).toBeTrue();
|
||||||
|
expect(entities[0].attributes?.deviceClass).toEqual('timestamp');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes GTFS read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new GtfsIntegration();
|
||||||
|
const alias = new HomeAssistantGtfsIntegration();
|
||||||
|
expect(alias instanceof GtfsIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('gtfs');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(gtfsProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(gtfsProfile.metadata.qualityScale).toEqual('legacy');
|
||||||
|
expect(gtfsProfile.metadata.requirements).toEqual(['pygtfs==0.1.9']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'GTFS Runtime', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'gtfs', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'gtfs', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IGtfsSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('GTFS Downtown Route');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'gtfs', 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,87 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { GuardianClient, GuardianConfigFlow, GuardianIntegration, GuardianMapper, HomeAssistantGuardianIntegration, createGuardianDiscoveryDescriptor, guardianProfile, type IGuardianSnapshot, type TGuardianRawData } from '../../ts/integrations/guardian/index.js';
|
||||||
|
|
||||||
|
const rawData: TGuardianRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'guardian-5410ec688bcf',
|
||||||
|
name: 'Guardian Valve Controller 5410EC688BCF',
|
||||||
|
manufacturer: 'Elexa',
|
||||||
|
model: 'Guardian valve controller',
|
||||||
|
serialNumber: '5410EC688BCF',
|
||||||
|
host: '192.0.2.77',
|
||||||
|
port: 7777,
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'leak_detected', name: 'Leak Detected', platform: 'binary_sensor', state: false, deviceClass: 'moisture' },
|
||||||
|
{ id: 'temperature', name: 'Temperature', platform: 'sensor', state: 68.2, unit: 'F', deviceClass: 'temperature' },
|
||||||
|
{ id: 'average_current', name: 'Average Current', platform: 'sensor', state: 124, unit: 'mA', deviceClass: 'current' },
|
||||||
|
{ id: 'valve', name: 'Valve', platform: 'switch', state: true, writable: true, attributes: { valveState: 'open', travel_count: 4 } },
|
||||||
|
{ id: 'onboard_ap', name: 'Onboard Access Point', platform: 'switch', state: false, writable: true, attributes: { connected_clients: 0 } },
|
||||||
|
{ id: 'reboot', name: 'Reboot', platform: 'button', state: 'idle', writable: true },
|
||||||
|
],
|
||||||
|
apis: {
|
||||||
|
valve_status: { state: 'open', average_current: 124, travel_count: 4 },
|
||||||
|
system_diagnostics: { firmware: '1.2.3', uptime: 720 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Guardian candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createGuardianDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'guardian-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', host: '192.0.2.77', name: 'Guardian 5410EC688BCF', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('guardian');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new GuardianConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('192.0.2.77');
|
||||||
|
expect(done.config?.port).toEqual(7777);
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Guardian raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new GuardianClient({ name: 'Guardian Runtime', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = GuardianMapper.toSnapshotFromRaw({ name: 'Guardian Runtime' }, rawData);
|
||||||
|
const devices = GuardianMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = GuardianMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('guardian');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Elexa');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.guardian_valve_controller_5410ec688bcf_leak_detected')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'switch.guardian_valve_controller_5410ec688bcf_valve')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Guardian read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new GuardianIntegration();
|
||||||
|
const alias = new HomeAssistantGuardianIntegration();
|
||||||
|
expect(alias instanceof GuardianIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('guardian');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(guardianProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(guardianProfile.metadata.qualityScale).toEqual(undefined);
|
||||||
|
expect(guardianProfile.metadata.requirements).toEqual(['aioguardian==2026.01.1']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'Guardian Runtime', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'guardian', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'guardian', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IGuardianSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Guardian Valve Controller 5410EC688BCF');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'valve', service: 'open_valve', target: { entityId: 'switch.guardian_valve_controller_5410ec688bcf_valve' } });
|
||||||
|
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 { HassioClient, HassioConfigFlow, HassioIntegration, HassioMapper, HomeAssistantHassioIntegration, createHassioDiscoveryDescriptor, hassioProfile, type IHassioSnapshot, type THassioRawData } from '../../ts/integrations/hassio/index.js';
|
||||||
|
|
||||||
|
const rawData: THassioRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'home-assistant-supervisor',
|
||||||
|
name: 'Home Assistant Supervisor',
|
||||||
|
manufacturer: 'Home Assistant',
|
||||||
|
model: 'Home Assistant Supervisor',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'supervisor_version', name: 'Supervisor Version', platform: 'sensor', state: '2026.05.1', attributes: { channel: 'stable', healthy: true, supported: true } },
|
||||||
|
{ id: 'host_disk_free', name: 'Host Disk Free', platform: 'sensor', state: 128.4, unit: 'GB', deviceClass: 'data_size' },
|
||||||
|
{ id: 'core_cpu_percent', name: 'Core CPU Percent', platform: 'sensor', state: 8.2, unit: '%' },
|
||||||
|
{ id: 'core_ssh_state', name: 'Terminal Add-on', platform: 'binary_sensor', state: true, deviceClass: 'running', attributes: { slug: 'core_ssh', version: '9.15.0' } },
|
||||||
|
{ id: 'core_ssh_switch', name: 'Terminal Add-on Switch', platform: 'switch', state: true, writable: true, attributes: { slug: 'core_ssh' } },
|
||||||
|
{ id: 'core_update', name: 'Home Assistant Core Update', platform: 'update', state: false, writable: true, attributes: { installed_version: '2026.5.0', latest_version: '2026.5.0' } },
|
||||||
|
],
|
||||||
|
supervisor: {
|
||||||
|
healthy: true,
|
||||||
|
supported: true,
|
||||||
|
version: '2026.05.1',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Hassio candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createHassioDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hassio-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'home-assistant-supervisor', name: 'Home Assistant Supervisor', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('hassio');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new HassioConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('Home Assistant Supervisor');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Hassio raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new HassioClient({ name: 'Hassio Runtime', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = HassioMapper.toSnapshotFromRaw({ name: 'Hassio Runtime' }, rawData);
|
||||||
|
const devices = HassioMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = HassioMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('hassio');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Home Assistant');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.home_assistant_supervisor_supervisor_version')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'update.home_assistant_supervisor_core_update')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Hassio read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new HassioIntegration();
|
||||||
|
const alias = new HomeAssistantHassioIntegration();
|
||||||
|
expect(alias instanceof HassioIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('hassio');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(hassioProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(hassioProfile.metadata.qualityScale).toEqual('internal');
|
||||||
|
expect(hassioProfile.metadata.dependencies).toEqual(['http', 'repairs']);
|
||||||
|
expect(hassioProfile.metadata.requirements).toEqual(['aiohasupervisor==0.4.3']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'Hassio Runtime', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'hassio', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'hassio', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IHassioSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Home Assistant Supervisor');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'hassio', service: 'addon_start', target: {}, data: { addon: 'core_ssh' } });
|
||||||
|
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,86 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HdfuryClient, HdfuryConfigFlow, HdfuryIntegration, HdfuryMapper, HomeAssistantHdfuryIntegration, createHdfuryDiscoveryDescriptor, hdfuryProfile, type IHdfurySnapshot, type THdfuryRawData } from '../../ts/integrations/hdfury/index.js';
|
||||||
|
|
||||||
|
const rawData: THdfuryRawData = {
|
||||||
|
board: {
|
||||||
|
hostname: 'vrroom-lab',
|
||||||
|
serial: 'HDF123456',
|
||||||
|
version: 'FW: 1.23',
|
||||||
|
pcbv: 'A1',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
TX0: '4K60',
|
||||||
|
AUDOUT: 'eARC',
|
||||||
|
portseltx0: '1',
|
||||||
|
opmode: '2',
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
macaddr: '00:11:22:33:44:55',
|
||||||
|
cec: '1',
|
||||||
|
oled: '0',
|
||||||
|
oledfade: '15',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual HDFury candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createHdfuryDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hdfury-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', host: 'vrroom.local', name: 'HDFury VRROOM', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('hdfury');
|
||||||
|
expect(result.candidate?.port).toEqual(80);
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new HdfuryConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('vrroom.local');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps HDFury raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new HdfuryClient({ host: 'vrroom.local', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = HdfuryMapper.toSnapshotFromRaw({ host: 'vrroom.local' }, rawData);
|
||||||
|
const devices = HdfuryMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = HdfuryMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.device.serialNumber).toEqual('HDF123456');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('hdfury');
|
||||||
|
expect(devices[0].manufacturer).toEqual('HDFury');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.vrroom_lab_tx0')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'switch.vrroom_lab_cec')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'number.vrroom_lab_oledfade')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'select.vrroom_lab_opmode')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes HDFury read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new HdfuryIntegration();
|
||||||
|
const alias = new HomeAssistantHdfuryIntegration();
|
||||||
|
expect(alias instanceof HdfuryIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('hdfury');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(hdfuryProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(hdfuryProfile.metadata.qualityScale).toEqual('platinum');
|
||||||
|
expect(hdfuryProfile.metadata.requirements).toEqual(['hdfury==1.6.0']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ host: 'vrroom.local', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'hdfury', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'hdfury', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IHdfurySnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('vrroom-lab');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'switch', service: 'turn_off', target: { entityId: 'switch.vrroom_lab_cec' } });
|
||||||
|
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,90 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HdmiCecClient, HdmiCecConfigFlow, HdmiCecIntegration, HdmiCecMapper, HomeAssistantHdmiCecIntegration, createHdmiCecDiscoveryDescriptor, hdmiCecProfile, type IHdmiCecSnapshot, type THdmiCecRawData } from '../../ts/integrations/hdmi_cec/index.js';
|
||||||
|
|
||||||
|
const rawData: THdmiCecRawData = {
|
||||||
|
name: 'Living Room CEC Bus',
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
logicalAddress: 4,
|
||||||
|
physicalAddress: '1.0.0.0',
|
||||||
|
typeId: 4,
|
||||||
|
typeName: 'Playback',
|
||||||
|
vendor: 'Sony',
|
||||||
|
vendorId: 43775,
|
||||||
|
osdName: 'Blu-ray',
|
||||||
|
powerStatus: 1,
|
||||||
|
status: 'playing',
|
||||||
|
platform: 'media_player',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
logicalAddress: 0,
|
||||||
|
physicalAddress: '0.0.0.0',
|
||||||
|
typeId: 0,
|
||||||
|
typeName: 'TV',
|
||||||
|
vendor: 'LG',
|
||||||
|
osdName: 'TV',
|
||||||
|
powerStatus: 0,
|
||||||
|
platform: 'switch',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual HDMI-CEC candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createHdmiCecDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hdmi_cec-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'living-room-cec', name: 'HDMI-CEC', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('hdmi_cec');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new HdmiCecConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('HDMI-CEC');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps HDMI-CEC raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new HdmiCecClient({ name: 'Living Room CEC Bus', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = HdmiCecMapper.toSnapshotFromRaw({ name: 'Living Room CEC Bus' }, rawData);
|
||||||
|
const devices = HdmiCecMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = HdmiCecMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(devices[0].integrationDomain).toEqual('hdmi_cec');
|
||||||
|
expect(devices[0].manufacturer).toEqual('HDMI-CEC');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'media_player.living_room_cec_bus_hdmi_4')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'switch.living_room_cec_bus_hdmi_0')).toBeTrue();
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'media_player.living_room_cec_bus_hdmi_4')?.state).toEqual('playing');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes HDMI-CEC read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new HdmiCecIntegration();
|
||||||
|
const alias = new HomeAssistantHdmiCecIntegration();
|
||||||
|
expect(alias instanceof HdmiCecIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('hdmi_cec');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(hdmiCecProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(hdmiCecProfile.metadata.qualityScale).toEqual('legacy');
|
||||||
|
expect(hdmiCecProfile.metadata.requirements).toEqual(['pyCEC==0.5.2']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'Living Room CEC Bus', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'hdmi_cec', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'hdmi_cec', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IHdmiCecSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.entities()).length).toEqual(2);
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'hdmi_cec', service: 'send_command', target: {}, data: { raw: '10:36' } });
|
||||||
|
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,84 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HeatmiserClient, HeatmiserConfigFlow, HeatmiserIntegration, HeatmiserMapper, HomeAssistantHeatmiserIntegration, createHeatmiserDiscoveryDescriptor, heatmiserProfile, type IHeatmiserSnapshot, type THeatmiserRawData } from '../../ts/integrations/heatmiser/index.js';
|
||||||
|
|
||||||
|
const rawData: THeatmiserRawData = {
|
||||||
|
thermostats: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Hallway',
|
||||||
|
floorTemp: 21,
|
||||||
|
targetTemp: 22,
|
||||||
|
currentState: 1,
|
||||||
|
temperatureFormat: 'C',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Bedroom',
|
||||||
|
floorTemp: 18,
|
||||||
|
targetTemp: 16,
|
||||||
|
currentState: 0,
|
||||||
|
temperatureFormat: 'C',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Heatmiser candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createHeatmiserDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'heatmiser-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', host: 'heatmiser.local', port: 4242, name: 'Heatmiser UH1', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('heatmiser');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new HeatmiserConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('heatmiser.local');
|
||||||
|
expect(done.config?.port).toEqual(4242);
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Heatmiser raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new HeatmiserClient({ host: 'heatmiser.local', port: 4242, rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = HeatmiserMapper.toSnapshotFromRaw({ host: 'heatmiser.local', port: 4242 }, rawData);
|
||||||
|
const devices = HeatmiserMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = HeatmiserMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(devices[0].integrationDomain).toEqual('heatmiser');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Heatmiser');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'climate.heatmiser_thermostat_1')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'climate.heatmiser_thermostat_2')).toBeTrue();
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'climate.heatmiser_thermostat_2')?.state).toEqual('off');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Heatmiser read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new HeatmiserIntegration();
|
||||||
|
const alias = new HomeAssistantHeatmiserIntegration();
|
||||||
|
expect(alias instanceof HeatmiserIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('heatmiser');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(heatmiserProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(heatmiserProfile.metadata.qualityScale).toEqual('legacy');
|
||||||
|
expect(heatmiserProfile.metadata.requirements).toEqual(['heatmiserV3==2.0.4']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ host: 'heatmiser.local', port: 4242, rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'heatmiser', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'heatmiser', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IHeatmiserSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.entities()).length).toEqual(2);
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'climate', service: 'set_temperature', target: { entityId: 'climate.heatmiser_thermostat_1' }, 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();
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HegelClient, HegelConfigFlow, HegelIntegration, HegelMapper, HomeAssistantHegelIntegration, createHegelDiscoveryDescriptor, hegelProfile, type IHegelSnapshot, type THegelRawData } from '../../ts/integrations/hegel/index.js';
|
||||||
|
|
||||||
|
const rawData: THegelRawData = {
|
||||||
|
name: 'Living Room Hegel',
|
||||||
|
model: 'H390',
|
||||||
|
state: {
|
||||||
|
power: true,
|
||||||
|
volume: 35,
|
||||||
|
mute: false,
|
||||||
|
input: 10,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Hegel candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createHegelDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hegel-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', host: 'hegel.local', name: 'Hegel H390', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('hegel');
|
||||||
|
expect(result.candidate?.port).toEqual(50001);
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new HegelConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('hegel.local');
|
||||||
|
expect(done.config?.port).toEqual(50001);
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Hegel raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new HegelClient({ host: 'hegel.local', model: 'H390', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = HegelMapper.toSnapshotFromRaw({ host: 'hegel.local', model: 'H390' }, rawData);
|
||||||
|
const devices = HegelMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = HegelMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(devices[0].integrationDomain).toEqual('hegel');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Hegel');
|
||||||
|
expect(entities[0].id).toEqual('media_player.living_room_hegel_media_player');
|
||||||
|
expect(entities[0].state).toEqual('on');
|
||||||
|
expect(entities[0].attributes?.source).toEqual('Network');
|
||||||
|
expect(entities[0].attributes?.volumeLevel).toEqual(0.35);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Hegel read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new HegelIntegration();
|
||||||
|
const alias = new HomeAssistantHegelIntegration();
|
||||||
|
expect(alias instanceof HegelIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('hegel');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(hegelProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(hegelProfile.metadata.qualityScale).toEqual('silver');
|
||||||
|
expect(hegelProfile.metadata.requirements).toEqual(['hegel-ip-client==0.1.4']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ host: 'hegel.local', model: 'H390', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'hegel', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'hegel', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IHegelSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.entities())[0].platform).toEqual('media_player');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'media_player', service: 'turn_off', target: { entityId: 'media_player.living_room_hegel_media_player' } });
|
||||||
|
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,70 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HikvisioncamClient, HikvisioncamConfigFlow, HikvisioncamIntegration, HikvisioncamMapper, HomeAssistantHikvisioncamIntegration, createHikvisioncamDiscoveryDescriptor, hikvisioncamProfile, type IHikvisioncamSnapshot, type THikvisioncamRawData } from '../../ts/integrations/hikvisioncam/index.js';
|
||||||
|
|
||||||
|
const rawData: THikvisioncamRawData = {
|
||||||
|
id: 'front-door-camera',
|
||||||
|
name: 'Front Door Camera',
|
||||||
|
model: 'DS-2CD2042WD-I',
|
||||||
|
motionDetectionEnabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Hikvision candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createHikvisioncamDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hikvisioncam-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', host: 'hikvision.local', name: 'Front Door Camera', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('hikvisioncam');
|
||||||
|
expect(result.candidate?.port).toEqual(80);
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new HikvisioncamConfigFlow().start(result.candidate!, {})).submit!({ username: 'admin', password: '12345' });
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('hikvision.local');
|
||||||
|
expect(done.config?.port).toEqual(80);
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Hikvision raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new HikvisioncamClient({ host: 'hikvision.local', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = HikvisioncamMapper.toSnapshotFromRaw({ host: 'hikvision.local' }, rawData);
|
||||||
|
const devices = HikvisioncamMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = HikvisioncamMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(devices[0].integrationDomain).toEqual('hikvisioncam');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Hikvision');
|
||||||
|
expect(entities[0].id).toEqual('switch.front_door_camera_motion_detection');
|
||||||
|
expect(entities[0].state).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Hikvision read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new HikvisioncamIntegration();
|
||||||
|
const alias = new HomeAssistantHikvisioncamIntegration();
|
||||||
|
expect(alias instanceof HikvisioncamIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('hikvisioncam');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(hikvisioncamProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(hikvisioncamProfile.metadata.qualityScale).toEqual('legacy');
|
||||||
|
expect(hikvisioncamProfile.metadata.requirements).toEqual(['hikvision==0.4']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ host: 'hikvision.local', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'hikvisioncam', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'hikvisioncam', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IHikvisioncamSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Front Door Camera');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'switch', service: 'turn_off', target: { entityId: 'switch.front_door_camera_motion_detection' } });
|
||||||
|
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,78 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HisenseAehw4a1Client, HisenseAehw4a1ConfigFlow, HisenseAehw4a1Integration, HisenseAehw4a1Mapper, HomeAssistantHisenseAehw4a1Integration, createHisenseAehw4a1DiscoveryDescriptor, hisenseAehw4a1Profile, type IHisenseAehw4a1Snapshot, type THisenseAehw4a1RawData } from '../../ts/integrations/hisense_aehw4a1/index.js';
|
||||||
|
|
||||||
|
const rawData: THisenseAehw4a1RawData = {
|
||||||
|
status: {
|
||||||
|
run_status: '1',
|
||||||
|
mode_status: '0010',
|
||||||
|
wind_status: '00000110',
|
||||||
|
up_down: '1',
|
||||||
|
left_right: '0',
|
||||||
|
temperature_Fahrenheit: '0',
|
||||||
|
indoor_temperature_status: '00010101',
|
||||||
|
indoor_temperature_setting: '00010011',
|
||||||
|
efficient: '0',
|
||||||
|
low_electricity: '0',
|
||||||
|
sleep_status: '0000000',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Hisense AEH-W4A1 candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createHisenseAehw4a1DiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hisense_aehw4a1-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: '192.0.2.44', host: '192.0.2.44', name: 'Bedroom AC', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('hisense_aehw4a1');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new HisenseAehw4a1ConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('192.0.2.44');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Hisense AEH-W4A1 raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new HisenseAehw4a1Client({ name: 'Bedroom AC', host: '192.0.2.44', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = HisenseAehw4a1Mapper.toSnapshotFromRaw({ name: 'Bedroom AC', host: '192.0.2.44' }, rawData);
|
||||||
|
const devices = HisenseAehw4a1Mapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = HisenseAehw4a1Mapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('hisense_aehw4a1');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Hisense');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'climate.bedroom_ac_climate')).toBeTrue();
|
||||||
|
expect(entities[0].attributes?.currentTemperature).toEqual(21);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Hisense AEH-W4A1 read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new HisenseAehw4a1Integration();
|
||||||
|
const alias = new HomeAssistantHisenseAehw4a1Integration();
|
||||||
|
expect(alias instanceof HisenseAehw4a1Integration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('hisense_aehw4a1');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(hisenseAehw4a1Profile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(hisenseAehw4a1Profile.metadata.requirements).toEqual(['pyaehw4a1==0.3.9']);
|
||||||
|
expect(hisenseAehw4a1Profile.metadata.qualityScale).toBeUndefined();
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'Bedroom AC', host: '192.0.2.44', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'hisense_aehw4a1', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'hisense_aehw4a1', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IHisenseAehw4a1Snapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Bedroom AC');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'climate', service: 'set_temperature', target: { entityId: 'climate.bedroom_ac_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();
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HitronCodaClient, HitronCodaConfigFlow, HitronCodaIntegration, HitronCodaMapper, HomeAssistantHitronCodaIntegration, createHitronCodaDiscoveryDescriptor, hitronCodaProfile, type IHitronCodaSnapshot, type THitronCodaRawData } from '../../ts/integrations/hitron_coda/index.js';
|
||||||
|
|
||||||
|
const rawData: THitronCodaRawData = [
|
||||||
|
{ macAddr: 'aa:bb:cc:dd:ee:01', hostName: 'phone', ipAddr: '192.0.2.10', type: 'wifi' },
|
||||||
|
{ macAddr: 'aa:bb:cc:dd:ee:02', hostName: 'laptop', ipAddr: '192.0.2.11', type: 'ethernet' },
|
||||||
|
];
|
||||||
|
|
||||||
|
tap.test('matches manual Hitron CODA candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createHitronCodaDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hitron_coda-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', host: '192.0.2.1', name: 'CODA Router', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('hitron_coda');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new HitronCodaConfigFlow().start(result.candidate!, {})).submit!({ username: 'cusadmin', password: 'secret' });
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('192.0.2.1');
|
||||||
|
expect(done.config?.username).toEqual('cusadmin');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Hitron CODA raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new HitronCodaClient({ name: 'CODA Router', host: '192.0.2.1', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = HitronCodaMapper.toSnapshotFromRaw({ name: 'CODA Router', host: '192.0.2.1' }, rawData);
|
||||||
|
const devices = HitronCodaMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = HitronCodaMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('hitron_coda');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Hitron');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.coda_router_connected_devices')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.coda_router_client_aa_bb_cc_dd_ee_01')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Hitron CODA read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new HitronCodaIntegration();
|
||||||
|
const alias = new HomeAssistantHitronCodaIntegration();
|
||||||
|
expect(alias instanceof HitronCodaIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('hitron_coda');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(hitronCodaProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(hitronCodaProfile.metadata.qualityScale).toEqual('legacy');
|
||||||
|
expect(hitronCodaProfile.metadata.requirements).toEqual([]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'CODA Router', host: '192.0.2.1', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'hitron_coda', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'hitron_coda', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IHitronCodaSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('CODA Router');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'hitron_coda', 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,72 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HlkSw16Client, HlkSw16ConfigFlow, HlkSw16Integration, HlkSw16Mapper, HomeAssistantHlkSw16Integration, createHlkSw16DiscoveryDescriptor, hlkSw16Profile, type IHlkSw16Snapshot, type THlkSw16RawData } from '../../ts/integrations/hlk_sw16/index.js';
|
||||||
|
|
||||||
|
const rawData: THlkSw16RawData = {
|
||||||
|
relays: {
|
||||||
|
'0': true,
|
||||||
|
'1': false,
|
||||||
|
a: true,
|
||||||
|
f: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual HLK-SW16 candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createHlkSw16DiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hlk_sw16-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', host: '192.0.2.16', port: 8080, name: 'HLK-SW16 Board', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('hlk_sw16');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new HlkSw16ConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('192.0.2.16');
|
||||||
|
expect(done.config?.port).toEqual(8080);
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps HLK-SW16 raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new HlkSw16Client({ name: 'HLK-SW16 Board', host: '192.0.2.16', rawData, switches: { '0': 'Pump', a: { name: 'Light Circuit' } } });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = HlkSw16Mapper.toSnapshotFromRaw({ name: 'HLK-SW16 Board', host: '192.0.2.16', switches: { '0': 'Pump' } }, rawData);
|
||||||
|
const devices = HlkSw16Mapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = HlkSw16Mapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.entities.length).toEqual(16);
|
||||||
|
expect(devices[0].integrationDomain).toEqual('hlk_sw16');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Hi-Link');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'switch.hlk_sw16_board_relay_0')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'switch.hlk_sw16_board_relay_a')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes HLK-SW16 read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new HlkSw16Integration();
|
||||||
|
const alias = new HomeAssistantHlkSw16Integration();
|
||||||
|
expect(alias instanceof HlkSw16Integration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('hlk_sw16');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(hlkSw16Profile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(hlkSw16Profile.metadata.requirements).toEqual(['hlk-sw16==0.0.9']);
|
||||||
|
expect(hlkSw16Profile.metadata.qualityScale).toBeUndefined();
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'HLK-SW16 Board', host: '192.0.2.16', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'hlk_sw16', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'hlk_sw16', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IHlkSw16Snapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('HLK-SW16 Board');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'switch', service: 'turn_on', target: { entityId: 'switch.hlk_sw16_board_relay_0' } });
|
||||||
|
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,70 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HolidayClient, HolidayConfigFlow, HolidayIntegration, HolidayMapper, HomeAssistantHolidayIntegration, createHolidayDiscoveryDescriptor, holidayProfile, type IHolidaySnapshot, type THolidayRawData } from '../../ts/integrations/holiday/index.js';
|
||||||
|
|
||||||
|
const rawData: THolidayRawData = {
|
||||||
|
country: 'US',
|
||||||
|
holidays: [
|
||||||
|
{ date: '2099-01-01', name: "New Year's Day" },
|
||||||
|
{ date: '2099-12-25', name: 'Christmas Day' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Holiday candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createHolidayDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'holiday-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'us-holidays', name: 'US Holidays', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('holiday');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new HolidayConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('US Holidays');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Holiday raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new HolidayClient({ name: 'US Holidays', country: 'US', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = HolidayMapper.toSnapshotFromRaw({ name: 'US Holidays', country: 'US' }, rawData);
|
||||||
|
const devices = HolidayMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = HolidayMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('holiday');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Home Assistant');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.us_holidays_next_holiday')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.us_holidays_holiday_today')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Holiday read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new HolidayIntegration();
|
||||||
|
const alias = new HomeAssistantHolidayIntegration();
|
||||||
|
expect(alias instanceof HolidayIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('holiday');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(holidayProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(holidayProfile.metadata.requirements).toEqual(['holidays==0.95', 'babel==2.15.0']);
|
||||||
|
expect(holidayProfile.metadata.qualityScale).toBeUndefined();
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'US Holidays', country: 'US', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'holiday', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'holiday', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IHolidaySnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('US Holidays');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'holiday', 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,95 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantHomeeIntegration, HomeeClient, HomeeConfigFlow, HomeeIntegration, HomeeMapper, createHomeeDiscoveryDescriptor, homeeProfile, type IHomeeSnapshot, type THomeeRawData } from '../../ts/integrations/homee/index.js';
|
||||||
|
|
||||||
|
const rawData: THomeeRawData = {
|
||||||
|
settings: {
|
||||||
|
uid: 'homee-1234',
|
||||||
|
homee_name: 'Homee Cube',
|
||||||
|
mac_address: '00:11:22:33:44:55',
|
||||||
|
version: '2.40.0',
|
||||||
|
},
|
||||||
|
connected: true,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Living Room Thermostat',
|
||||||
|
profile: 'room_thermostat',
|
||||||
|
state: 'available',
|
||||||
|
attributes: [
|
||||||
|
{ id: 1, name: 'Temperature', type: 'temperature', value: 21.5, unit: 'C', platform: 'sensor' },
|
||||||
|
{ id: 2, name: 'Target Temperature', type: 'target_temperature', value: 20, unit: 'C', platform: 'climate', writable: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Kitchen Plug',
|
||||||
|
profile: 'metering_switch',
|
||||||
|
state: 'available',
|
||||||
|
attributes: [
|
||||||
|
{ id: 1, name: 'Switch', type: 'on_off', value: true, platform: 'switch', writable: true },
|
||||||
|
{ id: 2, name: 'Energy', type: 'energy', value: 1.2, unit: 'kWh' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Homee candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createHomeeDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'homee-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', host: '192.0.2.50', id: 'homee-1234', name: 'Homee Cube', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('homee');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new HomeeConfigFlow().start(result.candidate!, {})).submit!({ username: 'user', password: 'secret' });
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('192.0.2.50');
|
||||||
|
expect(done.config?.username).toEqual('user');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Homee raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new HomeeClient({ name: 'Homee Cube', host: '192.0.2.50', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = HomeeMapper.toSnapshotFromRaw({ name: 'Homee Cube', host: '192.0.2.50' }, rawData);
|
||||||
|
const devices = HomeeMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = HomeeMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('homee');
|
||||||
|
expect(devices[0].manufacturer).toEqual('homee');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.homee_cube_living_room_thermostat_temperature')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'switch.homee_cube_kitchen_plug_switch')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Homee read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new HomeeIntegration();
|
||||||
|
const alias = new HomeAssistantHomeeIntegration();
|
||||||
|
expect(alias instanceof HomeeIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('homee');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(homeeProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(homeeProfile.metadata.qualityScale).toEqual('silver');
|
||||||
|
expect(homeeProfile.metadata.requirements).toEqual(['pyHomee==1.3.8']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'Homee Cube', host: '192.0.2.50', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'homee', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'homee', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IHomeeSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Homee Cube');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'switch', service: 'turn_off', target: { entityId: 'switch.homee_cube_kitchen_plug_switch' } });
|
||||||
|
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,78 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantHomekitIntegration, HomekitClient, HomekitConfigFlow, HomekitIntegration, HomekitMapper, createHomekitDiscoveryDescriptor, homekitProfile, type IHomekitSnapshot, type THomekitRawData } from '../../ts/integrations/homekit/index.js';
|
||||||
|
|
||||||
|
const rawData: THomekitRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'homekit-bridge',
|
||||||
|
name: 'HomeKit Bridge',
|
||||||
|
manufacturer: 'Home Assistant',
|
||||||
|
model: 'HomeKit Bridge',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'living_room_lamp', name: 'Living Room Lamp', platform: 'light', state: true, writable: true, attributes: { brightness: 180, homekitService: 'Lightbulb' } },
|
||||||
|
{ id: 'front_door', name: 'Front Door', platform: 'binary_sensor', state: false, deviceClass: 'door', attributes: { homekitService: 'ContactSensor' } },
|
||||||
|
{ id: 'thermostat', name: 'Thermostat', platform: 'climate', state: 'heat', writable: true, attributes: { currentTemperature: 20.5, targetTemperature: 21 } },
|
||||||
|
],
|
||||||
|
mode: 'bridge',
|
||||||
|
port: 21064,
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual HomeKit candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createHomekitDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'homekit-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'homekit-bridge', name: 'HomeKit Bridge', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('homekit');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new HomekitConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('HomeKit Bridge');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps HomeKit raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new HomekitClient({ name: 'HomeKit Runtime', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = HomekitMapper.toSnapshotFromRaw({ name: 'HomeKit Runtime' }, rawData);
|
||||||
|
const devices = HomekitMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = HomekitMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('homekit');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Home Assistant');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'light.homekit_bridge_living_room_lamp')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.homekit_bridge_front_door')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes HomeKit read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new HomekitIntegration();
|
||||||
|
const alias = new HomeAssistantHomekitIntegration();
|
||||||
|
expect(alias instanceof HomekitIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('homekit');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(homekitProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(homekitProfile.metadata.requirements).toEqual(['HAP-python==5.0.0', 'fnv-hash-fast==2.0.2', 'homekit-audio-proxy==1.2.1', 'PyQRCode==1.2.1', 'base36==0.1.1']);
|
||||||
|
expect(homekitProfile.metadata.afterDependencies).toEqual(['camera', 'zeroconf']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'HomeKit Runtime', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'homekit', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'homekit', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IHomekitSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('HomeKit Bridge');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'homekit', service: 'reset_accessory', target: {}, data: { entity_id: ['light.homekit_bridge_living_room_lamp'] } });
|
||||||
|
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,79 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantHomevoltIntegration, HomevoltClient, HomevoltConfigFlow, HomevoltIntegration, HomevoltMapper, createHomevoltDiscoveryDescriptor, homevoltProfile, type IHomevoltSnapshot, type THomevoltRawData } from '../../ts/integrations/homevolt/index.js';
|
||||||
|
|
||||||
|
const rawData: THomevoltRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'homevolt-ems-1',
|
||||||
|
name: 'Homevolt Battery',
|
||||||
|
manufacturer: 'Homevolt',
|
||||||
|
model: 'Battery system',
|
||||||
|
host: 'homevolt.local',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'state_of_charge', name: 'State Of Charge', platform: 'sensor', state: 67, unit: '%', deviceClass: 'battery' },
|
||||||
|
{ id: 'available_charging_power', name: 'Available Charging Power', platform: 'sensor', state: 4200, unit: 'W', deviceClass: 'power' },
|
||||||
|
{ id: 'energy_imported', name: 'Energy Imported', platform: 'sensor', state: 18.4, unit: 'kWh', deviceClass: 'energy', stateClass: 'total_increasing' },
|
||||||
|
{ id: 'local_mode', name: 'Local Mode', platform: 'switch', state: true, writable: true, attributes: { entityCategory: 'config' } },
|
||||||
|
],
|
||||||
|
uniqueId: 'HV-1234',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Homevolt candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createHomevoltDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'homevolt-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'HV-1234', name: 'Homevolt Battery', host: 'homevolt.local', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('homevolt');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new HomevoltConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('homevolt.local');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Homevolt raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new HomevoltClient({ name: 'Homevolt Runtime', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = HomevoltMapper.toSnapshotFromRaw({ name: 'Homevolt Runtime' }, rawData);
|
||||||
|
const devices = HomevoltMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = HomevoltMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('homevolt');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Homevolt');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.homevolt_battery_state_of_charge')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'switch.homevolt_battery_local_mode')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Homevolt read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new HomevoltIntegration();
|
||||||
|
const alias = new HomeAssistantHomevoltIntegration();
|
||||||
|
expect(alias instanceof HomevoltIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('homevolt');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(homevoltProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(homevoltProfile.metadata.qualityScale).toEqual('silver');
|
||||||
|
expect(homevoltProfile.metadata.requirements).toEqual(['homevolt==0.5.0']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'Homevolt Runtime', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'homevolt', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'homevolt', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IHomevoltSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Homevolt Battery');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'switch', service: 'turn_off', target: { entityId: 'switch.homevolt_battery_local_mode' } });
|
||||||
|
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,79 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantHomeworksIntegration, HomeworksClient, HomeworksConfigFlow, HomeworksIntegration, HomeworksMapper, createHomeworksDiscoveryDescriptor, homeworksProfile, type IHomeworksSnapshot, type THomeworksRawData } from '../../ts/integrations/homeworks/index.js';
|
||||||
|
|
||||||
|
const rawData: THomeworksRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'homeworks-main',
|
||||||
|
name: 'Lutron Homeworks',
|
||||||
|
manufacturer: 'Lutron',
|
||||||
|
model: 'Homeworks controller',
|
||||||
|
host: 'homeworks.local',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'kitchen_dimmer', name: 'Kitchen Dimmer', platform: 'light', state: 128, writable: true, attributes: { address: '[02:08:02:01]', rate: 1 } },
|
||||||
|
{ id: 'keypad_button_1', name: 'Keypad Button 1', platform: 'button', state: 'idle', writable: true, attributes: { address: '[02:08:02:02]', number: 1 } },
|
||||||
|
{ id: 'keypad_led_1', name: 'Keypad LED 1', platform: 'binary_sensor', state: true, attributes: { address: '[02:08:02:02]', number: 1 } },
|
||||||
|
],
|
||||||
|
controllerId: 'lutron_homeworks',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Homeworks candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createHomeworksDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'homeworks-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'lutron_homeworks', name: 'Lutron Homeworks', host: 'homeworks.local', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('homeworks');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new HomeworksConfigFlow().start(result.candidate!, {})).submit!({ port: 23 });
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('homeworks.local');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Homeworks raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new HomeworksClient({ name: 'Homeworks Runtime', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = HomeworksMapper.toSnapshotFromRaw({ name: 'Homeworks Runtime' }, rawData);
|
||||||
|
const devices = HomeworksMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = HomeworksMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('homeworks');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Lutron');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'light.lutron_homeworks_kitchen_dimmer')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'button.lutron_homeworks_keypad_button_1')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.lutron_homeworks_keypad_led_1')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Homeworks read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new HomeworksIntegration();
|
||||||
|
const alias = new HomeAssistantHomeworksIntegration();
|
||||||
|
expect(alias instanceof HomeworksIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('homeworks');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(homeworksProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(homeworksProfile.metadata.requirements).toEqual(['pyhomeworks==1.1.2']);
|
||||||
|
expect(homeworksProfile.metadata.dependencies).toEqual([]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'Homeworks Runtime', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'homeworks', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'homeworks', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IHomeworksSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Lutron Homeworks');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'homeworks', service: 'send_command', target: {}, data: { controller_id: 'lutron_homeworks', command: ['KBP, [02:08:02:02], 1'] } });
|
||||||
|
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,77 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantHorizonIntegration, HorizonClient, HorizonConfigFlow, HorizonIntegration, HorizonMapper, createHorizonDiscoveryDescriptor, horizonProfile, type IHorizonSnapshot, type THorizonRawData } from '../../ts/integrations/horizon/index.js';
|
||||||
|
|
||||||
|
const rawData: THorizonRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'horizon-recorder',
|
||||||
|
name: 'Horizon Recorder',
|
||||||
|
manufacturer: 'Unitymedia',
|
||||||
|
model: 'Horizon HD Recorder',
|
||||||
|
host: 'horizon.local',
|
||||||
|
port: 5900,
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'media_player', name: 'Media Player', platform: 'media_player', state: 'playing', writable: true, attributes: { mediaType: 'channel', channel: 101, supportedFeatures: ['turn_on', 'turn_off', 'media_play', 'media_pause'] } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Horizon candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createHorizonDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'horizon-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'horizon-recorder', name: 'Horizon Recorder', host: 'horizon.local', port: 5900, metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('horizon');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new HorizonConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('horizon.local');
|
||||||
|
expect(done.config?.port).toEqual(5900);
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Horizon raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new HorizonClient({ name: 'Horizon Runtime', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = HorizonMapper.toSnapshotFromRaw({ name: 'Horizon Runtime' }, rawData);
|
||||||
|
const devices = HorizonMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = HorizonMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('horizon');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Unitymedia');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'media_player.horizon_recorder_media_player')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.platform === 'media_player' && entityArg.state === 'playing')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Horizon read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new HorizonIntegration();
|
||||||
|
const alias = new HomeAssistantHorizonIntegration();
|
||||||
|
expect(alias instanceof HorizonIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('horizon');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(horizonProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(horizonProfile.metadata.qualityScale).toEqual('legacy');
|
||||||
|
expect(horizonProfile.metadata.requirements).toEqual(['horimote==0.4.1']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'Horizon Runtime', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'horizon', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'horizon', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IHorizonSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Horizon Recorder');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'media_player', service: 'media_play_pause', target: { entityId: 'media_player.horizon_recorder_media_player' } });
|
||||||
|
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,80 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantHpIloIntegration, HpIloClient, HpIloConfigFlow, HpIloIntegration, HpIloMapper, createHpIloDiscoveryDescriptor, hpIloProfile, type IHpIloSnapshot, type THpIloRawData } from '../../ts/integrations/hp_ilo/index.js';
|
||||||
|
|
||||||
|
const rawData: THpIloRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'hp-ilo-server-1',
|
||||||
|
name: 'HP iLO Server',
|
||||||
|
manufacturer: 'HP',
|
||||||
|
model: 'Integrated Lights-Out',
|
||||||
|
host: 'ilo.local',
|
||||||
|
port: 443,
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'server_power_status', name: 'Server Power Status', platform: 'sensor', state: 'ON' },
|
||||||
|
{ id: 'server_health', name: 'Server Health', platform: 'sensor', state: 'OK' },
|
||||||
|
{ id: 'server_uid_status', name: 'Server UID Status', platform: 'sensor', state: 'OFF' },
|
||||||
|
{ id: 'server_power_on_time', name: 'Server Power On Time', platform: 'sensor', state: 123456, unit: 's' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual HP iLO candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createHpIloDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hp_ilo-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'hp-ilo-server-1', name: 'HP iLO Server', host: 'ilo.local', port: 443, metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('hp_ilo');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new HpIloConfigFlow().start(result.candidate!, {})).submit!({ username: 'admin', password: 'secret' });
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('ilo.local');
|
||||||
|
expect(done.config?.port).toEqual(443);
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps HP iLO raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new HpIloClient({ name: 'HP iLO Runtime', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = HpIloMapper.toSnapshotFromRaw({ name: 'HP iLO Runtime' }, rawData);
|
||||||
|
const devices = HpIloMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = HpIloMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('hp_ilo');
|
||||||
|
expect(devices[0].manufacturer).toEqual('HP');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.hp_ilo_server_server_power_status')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.hp_ilo_server_server_health')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes HP iLO read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new HpIloIntegration();
|
||||||
|
const alias = new HomeAssistantHpIloIntegration();
|
||||||
|
expect(alias instanceof HpIloIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('hp_ilo');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(hpIloProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(hpIloProfile.metadata.qualityScale).toEqual('legacy');
|
||||||
|
expect(hpIloProfile.metadata.requirements).toEqual(['python-hpilo==4.4.3']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'HP iLO Runtime', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'hp_ilo', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'hp_ilo', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IHpIloSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('HP iLO Server');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'hp_ilo', service: 'turn_on', target: { deviceId: 'hp_ilo.device.hp_ilo_server' } });
|
||||||
|
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,83 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantHrEnergyQubeIntegration, HrEnergyQubeClient, HrEnergyQubeConfigFlow, HrEnergyQubeIntegration, HrEnergyQubeMapper, createHrEnergyQubeDiscoveryDescriptor, hrEnergyQubeProfile, type IHrEnergyQubeSnapshot, type THrEnergyQubeRawData } from '../../ts/integrations/hr_energy_qube/index.js';
|
||||||
|
|
||||||
|
const rawData: THrEnergyQubeRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'qube-heat-pump',
|
||||||
|
name: 'Qube Heat Pump',
|
||||||
|
manufacturer: 'Qube',
|
||||||
|
model: 'Heat Pump',
|
||||||
|
host: '192.0.2.10',
|
||||||
|
port: 502,
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'temp_supply', name: 'Supply temperature CH', platform: 'sensor', state: 35.2, unit: 'C', deviceClass: 'temperature', stateClass: 'measurement' },
|
||||||
|
{ id: 'power_electric', name: 'Electric power', platform: 'sensor', state: 1200, unit: 'W', deviceClass: 'power', stateClass: 'measurement' },
|
||||||
|
{ id: 'status_heatpump', name: 'Heat pump status', platform: 'sensor', state: 'heating', deviceClass: 'enum' },
|
||||||
|
{ id: 'alarm_global', name: 'Global alarm', platform: 'binary_sensor', state: false, deviceClass: 'problem' },
|
||||||
|
],
|
||||||
|
registers: {
|
||||||
|
statusCode: 16,
|
||||||
|
softwareVersion: '1.8.0',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Qube candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createHrEnergyQubeDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hr_energy_qube-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'qube-heat-pump', name: 'Qube heat pump', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('hr_energy_qube');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new HrEnergyQubeConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('Qube heat pump');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Qube raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new HrEnergyQubeClient({ rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = HrEnergyQubeMapper.toSnapshotFromRaw({}, rawData);
|
||||||
|
const devices = HrEnergyQubeMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = HrEnergyQubeMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('hr_energy_qube');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Qube');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.qube_heat_pump_temp_supply')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.qube_heat_pump_alarm_global')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Qube read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new HrEnergyQubeIntegration();
|
||||||
|
const alias = new HomeAssistantHrEnergyQubeIntegration();
|
||||||
|
expect(alias instanceof HrEnergyQubeIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('hr_energy_qube');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(hrEnergyQubeProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(hrEnergyQubeProfile.metadata.qualityScale).toEqual('bronze');
|
||||||
|
expect(hrEnergyQubeProfile.metadata.requirements).toEqual(['python-qube-heatpump==1.8.0']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'hr_energy_qube', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'hr_energy_qube', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IHrEnergyQubeSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Qube Heat Pump');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'hr_energy_qube', 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,89 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantHueBleIntegration, HueBleClient, HueBleConfigFlow, HueBleIntegration, HueBleMapper, createHueBleDiscoveryDescriptor, hueBleProfile, type IHueBleSnapshot, type THueBleRawData } from '../../ts/integrations/hue_ble/index.js';
|
||||||
|
|
||||||
|
const rawData: THueBleRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'aa:bb:cc:dd:ee:ff',
|
||||||
|
name: 'Hue BLE Lamp',
|
||||||
|
manufacturer: 'Philips Hue',
|
||||||
|
model: 'Hue BLE light',
|
||||||
|
serialNumber: 'AA:BB:CC:DD:EE:FF',
|
||||||
|
protocol: 'local',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{
|
||||||
|
id: 'light',
|
||||||
|
name: 'Light',
|
||||||
|
platform: 'light',
|
||||||
|
state: true,
|
||||||
|
writable: true,
|
||||||
|
attributes: {
|
||||||
|
brightness: 190,
|
||||||
|
colorMode: 'color_temp',
|
||||||
|
colorTempKelvin: 2700,
|
||||||
|
supportedColorModes: ['brightness', 'color_temp', 'xy'],
|
||||||
|
xyColor: [0.45, 0.41],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Hue BLE candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createHueBleDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hue_ble-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'aa:bb:cc:dd:ee:ff', name: 'Hue BLE Lamp', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('hue_ble');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new HueBleConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('Hue BLE Lamp');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Hue BLE raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new HueBleClient({ rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = HueBleMapper.toSnapshotFromRaw({}, rawData);
|
||||||
|
const devices = HueBleMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = HueBleMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('hue_ble');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Philips Hue');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'light.hue_ble_lamp_light')).toBeTrue();
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'light.hue_ble_lamp_light')?.attributes?.brightness).toEqual(190);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Hue BLE read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new HueBleIntegration();
|
||||||
|
const alias = new HomeAssistantHueBleIntegration();
|
||||||
|
expect(alias instanceof HueBleIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('hue_ble');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(hueBleProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(hueBleProfile.metadata.qualityScale).toEqual('bronze');
|
||||||
|
expect(hueBleProfile.metadata.dependencies).toEqual(['bluetooth_adapters']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'light', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'hue_ble', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IHueBleSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Hue BLE Lamp');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'light', service: 'turn_on', target: { entityId: 'light.hue_ble_lamp_light' }, data: { brightness: 255 } });
|
||||||
|
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,82 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantHusqvarnaAutomowerBleIntegration, HusqvarnaAutomowerBleClient, HusqvarnaAutomowerBleConfigFlow, HusqvarnaAutomowerBleIntegration, HusqvarnaAutomowerBleMapper, createHusqvarnaAutomowerBleDiscoveryDescriptor, husqvarnaAutomowerBleProfile, type IHusqvarnaAutomowerBleSnapshot, type THusqvarnaAutomowerBleRawData } from '../../ts/integrations/husqvarna_automower_ble/index.js';
|
||||||
|
|
||||||
|
const rawData: THusqvarnaAutomowerBleRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'husqvarna-aa-bb-cc-dd-ee-ff',
|
||||||
|
name: 'Garden Automower',
|
||||||
|
manufacturer: 'Husqvarna',
|
||||||
|
model: 'Automower 430X',
|
||||||
|
serialNumber: 'AA:BB:CC:DD:EE:FF',
|
||||||
|
protocol: 'local',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'activity', name: 'Mower activity', platform: 'sensor', state: 'mowing' },
|
||||||
|
{ id: 'state', name: 'Mower state', platform: 'sensor', state: 'in_operation' },
|
||||||
|
{ id: 'battery_level', name: 'Battery level', platform: 'sensor', state: 82, unit: '%', deviceClass: 'battery', stateClass: 'measurement' },
|
||||||
|
],
|
||||||
|
mower: {
|
||||||
|
activity: 'mowing',
|
||||||
|
state: 'in_operation',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Husqvarna Automower BLE candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createHusqvarnaAutomowerBleDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'husqvarna_automower_ble-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'husqvarna-aa-bb-cc-dd-ee-ff', name: 'Garden Automower', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('husqvarna_automower_ble');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new HusqvarnaAutomowerBleConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('Garden Automower');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Husqvarna Automower BLE raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new HusqvarnaAutomowerBleClient({ rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = HusqvarnaAutomowerBleMapper.toSnapshotFromRaw({}, rawData);
|
||||||
|
const devices = HusqvarnaAutomowerBleMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = HusqvarnaAutomowerBleMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('husqvarna_automower_ble');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Husqvarna');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.garden_automower_activity')).toBeTrue();
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'sensor.garden_automower_battery_level')?.state).toEqual(82);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Husqvarna Automower BLE read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new HusqvarnaAutomowerBleIntegration();
|
||||||
|
const alias = new HomeAssistantHusqvarnaAutomowerBleIntegration();
|
||||||
|
expect(alias instanceof HusqvarnaAutomowerBleIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('husqvarna_automower_ble');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(husqvarnaAutomowerBleProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(husqvarnaAutomowerBleProfile.metadata.qualityScale).toEqual(undefined);
|
||||||
|
expect(husqvarnaAutomowerBleProfile.metadata.dependencies).toEqual(['bluetooth_adapters']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'lawn_mower', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'husqvarna_automower_ble', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IHusqvarnaAutomowerBleSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Garden Automower');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'lawn_mower', service: 'start_mowing', target: { entityId: 'lawn_mower.garden_automower' } });
|
||||||
|
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,79 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantIalarmIntegration, IalarmClient, IalarmConfigFlow, IalarmIntegration, IalarmMapper, createIalarmDiscoveryDescriptor, ialarmProfile, type IIalarmSnapshot, type TIalarmRawData } from '../../ts/integrations/ialarm/index.js';
|
||||||
|
|
||||||
|
const rawData: TIalarmRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'ialarm-aa-bb-cc-dd-ee-ff',
|
||||||
|
name: 'iAlarm',
|
||||||
|
manufacturer: 'Antifurto365 - Meian',
|
||||||
|
model: 'iAlarm',
|
||||||
|
serialNumber: 'AA:BB:CC:DD:EE:FF',
|
||||||
|
host: '192.0.2.34',
|
||||||
|
port: 18034,
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'alarm_state', name: 'Alarm state', platform: 'sensor', state: 'armed_away', writable: true, attributes: { supportedFeatures: ['arm_home', 'arm_away'] } },
|
||||||
|
{ id: 'online', name: 'Online', platform: 'binary_sensor', state: true, deviceClass: 'connectivity' },
|
||||||
|
],
|
||||||
|
status: 'armed_away',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual iAlarm candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createIalarmDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'ialarm-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'ialarm-aa-bb-cc-dd-ee-ff', name: 'Antifurto365 iAlarm', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('ialarm');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new IalarmConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('Antifurto365 iAlarm');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps iAlarm raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new IalarmClient({ rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = IalarmMapper.toSnapshotFromRaw({}, rawData);
|
||||||
|
const devices = IalarmMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = IalarmMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('ialarm');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Antifurto365 - Meian');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.ialarm_alarm_state')).toBeTrue();
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'sensor.ialarm_alarm_state')?.state).toEqual('armed_away');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes iAlarm read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new IalarmIntegration();
|
||||||
|
const alias = new HomeAssistantIalarmIntegration();
|
||||||
|
expect(alias instanceof IalarmIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('ialarm');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(ialarmProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(ialarmProfile.metadata.qualityScale).toEqual(undefined);
|
||||||
|
expect(ialarmProfile.metadata.requirements).toEqual(['pyialarm==2.2.0']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'alarm_control_panel', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'ialarm', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IIalarmSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('iAlarm');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'alarm_control_panel', service: 'alarm_arm_away', target: { entityId: 'alarm_control_panel.ialarm' } });
|
||||||
|
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,82 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantIammeterIntegration, IammeterClient, IammeterConfigFlow, IammeterIntegration, IammeterMapper, createIammeterDiscoveryDescriptor, iammeterProfile, type IIammeterSnapshot, type TIammeterRawData } from '../../ts/integrations/iammeter/index.js';
|
||||||
|
|
||||||
|
const rawData: TIammeterRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'iammeter-12345678',
|
||||||
|
name: 'IamMeter WEM3080T',
|
||||||
|
manufacturer: 'IamMeter',
|
||||||
|
model: 'WEM3080T',
|
||||||
|
serialNumber: '12345678',
|
||||||
|
host: '192.0.2.15',
|
||||||
|
port: 80,
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'Voltage_A', name: 'Voltage A', platform: 'sensor', state: 231.2, unit: 'V', deviceClass: 'voltage', stateClass: 'measurement' },
|
||||||
|
{ id: 'Current_A', name: 'Current A', platform: 'sensor', state: 4.1, unit: 'A', deviceClass: 'current', stateClass: 'measurement' },
|
||||||
|
{ id: 'Power_A', name: 'Power A', platform: 'sensor', state: 840, unit: 'W', deviceClass: 'power', stateClass: 'measurement' },
|
||||||
|
{ id: 'ImportEnergy_A', name: 'ImportEnergy A', platform: 'sensor', state: 1234.56, unit: 'kWh', deviceClass: 'energy', stateClass: 'total_increasing' },
|
||||||
|
],
|
||||||
|
Model: 'WEM3080T',
|
||||||
|
sn: '12345678',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual IamMeter candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createIammeterDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'iammeter-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'iammeter-12345678', name: 'IamMeter WEM3080T', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('iammeter');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new IammeterConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.name).toEqual('IamMeter WEM3080T');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps IamMeter raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new IammeterClient({ rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = IammeterMapper.toSnapshotFromRaw({}, rawData);
|
||||||
|
const devices = IammeterMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = IammeterMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('iammeter');
|
||||||
|
expect(devices[0].manufacturer).toEqual('IamMeter');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.iammeter_wem3080t_voltage_a')).toBeTrue();
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'sensor.iammeter_wem3080t_power_a')?.state).toEqual(840);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes IamMeter read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new IammeterIntegration();
|
||||||
|
const alias = new HomeAssistantIammeterIntegration();
|
||||||
|
expect(alias instanceof IammeterIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('iammeter');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(iammeterProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(iammeterProfile.metadata.qualityScale).toEqual('legacy');
|
||||||
|
expect(iammeterProfile.metadata.requirements).toEqual(['iammeter==0.2.1']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'iammeter', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'iammeter', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IIammeterSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('IamMeter WEM3080T');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'iammeter', 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 { IbeaconClient, IbeaconConfigFlow, IbeaconIntegration, IbeaconMapper, HomeAssistantIbeaconIntegration, createIbeaconDiscoveryDescriptor, ibeaconProfile, type IIbeaconSnapshot, type TIbeaconRawData } from '../../ts/integrations/ibeacon/index.js';
|
||||||
|
|
||||||
|
const rawData: TIbeaconRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'fda50693-a4e2-4fb1-afcf-c6eb07647825_10_42_AA:BB:CC:DD:EE:FF',
|
||||||
|
name: 'Desk Beacon',
|
||||||
|
manufacturer: 'Apple',
|
||||||
|
model: 'iBeacon advertisement',
|
||||||
|
serialNumber: 'fda50693-a4e2-4fb1-afcf-c6eb07647825_10_42',
|
||||||
|
attributes: {
|
||||||
|
address: 'AA:BB:CC:DD:EE:FF',
|
||||||
|
source: 'bluetooth',
|
||||||
|
uuid: 'fda50693-a4e2-4fb1-afcf-c6eb07647825',
|
||||||
|
major: 10,
|
||||||
|
minor: 42,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'presence', name: 'Presence', platform: 'binary_sensor', state: true, deviceClass: 'presence' },
|
||||||
|
{ id: 'rssi', name: 'RSSI', platform: 'sensor', state: -63, unit: 'dBm', deviceClass: 'signal_strength', stateClass: 'measurement' },
|
||||||
|
{ id: 'power', name: 'Power', platform: 'sensor', state: -59, unit: 'dBm', deviceClass: 'signal_strength', stateClass: 'measurement' },
|
||||||
|
{ id: 'estimated_distance', name: 'Estimated Distance', platform: 'sensor', state: 1.8, unit: 'm', deviceClass: 'distance', stateClass: 'measurement' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual iBeacon candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createIbeaconDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'ibeacon-manual-match');
|
||||||
|
const result = await matcher!.matches({ id: 'AA:BB:CC:DD:EE:FF', name: 'Desk iBeacon', metadata: { uuid: 'fda50693-a4e2-4fb1-afcf-c6eb07647825', rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('ibeacon');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new IbeaconConfigFlow().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 iBeacon raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new IbeaconClient({ name: 'iBeacon Runtime', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = IbeaconMapper.toSnapshotFromRaw({ name: 'iBeacon Runtime' }, rawData);
|
||||||
|
const devices = IbeaconMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = IbeaconMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('ibeacon');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Apple');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.desk_beacon_presence')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.desk_beacon_estimated_distance')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes iBeacon read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new IbeaconIntegration();
|
||||||
|
const alias = new HomeAssistantIbeaconIntegration();
|
||||||
|
expect(alias instanceof IbeaconIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('ibeacon');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(ibeaconProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(ibeaconProfile.metadata.requirements).toEqual(['ibeacon-ble==1.2.0']);
|
||||||
|
expect(ibeaconProfile.metadata.dependencies).toEqual(['bluetooth_adapters']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'iBeacon Runtime', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'ibeacon', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'ibeacon', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IIbeaconSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Desk Beacon');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'ibeacon', 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,82 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { IdasenDeskClient, IdasenDeskConfigFlow, IdasenDeskIntegration, IdasenDeskMapper, HomeAssistantIdasenDeskIntegration, createIdasenDeskDiscoveryDescriptor, idasenDeskProfile, idasenDeskServiceUuid, type IIdasenDeskSnapshot, type TIdasenDeskRawData } from '../../ts/integrations/idasen_desk/index.js';
|
||||||
|
|
||||||
|
const rawData: TIdasenDeskRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'idasen-AA:BB:CC:DD:EE:FF',
|
||||||
|
name: 'Bedroom Desk',
|
||||||
|
manufacturer: 'LINAK',
|
||||||
|
model: 'IKEA Idasen Desk',
|
||||||
|
serialNumber: 'AA:BB:CC:DD:EE:FF',
|
||||||
|
attributes: {
|
||||||
|
address: 'AA:BB:CC:DD:EE:FF',
|
||||||
|
serviceUuid: idasenDeskServiceUuid,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'desk', name: 'Desk', platform: 'cover', state: 'open', writable: true, attributes: { currentPosition: 72, supportedFeatures: ['open', 'close', 'stop', 'set_position'] } },
|
||||||
|
{ id: 'height', name: 'Height', platform: 'sensor', state: 1.13, unit: 'm', deviceClass: 'distance', stateClass: 'measurement' },
|
||||||
|
{ id: 'connect', name: 'Connect', platform: 'button', state: 'idle', writable: true, attributes: { category: 'config' } },
|
||||||
|
{ id: 'disconnect', name: 'Disconnect', platform: 'button', state: 'idle', writable: true, attributes: { category: 'config' } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Idasen Desk candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createIdasenDeskDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'idasen_desk-manual-match');
|
||||||
|
const result = await matcher!.matches({ id: 'AA:BB:CC:DD:EE:FF', name: 'Bedroom Idasen Desk', metadata: { address: 'AA:BB:CC:DD:EE:FF', serviceUuid: idasenDeskServiceUuid, rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('idasen_desk');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new IdasenDeskConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('AA:BB:CC:DD:EE:FF');
|
||||||
|
expect(done.config?.metadata?.serviceUuid).toEqual(idasenDeskServiceUuid);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Idasen Desk raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new IdasenDeskClient({ name: 'Desk Runtime', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = IdasenDeskMapper.toSnapshotFromRaw({ name: 'Desk Runtime' }, rawData);
|
||||||
|
const devices = IdasenDeskMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = IdasenDeskMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('idasen_desk');
|
||||||
|
expect(devices[0].manufacturer).toEqual('LINAK');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'cover.bedroom_desk_desk')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.bedroom_desk_height')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Idasen Desk read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new IdasenDeskIntegration();
|
||||||
|
const alias = new HomeAssistantIdasenDeskIntegration();
|
||||||
|
expect(alias instanceof IdasenDeskIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('idasen_desk');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(idasenDeskProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(idasenDeskProfile.metadata.qualityScale).toEqual('bronze');
|
||||||
|
expect(idasenDeskProfile.metadata.requirements).toEqual(['idasen-ha==2.6.5']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'Desk Runtime', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'idasen_desk', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'idasen_desk', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IIdasenDeskSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Bedroom Desk');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'cover', service: 'open_cover', target: { entityId: 'cover.bedroom_desk_desk' } });
|
||||||
|
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,80 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantIdteckProxIntegration, IdteckProxClient, IdteckProxConfigFlow, IdteckProxIntegration, IdteckProxMapper, createIdteckProxDiscoveryDescriptor, idteckProxProfile, type IIdteckProxSnapshot, type TIdteckProxRawData } from '../../ts/integrations/idteck_prox/index.js';
|
||||||
|
|
||||||
|
const rawData: TIdteckProxRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'idteck-front-door',
|
||||||
|
name: 'Front Door Reader',
|
||||||
|
manufacturer: 'IDTECK',
|
||||||
|
model: 'RFK101 proximity reader',
|
||||||
|
host: '192.0.2.11',
|
||||||
|
port: 9000,
|
||||||
|
protocol: 'tcp',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'last_card', name: 'Last Card', platform: 'sensor', state: '1234567890', attributes: { event: 'idteck_prox_keycard' } },
|
||||||
|
{ id: 'reader_name', name: 'Reader Name', platform: 'sensor', state: 'Front Door Reader' },
|
||||||
|
{ id: 'event_count', name: 'Event Count', platform: 'sensor', state: 7, stateClass: 'measurement' },
|
||||||
|
{ id: 'connected', name: 'Connected', platform: 'binary_sensor', state: true, deviceClass: 'connectivity' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual IDTECK Prox candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createIdteckProxDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'idteck_prox-manual-match');
|
||||||
|
const result = await matcher!.matches({ id: 'front-door-reader', host: '192.0.2.11', port: 9000, name: 'Front Door Reader', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('idteck_prox');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new IdteckProxConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('192.0.2.11');
|
||||||
|
expect(done.config?.port).toEqual(9000);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps IDTECK Prox raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new IdteckProxClient({ name: 'IDTECK Runtime', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = IdteckProxMapper.toSnapshotFromRaw({ name: 'IDTECK Runtime' }, rawData);
|
||||||
|
const devices = IdteckProxMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = IdteckProxMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('idteck_prox');
|
||||||
|
expect(devices[0].manufacturer).toEqual('IDTECK');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'sensor.front_door_reader_last_card')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.front_door_reader_connected')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes IDTECK Prox read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new IdteckProxIntegration();
|
||||||
|
const alias = new HomeAssistantIdteckProxIntegration();
|
||||||
|
expect(alias instanceof IdteckProxIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('idteck_prox');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(idteckProxProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(idteckProxProfile.metadata.qualityScale).toEqual('legacy');
|
||||||
|
expect(idteckProxProfile.metadata.requirements).toEqual(['rfk101py==0.0.1']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'IDTECK Runtime', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'idteck_prox', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'idteck_prox', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IIdteckProxSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Front Door Reader');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'idteck_prox', 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,79 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantIgloIntegration, IgloClient, IgloConfigFlow, IgloIntegration, IgloMapper, createIgloDiscoveryDescriptor, igloDefaultPort, igloProfile, type IIgloSnapshot, type TIgloRawData } from '../../ts/integrations/iglo/index.js';
|
||||||
|
|
||||||
|
const rawData: TIgloRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'iglo-kitchen',
|
||||||
|
name: 'Kitchen iGlo',
|
||||||
|
manufacturer: 'iGlo',
|
||||||
|
model: 'iGlo Light',
|
||||||
|
host: '192.0.2.21',
|
||||||
|
port: igloDefaultPort,
|
||||||
|
attributes: {
|
||||||
|
lightId: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'light', name: 'Light', platform: 'light', state: true, writable: true, attributes: { brightness: 153, brightnessScale: 255, colorMode: 'hs', hsColor: [32, 76], colorTempKelvin: 3200, effect: 'Rainbow', effectList: ['Rainbow', 'Pulse'] } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual iGlo candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createIgloDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'iglo-manual-match');
|
||||||
|
const result = await matcher!.matches({ id: 'iglo-kitchen', host: '192.0.2.21', port: igloDefaultPort, name: 'Kitchen iGlo', metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('iglo');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new IgloConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.host).toEqual('192.0.2.21');
|
||||||
|
expect(done.config?.port).toEqual(igloDefaultPort);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps iGlo raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new IgloClient({ name: 'iGlo Runtime', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = IgloMapper.toSnapshotFromRaw({ name: 'iGlo Runtime' }, rawData);
|
||||||
|
const devices = IgloMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = IgloMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('iglo');
|
||||||
|
expect(devices[0].manufacturer).toEqual('iGlo');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'light.kitchen_iglo_light')).toBeTrue();
|
||||||
|
expect(entities[0].attributes?.effect).toEqual('Rainbow');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes iGlo read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new IgloIntegration();
|
||||||
|
const alias = new HomeAssistantIgloIntegration();
|
||||||
|
expect(alias instanceof IgloIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('iglo');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(igloProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(igloProfile.metadata.qualityScale).toEqual('legacy');
|
||||||
|
expect(igloProfile.metadata.requirements).toEqual(['iglo==1.2.7']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'iGlo Runtime', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'iglo', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'iglo', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IIgloSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Kitchen iGlo');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'light', service: 'turn_on', target: { entityId: 'light.kitchen_iglo_light' }, data: { brightness: 128 } });
|
||||||
|
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,83 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantIhcIntegration, IhcClient, IhcConfigFlow, IhcIntegration, IhcMapper, createIhcDiscoveryDescriptor, ihcProfile, type IIhcSnapshot, type TIhcRawData } from '../../ts/integrations/ihc/index.js';
|
||||||
|
|
||||||
|
const rawData: TIhcRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'ihc-controller-123456',
|
||||||
|
name: 'Main IHC Controller',
|
||||||
|
manufacturer: 'Schneider Electric',
|
||||||
|
model: 'IHC Controller',
|
||||||
|
serialNumber: '123456',
|
||||||
|
attributes: {
|
||||||
|
url: 'http://ihc-controller.local',
|
||||||
|
controllerId: '123456',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'motion_1001', name: 'Hall Motion', platform: 'binary_sensor', state: true, deviceClass: 'motion', attributes: { ihcId: 1001 } },
|
||||||
|
{ id: 'light_2001', name: 'Hall Light', platform: 'light', state: true, writable: true, attributes: { ihcId: 2001, brightness: 180 } },
|
||||||
|
{ id: 'temperature_3001', name: 'Living Room Temperature', platform: 'sensor', state: 21.4, unit: 'C', deviceClass: 'temperature', stateClass: 'measurement', attributes: { ihcId: 3001 } },
|
||||||
|
{ id: 'switch_4001', name: 'Pump Switch', platform: 'switch', state: false, writable: true, attributes: { ihcId: 4001 } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual IHC candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createIhcDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'ihc-manual-match');
|
||||||
|
const result = await matcher!.matches({ id: 'ihc-controller-123456', name: 'Main IHC Controller', metadata: { url: 'http://ihc-controller.local', rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual('ihc');
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new IhcConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('ihc-controller-123456');
|
||||||
|
expect(done.config?.metadata?.url).toEqual('http://ihc-controller.local');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps IHC raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new IhcClient({ name: 'IHC Runtime', rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = IhcMapper.toSnapshotFromRaw({ name: 'IHC Runtime' }, rawData);
|
||||||
|
const devices = IhcMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = IhcMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual('ihc');
|
||||||
|
expect(devices[0].manufacturer).toEqual('Schneider Electric');
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'binary_sensor.main_ihc_controller_motion_1001')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'light.main_ihc_controller_light_2001')).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.id === 'switch.main_ihc_controller_switch_4001')).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes IHC read-only runtime, HA alias, and unsupported control', async () => {
|
||||||
|
const integration = new IhcIntegration();
|
||||||
|
const alias = new HomeAssistantIhcIntegration();
|
||||||
|
expect(alias instanceof IhcIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual('ihc');
|
||||||
|
expect(integration.status).toEqual('read-only-runtime');
|
||||||
|
expect(ihcProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(ihcProfile.metadata.qualityScale).toEqual('legacy');
|
||||||
|
expect(ihcProfile.metadata.requirements).toEqual(['defusedxml==0.7.1', 'ihcsdk==2.8.5']);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: 'IHC Runtime', rawData }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'ihc', service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: 'ihc', service: 'refresh', target: {} });
|
||||||
|
const snapshot = status.data as IIhcSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual('Main IHC Controller');
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: 'ihc', service: 'set_runtime_value_bool', target: {}, data: { ihc_id: 2001, value: true } });
|
||||||
|
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,106 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { ImeonInverterClient, ImeonInverterIntegration, imeonInverterProfile, type IImeonInverterSnapshot } from '../../ts/integrations/imeon_inverter/index.js';
|
||||||
|
|
||||||
|
tap.test('logs in, polls live Imeon HTTP endpoints, and maps HA sensor/select keys', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const requests: Array<{ url: string; method: string; cookie?: string; body?: unknown }> = [];
|
||||||
|
globalThis.fetch = (async (inputArg: RequestInfo | URL, initArg?: RequestInit) => {
|
||||||
|
const url = new URL(String(inputArg));
|
||||||
|
const headers = initArg?.headers as Record<string, string> | undefined;
|
||||||
|
requests.push({ url: url.toString(), method: initArg?.method || 'GET', cookie: headers?.cookie, body: initArg?.body });
|
||||||
|
|
||||||
|
if (url.pathname === '/login') {
|
||||||
|
expect(String(initArg?.body)).toContain('email=user%40local');
|
||||||
|
return jsonResponse({ accessGranted: true }, 200, { 'set-cookie': 'session=abc; Path=/' });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/data') {
|
||||||
|
return jsonResponse({ type: 'Imeon 9.12', software: '1.8.1.0', serial: 'IMEON123', max_ac_charging_current: 16, injection_power: 2500, enable_status: { discharge_night: '1', charge_bat_with_grid: '0' } });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/battery') {
|
||||||
|
return imeonResult({ power: 123, soc: 88, status: 'charging', stored: 20, consumed: 10 });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/grid') {
|
||||||
|
expect(url.searchParams.get('threephase')).toEqual('true');
|
||||||
|
return imeonResult({ current_l1: 1, current_l2: 2, current_l3: 3, frequency: 50, voltage_l1: 230, voltage_l2: 231, voltage_l3: 232 });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/pv') {
|
||||||
|
return imeonResult({ consumed: 4, injected: 5, power_1: 100, power_2: 110, power_total: 210 });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/input') {
|
||||||
|
return imeonResult({ power_l1: 1, power_l2: 2, power_l3: 3, power_total: 6 });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/output') {
|
||||||
|
return imeonResult({ current_l1: 1, current_l2: 2, current_l3: 3, frequency: 50, power_l1: 10, power_l2: 20, power_l3: 30, power_total: 60, voltage_l1: 230, voltage_l2: 231, voltage_l3: 232 });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/em') {
|
||||||
|
return imeonResult({ power: 42 });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/temp') {
|
||||||
|
return imeonResult({ air_temperature: 21, component_temperature: 33 });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/timeline') {
|
||||||
|
return imeonResult({ type_msg: 'good_1' });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/energy') {
|
||||||
|
return imeonResult({ pv: 1000, grid_injected: 200, grid_consumed: 300, building_consumption: 400, battery_stored: 500, battery_consumed: 600 });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/forecast') {
|
||||||
|
return imeonResult({ cons_remaining_today: 700, prod_remaining_today: 800 });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/monitor') {
|
||||||
|
return url.searchParams.get('time') === 'minute'
|
||||||
|
? imeonResult({ building_consumption: 111, grid_consumption: 222, grid_injection: 333, grid_power_flow: 444, solar_production: 555 })
|
||||||
|
: imeonResult({ self_consumption: 67, self_sufficiency: 89 });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/manager') {
|
||||||
|
return imeonResult({ inverter_state: 'grid_consumption', inverter_mode: 'smg' });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/smartload') {
|
||||||
|
return imeonResult({ active: false });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/set') {
|
||||||
|
expect(initArg?.body instanceof FormData).toBeTrue();
|
||||||
|
expect((initArg?.body as FormData).get('inverter_mode')).toEqual('bup');
|
||||||
|
return new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||||
|
}
|
||||||
|
return jsonResponse({ message: 'not found' }, 404);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new ImeonInverterClient({ host: 'imeon.local', username: 'user@local', password: 'password' });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const command = await client.execute({ domain: 'select', service: 'select_option', target: {}, data: { option: 'backup' } });
|
||||||
|
const runtime = await new ImeonInverterIntegration().setup({ host: 'imeon.local', username: 'user@local', password: 'password' }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'imeon_inverter', service: 'status', target: {} });
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.source).toEqual('http');
|
||||||
|
expect(snapshot.device.serialNumber).toEqual('IMEON123');
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'battery_power')?.state).toEqual(123);
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'manager_inverter_mode')?.state).toEqual('smart_grid');
|
||||||
|
expect(command.success).toBeTrue();
|
||||||
|
expect((status.data as IImeonInverterSnapshot).online).toBeTrue();
|
||||||
|
expect(requests.some((requestArg) => requestArg.url.includes('/api/battery?time=minute') && requestArg.cookie === 'session=abc')).toBeTrue();
|
||||||
|
expect(requests.some((requestArg) => requestArg.url.includes('/api/set'))).toBeTrue();
|
||||||
|
await runtime.destroy();
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('documents Imeon native HTTP support and safe unsupported cases', async () => {
|
||||||
|
const localApi = imeonInverterProfile.metadata.localApi as { implemented: string[]; explicitUnsupported: string[] };
|
||||||
|
|
||||||
|
expect(localApi.implemented.some((itemArg) => itemArg.includes('POST /login'))).toBeTrue();
|
||||||
|
expect(localApi.implemented.some((itemArg) => itemArg.includes('POST /api/set'))).toBeTrue();
|
||||||
|
expect(localApi.explicitUnsupported.some((itemArg) => itemArg.includes('ETH port access only'))).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
const imeonResult = (valueArg: Record<string, unknown>): Response => jsonResponse({ result: JSON.stringify(valueArg) });
|
||||||
|
|
||||||
|
const jsonResponse = (valueArg: unknown, statusArg = 200, headersArg: Record<string, string> = {}): Response => new Response(JSON.stringify(valueArg), {
|
||||||
|
status: statusArg,
|
||||||
|
headers: { 'content-type': 'application/json', ...headersArg },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { ImeonInverterClient, ImeonInverterConfigFlow, ImeonInverterIntegration, ImeonInverterMapper, createImeonInverterDiscoveryDescriptor, imeonInverterProfile, type IImeonInverterSnapshot, type TImeonInverterRawData } from '../../ts/integrations/imeon_inverter/index.js';
|
||||||
|
|
||||||
|
const rawData: TImeonInverterRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'imeon_inverter-device-1',
|
||||||
|
name: "Imeon Inverter Device",
|
||||||
|
manufacturer: "Imeon Inverter",
|
||||||
|
model: "Imeon Inverter local integration",
|
||||||
|
serialNumber: 'imeon_inverter-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "select", state: true, attributes: { domain: "imeon_inverter" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Imeon Inverter candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createImeonInverterDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'imeon_inverter-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'imeon_inverter-device-1', name: "Imeon Inverter Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("imeon_inverter");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new ImeonInverterConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('imeon_inverter-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Imeon Inverter raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new ImeonInverterClient({ name: "Imeon Inverter Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = ImeonInverterMapper.toSnapshotFromRaw({ name: "Imeon Inverter Runtime" }, rawData);
|
||||||
|
const devices = ImeonInverterMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = ImeonInverterMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("imeon_inverter");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Imeon Inverter");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "imeon_inverter" && entityArg.platform === "select")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Imeon Inverter runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new ImeonInverterIntegration();
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(imeonInverterProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(imeonInverterProfile.metadata.requirements).toEqual([
|
||||||
|
"imeon_inverter_api==0.4.0",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Imeon Inverter Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "imeon_inverter", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "imeon_inverter", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IImeonInverterSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Imeon Inverter Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "imeon_inverter", service: imeonInverterProfile.controlServices?.[0] || 'turn_on', target: {} });
|
||||||
|
expect(command.success).toBeFalse();
|
||||||
|
expect(command.error!).toContain('select_option requires config.host/config.url plus config.username and config.password');
|
||||||
|
await runtime.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { ImmichClient, ImmichIntegration, immichProfile, type IImmichSnapshot } from '../../ts/integrations/immich/index.js';
|
||||||
|
|
||||||
|
tap.test('polls live Immich server data through the local HTTP API', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const requests: Array<{ url: string; method: string; apiKey?: string }> = [];
|
||||||
|
globalThis.fetch = (async (inputArg: RequestInfo | URL, initArg?: RequestInit) => {
|
||||||
|
const url = new URL(String(inputArg));
|
||||||
|
const headers = initArg?.headers as Record<string, string>;
|
||||||
|
requests.push({ url: url.toString(), method: initArg?.method || 'GET', apiKey: headers?.['x-api-key'] });
|
||||||
|
|
||||||
|
if (url.pathname === '/api/users/me') {
|
||||||
|
return jsonResponse({ id: 'user-1', name: 'Ada', email: 'ada@example.test', isAdmin: true });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/server/about') {
|
||||||
|
return jsonResponse({ version: 'v1.134.1', licensed: true, versionUrl: 'https://example.test/version' });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/server/storage') {
|
||||||
|
return jsonResponse({ diskAvailableRaw: 600, diskAvailable: '600 B', diskSizeRaw: 1000, diskSize: '1000 B', diskUsagePercentage: 40, diskUseRaw: 400, diskUse: '400 B' });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/server/statistics') {
|
||||||
|
return jsonResponse({ photos: 12, videos: 3, usage: 400, usagePhotos: 250, usageVideos: 150, usageByUser: [] });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/server/version-check') {
|
||||||
|
return jsonResponse({ checkedAt: '2026-05-11T00:00:00.000Z', releaseVersion: 'v1.135.0' });
|
||||||
|
}
|
||||||
|
return jsonResponse({ message: 'not found' }, 404);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new ImmichClient({ url: 'http://immich.local:2283', apiKey: 'secret' });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const runtime = await new ImmichIntegration().setup({ url: 'http://immich.local:2283', apiKey: 'secret' }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'immich', service: 'status', target: {} });
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.source).toEqual('http');
|
||||||
|
expect(snapshot.device.name).toEqual('Ada');
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'disk_size')?.state).toEqual(1000);
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'photos_count')?.state).toEqual(12);
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'update')?.attributes?.latestVersion).toEqual('v1.135.0');
|
||||||
|
expect((status.data as IImmichSnapshot).online).toBeTrue();
|
||||||
|
expect(requests.every((requestArg) => requestArg.apiKey === 'secret')).toBeTrue();
|
||||||
|
await runtime.destroy();
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('uploads a local file to Immich and adds it to an album', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'immich-client-'));
|
||||||
|
const filePath = path.join(tempDir, 'image.jpg');
|
||||||
|
await fs.writeFile(filePath, Buffer.from([1, 2, 3]));
|
||||||
|
const requests: Array<{ path: string; method: string; body?: unknown }> = [];
|
||||||
|
|
||||||
|
globalThis.fetch = (async (inputArg: RequestInfo | URL, initArg?: RequestInit) => {
|
||||||
|
const url = new URL(String(inputArg));
|
||||||
|
requests.push({ path: url.pathname, method: initArg?.method || 'GET', body: initArg?.body });
|
||||||
|
if (url.pathname === '/api/albums/album-1') {
|
||||||
|
expect(url.searchParams.get('withoutAssets')).toEqual('true');
|
||||||
|
return jsonResponse({ id: 'album-1', albumName: 'Uploads' });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/assets') {
|
||||||
|
expect(initArg?.body instanceof FormData).toBeTrue();
|
||||||
|
expect((initArg?.body as FormData).get('assetData')).toBeTruthy();
|
||||||
|
return jsonResponse({ id: 'asset-1', status: 'created' });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/albums/album-1/assets') {
|
||||||
|
expect(String(initArg?.body)).toEqual(JSON.stringify({ ids: ['asset-1'] }));
|
||||||
|
return jsonResponse([{ id: 'asset-1', success: true }]);
|
||||||
|
}
|
||||||
|
return jsonResponse({ message: 'not found' }, 404);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new ImmichClient({ host: '127.0.0.1', port: 2283, apiKey: 'secret' });
|
||||||
|
const result = await client.execute({ domain: 'immich', service: 'upload_file', target: {}, data: { path: filePath, album_id: 'album-1' } });
|
||||||
|
|
||||||
|
expect(result.success).toBeTrue();
|
||||||
|
expect(requests.map((requestArg) => `${requestArg.method} ${requestArg.path}`)).toEqual(['GET /api/albums/album-1', 'POST /api/assets', 'PUT /api/albums/album-1/assets']);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('documents Immich local API support and unsupported TLS bypass', async () => {
|
||||||
|
const localApi = immichProfile.metadata.localApi as { implemented: string[]; explicitUnsupported: string[] };
|
||||||
|
|
||||||
|
expect(localApi.implemented.some((itemArg) => itemArg.includes('/api/server/about'))).toBeTrue();
|
||||||
|
expect(localApi.explicitUnsupported.some((itemArg) => itemArg.includes('TLS certificate verification'))).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
const jsonResponse = (valueArg: unknown, statusArg = 200): Response => new Response(JSON.stringify(valueArg), {
|
||||||
|
status: statusArg,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { ImmichClient, ImmichConfigFlow, ImmichIntegration, ImmichMapper, createImmichDiscoveryDescriptor, immichProfile, type IImmichSnapshot, type TImmichRawData } from '../../ts/integrations/immich/index.js';
|
||||||
|
|
||||||
|
const rawData: TImmichRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'immich-device-1',
|
||||||
|
name: "Immich Device",
|
||||||
|
manufacturer: "Immich",
|
||||||
|
model: "Immich local integration",
|
||||||
|
serialNumber: 'immich-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "immich" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Immich candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createImmichDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'immich-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'immich-device-1', name: "Immich Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("immich");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new ImmichConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('immich-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Immich raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new ImmichClient({ name: "Immich Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = ImmichMapper.toSnapshotFromRaw({ name: "Immich Runtime" }, rawData);
|
||||||
|
const devices = ImmichMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = ImmichMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("immich");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Immich");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "immich" && entityArg.platform === "sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Immich runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new ImmichIntegration();
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(immichProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(immichProfile.metadata.requirements).toEqual([
|
||||||
|
"aioimmich==0.14.0",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Immich Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "immich", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "immich", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IImmichSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Immich Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "immich", service: immichProfile.controlServices?.[0] || 'turn_on', target: {} });
|
||||||
|
expect(command.success).toBeFalse();
|
||||||
|
expect(command.error!).toContain('upload_file requires config.url or config.host plus config.apiKey/config.token');
|
||||||
|
await runtime.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { ImprovBleClient, improvBleProfile } from '../../ts/integrations/improv_ble/index.js';
|
||||||
|
|
||||||
|
tap.test('keeps Improv BLE to safe snapshot inputs and documents the BLE stack blocker', async () => {
|
||||||
|
const localApi = improvBleProfile.metadata.localApi as { implemented: string[]; explicitUnsupported: string[] };
|
||||||
|
const client = new ImprovBleClient({ rawData: { device: { name: 'Improv Fixture' }, entities: [{ id: 'state', name: 'State', state: 'authorized' }] } });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.source).toEqual('manual');
|
||||||
|
expect(snapshot.entities[0].state).toEqual('authorized');
|
||||||
|
expect(improvBleProfile.discoverySources).toContain('bluetooth');
|
||||||
|
expect(localApi.implemented.some((itemArg) => itemArg.includes('safe manual snapshots'))).toBeTrue();
|
||||||
|
expect(localApi.explicitUnsupported.some((itemArg) => itemArg.includes('py-improv-ble-client') && itemArg.includes('BLE stack'))).toBeTrue();
|
||||||
|
expect(localApi.explicitUnsupported.some((itemArg) => itemArg.includes('Wi-Fi credential pairing'))).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { IncomfortClient, IncomfortConfigFlow, IncomfortIntegration, IncomfortMapper, createIncomfortDiscoveryDescriptor, incomfortProfile, type IIncomfortSnapshot, type TIncomfortRawData } from '../../ts/integrations/incomfort/index.js';
|
||||||
|
|
||||||
|
const json = (responseArg: ServerResponse, valueArg: unknown, statusArg = 200): void => {
|
||||||
|
responseArg.statusCode = statusArg;
|
||||||
|
responseArg.setHeader('content-type', 'application/json');
|
||||||
|
responseArg.end(JSON.stringify(valueArg));
|
||||||
|
};
|
||||||
|
|
||||||
|
const heaterPayload = {
|
||||||
|
displ_code: 126,
|
||||||
|
IO: 0x02,
|
||||||
|
ch_temp_msb: 12,
|
||||||
|
ch_temp_lsb: 48,
|
||||||
|
tap_temp_msb: 11,
|
||||||
|
tap_temp_lsb: 27,
|
||||||
|
ch_pressure_msb: 0,
|
||||||
|
ch_pressure_lsb: 123,
|
||||||
|
room_temp_1_msb: 8,
|
||||||
|
room_temp_1_lsb: 64,
|
||||||
|
room_temp_set_1_msb: 7,
|
||||||
|
room_temp_set_1_lsb: 158,
|
||||||
|
room_set_ovr_1_msb: 7,
|
||||||
|
room_set_ovr_1_lsb: 208,
|
||||||
|
room_temp_2_msb: 127,
|
||||||
|
room_temp_2_lsb: 255,
|
||||||
|
rf_message_rssi: 38,
|
||||||
|
rfstatus_cntr: 0,
|
||||||
|
nodenr: 200,
|
||||||
|
};
|
||||||
|
|
||||||
|
const startIncomfortServer = async (): Promise<{ url: string; setpointCalls: string[]; close(): Promise<void> }> => {
|
||||||
|
const setpointCalls: string[] = [];
|
||||||
|
const server = createServer((requestArg: IncomingMessage, responseArg: ServerResponse) => {
|
||||||
|
const url = new URL(requestArg.url || '/', 'http://127.0.0.1');
|
||||||
|
|
||||||
|
if (url.pathname === '/heaterlist.json') {
|
||||||
|
json(responseArg, { heaterlist: ['175t23072', '000W00000', null] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/data.json' && url.searchParams.get('heater') === '0') {
|
||||||
|
if (url.searchParams.has('setpoint')) {
|
||||||
|
setpointCalls.push(url.search);
|
||||||
|
}
|
||||||
|
json(responseArg, heaterPayload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
json(responseArg, {}, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const address = server.address();
|
||||||
|
const port = typeof address === 'object' && address ? address.port : 0;
|
||||||
|
return {
|
||||||
|
url: `http://127.0.0.1:${port}`,
|
||||||
|
setpointCalls,
|
||||||
|
close: async () => new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawData: TIncomfortRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'incomfort-device-1',
|
||||||
|
name: "Intergas gateway Device",
|
||||||
|
manufacturer: "Intergas gateway",
|
||||||
|
model: "Intergas gateway local integration",
|
||||||
|
serialNumber: 'incomfort-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "incomfort" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Intergas gateway candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createIncomfortDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'incomfort-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'incomfort-device-1', name: "Intergas gateway Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("incomfort");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new IncomfortConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('incomfort-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Intergas gateway raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new IncomfortClient({ name: "Intergas gateway Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = IncomfortMapper.toSnapshotFromRaw({ name: "Intergas gateway Runtime" }, rawData);
|
||||||
|
const devices = IncomfortMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = IncomfortMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("incomfort");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Intergas gateway");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "incomfort" && entityArg.platform === "binary_sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reads InComfort gateway snapshots and writes thermostat setpoints over local HTTP', async () => {
|
||||||
|
const server = await startIncomfortServer();
|
||||||
|
try {
|
||||||
|
const endpoint = new URL(server.url);
|
||||||
|
const client = new IncomfortClient({ host: endpoint.hostname, port: Number(endpoint.port), timeoutMs: 1000 });
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
const entities = IncomfortMapper.toEntities(snapshot);
|
||||||
|
const climate = entities.find((entityArg) => entityArg.platform === 'climate');
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.source).toEqual('http');
|
||||||
|
expect(snapshot.device.manufacturer).toEqual('Intergas');
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'sensor.intergas_gateway_heater_0_cv_pressure')?.state).toEqual(1.23);
|
||||||
|
expect(climate?.attributes?.heaterIndex).toEqual(0);
|
||||||
|
expect(climate?.attributes?.roomNumber).toEqual(1);
|
||||||
|
|
||||||
|
const runtime = await new IncomfortIntegration().setup({ host: endpoint.hostname, port: Number(endpoint.port), timeoutMs: 1000 }, {});
|
||||||
|
const result = await runtime.callService?.({ domain: 'climate', service: 'set_temperature', target: { entityId: climate?.id }, data: { temperature: 19.5 } });
|
||||||
|
|
||||||
|
expect(result?.success).toBeTrue();
|
||||||
|
expect(server.setpointCalls[0]).toContain('heater=0');
|
||||||
|
expect(server.setpointCalls[0]).toContain('thermostat=0');
|
||||||
|
expect(server.setpointCalls[0]).toContain('setpoint=145');
|
||||||
|
await runtime.destroy();
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Intergas gateway runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new IncomfortIntegration();
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(incomfortProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(incomfortProfile.metadata.requirements).toEqual([
|
||||||
|
"incomfort-client==0.7.0",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Intergas gateway Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "incomfort", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "incomfort", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IIncomfortSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Intergas gateway Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "incomfort", service: incomfortProfile.controlServices?.[0] || '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,125 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { IndevoltClient, IndevoltConfigFlow, IndevoltIntegration, IndevoltMapper, createIndevoltDiscoveryDescriptor, indevoltProfile, type IIndevoltSnapshot, type TIndevoltRawData } from '../../ts/integrations/indevolt/index.js';
|
||||||
|
|
||||||
|
const liveSensorKeys = ['7101', '2101', '6002', '2618', '6105'];
|
||||||
|
|
||||||
|
const rawData: TIndevoltRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'indevolt-device-1',
|
||||||
|
name: "Indevolt Device",
|
||||||
|
manufacturer: "Indevolt",
|
||||||
|
model: "Indevolt local integration",
|
||||||
|
serialNumber: 'indevolt-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "indevolt" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Indevolt candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createIndevoltDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'indevolt-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'indevolt-device-1', name: "Indevolt Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("indevolt");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new IndevoltConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('indevolt-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Indevolt raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new IndevoltClient({ name: "Indevolt Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = IndevoltMapper.toSnapshotFromRaw({ name: "Indevolt Runtime" }, rawData);
|
||||||
|
const devices = IndevoltMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = IndevoltMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("indevolt");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Indevolt");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "indevolt" && entityArg.platform === "binary_sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reads Indevolt local HTTP RPC snapshots and executes realtime charge commands', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const calls: Array<{ url: string; method?: string; config?: Record<string, unknown> }> = [];
|
||||||
|
globalThis.fetch = (async (urlArg: URL | RequestInfo, initArg?: RequestInit) => {
|
||||||
|
const url = new URL(String(urlArg));
|
||||||
|
const config = url.searchParams.get('config') ? JSON.parse(url.searchParams.get('config')!) as Record<string, unknown> : undefined;
|
||||||
|
calls.push({ url: String(urlArg), method: initArg?.method, config });
|
||||||
|
|
||||||
|
if (url.pathname === '/rpc/Sys.GetConfig') {
|
||||||
|
return new Response(JSON.stringify({ device: { sn: 'INVOLT123', type: 'CMS-SP2000', fw: '1.2.3' } }), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/rpc/Indevolt.GetData') {
|
||||||
|
expect(config?.t).toEqual(liveSensorKeys.map((keyArg) => Number(keyArg)));
|
||||||
|
return new Response(JSON.stringify({ '7101': 1, '2101': 500, '6002': 74, '2618': 1000, '6105': 20 }), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/rpc/Indevolt.SetData') {
|
||||||
|
return new Response(JSON.stringify({ result: true }), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('{}', { status: 404, headers: { 'content-type': 'application/json' } });
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = { host: '192.0.2.10', port: 8080, sensorKeys: liveSensorKeys, timeoutMs: 1000 };
|
||||||
|
const client = new IndevoltClient(config);
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
const entities = IndevoltMapper.toEntities(snapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.source).toEqual('http');
|
||||||
|
expect(snapshot.device.serialNumber).toEqual('INVOLT123');
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'select.indevolt_cms_sp2000_energy_mode')?.state).toEqual('self_consumed_prioritized');
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'switch.indevolt_cms_sp2000_grid_charging')?.state).toEqual(false);
|
||||||
|
|
||||||
|
const runtime = await new IndevoltIntegration().setup(config, {});
|
||||||
|
await runtime.entities();
|
||||||
|
const result = await runtime.callService?.({ domain: 'indevolt', service: 'charge', target: {}, data: { power: 700, target_soc: 80 } });
|
||||||
|
const setDataCalls = calls.filter((callArg) => new URL(callArg.url).pathname === '/rpc/Indevolt.SetData');
|
||||||
|
|
||||||
|
expect(result?.success).toBeTrue();
|
||||||
|
expect(setDataCalls[0].config).toEqual({ f: 16, t: 47005, v: [4] });
|
||||||
|
expect(setDataCalls[1].config).toEqual({ f: 16, t: 47015, v: [1, 700, 80] });
|
||||||
|
await runtime.destroy();
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Indevolt runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new IndevoltIntegration();
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(indevoltProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(indevoltProfile.metadata.requirements).toEqual([
|
||||||
|
"indevolt-api==1.7.1",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Indevolt Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "indevolt", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "indevolt", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IIndevoltSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Indevolt Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "indevolt", service: indevoltProfile.controlServices?.[0] || '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,77 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { InelsClient, InelsConfigFlow, InelsIntegration, InelsMapper, createInelsDiscoveryDescriptor, inelsProfile, type IInelsSnapshot, type TInelsRawData } from '../../ts/integrations/inels/index.js';
|
||||||
|
|
||||||
|
const rawData: TInelsRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'inels-device-1',
|
||||||
|
name: "iNELS Device",
|
||||||
|
manufacturer: "iNELS",
|
||||||
|
model: "iNELS local integration",
|
||||||
|
serialNumber: 'inels-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "switch", state: true, attributes: { domain: "inels" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual iNELS candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createInelsDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'inels-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'inels-device-1', name: "iNELS Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("inels");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new InelsConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('inels-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps iNELS raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new InelsClient({ name: "iNELS Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = InelsMapper.toSnapshotFromRaw({ name: "iNELS Runtime" }, rawData);
|
||||||
|
const devices = InelsMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = InelsMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("inels");
|
||||||
|
expect(devices[0].manufacturer).toEqual("iNELS");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "inels" && entityArg.platform === "switch")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes iNELS runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new InelsIntegration();
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(inelsProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(inelsProfile.metadata.requirements).toEqual([
|
||||||
|
"elkoep-aio-mqtt==0.1.0b4",
|
||||||
|
]);
|
||||||
|
expect((inelsProfile.metadata.localApi as Record<string, unknown>).status).toContain('MQTT broker semantics');
|
||||||
|
expect(((inelsProfile.metadata.localApi as Record<string, unknown>).explicitUnsupported as string[])[0]).toContain('elkoep-aio-mqtt/inelsmqtt');
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "iNELS Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "inels", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "inels", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IInelsSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("iNELS Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "inels", service: inelsProfile.controlServices?.[0] || '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,178 @@
|
|||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantInfluxdbIntegration, InfluxdbClient, InfluxdbConfigFlow, InfluxdbIntegration, InfluxdbMapper, createInfluxdbDiscoveryDescriptor, influxdbProfile, type IInfluxdbSnapshot, type TInfluxdbRawData } from '../../ts/integrations/influxdb/index.js';
|
||||||
|
|
||||||
|
const rawData: TInfluxdbRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'influxdb-device-1',
|
||||||
|
name: "InfluxDB Device",
|
||||||
|
manufacturer: "InfluxDB",
|
||||||
|
model: "InfluxDB local integration",
|
||||||
|
serialNumber: 'influxdb-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "influxdb" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual InfluxDB candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createInfluxdbDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'influxdb-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'influxdb-device-1', name: "InfluxDB Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("influxdb");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new InfluxdbConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('influxdb-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps InfluxDB raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new InfluxdbClient({ name: "InfluxDB Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = InfluxdbMapper.toSnapshotFromRaw({ name: "InfluxDB Runtime" }, rawData);
|
||||||
|
const devices = InfluxdbMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = InfluxdbMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("influxdb");
|
||||||
|
expect(devices[0].manufacturer).toEqual("InfluxDB");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "influxdb" && entityArg.platform === "sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes InfluxDB runtime, HA alias, and unsupported control without executor', async () => {
|
||||||
|
const integration = new InfluxdbIntegration();
|
||||||
|
const alias = new HomeAssistantInfluxdbIntegration();
|
||||||
|
expect(alias instanceof InfluxdbIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual("influxdb");
|
||||||
|
expect(integration.status).toEqual("read-only-runtime");
|
||||||
|
expect(influxdbProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(influxdbProfile.metadata.requirements).toEqual([
|
||||||
|
"influxdb==5.3.1",
|
||||||
|
"influxdb-client==1.50.0",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "InfluxDB Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "influxdb", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "influxdb", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IInfluxdbSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("InfluxDB Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "influxdb", service: influxdbProfile.controlServices?.[0] || 'turn_on', target: {} });
|
||||||
|
expect(command.success).toBeFalse();
|
||||||
|
expect(command.error!).toContain('requires an injected client.execute() or commandExecutor');
|
||||||
|
await runtime.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reads InfluxDB v1 repositories and query sensors over local HTTP', async () => {
|
||||||
|
const requests: Array<{ url: string; authorization?: string }> = [];
|
||||||
|
const server = createServer((requestArg: IncomingMessage, responseArg: ServerResponse) => {
|
||||||
|
const url = new URL(requestArg.url || '/', 'http://127.0.0.1');
|
||||||
|
requests.push({ url: requestArg.url || '', authorization: requestArg.headers.authorization });
|
||||||
|
responseArg.setHeader('content-type', 'application/json');
|
||||||
|
|
||||||
|
if (url.pathname === '/query' && url.searchParams.get('q') === 'SHOW DATABASES;') {
|
||||||
|
responseArg.end(JSON.stringify({ results: [{ series: [{ name: 'databases', columns: ['name'], values: [['home_assistant'], ['_internal']] }] }] }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/query' && url.searchParams.get('db') === 'home_assistant' && (url.searchParams.get('q') || '').startsWith('select mean(value)')) {
|
||||||
|
responseArg.end(JSON.stringify({ results: [{ series: [{ name: 'temperature', columns: ['time', 'value'], values: [['2026-01-01T00:00:00Z', 21.5]] }] }] }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
responseArg.statusCode = 404;
|
||||||
|
responseArg.end(JSON.stringify({ error: 'not found' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
const port = typeof address === 'object' && address ? address.port : 0;
|
||||||
|
const config = {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
apiVersion: '1' as const,
|
||||||
|
database: 'home_assistant',
|
||||||
|
username: 'ha',
|
||||||
|
password: 'secret',
|
||||||
|
timeoutMs: 1000,
|
||||||
|
name: 'Local InfluxDB',
|
||||||
|
queries: [{ name: 'Mean Temperature', measurement: 'temperature', field: 'value', where: 'time > now() - 15m', unitOfMeasurement: 'C' }],
|
||||||
|
};
|
||||||
|
const snapshot = await new InfluxdbClient(config).getSnapshot(true);
|
||||||
|
const entities = InfluxdbMapper.toEntities(snapshot);
|
||||||
|
|
||||||
|
expect(requests[0].authorization).toEqual(`Basic ${Buffer.from('ha:secret').toString('base64')}`);
|
||||||
|
expect(requests.some((requestArg) => requestArg.url.includes('SHOW+DATABASES'))).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.source).toEqual('http');
|
||||||
|
expect((snapshot.rawData as { repositories: Array<{ name: string }> }).repositories.map((repositoryArg) => repositoryArg.name)).toEqual(['home_assistant', '_internal']);
|
||||||
|
expect(entities.find((entityArg) => entityArg.id === 'sensor.local_influxdb_mean_temperature')?.state).toEqual(21.5);
|
||||||
|
|
||||||
|
const runtime = await new InfluxdbIntegration().setup(config, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'influxdb', service: 'status', target: {} });
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect((status.data as IInfluxdbSnapshot).source).toEqual('http');
|
||||||
|
await runtime.destroy();
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('writes InfluxDB v2 line protocol over local HTTP', async () => {
|
||||||
|
const bodies: string[] = [];
|
||||||
|
const requests: string[] = [];
|
||||||
|
const server = createServer((requestArg: IncomingMessage, responseArg: ServerResponse) => {
|
||||||
|
const url = new URL(requestArg.url || '/', 'http://127.0.0.1');
|
||||||
|
requests.push(`${requestArg.method} ${url.pathname}?${url.searchParams.toString()}`);
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
requestArg.on('data', (chunkArg) => chunks.push(Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg)));
|
||||||
|
requestArg.on('end', () => {
|
||||||
|
bodies.push(Buffer.concat(chunks).toString('utf8'));
|
||||||
|
expect(requestArg.headers.authorization).toEqual('Token token-123');
|
||||||
|
if (requestArg.method === 'POST' && url.pathname === '/api/v2/write') {
|
||||||
|
expect(url.searchParams.get('org')).toEqual('home');
|
||||||
|
expect(url.searchParams.get('bucket')).toEqual('Home Assistant');
|
||||||
|
responseArg.statusCode = 204;
|
||||||
|
responseArg.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
responseArg.statusCode = 404;
|
||||||
|
responseArg.end('not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
const port = typeof address === 'object' && address ? address.port : 0;
|
||||||
|
const result = await new InfluxdbClient({ host: '127.0.0.1', port, apiVersion: '2', token: 'token-123', organization: 'home', bucket: 'Home Assistant', timeoutMs: 1000 }).write({
|
||||||
|
measurement: 'temperature',
|
||||||
|
tags: { entity_id: 'sensor.kitchen' },
|
||||||
|
fields: { value: 21.5, state: '21.5' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBeTrue();
|
||||||
|
expect(result.repository).toEqual('Home Assistant');
|
||||||
|
expect(requests).toEqual(['POST /api/v2/write?org=home&bucket=Home+Assistant']);
|
||||||
|
expect(bodies[0]).toEqual('temperature,entity_id=sensor.kitchen value=21.5,state="21.5"');
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantInkbirdIntegration, InkbirdClient, InkbirdConfigFlow, InkbirdIntegration, InkbirdMapper, createInkbirdDiscoveryDescriptor, inkbirdProfile, type IInkbirdSnapshot, type TInkbirdRawData } from '../../ts/integrations/inkbird/index.js';
|
||||||
|
|
||||||
|
const rawData: TInkbirdRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'inkbird-device-1',
|
||||||
|
name: "INKBIRD Device",
|
||||||
|
manufacturer: "INKBIRD",
|
||||||
|
model: "INKBIRD local integration",
|
||||||
|
serialNumber: 'inkbird-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "inkbird" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual INKBIRD candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createInkbirdDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'inkbird-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'inkbird-device-1', name: "INKBIRD Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("inkbird");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new InkbirdConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('inkbird-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps INKBIRD raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new InkbirdClient({ name: "INKBIRD Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = InkbirdMapper.toSnapshotFromRaw({ name: "INKBIRD Runtime" }, rawData);
|
||||||
|
const devices = InkbirdMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = InkbirdMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("inkbird");
|
||||||
|
expect(devices[0].manufacturer).toEqual("INKBIRD");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "inkbird" && entityArg.platform === "sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes INKBIRD runtime, HA alias, and unsupported control without executor', async () => {
|
||||||
|
const integration = new InkbirdIntegration();
|
||||||
|
const alias = new HomeAssistantInkbirdIntegration();
|
||||||
|
expect(alias instanceof InkbirdIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual("inkbird");
|
||||||
|
expect(integration.status).toEqual("read-only-runtime");
|
||||||
|
expect(inkbirdProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(inkbirdProfile.metadata.requirements).toEqual([
|
||||||
|
"inkbird-ble==1.1.1",
|
||||||
|
]);
|
||||||
|
expect(((inkbirdProfile.metadata.localApi as { explicitUnsupported: string[] }).explicitUnsupported).join(' ')).toContain('BLE stack');
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "INKBIRD Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "inkbird", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "inkbird", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IInkbirdSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("INKBIRD Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "inkbird", service: inkbirdProfile.controlServices?.[0] || '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,81 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantInsteonIntegration, InsteonClient, InsteonConfigFlow, InsteonIntegration, InsteonMapper, createInsteonDiscoveryDescriptor, insteonProfile, type IInsteonSnapshot, type TInsteonRawData } from '../../ts/integrations/insteon/index.js';
|
||||||
|
|
||||||
|
const rawData: TInsteonRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'insteon-device-1',
|
||||||
|
name: "Insteon Device",
|
||||||
|
manufacturer: "Insteon",
|
||||||
|
model: "Insteon local integration",
|
||||||
|
serialNumber: 'insteon-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "insteon" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Insteon candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createInsteonDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'insteon-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'insteon-device-1', name: "Insteon Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("insteon");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new InsteonConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('insteon-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Insteon raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new InsteonClient({ name: "Insteon Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = InsteonMapper.toSnapshotFromRaw({ name: "Insteon Runtime" }, rawData);
|
||||||
|
const devices = InsteonMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = InsteonMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("insteon");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Insteon");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "insteon" && entityArg.platform === "binary_sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Insteon runtime, HA alias, and unsupported control without executor', async () => {
|
||||||
|
const integration = new InsteonIntegration();
|
||||||
|
const alias = new HomeAssistantInsteonIntegration();
|
||||||
|
expect(alias instanceof InsteonIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual("insteon");
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(insteonProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(insteonProfile.metadata.requirements).toEqual([
|
||||||
|
"pyinsteon==1.6.4",
|
||||||
|
"insteon-frontend-home-assistant==0.6.2",
|
||||||
|
]);
|
||||||
|
expect(((insteonProfile.metadata.localApi as { explicitUnsupported: string[] }).explicitUnsupported).join(' ')).toContain('pyinsteon');
|
||||||
|
expect(((insteonProfile.metadata.localApi as { explicitUnsupported: string[] }).explicitUnsupported).join(' ')).toContain('binary framing');
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Insteon Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "insteon", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "insteon", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IInsteonSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Insteon Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "insteon", service: insteonProfile.controlServices?.[0] || '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,175 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { IntellifireClient, IntellifireConfigFlow, IntellifireIntegration, IntellifireMapper, createIntellifireDiscoveryDescriptor, intellifireProfile, type IIntellifireSnapshot, type TIntellifireRawData } from '../../ts/integrations/intellifire/index.js';
|
||||||
|
|
||||||
|
const json = (responseArg: ServerResponse, valueArg: unknown, statusArg = 200): void => {
|
||||||
|
responseArg.statusCode = statusArg;
|
||||||
|
responseArg.setHeader('content-type', 'application/json');
|
||||||
|
responseArg.end(JSON.stringify(valueArg));
|
||||||
|
};
|
||||||
|
|
||||||
|
const readBody = async (requestArg: IncomingMessage): Promise<string> => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of requestArg) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks).toString('utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
const intellifireResponse = (apiKeyArg: string, challengeArg: string, commandArg: string, valueArg: number): string => {
|
||||||
|
const apiBytes = Buffer.from(apiKeyArg, 'hex');
|
||||||
|
const challengeBytes = Buffer.from(challengeArg, 'hex');
|
||||||
|
const payloadBytes = Buffer.from(`post:command=${commandArg}&value=${valueArg}`, 'utf8');
|
||||||
|
const inner = createHash('sha256').update(Buffer.concat([apiBytes, challengeBytes, payloadBytes])).digest();
|
||||||
|
return createHash('sha256').update(Buffer.concat([apiBytes, inner])).digest('hex');
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawData: TIntellifireRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'intellifire-device-1',
|
||||||
|
name: "IntelliFire Device",
|
||||||
|
manufacturer: "IntelliFire",
|
||||||
|
model: "IntelliFire local integration",
|
||||||
|
serialNumber: 'intellifire-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "intellifire" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual IntelliFire candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createIntellifireDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'intellifire-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'intellifire-device-1', name: "IntelliFire Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("intellifire");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new IntellifireConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('intellifire-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps IntelliFire raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new IntellifireClient({ name: "IntelliFire Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = IntellifireMapper.toSnapshotFromRaw({ name: "IntelliFire Runtime" }, rawData);
|
||||||
|
const devices = IntellifireMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = IntellifireMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("intellifire");
|
||||||
|
expect(devices[0].manufacturer).toEqual("IntelliFire");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "intellifire" && entityArg.platform === "binary_sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reads IntelliFire /poll over local HTTP and signs local /post controls', async () => {
|
||||||
|
const apiKey = '00112233445566778899aabbccddeeff';
|
||||||
|
const challenge = 'abcdef0123456789';
|
||||||
|
const bodies: string[] = [];
|
||||||
|
const server = createServer((requestArg: IncomingMessage, responseArg: ServerResponse) => {
|
||||||
|
void (async () => {
|
||||||
|
const url = new URL(requestArg.url || '/', 'http://127.0.0.1');
|
||||||
|
if (url.pathname === '/poll') {
|
||||||
|
json(responseArg, {
|
||||||
|
serial: 'IFT123456',
|
||||||
|
name: 'Living Room Fireplace',
|
||||||
|
power: 1,
|
||||||
|
pilot: 1,
|
||||||
|
timer: 0,
|
||||||
|
thermostat: 1,
|
||||||
|
temperature: 22,
|
||||||
|
setpoint: 2100,
|
||||||
|
height: 3,
|
||||||
|
fanspeed: 2,
|
||||||
|
light: 2,
|
||||||
|
feature_fan: 1,
|
||||||
|
feature_light: 1,
|
||||||
|
feature_thermostat: 1,
|
||||||
|
ipv4_address: '127.0.0.1',
|
||||||
|
errors: [6],
|
||||||
|
firmware_version: '0x01000000',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/get_challenge') {
|
||||||
|
responseArg.statusCode = 200;
|
||||||
|
responseArg.setHeader('content-type', 'text/plain');
|
||||||
|
responseArg.end(challenge);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/post' && requestArg.method === 'POST') {
|
||||||
|
const body = await readBody(requestArg);
|
||||||
|
bodies.push(body);
|
||||||
|
const params = new URLSearchParams(body);
|
||||||
|
expect(params.get('command')).toEqual('power');
|
||||||
|
expect(params.get('value')).toEqual('0');
|
||||||
|
expect(params.get('user')).toEqual('user-123');
|
||||||
|
expect(params.get('response')).toEqual(intellifireResponse(apiKey, challenge, 'power', 0));
|
||||||
|
json(responseArg, { ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
json(responseArg, { error: 'not_found' }, 404);
|
||||||
|
})().catch((errorArg) => {
|
||||||
|
responseArg.statusCode = 500;
|
||||||
|
responseArg.end(String(errorArg));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
const port = typeof address === 'object' && address ? address.port : 0;
|
||||||
|
const url = `http://127.0.0.1:${port}`;
|
||||||
|
const client = new IntellifireClient({ url, userId: 'user-123', apiKey, timeoutMs: 1000 });
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
const command = await client.execute({ domain: 'switch', service: 'turn_off', target: { entityId: 'switch.living_room_fireplace_flame' } });
|
||||||
|
const runtime = await new IntellifireIntegration().setup({ url, timeoutMs: 1000 }, {});
|
||||||
|
const entities = await runtime.entities();
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.source).toEqual('http');
|
||||||
|
expect(snapshot.device.serialNumber).toEqual('IFT123456');
|
||||||
|
expect(entities.some((entityArg) => entityArg.attributes?.key === 'fanspeed' && entityArg.platform === 'fan')).toBeTrue();
|
||||||
|
expect(command.success).toBeTrue();
|
||||||
|
expect(bodies.length).toEqual(1);
|
||||||
|
await runtime.destroy();
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes IntelliFire runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new IntellifireIntegration();
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(intellifireProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(intellifireProfile.metadata.requirements).toEqual([
|
||||||
|
"intellifire4py==4.4.0",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "IntelliFire Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "intellifire", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "intellifire", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IIntellifireSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeFalse();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("IntelliFire Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "switch", service: 'turn_on', target: { entityId: 'switch.intellifire_runtime_flame' } });
|
||||||
|
expect(command.success).toBeFalse();
|
||||||
|
expect(command.error!).toContain('requires config.host or config.url');
|
||||||
|
await runtime.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { IometerClient, IometerConfigFlow, IometerIntegration, IometerMapper, createIometerDiscoveryDescriptor, iometerProfile, type IIometerSnapshot, type TIometerRawData } from '../../ts/integrations/iometer/index.js';
|
||||||
|
|
||||||
|
const json = (responseArg: ServerResponse, valueArg: unknown, statusArg = 200): void => {
|
||||||
|
responseArg.statusCode = statusArg;
|
||||||
|
responseArg.setHeader('content-type', 'application/json');
|
||||||
|
responseArg.end(JSON.stringify(valueArg));
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawData: TIometerRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'iometer-device-1',
|
||||||
|
name: "IOmeter Device",
|
||||||
|
manufacturer: "IOmeter",
|
||||||
|
model: "IOmeter local integration",
|
||||||
|
serialNumber: 'iometer-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "iometer" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual IOmeter candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createIometerDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'iometer-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'iometer-device-1', name: "IOmeter Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("iometer");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new IometerConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('iometer-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps IOmeter raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new IometerClient({ name: "IOmeter Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = IometerMapper.toSnapshotFromRaw({ name: "IOmeter Runtime" }, rawData);
|
||||||
|
const devices = IometerMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = IometerMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("iometer");
|
||||||
|
expect(devices[0].manufacturer).toEqual("IOmeter");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "iometer" && entityArg.platform === "binary_sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reads IOmeter /v1/reading and /v1/status over local HTTP', async () => {
|
||||||
|
const requests: Array<{ url?: string; userAgent?: string | string[] }> = [];
|
||||||
|
const server = createServer((requestArg: IncomingMessage, responseArg: ServerResponse) => {
|
||||||
|
const url = new URL(requestArg.url || '/', 'http://127.0.0.1');
|
||||||
|
requests.push({ url: url.pathname, userAgent: requestArg.headers['user-agent'] });
|
||||||
|
if (url.pathname === '/v1/reading') {
|
||||||
|
json(responseArg, {
|
||||||
|
meter: {
|
||||||
|
number: 'METER123',
|
||||||
|
reading: {
|
||||||
|
time: '2026-01-01T00:00:00Z',
|
||||||
|
registers: [
|
||||||
|
{ obis: '01-00:01.08.00*ff', value: 12345, unit: 'Wh' },
|
||||||
|
{ obis: '01-00:02.08.00*ff', value: 234, unit: 'Wh' },
|
||||||
|
{ obis: '01-00:10.07.00*ff', value: 321, unit: 'W' },
|
||||||
|
{ obis: '01-00:01.08.01*ff', value: 10000, unit: 'Wh' },
|
||||||
|
{ obis: '01-00:01.08.02*ff', value: 2345, unit: 'Wh' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/v1/status') {
|
||||||
|
json(responseArg, {
|
||||||
|
meter: { number: 'METER123' },
|
||||||
|
device: {
|
||||||
|
id: 'bridge-abc',
|
||||||
|
bridge: { rssi: -55, version: '1.2.3' },
|
||||||
|
core: { connectionStatus: 'connected', rssi: -61, version: '2.3.4', powerStatus: 'wired', attachmentStatus: 'attached', pinStatus: 'entered', batteryLevel: 88 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
json(responseArg, { error: 'not_found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
const port = typeof address === 'object' && address ? address.port : 0;
|
||||||
|
const url = `http://127.0.0.1:${port}`;
|
||||||
|
const client = new IometerClient({ url, timeoutMs: 1000 });
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
const runtime = await new IometerIntegration().setup({ url, timeoutMs: 1000 }, {});
|
||||||
|
const entities = await runtime.entities();
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.source).toEqual('http');
|
||||||
|
expect(snapshot.device.serialNumber).toEqual('bridge-abc');
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'power')?.state).toEqual(321);
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'connection_status')?.state).toBeTrue();
|
||||||
|
expect(entities.some((entityArg) => entityArg.state === 321 && entityArg.attributes?.deviceClass === 'power')).toBeTrue();
|
||||||
|
expect(requests.every((requestArg) => requestArg.userAgent === 'PythonIOmeter/0.1')).toBeTrue();
|
||||||
|
await runtime.destroy();
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes IOmeter runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new IometerIntegration();
|
||||||
|
expect(integration.status).toEqual("read-only-runtime");
|
||||||
|
expect(iometerProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(iometerProfile.metadata.requirements).toEqual([
|
||||||
|
"iometer==0.4.0",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "IOmeter Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "iometer", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "iometer", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IIometerSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeFalse();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("IOmeter Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "iometer", service: iometerProfile.controlServices?.[0] || '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,133 @@
|
|||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { IotawattClient, IotawattConfigFlow, IotawattIntegration, IotawattMapper, createIotawattDiscoveryDescriptor, iotawattProfile, type IIotawattSnapshot, type TIotawattRawData } from '../../ts/integrations/iotawatt/index.js';
|
||||||
|
|
||||||
|
const json = (responseArg: ServerResponse, valueArg: unknown, statusArg = 200): void => {
|
||||||
|
responseArg.statusCode = statusArg;
|
||||||
|
responseArg.setHeader('content-type', 'application/json');
|
||||||
|
responseArg.end(JSON.stringify(valueArg));
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawData: TIotawattRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'iotawatt-device-1',
|
||||||
|
name: "IoTaWatt Device",
|
||||||
|
manufacturer: "IoTaWatt",
|
||||||
|
model: "IoTaWatt local integration",
|
||||||
|
serialNumber: 'iotawatt-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "iotawatt" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual IoTaWatt candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createIotawattDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'iotawatt-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'iotawatt-device-1', name: "IoTaWatt Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("iotawatt");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new IotawattConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('iotawatt-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps IoTaWatt raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new IotawattClient({ name: "IoTaWatt Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = IotawattMapper.toSnapshotFromRaw({ name: "IoTaWatt Runtime" }, rawData);
|
||||||
|
const devices = IotawattMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = IotawattMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("iotawatt");
|
||||||
|
expect(devices[0].manufacturer).toEqual("IoTaWatt");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "iotawatt" && entityArg.platform === "sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reads IoTaWatt /status and /query over local HTTP', async () => {
|
||||||
|
const requests: string[] = [];
|
||||||
|
const server = createServer((requestArg: IncomingMessage, responseArg: ServerResponse) => {
|
||||||
|
const url = new URL(requestArg.url || '/', 'http://127.0.0.1');
|
||||||
|
requests.push(`${url.pathname}?${url.searchParams.toString()}`);
|
||||||
|
if (url.pathname === '/status' && url.searchParams.get('wifi') === 'yes') {
|
||||||
|
json(responseArg, { wifi: { mac: 'AA:BB:CC:DD:EE:FF' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/status' && url.searchParams.get('inputs') === 'yes' && url.searchParams.get('outputs') === 'yes') {
|
||||||
|
json(responseArg, { inputs: [{ channel: 0 }], outputs: [{ name: 'Solar', units: 'Watts' }] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/query' && url.searchParams.get('show') === 'series') {
|
||||||
|
json(responseArg, { series: [{ name: 'Mains', unit: 'Watts' }] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/query' && url.searchParams.get('select') === '[Mains.watts,Solar.watts]') {
|
||||||
|
json(responseArg, [[42, -500]]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/query' && url.searchParams.get('select') === '[time.iso,Mains.wh,Solar.wh]') {
|
||||||
|
json(responseArg, [['2026-01-01T00:00:00Z', 12345, 6789]]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
json(responseArg, { error: 'not_found', select: url.searchParams.get('select') }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
const port = typeof address === 'object' && address ? address.port : 0;
|
||||||
|
const url = `http://127.0.0.1:${port}`;
|
||||||
|
const client = new IotawattClient({ url, timeoutMs: 1000, timespanSeconds: 30 });
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
const runtime = await new IotawattIntegration().setup({ url, timeoutMs: 1000, timespanSeconds: 30 }, {});
|
||||||
|
const entities = await runtime.entities();
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.source).toEqual('http');
|
||||||
|
expect(snapshot.device.serialNumber).toEqual('AABBCCDDEEFF');
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'input_0')?.state).toEqual(42);
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'input_0_total_energy')?.state).toEqual(12345);
|
||||||
|
expect(entities.some((entityArg) => entityArg.state === -500 && entityArg.name === 'Solar')).toBeTrue();
|
||||||
|
expect(requests.some((requestArg) => requestArg.includes('show=series'))).toBeTrue();
|
||||||
|
await runtime.destroy();
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes IoTaWatt runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new IotawattIntegration();
|
||||||
|
expect(integration.status).toEqual("read-only-runtime");
|
||||||
|
expect(iotawattProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(iotawattProfile.metadata.requirements).toEqual([
|
||||||
|
"ha-iotawattpy==0.1.2",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "IoTaWatt Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "iotawatt", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "iotawatt", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IIotawattSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeFalse();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("IoTaWatt Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "iotawatt", service: iotawattProfile.controlServices?.[0] || '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,99 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantIperf3Integration, Iperf3Client, Iperf3ConfigFlow, Iperf3Integration, Iperf3Mapper, createIperf3DiscoveryDescriptor, iperf3Profile, type IIperf3Snapshot, type TIperf3RawData } from '../../ts/integrations/iperf3/index.js';
|
||||||
|
|
||||||
|
const rawData: TIperf3RawData = {
|
||||||
|
device: {
|
||||||
|
id: 'iperf3-device-1',
|
||||||
|
name: "Iperf3 Device",
|
||||||
|
manufacturer: "Iperf3",
|
||||||
|
model: "Iperf3 local integration",
|
||||||
|
serialNumber: 'iperf3-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "iperf3" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Iperf3 candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createIperf3DiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'iperf3-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'iperf3-device-1', name: "Iperf3 Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("iperf3");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new Iperf3ConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('iperf3-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Iperf3 raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new Iperf3Client({ name: "Iperf3 Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = Iperf3Mapper.toSnapshotFromRaw({ name: "Iperf3 Runtime" }, rawData);
|
||||||
|
const devices = Iperf3Mapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = Iperf3Mapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("iperf3");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Iperf3");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "iperf3" && entityArg.platform === "sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('does not treat arbitrary Iperf3 host/path as a native live API', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let fetched = false;
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
fetched = true;
|
||||||
|
return new Response('{}');
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snapshot = await new Iperf3Client({ host: '127.0.0.1', path: '/api', timeoutMs: 1000 }).getSnapshot(true);
|
||||||
|
|
||||||
|
expect(fetched).toBeFalse();
|
||||||
|
expect(snapshot.online).toBeFalse();
|
||||||
|
expect(snapshot.error!).toContain('snapshots require');
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Iperf3 runtime, HA alias, and unsupported control without executor', async () => {
|
||||||
|
const integration = new Iperf3Integration();
|
||||||
|
const alias = new HomeAssistantIperf3Integration();
|
||||||
|
expect(alias instanceof Iperf3Integration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual("iperf3");
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(iperf3Profile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(iperf3Profile.metadata.requirements).toEqual([
|
||||||
|
"iperf3==0.1.11",
|
||||||
|
]);
|
||||||
|
expect((iperf3Profile.metadata.localApi as { status: string }).status).toContain('libiperf');
|
||||||
|
expect((iperf3Profile.metadata.localApi as { explicitUnsupported: string[] }).explicitUnsupported.some((entryArg) => entryArg.includes('libiperf.so'))).toBeTrue();
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Iperf3 Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "iperf3", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "iperf3", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IIperf3Snapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Iperf3 Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "iperf3", service: iperf3Profile.controlServices?.[0] || '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,99 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantIronOsIntegration, IronOsClient, IronOsConfigFlow, IronOsIntegration, IronOsMapper, createIronOsDiscoveryDescriptor, ironOsProfile, type IIronOsSnapshot, type TIronOsRawData } from '../../ts/integrations/iron_os/index.js';
|
||||||
|
|
||||||
|
const rawData: TIronOsRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'iron_os-device-1',
|
||||||
|
name: "IronOS Device",
|
||||||
|
manufacturer: "IronOS",
|
||||||
|
model: "IronOS local integration",
|
||||||
|
serialNumber: 'iron_os-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "iron_os" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual IronOS candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createIronOsDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'iron_os-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'iron_os-device-1', name: "IronOS Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("iron_os");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new IronOsConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('iron_os-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps IronOS raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new IronOsClient({ name: "IronOS Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = IronOsMapper.toSnapshotFromRaw({ name: "IronOS Runtime" }, rawData);
|
||||||
|
const devices = IronOsMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = IronOsMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("iron_os");
|
||||||
|
expect(devices[0].manufacturer).toEqual("IronOS");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "iron_os" && entityArg.platform === "binary_sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('does not treat arbitrary IronOS host/path as a native live API', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let fetched = false;
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
fetched = true;
|
||||||
|
return new Response('{}');
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snapshot = await new IronOsClient({ host: '127.0.0.1', path: '/api', timeoutMs: 1000 }).getSnapshot(true);
|
||||||
|
|
||||||
|
expect(fetched).toBeFalse();
|
||||||
|
expect(snapshot.online).toBeFalse();
|
||||||
|
expect(snapshot.error!).toContain('snapshots require');
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes IronOS runtime, HA alias, and unsupported control without executor', async () => {
|
||||||
|
const integration = new IronOsIntegration();
|
||||||
|
const alias = new HomeAssistantIronOsIntegration();
|
||||||
|
expect(alias instanceof IronOsIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual("iron_os");
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(ironOsProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(ironOsProfile.metadata.requirements).toEqual([
|
||||||
|
"pynecil==4.2.1",
|
||||||
|
]);
|
||||||
|
expect((ironOsProfile.metadata.localApi as { status: string }).status).toContain('Bluetooth LE/GATT');
|
||||||
|
expect((ironOsProfile.metadata.localApi as { explicitUnsupported: string[] }).explicitUnsupported.some((entryArg) => entryArg.includes('bleak/habluetooth'))).toBeTrue();
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "IronOS Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "iron_os", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "iron_os", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IIronOsSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("IronOS Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "iron_os", service: ironOsProfile.controlServices?.[0] || '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,149 @@
|
|||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantIskraIntegration, IskraClient, IskraConfigFlow, IskraIntegration, IskraMapper, createIskraDiscoveryDescriptor, iskraProfile, type IIskraSnapshot, type TIskraRawData } from '../../ts/integrations/iskra/index.js';
|
||||||
|
|
||||||
|
const json = (responseArg: ServerResponse, valueArg: unknown, statusArg = 200): void => {
|
||||||
|
responseArg.statusCode = statusArg;
|
||||||
|
responseArg.setHeader('content-type', 'application/json');
|
||||||
|
responseArg.end(JSON.stringify(valueArg));
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawData: TIskraRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'iskra-device-1',
|
||||||
|
name: "iskra Device",
|
||||||
|
manufacturer: "iskra",
|
||||||
|
model: "iskra local integration",
|
||||||
|
serialNumber: 'iskra-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "iskra" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual iskra candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createIskraDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'iskra-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'iskra-device-1', name: "iskra Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("iskra");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new IskraConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('iskra-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps iskra raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new IskraClient({ name: "iskra Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = IskraMapper.toSnapshotFromRaw({ name: "iskra Runtime" }, rawData);
|
||||||
|
const devices = IskraMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = IskraMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("iskra");
|
||||||
|
expect(devices[0].manufacturer).toEqual("iskra");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "iskra" && entityArg.platform === "sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reads iskra Smart Gateway snapshots over the local REST API', async () => {
|
||||||
|
const requests: Array<{ url?: string; cookie?: string }> = [];
|
||||||
|
const server = createServer((requestArg: IncomingMessage, responseArg: ServerResponse) => {
|
||||||
|
const url = new URL(requestArg.url || '/', 'http://127.0.0.1');
|
||||||
|
requests.push({ url: url.pathname, cookie: requestArg.headers.cookie });
|
||||||
|
|
||||||
|
if (url.pathname === '/api') {
|
||||||
|
json(responseArg, { device: { model_type: 'SG', serial_number: 'SG123', description: 'Main Gateway', location: 'Panel', sw_ver: '1.2' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/devices') {
|
||||||
|
json(responseArg, { devices: [{ model: 'IE38', serial: 'IE38SER', description: 'Main meter', location: 'Panel', interface: 'RS485' }] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/measurement/0') {
|
||||||
|
json(responseArg, {
|
||||||
|
measurements: {
|
||||||
|
Phases: [
|
||||||
|
{ U: '230 V', I: { value: 5, unit: 'A' }, P: '100 W' },
|
||||||
|
{ U: '231 V', I: '4 A', P: '110 W' },
|
||||||
|
{ U: '232 V', I: '3 A', P: '120 W' },
|
||||||
|
],
|
||||||
|
Total: { P: '330 W', Q: '12 var', S: '350 VA' },
|
||||||
|
Frequency: '50 Hz',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/counter/0') {
|
||||||
|
json(responseArg, { counters: { non_resettable: [{ value: 1234, unit: 'Wh', direction: 'import' }], resettable: [{ value: 56, unit: 'Wh', direction: 'export' }] } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
json(responseArg, { error: 'not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
const port = typeof address === 'object' && address ? address.port : 0;
|
||||||
|
const config = { url: `http://127.0.0.1:${port}`, protocol: 'rest_api' as const, username: 'admin', password: 'iskra', timeoutMs: 1000 };
|
||||||
|
const client = new IskraClient(config);
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
const runtime = await new IskraIntegration().setup(config, {});
|
||||||
|
const entities = await runtime.entities();
|
||||||
|
const status = await runtime.callService!({ domain: 'iskra', service: 'status', target: {} });
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.source).toEqual('http');
|
||||||
|
expect(snapshot.device.serialNumber).toEqual('SG123');
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.attributes?.key === 'total_active_power')?.state).toEqual(330);
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.attributes?.key === 'phase2_voltage')?.state).toEqual(231);
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.attributes?.key === 'non_resettable_counter_1')?.state).toEqual(1234);
|
||||||
|
expect(entities.find((entityArg) => entityArg.attributes?.key === 'frequency')?.state).toEqual(50);
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(requests.some((requestArg) => requestArg.url === '/api/measurement/0')).toBeTrue();
|
||||||
|
expect(requests.every((requestArg) => requestArg.cookie?.startsWith('Authorization=Basic '))).toBeTrue();
|
||||||
|
await runtime.destroy();
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes iskra runtime, HA alias, and unsupported control without executor', async () => {
|
||||||
|
const integration = new IskraIntegration();
|
||||||
|
const alias = new HomeAssistantIskraIntegration();
|
||||||
|
expect(alias instanceof IskraIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual("iskra");
|
||||||
|
expect(integration.status).toEqual("read-only-runtime");
|
||||||
|
expect(iskraProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(iskraProfile.metadata.requirements).toEqual([
|
||||||
|
"pyiskra==0.1.27",
|
||||||
|
]);
|
||||||
|
expect((iskraProfile.metadata.localApi as { implemented: string[] }).implemented.some((entryArg) => entryArg.includes('/api/measurement/{index}'))).toBeTrue();
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "iskra Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "iskra", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "iskra", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IIskraSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("iskra Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "iskra", service: iskraProfile.controlServices?.[0] || '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,131 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as http from 'node:http';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import { HomeAssistantIsy994Integration, Isy994Client, Isy994ConfigFlow, Isy994Integration, Isy994Mapper, createIsy994DiscoveryDescriptor, isy994Profile, type IIsy994Snapshot, type TIsy994RawData } from '../../ts/integrations/isy994/index.js';
|
||||||
|
|
||||||
|
const rawData: TIsy994RawData = {
|
||||||
|
device: {
|
||||||
|
id: 'isy994-device-1',
|
||||||
|
name: "Universal Devices ISY/IoX Device",
|
||||||
|
manufacturer: "Universal Devices ISY/IoX",
|
||||||
|
model: "Universal Devices ISY/IoX local integration",
|
||||||
|
serialNumber: 'isy994-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "isy994" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
const listen = async (serverArg: http.Server): Promise<number> => await new Promise((resolveArg) => {
|
||||||
|
serverArg.listen(0, '127.0.0.1', () => resolveArg((serverArg.address() as AddressInfo).port));
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = async (serverArg: http.Server): Promise<void> => await new Promise((resolveArg, rejectArg) => {
|
||||||
|
serverArg.close((errorArg) => errorArg ? rejectArg(errorArg) : resolveArg());
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('matches manual Universal Devices ISY/IoX candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createIsy994DiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'isy994-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'isy994-device-1', name: "Universal Devices ISY/IoX Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("isy994");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new Isy994ConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('isy994-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Universal Devices ISY/IoX raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new Isy994Client({ name: "Universal Devices ISY/IoX Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = Isy994Mapper.toSnapshotFromRaw({ name: "Universal Devices ISY/IoX Runtime" }, rawData);
|
||||||
|
const devices = Isy994Mapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = Isy994Mapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("isy994");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Universal Devices ISY/IoX");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "isy994" && entityArg.platform === "binary_sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('polls Universal Devices ISY/IoX REST XML and sends a native node command', async () => {
|
||||||
|
const requests: Array<{ url?: string; authorization?: string }> = [];
|
||||||
|
const server = http.createServer((requestArg, responseArg) => {
|
||||||
|
requests.push({ url: requestArg.url, authorization: requestArg.headers.authorization });
|
||||||
|
responseArg.setHeader('content-type', 'application/xml');
|
||||||
|
if (requestArg.url?.startsWith('/rest/config')) {
|
||||||
|
responseArg.end('<configuration><root><id>00:21:b9:aa:bb:cc</id><name>Test ISY</name></root><product><desc>ISY994i</desc></product><app_full_version>5.3.4</app_full_version><variables>true</variables><nodedefs>true</nodedefs></configuration>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestArg.url?.startsWith('/rest/nodes/11%2022%2033%201/cmd/DON/255')) {
|
||||||
|
responseArg.end('<RestResponse succeeded="true" />');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestArg.url?.startsWith('/rest/nodes')) {
|
||||||
|
responseArg.end('<nodes><node nodeDefId="DimmerSwitch"><address>11 22 33 1</address><name>Kitchen Light</name><family>1</family><type>1.2.9.0</type><enabled>true</enabled><property id="ST" value="128" formatted="50%" uom="100" prec="0" /></node></nodes>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestArg.url?.startsWith('/rest/status')) {
|
||||||
|
responseArg.end('<nodes><node id="11 22 33 1"><property id="ST" value="255" formatted="On" uom="100" prec="0" /></node></nodes>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
responseArg.statusCode = 404;
|
||||||
|
responseArg.end('not found');
|
||||||
|
});
|
||||||
|
const port = await listen(server);
|
||||||
|
try {
|
||||||
|
const client = new Isy994Client({ host: '127.0.0.1', port, username: 'admin', password: 'secret' });
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
const command = await client.execute({ domain: 'isy994', service: 'send_raw_node_command', target: {}, data: { address: '11 22 33 1', command: 'DON', value: 255 } });
|
||||||
|
|
||||||
|
expect(snapshot.source).toEqual('http');
|
||||||
|
expect(snapshot.device.name).toEqual('Test ISY');
|
||||||
|
expect(snapshot.entities[0].name).toEqual('Kitchen Light');
|
||||||
|
expect(snapshot.entities[0].platform).toEqual('light');
|
||||||
|
expect(snapshot.entities[0].state).toEqual(255);
|
||||||
|
expect(command.success).toBeTrue();
|
||||||
|
expect(requests.some((requestArg) => requestArg.url === '/rest/nodes/11%2022%2033%201/cmd/DON/255')).toBeTrue();
|
||||||
|
expect(requests.every((requestArg) => requestArg.authorization === 'Basic YWRtaW46c2VjcmV0')).toBeTrue();
|
||||||
|
} finally {
|
||||||
|
await close(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Universal Devices ISY/IoX runtime, HA alias, and unsupported control without executor', async () => {
|
||||||
|
const integration = new Isy994Integration();
|
||||||
|
const alias = new HomeAssistantIsy994Integration();
|
||||||
|
expect(alias instanceof Isy994Integration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual("isy994");
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(isy994Profile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(isy994Profile.metadata.requirements).toEqual([
|
||||||
|
"pyisy==3.5.1",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Universal Devices ISY/IoX Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "isy994", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "isy994", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IIsy994Snapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Universal Devices ISY/IoX Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "isy994", service: isy994Profile.controlServices?.[0] || '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,124 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as http from 'node:http';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import { HomeAssistantItunesIntegration, ItunesClient, ItunesConfigFlow, ItunesIntegration, ItunesMapper, createItunesDiscoveryDescriptor, itunesProfile, type IItunesSnapshot, type TItunesRawData } from '../../ts/integrations/itunes/index.js';
|
||||||
|
|
||||||
|
const rawData: TItunesRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'itunes-device-1',
|
||||||
|
name: "Apple iTunes Device",
|
||||||
|
manufacturer: "Apple iTunes",
|
||||||
|
model: "Apple iTunes local integration",
|
||||||
|
serialNumber: 'itunes-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "media_player", state: true, attributes: { domain: "itunes" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
const listen = async (serverArg: http.Server): Promise<number> => await new Promise((resolveArg) => {
|
||||||
|
serverArg.listen(0, '127.0.0.1', () => resolveArg((serverArg.address() as AddressInfo).port));
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = async (serverArg: http.Server): Promise<void> => await new Promise((resolveArg, rejectArg) => {
|
||||||
|
serverArg.close((errorArg) => errorArg ? rejectArg(errorArg) : resolveArg());
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('matches manual Apple iTunes candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createItunesDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'itunes-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'itunes-device-1', name: "Apple iTunes Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("itunes");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new ItunesConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('itunes-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Apple iTunes raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new ItunesClient({ name: "Apple iTunes Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = ItunesMapper.toSnapshotFromRaw({ name: "Apple iTunes Runtime" }, rawData);
|
||||||
|
const devices = ItunesMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = ItunesMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("itunes");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Apple iTunes");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "itunes" && entityArg.platform === "media_player")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('polls itunes-api HTTP endpoints and sends native playback command', async () => {
|
||||||
|
const requests: Array<{ method?: string; url?: string }> = [];
|
||||||
|
const server = http.createServer((requestArg, responseArg) => {
|
||||||
|
requests.push({ method: requestArg.method, url: requestArg.url });
|
||||||
|
responseArg.setHeader('content-type', 'application/json');
|
||||||
|
if (requestArg.method === 'GET' && requestArg.url === '/now_playing') {
|
||||||
|
responseArg.end(JSON.stringify({ player_state: 'playing', volume: 40, muted: false, name: 'Test Track', album: 'Album', artist: 'Artist', playlist: 'Library', id: 'track-1', shuffle: 'off' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestArg.method === 'GET' && requestArg.url === '/airplay_devices') {
|
||||||
|
responseArg.end(JSON.stringify({ airplay_devices: [{ id: 'speaker-1', name: 'Kitchen', selected: true, sound_volume: 55, supports_audio: true, supports_video: false }] }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestArg.method === 'PUT' && requestArg.url === '/pause') {
|
||||||
|
responseArg.end(JSON.stringify({ player_state: 'paused' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
responseArg.statusCode = 404;
|
||||||
|
responseArg.end(JSON.stringify({ error: 'not found' }));
|
||||||
|
});
|
||||||
|
const port = await listen(server);
|
||||||
|
try {
|
||||||
|
const client = new ItunesClient({ host: '127.0.0.1', port });
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
const command = await client.execute({ domain: 'itunes', service: 'media_pause', target: {} });
|
||||||
|
|
||||||
|
expect(snapshot.source).toEqual('http');
|
||||||
|
expect(snapshot.entities.some((entityArg) => entityArg.id === 'player' && entityArg.state === 'playing')).toBeTrue();
|
||||||
|
expect(snapshot.entities.some((entityArg) => entityArg.id === 'airplay_speaker_1' && entityArg.state === 'on')).toBeTrue();
|
||||||
|
expect(command.success).toBeTrue();
|
||||||
|
expect(requests.some((requestArg) => requestArg.method === 'GET' && requestArg.url === '/now_playing')).toBeTrue();
|
||||||
|
expect(requests.some((requestArg) => requestArg.method === 'GET' && requestArg.url === '/airplay_devices')).toBeTrue();
|
||||||
|
expect(requests.some((requestArg) => requestArg.method === 'PUT' && requestArg.url === '/pause')).toBeTrue();
|
||||||
|
} finally {
|
||||||
|
await close(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Apple iTunes runtime, HA alias, and unsupported control without executor', async () => {
|
||||||
|
const integration = new ItunesIntegration();
|
||||||
|
const alias = new HomeAssistantItunesIntegration();
|
||||||
|
expect(alias instanceof ItunesIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual("itunes");
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(itunesProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(itunesProfile.metadata.requirements).toEqual([]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Apple iTunes Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "itunes", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "itunes", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IItunesSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Apple iTunes Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "itunes", service: itunesProfile.controlServices?.[0] || '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,142 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as http from 'node:http';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import { HomeAssistantIzoneIntegration, IzoneClient, IzoneConfigFlow, IzoneIntegration, IzoneMapper, createIzoneDiscoveryDescriptor, izoneProfile, type IIzoneSnapshot, type TIzoneRawData } from '../../ts/integrations/izone/index.js';
|
||||||
|
|
||||||
|
const rawData: TIzoneRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'izone-device-1',
|
||||||
|
name: "iZone Device",
|
||||||
|
manufacturer: "iZone",
|
||||||
|
model: "iZone local integration",
|
||||||
|
serialNumber: 'izone-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "climate", state: true, attributes: { domain: "izone" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
const listen = async (serverArg: http.Server): Promise<number> => await new Promise((resolveArg) => {
|
||||||
|
serverArg.listen(0, '127.0.0.1', () => resolveArg((serverArg.address() as AddressInfo).port));
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = async (serverArg: http.Server): Promise<void> => await new Promise((resolveArg, rejectArg) => {
|
||||||
|
serverArg.close((errorArg) => errorArg ? rejectArg(errorArg) : resolveArg());
|
||||||
|
});
|
||||||
|
|
||||||
|
const readBody = async (requestArg: http.IncomingMessage): Promise<string> => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of requestArg) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks).toString('utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual iZone candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createIzoneDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'izone-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'izone-device-1', name: "iZone Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("izone");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new IzoneConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('izone-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps iZone raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new IzoneClient({ name: "iZone Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = IzoneMapper.toSnapshotFromRaw({ name: "iZone Runtime" }, rawData);
|
||||||
|
const devices = IzoneMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = IzoneMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("izone");
|
||||||
|
expect(devices[0].manufacturer).toEqual("iZone");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "izone" && entityArg.platform === "climate")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('polls iZone HTTP resources and sends native raw TCP HTTP command', async () => {
|
||||||
|
const requests: Array<{ method?: string; url?: string; body?: unknown }> = [];
|
||||||
|
const server = http.createServer(async (requestArg, responseArg) => {
|
||||||
|
responseArg.setHeader('content-type', 'application/json');
|
||||||
|
if (requestArg.method === 'GET' && requestArg.url === '/SystemSettings') {
|
||||||
|
requests.push({ method: requestArg.method, url: requestArg.url });
|
||||||
|
responseArg.end(JSON.stringify({ AirStreamDeviceUId: '000013170', SysOn: 'on', SysMode: 'cool', SysFan: 'medium', FanAuto: '3-speed', NoOfZones: 2, Supply: 12.3, Setpoint: 22, Temp: 23.5, EcoLock: 'false', EcoMin: 15, EcoMax: 30, RAS: 'master', CtrlZone: 1, NoOfConst: 0, SysType: '310', FreeAir: 'off' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestArg.method === 'GET' && requestArg.url === '/Zones1_4') {
|
||||||
|
requests.push({ method: requestArg.method, url: requestArg.url });
|
||||||
|
responseArg.end(JSON.stringify([
|
||||||
|
{ Index: 0, Name: 'Living', Type: 'auto', Mode: 'auto', SetPoint: 22, Temp: 23, MaxAir: 80, MinAir: 20 },
|
||||||
|
{ Index: 1, Name: 'Bed', Type: 'opcl', Mode: 'open', SetPoint: 0, Temp: 0, MaxAir: 100, MinAir: 10 },
|
||||||
|
]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestArg.method === 'POST' && requestArg.url === '/AirMinCommand') {
|
||||||
|
const body = JSON.parse(await readBody(requestArg)) as unknown;
|
||||||
|
requests.push({ method: requestArg.method, url: requestArg.url, body });
|
||||||
|
responseArg.end('{"ok":true}{OK}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requests.push({ method: requestArg.method, url: requestArg.url });
|
||||||
|
responseArg.statusCode = 404;
|
||||||
|
responseArg.end(JSON.stringify({ error: 'not found' }));
|
||||||
|
});
|
||||||
|
const port = await listen(server);
|
||||||
|
try {
|
||||||
|
const client = new IzoneClient({ host: '127.0.0.1', port });
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
const command = await client.execute({ domain: 'izone', service: 'airflow_min', target: {}, data: { zoneNo: 1, airflow: 25 } });
|
||||||
|
|
||||||
|
expect(snapshot.source).toEqual('http');
|
||||||
|
expect(snapshot.device.id).toEqual('000013170');
|
||||||
|
expect(snapshot.entities.some((entityArg) => entityArg.id === 'controller' && entityArg.state === 'cool')).toBeTrue();
|
||||||
|
expect(snapshot.entities.some((entityArg) => entityArg.id === 'zone_1' && entityArg.state === 'heat_cool')).toBeTrue();
|
||||||
|
expect(command.success).toBeTrue();
|
||||||
|
expect(requests.some((requestArg) => requestArg.method === 'GET' && requestArg.url === '/SystemSettings')).toBeTrue();
|
||||||
|
expect(requests.some((requestArg) => requestArg.method === 'GET' && requestArg.url === '/Zones1_4')).toBeTrue();
|
||||||
|
expect(requests.some((requestArg) => requestArg.method === 'POST' && requestArg.url === '/AirMinCommand' && JSON.stringify(requestArg.body) === JSON.stringify({ AirMinCommand: { ZoneNo: '1', Command: '25' } }))).toBeTrue();
|
||||||
|
} finally {
|
||||||
|
await close(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes iZone runtime, HA alias, and unsupported control without executor', async () => {
|
||||||
|
const integration = new IzoneIntegration();
|
||||||
|
const alias = new HomeAssistantIzoneIntegration();
|
||||||
|
expect(alias instanceof IzoneIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual("izone");
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(izoneProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(izoneProfile.metadata.requirements).toEqual([
|
||||||
|
"python-izone==1.2.9",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "iZone Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "izone", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "izone", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IIzoneSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("iZone Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "izone", service: izoneProfile.controlServices?.[0] || '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,158 @@
|
|||||||
|
import { createServer } from 'node:net';
|
||||||
|
import type { AddressInfo, Socket } from 'node:net';
|
||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantJvcProjectorIntegration, JvcProjectorClient, JvcProjectorConfigFlow, JvcProjectorIntegration, JvcProjectorMapper, createJvcProjectorDiscoveryDescriptor, jvcProjectorProfile, type IJvcProjectorSnapshot, type TJvcProjectorRawData } from '../../ts/integrations/jvc_projector/index.js';
|
||||||
|
|
||||||
|
const rawData: TJvcProjectorRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'jvc_projector-device-1',
|
||||||
|
name: "JVC Projector Device",
|
||||||
|
manufacturer: "JVC Projector",
|
||||||
|
model: "JVC Projector local integration",
|
||||||
|
serialNumber: 'jvc_projector-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "jvc_projector" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
const startJvcProjectorServer = async () => {
|
||||||
|
const received: string[] = [];
|
||||||
|
const pjReq = Buffer.from('PJREQ', 'latin1');
|
||||||
|
const pjAck = Buffer.from('PJACK', 'latin1');
|
||||||
|
const headAck = Buffer.from([0x06, 0x89, 0x01]);
|
||||||
|
const headRes = Buffer.from([0x40, 0x89, 0x01]);
|
||||||
|
const newline = Buffer.from('\n', 'latin1');
|
||||||
|
const responses: Record<string, string> = {
|
||||||
|
MD: 'B5A3',
|
||||||
|
LSMA: 'AA BB CC DD EE FF',
|
||||||
|
IFSV: '1.23',
|
||||||
|
PW: '1',
|
||||||
|
SC: '1',
|
||||||
|
IP: '6',
|
||||||
|
IFLT: '000A',
|
||||||
|
};
|
||||||
|
const knownCodes = Object.keys(responses).concat('RC').sort((leftArg, rightArg) => rightArg.length - leftArg.length);
|
||||||
|
const server = createServer((socketArg: Socket) => {
|
||||||
|
let authenticated = false;
|
||||||
|
let buffer = Buffer.alloc(0);
|
||||||
|
socketArg.write(Buffer.from('PJ_OK', 'latin1'));
|
||||||
|
|
||||||
|
const handleFrame = (frameArg: Buffer) => {
|
||||||
|
const isOperation = frameArg[0] === 0x21;
|
||||||
|
const body = frameArg.subarray(3, frameArg.length - 1).toString('latin1');
|
||||||
|
const code = knownCodes.find((codeArg) => body.startsWith(codeArg));
|
||||||
|
if (!code) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
received.push(`${isOperation ? 'op' : 'ref'}:${body}`);
|
||||||
|
const prefix = Buffer.from(code.slice(0, 2), 'latin1');
|
||||||
|
socketArg.write(Buffer.concat([headAck, prefix, newline]));
|
||||||
|
if (!isOperation) {
|
||||||
|
socketArg.write(Buffer.concat([headRes, prefix, Buffer.from(responses[code], 'latin1'), newline]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socketArg.on('data', (chunkArg: Buffer) => {
|
||||||
|
buffer = Buffer.concat([buffer, chunkArg]);
|
||||||
|
if (!authenticated && buffer.length >= pjReq.length && buffer.subarray(0, pjReq.length).equals(pjReq)) {
|
||||||
|
received.push('PJREQ');
|
||||||
|
buffer = buffer.subarray(pjReq.length);
|
||||||
|
authenticated = true;
|
||||||
|
socketArg.write(pjAck);
|
||||||
|
}
|
||||||
|
let index = buffer.indexOf(0x0a);
|
||||||
|
while (index >= 0) {
|
||||||
|
const frame = buffer.subarray(0, index + 1);
|
||||||
|
buffer = buffer.subarray(index + 1);
|
||||||
|
handleFrame(frame);
|
||||||
|
index = buffer.indexOf(0x0a);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
return { server, port: (server.address() as AddressInfo).port, received };
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual JVC Projector candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createJvcProjectorDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'jvc_projector-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'jvc_projector-device-1', name: "JVC Projector Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("jvc_projector");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new JvcProjectorConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('jvc_projector-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps JVC Projector raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new JvcProjectorClient({ name: "JVC Projector Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = JvcProjectorMapper.toSnapshotFromRaw({ name: "JVC Projector Runtime" }, rawData);
|
||||||
|
const devices = JvcProjectorMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = JvcProjectorMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("jvc_projector");
|
||||||
|
expect(devices[0].manufacturer).toEqual("JVC Projector");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "jvc_projector" && entityArg.platform === "binary_sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('polls and controls JVC Projector through the pyjvcprojector TCP protocol', async () => {
|
||||||
|
const { server, port, received } = await startJvcProjectorServer();
|
||||||
|
try {
|
||||||
|
const client = new JvcProjectorClient({ host: '127.0.0.1', port, name: 'Theater Projector' });
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
const command = await client.execute({ domain: 'remote', service: 'send_command', target: {}, data: { command: ['menu'] } });
|
||||||
|
|
||||||
|
expect(snapshot.source).toEqual('tcp');
|
||||||
|
expect(snapshot.device.model).toEqual('B5A3');
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'input')?.state).toEqual('hdmi1');
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'light_time')?.state).toEqual(10);
|
||||||
|
expect(command.success).toBeTrue();
|
||||||
|
expect(received.some((entryArg) => entryArg === 'ref:MD')).toBeTrue();
|
||||||
|
expect(received.some((entryArg) => entryArg === 'op:RC732E')).toBeTrue();
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes JVC Projector runtime, HA alias, and unsupported control without executor', async () => {
|
||||||
|
const integration = new JvcProjectorIntegration();
|
||||||
|
const alias = new HomeAssistantJvcProjectorIntegration();
|
||||||
|
expect(alias instanceof JvcProjectorIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual("jvc_projector");
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(jvcProjectorProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(jvcProjectorProfile.metadata.requirements).toEqual([
|
||||||
|
"pyjvcprojector==2.0.6",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "JVC Projector Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "jvc_projector", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "jvc_projector", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IJvcProjectorSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("JVC Projector Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "jvc_projector", service: jvcProjectorProfile.controlServices?.[0] || '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,146 @@
|
|||||||
|
import { createServer } from 'node:net';
|
||||||
|
import type { AddressInfo, Socket } from 'node:net';
|
||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantKaleidescapeIntegration, KaleidescapeClient, KaleidescapeConfigFlow, KaleidescapeIntegration, KaleidescapeMapper, createKaleidescapeDiscoveryDescriptor, kaleidescapeProfile, type IKaleidescapeSnapshot, type TKaleidescapeRawData } from '../../ts/integrations/kaleidescape/index.js';
|
||||||
|
|
||||||
|
const rawData: TKaleidescapeRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'kaleidescape-device-1',
|
||||||
|
name: "Kaleidescape Device",
|
||||||
|
manufacturer: "Kaleidescape",
|
||||||
|
model: "Kaleidescape local integration",
|
||||||
|
serialNumber: 'kaleidescape-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "button", state: true, attributes: { domain: "kaleidescape" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
const startKaleidescapeServer = async () => {
|
||||||
|
const received: string[] = [];
|
||||||
|
const responses: Record<string, string[]> = {
|
||||||
|
GET_DEVICE_INFO: ['unused', '#000000123456', '01', '192.168.001.050'],
|
||||||
|
GET_SYSTEM_VERSION: ['13', '10.0.0'],
|
||||||
|
GET_DEVICE_TYPE_NAME: ['Strato C'],
|
||||||
|
GET_NUM_ZONES: ['1', '1'],
|
||||||
|
GET_DEVICE_POWER_STATE: ['1', '1'],
|
||||||
|
GET_SYSTEM_READINESS_STATE: ['0'],
|
||||||
|
GET_FRIENDLY_NAME: ['Theater'],
|
||||||
|
GET_UI_STATE: ['7', '0', '0', '0'],
|
||||||
|
GET_HIGHLIGHTED_SELECTION: ['movie-handle'],
|
||||||
|
GET_PLAY_STATUS: ['2', '1', '1', '7200', '120', '1', '600', '120'],
|
||||||
|
GET_MOVIE_LOCATION: ['3'],
|
||||||
|
GET_VIDEO_MODE: ['0', '0', '27'],
|
||||||
|
GET_VIDEO_COLOR: ['2', '4', '30', '3'],
|
||||||
|
GET_SCREEN_MASK: ['5', '0', '0', '5', '0', '0'],
|
||||||
|
GET_CINEMASCAPE_MODE: ['1'],
|
||||||
|
};
|
||||||
|
const server = createServer((socketArg: Socket) => {
|
||||||
|
let buffer = '';
|
||||||
|
socketArg.on('data', (chunkArg: Buffer) => {
|
||||||
|
buffer += chunkArg.toString('latin1');
|
||||||
|
let index = buffer.indexOf('\n');
|
||||||
|
while (index >= 0) {
|
||||||
|
const line = buffer.slice(0, index).trim();
|
||||||
|
buffer = buffer.slice(index + 1);
|
||||||
|
const match = line.match(/^01\/([0-9])\/([^:]+):(.*)$/);
|
||||||
|
if (match) {
|
||||||
|
const seq = match[1];
|
||||||
|
const requestName = match[2];
|
||||||
|
received.push(requestName);
|
||||||
|
const responseName = requestName.startsWith('GET_') ? requestName.slice(4) : '';
|
||||||
|
const fields = responses[requestName] || [];
|
||||||
|
const body = responseName ? `${responseName}:${fields.join(':')}:` : '';
|
||||||
|
socketArg.write(`01/${seq}/000:${body}/0\n`, 'latin1');
|
||||||
|
}
|
||||||
|
index = buffer.indexOf('\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
return { server, port: (server.address() as AddressInfo).port, received };
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Kaleidescape candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createKaleidescapeDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'kaleidescape-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'kaleidescape-device-1', name: "Kaleidescape Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("kaleidescape");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new KaleidescapeConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('kaleidescape-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Kaleidescape raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new KaleidescapeClient({ name: "Kaleidescape Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = KaleidescapeMapper.toSnapshotFromRaw({ name: "Kaleidescape Runtime" }, rawData);
|
||||||
|
const devices = KaleidescapeMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = KaleidescapeMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("kaleidescape");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Kaleidescape");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "kaleidescape" && entityArg.platform === "button")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('polls and controls Kaleidescape through the System Control Protocol TCP API', async () => {
|
||||||
|
const { server, port, received } = await startKaleidescapeServer();
|
||||||
|
try {
|
||||||
|
const client = new KaleidescapeClient({ host: '127.0.0.1', port, name: 'Theater' });
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
const command = await client.execute({ domain: 'media_player', service: 'media_pause', target: {} });
|
||||||
|
|
||||||
|
expect(snapshot.source).toEqual('tcp');
|
||||||
|
expect(snapshot.device.model).toEqual('Strato C');
|
||||||
|
expect(snapshot.device.serialNumber).toEqual('000000123456');
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'media_player')?.state).toEqual('playing');
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'video_mode')?.state).toEqual('3840x2160p23976_16:9');
|
||||||
|
expect(command.success).toBeTrue();
|
||||||
|
expect(received.includes('GET_DEVICE_INFO')).toBeTrue();
|
||||||
|
expect(received.includes('PAUSE')).toBeTrue();
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Kaleidescape runtime, HA alias, and unsupported control without executor', async () => {
|
||||||
|
const integration = new KaleidescapeIntegration();
|
||||||
|
const alias = new HomeAssistantKaleidescapeIntegration();
|
||||||
|
expect(alias instanceof KaleidescapeIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual("kaleidescape");
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(kaleidescapeProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(kaleidescapeProfile.metadata.requirements).toEqual([
|
||||||
|
"pykaleidescape==1.1.5",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Kaleidescape Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "kaleidescape", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "kaleidescape", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IKaleidescapeSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Kaleidescape Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "kaleidescape", service: kaleidescapeProfile.controlServices?.[0] || '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,107 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantKankunIntegration, KankunClient, KankunConfigFlow, KankunIntegration, KankunMapper, createKankunDiscoveryDescriptor, kankunProfile, type IKankunSnapshot, type TKankunRawData } from '../../ts/integrations/kankun/index.js';
|
||||||
|
|
||||||
|
const rawData: TKankunRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'kankun-device-1',
|
||||||
|
name: "Kankun Device",
|
||||||
|
manufacturer: "Kankun",
|
||||||
|
model: "Kankun local integration",
|
||||||
|
serialNumber: 'kankun-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "switch", state: true, attributes: { domain: "kankun" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Kankun candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createKankunDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'kankun-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'kankun-device-1', name: "Kankun Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("kankun");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new KankunConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('kankun-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Kankun raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new KankunClient({ name: "Kankun Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = KankunMapper.toSnapshotFromRaw({ name: "Kankun Runtime" }, rawData);
|
||||||
|
const devices = KankunMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = KankunMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("kankun");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Kankun");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "kankun" && entityArg.platform === "switch")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('polls and controls Kankun switches through the documented json.cgi HTTP API', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||||
|
globalThis.fetch = (async (inputArg: string | URL | Request, initArg?: RequestInit) => {
|
||||||
|
const url = String(inputArg);
|
||||||
|
calls.push({ url, init: initArg });
|
||||||
|
if (url.endsWith('/cgi-bin/json.cgi?get=state')) {
|
||||||
|
return new Response(JSON.stringify({ state: 'on' }), { status: 200 });
|
||||||
|
}
|
||||||
|
if (url.endsWith('/cgi-bin/json.cgi?set=off')) {
|
||||||
|
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({ ok: false }), { status: 404 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new KankunClient({ host: 'plug.local', username: 'admin', password: 'secret', name: 'Bedroom Plug' });
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
const command = await client.execute({ domain: 'switch', service: 'turn_off', target: {} });
|
||||||
|
|
||||||
|
expect(snapshot.source).toEqual('http');
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'switch')?.state).toBeTrue();
|
||||||
|
expect(command.success).toBeTrue();
|
||||||
|
expect(calls[0].url).toEqual('http://plug.local/cgi-bin/json.cgi?get=state');
|
||||||
|
expect(String((calls[0].init?.headers as Record<string, string>).authorization)).toContain('Basic ');
|
||||||
|
expect(calls[1].url).toEqual('http://plug.local/cgi-bin/json.cgi?set=off');
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Kankun runtime, HA alias, and unsupported control without executor', async () => {
|
||||||
|
const integration = new KankunIntegration();
|
||||||
|
const alias = new HomeAssistantKankunIntegration();
|
||||||
|
expect(alias instanceof KankunIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual("kankun");
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(kankunProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(kankunProfile.metadata.requirements).toEqual([]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Kankun Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "kankun", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "kankun", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IKankunSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Kankun Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "kankun", service: kankunProfile.controlServices?.[0] || '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,82 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantKebaIntegration, KebaClient, KebaConfigFlow, KebaIntegration, KebaMapper, createKebaDiscoveryDescriptor, kebaProfile, type IKebaSnapshot, type TKebaRawData } from '../../ts/integrations/keba/index.js';
|
||||||
|
|
||||||
|
const rawData: TKebaRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'keba-device-1',
|
||||||
|
name: "Keba Charging Station Device",
|
||||||
|
manufacturer: "Keba Charging Station",
|
||||||
|
model: "Keba Charging Station local integration",
|
||||||
|
serialNumber: 'keba-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "keba" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Keba Charging Station candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createKebaDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'keba-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'keba-device-1', name: "Keba Charging Station Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("keba");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new KebaConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('keba-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Keba Charging Station raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new KebaClient({ name: "Keba Charging Station Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = KebaMapper.toSnapshotFromRaw({ name: "Keba Charging Station Runtime" }, rawData);
|
||||||
|
const devices = KebaMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = KebaMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("keba");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Keba Charging Station");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "keba" && entityArg.platform === "binary_sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Keba Charging Station runtime, HA alias, and unsupported control without executor', async () => {
|
||||||
|
const integration = new KebaIntegration();
|
||||||
|
const alias = new HomeAssistantKebaIntegration();
|
||||||
|
expect(alias instanceof KebaIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual("keba");
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(kebaProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(kebaProfile.metadata.requirements).toEqual([
|
||||||
|
"keba-kecontact==1.3.0",
|
||||||
|
]);
|
||||||
|
const localApi = kebaProfile.metadata.localApi as { status: string; explicitUnsupported: string[] };
|
||||||
|
expect(localApi.status).toContain('KEBA KeContact UDP');
|
||||||
|
expect(localApi.status).toContain('asyncio-dgram');
|
||||||
|
expect(localApi.explicitUnsupported.some((entryArg) => entryArg.includes('no HTTP/TCP/TLS/file protocol'))).toBeTrue();
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Keba Charging Station Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "keba", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "keba", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IKebaSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Keba Charging Station Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "keba", service: kebaProfile.controlServices?.[0] || '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,158 @@
|
|||||||
|
import { createServer } from 'node:net';
|
||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantKeeneticNdms2Integration, KeeneticNdms2Client, KeeneticNdms2ConfigFlow, KeeneticNdms2Integration, KeeneticNdms2Mapper, createKeeneticNdms2DiscoveryDescriptor, keeneticNdms2Profile, type IKeeneticNdms2Snapshot, type TKeeneticNdms2RawData } from '../../ts/integrations/keenetic_ndms2/index.js';
|
||||||
|
|
||||||
|
const rawData: TKeeneticNdms2RawData = {
|
||||||
|
device: {
|
||||||
|
id: 'keenetic_ndms2-device-1',
|
||||||
|
name: "Keenetic NDMS2 Router Device",
|
||||||
|
manufacturer: "Keenetic NDMS2 Router",
|
||||||
|
model: "Keenetic NDMS2 Router local integration",
|
||||||
|
serialNumber: 'keenetic_ndms2-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "keenetic_ndms2" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Keenetic NDMS2 Router candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createKeeneticNdms2DiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'keenetic_ndms2-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'keenetic_ndms2-device-1', name: "Keenetic NDMS2 Router Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("keenetic_ndms2");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new KeeneticNdms2ConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('keenetic_ndms2-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Keenetic NDMS2 Router raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new KeeneticNdms2Client({ name: "Keenetic NDMS2 Router Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = KeeneticNdms2Mapper.toSnapshotFromRaw({ name: "Keenetic NDMS2 Router Runtime" }, rawData);
|
||||||
|
const devices = KeeneticNdms2Mapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = KeeneticNdms2Mapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("keenetic_ndms2");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Keenetic NDMS2 Router");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "keenetic_ndms2" && entityArg.platform === "binary_sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reads Keenetic NDMS2 router and client presence through native local Telnet', async () => {
|
||||||
|
const commands: string[] = [];
|
||||||
|
const server = createServer((socketArg) => {
|
||||||
|
let stage: 'login' | 'password' | 'command' = 'login';
|
||||||
|
let buffer = '';
|
||||||
|
socketArg.setEncoding('utf8');
|
||||||
|
socketArg.write('Login: ');
|
||||||
|
socketArg.on('data', (chunkArg) => {
|
||||||
|
buffer += String(chunkArg).replace(/\r/g, '');
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
for (const line of lines) {
|
||||||
|
const value = line.trim();
|
||||||
|
if (stage === 'login') {
|
||||||
|
stage = 'password';
|
||||||
|
socketArg.write('Password: ');
|
||||||
|
} else if (stage === 'password') {
|
||||||
|
stage = 'command';
|
||||||
|
socketArg.write('\r\n(router)>');
|
||||||
|
} else if (value !== 'exit') {
|
||||||
|
commands.push(value);
|
||||||
|
socketArg.write(keeneticResponse(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
const port = typeof address === 'object' && address ? address.port : 0;
|
||||||
|
const config = { host: '127.0.0.1', port, username: 'admin', password: 'secret', timeoutMs: 1000, interfaces: ['Home'], name: 'Office Keenetic' };
|
||||||
|
const client = new KeeneticNdms2Client(config);
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
const raw = snapshot.rawData as { routerInfo: { model: string; name: string }; devices: Array<{ mac: string; ip: string; interface: string }> };
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.source).toEqual('tcp');
|
||||||
|
expect(raw.routerInfo.model).toEqual('Keenetic Giga');
|
||||||
|
expect(raw.routerInfo.name).toEqual('Office Router');
|
||||||
|
expect(raw.devices.length).toEqual(1);
|
||||||
|
expect(raw.devices[0].mac).toEqual('AA:BB:CC:DD:EE:01');
|
||||||
|
expect(raw.devices[0].interface).toEqual('Home');
|
||||||
|
expect(commands.includes('show version')).toBeTrue();
|
||||||
|
expect(commands.includes('show ip arp')).toBeTrue();
|
||||||
|
|
||||||
|
const runtime = await new KeeneticNdms2Integration().setup(config, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'keenetic_ndms2', service: 'status', target: {} });
|
||||||
|
const entities = await runtime.entities();
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect((status.data as IKeeneticNdms2Snapshot).source).toEqual('tcp');
|
||||||
|
expect(entities.find((entityArg) => entityArg.id.includes('client_aa_bb_cc_dd_ee_01'))?.attributes?.ipAddress).toEqual('192.168.1.10');
|
||||||
|
await runtime.destroy();
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Keenetic NDMS2 Router runtime, HA alias, and unsupported control without executor', async () => {
|
||||||
|
const integration = new KeeneticNdms2Integration();
|
||||||
|
const alias = new HomeAssistantKeeneticNdms2Integration();
|
||||||
|
expect(alias instanceof KeeneticNdms2Integration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual("keenetic_ndms2");
|
||||||
|
expect(integration.status).toEqual("read-only-runtime");
|
||||||
|
expect(keeneticNdms2Profile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(keeneticNdms2Profile.metadata.requirements).toEqual([
|
||||||
|
"ndms2-client==0.1.2",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Keenetic NDMS2 Router Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "keenetic_ndms2", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "keenetic_ndms2", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IKeeneticNdms2Snapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Keenetic NDMS2 Router Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "keenetic_ndms2", service: keeneticNdms2Profile.controlServices?.[0] || 'turn_on', target: {} });
|
||||||
|
expect(command.success).toBeFalse();
|
||||||
|
expect(command.error!).toContain('requires an injected client.execute() or commandExecutor');
|
||||||
|
await runtime.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const keeneticResponse = (commandArg: string): string => {
|
||||||
|
const responses: Record<string, string[]> = {
|
||||||
|
'show version': [
|
||||||
|
' description: Office Router',
|
||||||
|
' title: 4.1.0',
|
||||||
|
' model: Keenetic Giga',
|
||||||
|
' hw_version: A1',
|
||||||
|
' manufacturer: Keenetic Ltd.',
|
||||||
|
' vendor: Keenetic',
|
||||||
|
' region: EU',
|
||||||
|
],
|
||||||
|
'show ip hotspot': [],
|
||||||
|
'show ip arp': [
|
||||||
|
'Phone One 192.168.1.10 aa:bb:cc:dd:ee:01 Home ',
|
||||||
|
'Guest Device 192.168.2.20 aa:bb:cc:dd:ee:02 Guest ',
|
||||||
|
],
|
||||||
|
'show associations': [],
|
||||||
|
};
|
||||||
|
return `\r\n${commandArg}\r\n${(responses[commandArg] || []).join('\r\n')}\r\n(router)>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { createServer } from 'node:net';
|
||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantKefIntegration, KefClient, KefConfigFlow, KefIntegration, KefMapper, createKefDiscoveryDescriptor, kefProfile, type IKefSnapshot, type TKefRawData } from '../../ts/integrations/kef/index.js';
|
||||||
|
|
||||||
|
const rawData: TKefRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'kef-device-1',
|
||||||
|
name: "KEF Device",
|
||||||
|
manufacturer: "KEF",
|
||||||
|
model: "KEF local integration",
|
||||||
|
serialNumber: 'kef-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "media_player", state: true, attributes: { domain: "kef" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual KEF candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createKefDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'kef-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'kef-device-1', name: "KEF Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("kef");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new KefConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('kef-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps KEF raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new KefClient({ name: "KEF Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = KefMapper.toSnapshotFromRaw({ name: "KEF Runtime" }, rawData);
|
||||||
|
const devices = KefMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = KefMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("kef");
|
||||||
|
expect(devices[0].manufacturer).toEqual("KEF");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "kef" && entityArg.platform === "media_player")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reads and controls KEF speakers through the native local TCP protocol', async () => {
|
||||||
|
const commands: number[][] = [];
|
||||||
|
let volumeRaw = 25;
|
||||||
|
let sourceRaw = 2;
|
||||||
|
const server = createServer((socketArg) => {
|
||||||
|
let buffer = Buffer.alloc(0);
|
||||||
|
socketArg.on('data', (chunkArg) => {
|
||||||
|
buffer = Buffer.concat([buffer, Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg)]);
|
||||||
|
while (buffer.length >= 3) {
|
||||||
|
const length = buffer[0] === 0x53 ? 4 : 3;
|
||||||
|
if (buffer.length < length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const command = buffer.subarray(0, length);
|
||||||
|
buffer = buffer.subarray(length);
|
||||||
|
commands.push([...command]);
|
||||||
|
if (command[0] === 0x47) {
|
||||||
|
const value = command[1] === 0x25 ? volumeRaw : sourceRaw;
|
||||||
|
socketArg.write(Buffer.from([0x52, command[1], value, 0xff]));
|
||||||
|
} else if (command[0] === 0x53) {
|
||||||
|
if (command[1] === 0x25) {
|
||||||
|
volumeRaw = command[3];
|
||||||
|
}
|
||||||
|
if (command[1] === 0x30) {
|
||||||
|
sourceRaw = command[3];
|
||||||
|
}
|
||||||
|
socketArg.write(Buffer.from([0x52, 0x11, 0xff]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
const port = typeof address === 'object' && address ? address.port : 0;
|
||||||
|
const client = new KefClient({ host: '127.0.0.1', port, timeoutMs: 1000, name: 'Office KEF', speakerType: 'LSX' });
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
await client.execute({ domain: 'media_player', service: 'volume_up', target: {} });
|
||||||
|
await client.execute({ domain: 'media_player', service: 'select_source', target: {}, data: { source: 'Bluetooth' } });
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.source).toEqual('tcp');
|
||||||
|
expect((snapshot.rawData as { state: { source: string }; volume: { level: number } }).state.source).toEqual('Wifi');
|
||||||
|
expect((snapshot.rawData as { state: { source: string }; volume: { level: number } }).volume.level).toEqual(0.25);
|
||||||
|
expect(commands.some((commandArg) => commandArg.join(',') === '83,37,129,30')).toBeTrue();
|
||||||
|
expect(commands.some((commandArg) => commandArg.join(',') === '83,48,129,41')).toBeTrue();
|
||||||
|
|
||||||
|
const runtime = await new KefIntegration().setup({ host: '127.0.0.1', port, timeoutMs: 1000, name: 'Office KEF', speakerType: 'LSX' }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'kef', service: 'status', target: {} });
|
||||||
|
const entities = await runtime.entities();
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect((status.data as IKefSnapshot).source).toEqual('tcp');
|
||||||
|
expect(entities.find((entityArg) => entityArg.platform === 'media_player')?.attributes?.source).toEqual('Bluetooth');
|
||||||
|
await runtime.destroy();
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes KEF runtime, HA alias, and unsupported control without executor', async () => {
|
||||||
|
const integration = new KefIntegration();
|
||||||
|
const alias = new HomeAssistantKefIntegration();
|
||||||
|
expect(alias instanceof KefIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual("kef");
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(kefProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(kefProfile.metadata.requirements).toEqual([
|
||||||
|
"aiokef==0.2.16",
|
||||||
|
"getmac==0.9.5",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "KEF Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "kef", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "kef", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IKefSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("KEF Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "kef", service: kefProfile.controlServices?.[0] || '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,76 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { KegtronClient, KegtronConfigFlow, KegtronIntegration, KegtronMapper, createKegtronDiscoveryDescriptor, kegtronProfile, type IKegtronSnapshot, type TKegtronRawData } from '../../ts/integrations/kegtron/index.js';
|
||||||
|
|
||||||
|
const rawData: TKegtronRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'kegtron-device-1',
|
||||||
|
name: "Kegtron Device",
|
||||||
|
manufacturer: "Kegtron",
|
||||||
|
model: "Kegtron local integration",
|
||||||
|
serialNumber: 'kegtron-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "kegtron" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Kegtron candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createKegtronDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'kegtron-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'kegtron-device-1', name: "Kegtron Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("kegtron");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new KegtronConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('kegtron-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Kegtron raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new KegtronClient({ name: "Kegtron Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = KegtronMapper.toSnapshotFromRaw({ name: "Kegtron Runtime" }, rawData);
|
||||||
|
const devices = KegtronMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = KegtronMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("kegtron");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Kegtron");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "kegtron" && entityArg.platform === "sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Kegtron runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new KegtronIntegration();
|
||||||
|
expect(integration.status).toEqual("read-only-runtime");
|
||||||
|
expect(kegtronProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(kegtronProfile.metadata.requirements).toEqual([
|
||||||
|
"kegtron-ble==1.0.2",
|
||||||
|
]);
|
||||||
|
expect((kegtronProfile.metadata.localApi as { explicitUnsupported: string[] }).explicitUnsupported.some((itemArg) => itemArg.includes('no BLE scanner stack'))).toBeTrue();
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Kegtron Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "kegtron", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "kegtron", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IKegtronSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Kegtron Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "kegtron", service: kegtronProfile.controlServices?.[0] || '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,77 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { KeyboardRemoteClient, KeyboardRemoteConfigFlow, KeyboardRemoteIntegration, KeyboardRemoteMapper, createKeyboardRemoteDiscoveryDescriptor, keyboardRemoteProfile, type IKeyboardRemoteSnapshot, type TKeyboardRemoteRawData } from '../../ts/integrations/keyboard_remote/index.js';
|
||||||
|
|
||||||
|
const rawData: TKeyboardRemoteRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'keyboard_remote-device-1',
|
||||||
|
name: "Keyboard Remote Device",
|
||||||
|
manufacturer: "Keyboard Remote",
|
||||||
|
model: "Keyboard Remote local integration",
|
||||||
|
serialNumber: 'keyboard_remote-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "keyboard_remote" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Keyboard Remote candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createKeyboardRemoteDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'keyboard_remote-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'keyboard_remote-device-1', name: "Keyboard Remote Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("keyboard_remote");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new KeyboardRemoteConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('keyboard_remote-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Keyboard Remote raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new KeyboardRemoteClient({ name: "Keyboard Remote Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = KeyboardRemoteMapper.toSnapshotFromRaw({ name: "Keyboard Remote Runtime" }, rawData);
|
||||||
|
const devices = KeyboardRemoteMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = KeyboardRemoteMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("keyboard_remote");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Keyboard Remote");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "keyboard_remote" && entityArg.platform === "sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Keyboard Remote runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new KeyboardRemoteIntegration();
|
||||||
|
expect(integration.status).toEqual("read-only-runtime");
|
||||||
|
expect(keyboardRemoteProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(keyboardRemoteProfile.metadata.requirements).toEqual([
|
||||||
|
"evdev==1.9.3",
|
||||||
|
"asyncinotify==4.4.4",
|
||||||
|
]);
|
||||||
|
expect((keyboardRemoteProfile.metadata.localApi as { explicitUnsupported: string[] }).explicitUnsupported.some((itemArg) => itemArg.includes('no evdev/inotify/ioctl stack'))).toBeTrue();
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Keyboard Remote Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "keyboard_remote", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "keyboard_remote", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IKeyboardRemoteSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Keyboard Remote Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "keyboard_remote", service: keyboardRemoteProfile.controlServices?.[0] || '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,148 @@
|
|||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { KioskerClient, KioskerConfigFlow, KioskerIntegration, KioskerMapper, createKioskerDiscoveryDescriptor, kioskerProfile, type IKioskerSnapshot, type TKioskerRawData } from '../../ts/integrations/kiosker/index.js';
|
||||||
|
|
||||||
|
const rawData: TKioskerRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'kiosker-device-1',
|
||||||
|
name: "Kiosker Device",
|
||||||
|
manufacturer: "Kiosker",
|
||||||
|
model: "Kiosker local integration",
|
||||||
|
serialNumber: 'kiosker-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "kiosker" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Kiosker candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createKioskerDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'kiosker-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'kiosker-device-1', name: "Kiosker Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("kiosker");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new KioskerConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('kiosker-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Kiosker raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new KioskerClient({ name: "Kiosker Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = KioskerMapper.toSnapshotFromRaw({ name: "Kiosker Runtime" }, rawData);
|
||||||
|
const devices = KioskerMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = KioskerMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("kiosker");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Kiosker");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "kiosker" && entityArg.platform === "binary_sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('polls the documented Kiosker HTTP API and maps Home Assistant entities', async () => {
|
||||||
|
const requests: string[] = [];
|
||||||
|
const server = createServer((requestArg, responseArg) => {
|
||||||
|
requests.push(`${requestArg.method} ${requestArg.url} ${requestArg.headers.authorization}`);
|
||||||
|
if (requestArg.headers.authorization !== 'Bearer test-token') {
|
||||||
|
responseArg.writeHead(401, { 'content-type': 'application/json' });
|
||||||
|
responseArg.end(JSON.stringify({ error: true, reason: 'unauthorized' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
responseArg.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
if (requestArg.url === '/api/v1/status') {
|
||||||
|
responseArg.end(JSON.stringify({
|
||||||
|
status: {
|
||||||
|
batteryLevel: 87,
|
||||||
|
batteryState: 'Charging',
|
||||||
|
model: 'iPad13,4',
|
||||||
|
osVersion: '17.5',
|
||||||
|
appName: 'Kiosker Pro',
|
||||||
|
appVersion: '25.1.0',
|
||||||
|
lastInteraction: '2026-01-01T00:00:00+00:00',
|
||||||
|
lastMotion: null,
|
||||||
|
ambientLight: 12.5,
|
||||||
|
date: '2026-01-01T00:00:05+00:00',
|
||||||
|
deviceId: '2904C1F2-93FB-4954-BF85-FAAEFBA814F6',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestArg.url === '/api/v1/blackout/state') {
|
||||||
|
responseArg.end(JSON.stringify({ blackout: { visible: true, text: 'Maintenance' } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestArg.url === '/api/v1/screensaver/state') {
|
||||||
|
responseArg.end(JSON.stringify({ screensaver: { visible: false, disabled: false } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
responseArg.end(JSON.stringify({}));
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
const port = typeof address === 'object' && address ? address.port : 0;
|
||||||
|
const config = { host: '127.0.0.1', port, token: 'test-token', name: 'Lobby Kiosk', timeoutMs: 1000 };
|
||||||
|
const client = new KioskerClient(config);
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
const entities = KioskerMapper.toEntities(snapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.source).toEqual('http');
|
||||||
|
expect(snapshot.device.serialNumber).toEqual('2904C1F2-93FB-4954-BF85-FAAEFBA814F6');
|
||||||
|
expect(entities.find((entityArg) => entityArg.uniqueId.endsWith('_batterylevel'))?.state).toEqual(87);
|
||||||
|
expect(entities.find((entityArg) => entityArg.uniqueId.endsWith('_blackoutstate'))?.state).toBeTrue();
|
||||||
|
expect(entities.find((entityArg) => entityArg.uniqueId.endsWith('_charging'))?.state).toBeTrue();
|
||||||
|
expect([...requests].sort()).toEqual([
|
||||||
|
'GET /api/v1/blackout/state Bearer test-token',
|
||||||
|
'GET /api/v1/screensaver/state Bearer test-token',
|
||||||
|
'GET /api/v1/status Bearer test-token',
|
||||||
|
].sort());
|
||||||
|
|
||||||
|
const runtime = await new KioskerIntegration().setup(config, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'kiosker', service: 'status', target: {} });
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect((status.data as IKioskerSnapshot).source).toEqual('http');
|
||||||
|
await runtime.destroy();
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Kiosker runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new KioskerIntegration();
|
||||||
|
expect(integration.status).toEqual("read-only-runtime");
|
||||||
|
expect(kioskerProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(kioskerProfile.metadata.requirements).toEqual([
|
||||||
|
"kiosker-python-api==1.2.9",
|
||||||
|
]);
|
||||||
|
expect((kioskerProfile.metadata.localApi as { implemented: string[] }).implemented.some((itemArg) => itemArg.includes('/api/v1/status'))).toBeTrue();
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Kiosker Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "kiosker", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "kiosker", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IKioskerSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Kiosker Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "kiosker", service: kioskerProfile.controlServices?.[0] || '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,96 @@
|
|||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { KiraClient, KiraConfigFlow, KiraIntegration, KiraMapper, createKiraDiscoveryDescriptor, kiraProfile, type IKiraSnapshot, type TKiraRawData } from '../../ts/integrations/kira/index.js';
|
||||||
|
|
||||||
|
const rawData: TKiraRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'kira-device-1',
|
||||||
|
name: "Kira Device",
|
||||||
|
manufacturer: "Kira",
|
||||||
|
model: "Kira local integration",
|
||||||
|
serialNumber: 'kira-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "button", state: true, attributes: { domain: "kira" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Kira candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createKiraDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'kira-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'kira-device-1', name: "Kira Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("kira");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new KiraConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('kira-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Kira raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new KiraClient({ name: "Kira Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = KiraMapper.toSnapshotFromRaw({ name: "Kira Runtime" }, rawData);
|
||||||
|
const devices = KiraMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = KiraMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("kira");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Kira");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "kira" && entityArg.platform === "button")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reads Kira Home Assistant code files as a native file snapshot', async () => {
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'kira-codes-'));
|
||||||
|
const codesPath = path.join(tempDir, 'kira_codes.yaml');
|
||||||
|
await fs.writeFile(codesPath, '- name: Power\n code: ABC123\n device: tv\n type: raw\n repeat: 2\n', 'utf8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new KiraClient({ name: 'Kira Codes', codesPath });
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
|
||||||
|
expect(snapshot.source).toEqual('client');
|
||||||
|
expect(snapshot.entities.some((entityArg) => entityArg.id === 'power' && entityArg.platform === 'button')).toBeTrue();
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'code_count')?.state).toEqual(1);
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'power')?.attributes?.code).toEqual('ABC123');
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Kira runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new KiraIntegration();
|
||||||
|
expect(integration.status).toEqual("read-only-runtime");
|
||||||
|
expect(kiraProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(kiraProfile.metadata.requirements).toEqual([
|
||||||
|
"pykira==0.1.1",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Kira Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "kira", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "kira", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IKiraSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Kira Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "kira", service: kiraProfile.controlServices?.[0] || 'turn_on', target: {} });
|
||||||
|
expect(command.success).toBeFalse();
|
||||||
|
expect(command.error!).toContain('pykira over a UDP IR-IP bridge protocol');
|
||||||
|
await runtime.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { KmtronicClient, KmtronicConfigFlow, KmtronicIntegration, KmtronicMapper, createKmtronicDiscoveryDescriptor, kmtronicProfile, type IKmtronicSnapshot, type TKmtronicRawData } from '../../ts/integrations/kmtronic/index.js';
|
||||||
|
|
||||||
|
const rawData: TKmtronicRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'kmtronic-device-1',
|
||||||
|
name: "KMtronic Device",
|
||||||
|
manufacturer: "KMtronic",
|
||||||
|
model: "KMtronic local integration",
|
||||||
|
serialNumber: 'kmtronic-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "switch", state: true, attributes: { domain: "kmtronic" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual KMtronic candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createKmtronicDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'kmtronic-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'kmtronic-device-1', name: "KMtronic Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("kmtronic");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new KmtronicConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('kmtronic-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps KMtronic raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new KmtronicClient({ name: "KMtronic Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = KmtronicMapper.toSnapshotFromRaw({ name: "KMtronic Runtime" }, rawData);
|
||||||
|
const devices = KmtronicMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = KmtronicMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("kmtronic");
|
||||||
|
expect(devices[0].manufacturer).toEqual("KMtronic");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "kmtronic" && entityArg.platform === "switch")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('polls and controls KMtronic relays through the pykmtronic HTTP endpoints', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||||
|
globalThis.fetch = (async (inputArg: string | URL | Request, initArg?: RequestInit) => {
|
||||||
|
const url = String(inputArg);
|
||||||
|
calls.push({ url, init: initArg });
|
||||||
|
if (url.endsWith('/status.xml')) {
|
||||||
|
return new Response('<response><status>ok</status><relay1>1</relay1><relay2>0</relay2></response>', { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response('ok', { status: 200 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new KmtronicClient({ host: 'relay.local', username: 'admin', password: 'secret', name: 'Relay Controller' });
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
const command = await client.execute({ domain: 'switch', service: 'turn_off', target: {}, data: { relayId: 1 } });
|
||||||
|
|
||||||
|
expect(snapshot.source).toEqual('http');
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'relay_1')?.state).toBeTrue();
|
||||||
|
expect(command.success).toBeTrue();
|
||||||
|
expect(calls[0].url).toEqual('http://relay.local/status.xml');
|
||||||
|
expect(String((calls[0].init?.headers as Record<string, string>).authorization)).toContain('Basic ');
|
||||||
|
expect(calls[1].url).toEqual('http://relay.local/FF0100');
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes KMtronic runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new KmtronicIntegration();
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(kmtronicProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(kmtronicProfile.metadata.requirements).toEqual([
|
||||||
|
"pykmtronic==0.3.0",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "KMtronic Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "kmtronic", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "kmtronic", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IKmtronicSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("KMtronic Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "kmtronic", service: kmtronicProfile.controlServices?.[0] || 'turn_on', target: {} });
|
||||||
|
expect(command.success).toBeFalse();
|
||||||
|
expect(command.error!).toContain('requires config.host/config.url');
|
||||||
|
await runtime.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { KonnectedClient, KonnectedConfigFlow, KonnectedIntegration, KonnectedMapper, createKonnectedDiscoveryDescriptor, konnectedProfile, type IKonnectedSnapshot, type TKonnectedRawData } from '../../ts/integrations/konnected/index.js';
|
||||||
|
|
||||||
|
const rawData: TKonnectedRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'konnected-device-1',
|
||||||
|
name: "Konnected.io (Legacy) Device",
|
||||||
|
manufacturer: "Konnected.io (Legacy)",
|
||||||
|
model: "Konnected.io (Legacy) local integration",
|
||||||
|
serialNumber: 'konnected-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "konnected" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Konnected.io (Legacy) candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createKonnectedDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'konnected-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'konnected-device-1', name: "Konnected.io (Legacy) Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("konnected");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new KonnectedConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('konnected-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Konnected.io (Legacy) raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new KonnectedClient({ name: "Konnected.io (Legacy) Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = KonnectedMapper.toSnapshotFromRaw({ name: "Konnected.io (Legacy) Runtime" }, rawData);
|
||||||
|
const devices = KonnectedMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = KonnectedMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("konnected");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Konnected.io (Legacy)");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "konnected" && entityArg.platform === "binary_sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('polls and controls Konnected panels through the documented HTTP API', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||||
|
globalThis.fetch = (async (inputArg: string | URL | Request, initArg?: RequestInit) => {
|
||||||
|
const url = String(inputArg);
|
||||||
|
calls.push({ url, init: initArg });
|
||||||
|
if (url.endsWith('/status')) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
mac: '2c:3a:e8:43:8a:38',
|
||||||
|
model: 'Konnected Pro',
|
||||||
|
swVersion: '2.1.3',
|
||||||
|
sensors: [{ zone: '1', state: 1 }],
|
||||||
|
actuators: [{ zone: 'out1', state: 0, trigger: 1 }],
|
||||||
|
rssi: -31,
|
||||||
|
}), { status: 200 });
|
||||||
|
}
|
||||||
|
if (url.includes('/zone?')) {
|
||||||
|
return new Response(JSON.stringify([{ zone: 'out1', state: 0 }]), { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({ zone: 'out1', state: 1 }), { status: 200 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new KonnectedClient({ host: 'panel.local', port: 17000, name: 'Alarm Panel' });
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
const command = await client.execute({ domain: 'switch', service: 'turn_on', target: {}, data: { zone: 'out1' } });
|
||||||
|
const toggle = await client.execute({ domain: 'switch', service: 'toggle', target: {}, data: { zone: 'out1' } });
|
||||||
|
|
||||||
|
expect(snapshot.source).toEqual('http');
|
||||||
|
expect(snapshot.entities.some((entityArg) => entityArg.id === 'zone_1' && entityArg.state === true)).toBeTrue();
|
||||||
|
expect(snapshot.entities.some((entityArg) => entityArg.id === 'actuator_zone_out1' && entityArg.platform === 'switch')).toBeTrue();
|
||||||
|
expect(command.success).toBeTrue();
|
||||||
|
expect(toggle.success).toBeTrue();
|
||||||
|
expect(calls[0].url).toEqual('http://panel.local:17000/status');
|
||||||
|
expect(calls[1].url).toEqual('http://panel.local:17000/zone');
|
||||||
|
expect(JSON.parse(String(calls[1].init?.body))).toEqual({ zone: 'out1', state: 1 });
|
||||||
|
expect(calls[2].url).toEqual('http://panel.local:17000/zone?zone=out1');
|
||||||
|
expect(JSON.parse(String(calls[3].init?.body))).toEqual({ zone: 'out1', state: 1 });
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Konnected.io (Legacy) runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new KonnectedIntegration();
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(konnectedProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(konnectedProfile.metadata.requirements).toEqual([
|
||||||
|
"konnected==1.2.0",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Konnected.io (Legacy) Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "konnected", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "konnected", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IKonnectedSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Konnected.io (Legacy) Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "konnected", service: konnectedProfile.controlServices?.[0] || 'turn_on', target: {} });
|
||||||
|
expect(command.success).toBeFalse();
|
||||||
|
expect(command.error!).toContain('requires config.host/config.url');
|
||||||
|
await runtime.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { KostalPlenticoreClient } from '../../ts/integrations/kostal_plenticore/index.js';
|
||||||
|
|
||||||
|
tap.test('authenticates to Kostal Plenticore REST API and reads/writes live data', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const calls: Array<{ path: string; method: string; authorization?: string; body?: unknown }> = [];
|
||||||
|
const password = 'secret-password';
|
||||||
|
const user = 'user';
|
||||||
|
const salt = Buffer.from('test-salt-for-pbkdf');
|
||||||
|
const serverNonce = Buffer.from('server-nonce-12');
|
||||||
|
const transactionId = Buffer.from('transaction-id-1');
|
||||||
|
const rounds = 2;
|
||||||
|
let clientNonce = Buffer.alloc(0);
|
||||||
|
|
||||||
|
globalThis.fetch = (async (urlArg: URL | RequestInfo, initArg?: RequestInit) => {
|
||||||
|
const url = new URL(String(urlArg));
|
||||||
|
const body = parseBody(initArg?.body);
|
||||||
|
const authorization = headerValue(initArg?.headers, 'authorization');
|
||||||
|
calls.push({ path: url.pathname, method: initArg?.method || 'GET', authorization, body });
|
||||||
|
|
||||||
|
if (url.pathname === '/api/v1/auth/start') {
|
||||||
|
clientNonce = Buffer.from(String((body as Record<string, unknown>).nonce), 'base64');
|
||||||
|
return jsonResponse({ nonce: b64(serverNonce), transactionId: b64(transactionId), salt: b64(salt), rounds });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/v1/auth/finish') {
|
||||||
|
return jsonResponse({ token: 'session-token', signature: b64(serverSignature(password, user, clientNonce, serverNonce, salt, rounds)) });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/v1/auth/create_session') {
|
||||||
|
expect(typeof (body as Record<string, unknown>).payload).toEqual('string');
|
||||||
|
return jsonResponse({ sessionId: 'SESSION1' });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/v1/info/version') {
|
||||||
|
expect(authorization).toEqual('Session SESSION1');
|
||||||
|
return jsonResponse({ hostname: 'kostal-host', name: 'Plenticore API', sw_version: 'FW1', api_version: '1' });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/v1/processdata') {
|
||||||
|
expect(authorization).toEqual('Session SESSION1');
|
||||||
|
return jsonResponse([
|
||||||
|
{ moduleid: 'devices:local', processdata: [{ id: 'Inverter:State', unit: '', value: 6 }, { id: 'Dc_P', unit: 'W', value: 1234.4 }] },
|
||||||
|
{ moduleid: 'devices:local:pv1', processdata: [{ id: 'U', unit: 'V', value: 398.2 }] },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/v1/settings' && initArg?.method === 'POST') {
|
||||||
|
expect(authorization).toEqual('Session SESSION1');
|
||||||
|
return jsonResponse([
|
||||||
|
{
|
||||||
|
moduleid: 'devices:local',
|
||||||
|
settings: [
|
||||||
|
{ id: 'Properties:SerialNo', value: 'SN123' },
|
||||||
|
{ id: 'Branding:ProductName1', value: 'PLENTICORE' },
|
||||||
|
{ id: 'Branding:ProductName2', value: 'plus' },
|
||||||
|
{ id: 'Properties:VersionIOC', value: 'IOC1' },
|
||||||
|
{ id: 'Properties:VersionMC', value: 'MC1' },
|
||||||
|
{ id: 'Battery:MinSoc', value: '10' },
|
||||||
|
{ id: 'Battery:SmartBatteryControl:Enable', value: '1' },
|
||||||
|
{ id: 'Battery:TimeControl:Enable', value: '0' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ moduleid: 'scb:network', settings: [{ id: 'Hostname', value: 'Kostal Test' }] },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/v1/settings' && initArg?.method === 'PUT') {
|
||||||
|
expect(authorization).toEqual('Session SESSION1');
|
||||||
|
return jsonResponse({ ok: true });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/v1/auth/logout') {
|
||||||
|
return jsonResponse({ ok: true });
|
||||||
|
}
|
||||||
|
return jsonResponse({ message: 'not found' }, 404);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new KostalPlenticoreClient({
|
||||||
|
host: 'inverter.local',
|
||||||
|
password,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
processData: { 'devices:local': ['Inverter:State', 'Dc_P'], 'devices:local:pv1': ['U'] },
|
||||||
|
});
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.source).toEqual('http');
|
||||||
|
expect(snapshot.device.serialNumber).toEqual('SN123');
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'devices_local_dc_p')?.state).toEqual(1234);
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'devices_local_inverter_state')?.state).toEqual('FeedIn');
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'battery_min_soc')?.state).toEqual(10);
|
||||||
|
|
||||||
|
const write = await client.execute({ domain: 'number', service: 'set_value', target: {}, data: { moduleId: 'devices:local', dataId: 'Battery:MinSoc', value: 25 } });
|
||||||
|
expect(write.success).toBeTrue();
|
||||||
|
const settingsWrite = calls.find((callArg) => callArg.path === '/api/v1/settings' && callArg.method === 'PUT');
|
||||||
|
expect(settingsWrite?.body).toEqual([{ moduleid: 'devices:local', settings: [{ id: 'Battery:MinSoc', value: '25' }] }]);
|
||||||
|
expect(calls.some((callArg) => callArg.path === '/api/v1/auth/start')).toBeTrue();
|
||||||
|
expect(calls.some((callArg) => callArg.path === '/api/v1/processdata')).toBeTrue();
|
||||||
|
await client.destroy();
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const serverSignature = (passwordArg: string, userArg: string, clientNonceArg: Buffer, serverNonceArg: Buffer, saltArg: Buffer, roundsArg: number): Buffer => {
|
||||||
|
const saltedPassword = crypto.pbkdf2Sync(Buffer.from(passwordArg, 'utf8'), saltArg, roundsArg, 32, 'sha256');
|
||||||
|
const serverKey = hmac(saltedPassword, 'Server Key');
|
||||||
|
const authMessage = `n=${userArg},r=${b64(clientNonceArg)},r=${b64(serverNonceArg)},s=${b64(saltArg)},i=${roundsArg},c=biws,r=${b64(serverNonceArg)}`;
|
||||||
|
return hmac(serverKey, authMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hmac = (keyArg: Buffer, messageArg: string): Buffer => crypto.createHmac('sha256', keyArg).update(Buffer.from(messageArg, 'utf8')).digest();
|
||||||
|
|
||||||
|
const b64 = (bufferArg: Buffer): string => bufferArg.toString('base64');
|
||||||
|
|
||||||
|
const jsonResponse = (valueArg: unknown, statusArg = 200): Response => new Response(JSON.stringify(valueArg), { status: statusArg, headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
const parseBody = (bodyArg: BodyInit | null | undefined): unknown => {
|
||||||
|
if (typeof bodyArg === 'string') {
|
||||||
|
return JSON.parse(bodyArg) as unknown;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerValue = (headersArg: HeadersInit | undefined, nameArg: string): string | undefined => {
|
||||||
|
if (!headersArg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (headersArg instanceof Headers) {
|
||||||
|
return headersArg.get(nameArg) || undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(headersArg)) {
|
||||||
|
return headersArg.find(([keyArg]) => keyArg.toLowerCase() === nameArg)?.[1];
|
||||||
|
}
|
||||||
|
return (headersArg as Record<string, string>)[nameArg];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { KostalPlenticoreClient, KostalPlenticoreConfigFlow, KostalPlenticoreIntegration, KostalPlenticoreMapper, createKostalPlenticoreDiscoveryDescriptor, kostalPlenticoreProfile, type IKostalPlenticoreSnapshot, type TKostalPlenticoreRawData } from '../../ts/integrations/kostal_plenticore/index.js';
|
||||||
|
|
||||||
|
const rawData: TKostalPlenticoreRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'kostal_plenticore-device-1',
|
||||||
|
name: "Kostal Plenticore Solar Inverter Device",
|
||||||
|
manufacturer: "Kostal Plenticore Solar Inverter",
|
||||||
|
model: "Kostal Plenticore Solar Inverter local integration",
|
||||||
|
serialNumber: 'kostal_plenticore-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "number", state: true, attributes: { domain: "kostal_plenticore" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Kostal Plenticore Solar Inverter candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createKostalPlenticoreDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'kostal_plenticore-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'kostal_plenticore-device-1', name: "Kostal Plenticore Solar Inverter Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("kostal_plenticore");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new KostalPlenticoreConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('kostal_plenticore-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Kostal Plenticore Solar Inverter raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new KostalPlenticoreClient({ name: "Kostal Plenticore Solar Inverter Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = KostalPlenticoreMapper.toSnapshotFromRaw({ name: "Kostal Plenticore Solar Inverter Runtime" }, rawData);
|
||||||
|
const devices = KostalPlenticoreMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = KostalPlenticoreMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("kostal_plenticore");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Kostal Plenticore Solar Inverter");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "kostal_plenticore" && entityArg.platform === "number")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Kostal Plenticore Solar Inverter runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new KostalPlenticoreIntegration();
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(kostalPlenticoreProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(kostalPlenticoreProfile.metadata.requirements).toEqual([
|
||||||
|
"pykoplenti==1.5.0",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Kostal Plenticore Solar Inverter Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "kostal_plenticore", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "kostal_plenticore", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IKostalPlenticoreSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Kostal Plenticore Solar Inverter Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "kostal_plenticore", service: kostalPlenticoreProfile.controlServices?.[0] || '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,75 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { KulerskyClient, KulerskyConfigFlow, KulerskyIntegration, KulerskyMapper, createKulerskyDiscoveryDescriptor, kulerskyProfile, type IKulerskySnapshot, type TKulerskyRawData } from '../../ts/integrations/kulersky/index.js';
|
||||||
|
|
||||||
|
const rawData: TKulerskyRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'kulersky-device-1',
|
||||||
|
name: "Kuler Sky Device",
|
||||||
|
manufacturer: "Kuler Sky",
|
||||||
|
model: "Kuler Sky local integration",
|
||||||
|
serialNumber: 'kulersky-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "light", state: true, attributes: { domain: "kulersky" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Kuler Sky candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createKulerskyDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'kulersky-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'kulersky-device-1', name: "Kuler Sky Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("kulersky");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new KulerskyConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('kulersky-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Kuler Sky raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new KulerskyClient({ name: "Kuler Sky Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = KulerskyMapper.toSnapshotFromRaw({ name: "Kuler Sky Runtime" }, rawData);
|
||||||
|
const devices = KulerskyMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = KulerskyMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("kulersky");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Kuler Sky");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "kulersky" && entityArg.platform === "light")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Kuler Sky runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new KulerskyIntegration();
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(kulerskyProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(kulerskyProfile.metadata.requirements).toEqual([
|
||||||
|
"pykulersky==0.5.8",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Kuler Sky Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "kulersky", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "kulersky", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IKulerskySnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Kuler Sky Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "kulersky", service: kulerskyProfile.controlServices?.[0] || '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,10 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { kulerskyProfile } from '../../ts/integrations/kulersky/index.js';
|
||||||
|
|
||||||
|
tap.test('documents Kuler Sky native BLE blocker instead of faking live transport', async () => {
|
||||||
|
const localApi = kulerskyProfile.metadata.localApi as { status: string; explicitUnsupported: string[] };
|
||||||
|
expect(localApi.status).toInclude('pykulersky over bleak/GATT Bluetooth');
|
||||||
|
expect(localApi.explicitUnsupported.some((itemArg) => itemArg.includes('no BLE stack is available'))).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { createServer } from 'node:net';
|
||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { KwbClient, KwbIntegration, type IKwbSnapshot } from '../../ts/integrations/kwb/index.js';
|
||||||
|
|
||||||
|
tap.test('reads KWB Easyfire TCP byte-stream packets into sensor snapshots', async () => {
|
||||||
|
const payload = Buffer.concat([sensePacket([21.5, 20.1, 64.4, 250.2, 48, 47.5, -3.2, 120.5, 12.3, 0, 0, 0, 55.5]), controlPacket()]);
|
||||||
|
const server = createServer((socketArg) => socketArg.end(payload));
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
const port = typeof address === 'object' && address ? address.port : 0;
|
||||||
|
const client = new KwbClient({ host: '127.0.0.1', port, timeoutMs: 1000 });
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.source).toEqual('tcp');
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'supply')?.state).toEqual(21.5);
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'return_mixer')?.state).toEqual(1);
|
||||||
|
|
||||||
|
const runtime = await new KwbIntegration().setup({ host: '127.0.0.1', port, timeoutMs: 1000 }, {});
|
||||||
|
const status = await runtime.callService!({ domain: 'kwb', service: 'status', target: {} });
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect((status.data as IKwbSnapshot).entities.find((entityArg) => entityArg.id === 'furnace')?.state).toEqual(250.2);
|
||||||
|
await runtime.destroy();
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sensePacket = (temperaturesArg: number[]): Buffer => {
|
||||||
|
const tempBytes = Buffer.concat(temperaturesArg.map((valueArg) => {
|
||||||
|
const encoded = Math.round(valueArg * 10);
|
||||||
|
const buffer = Buffer.alloc(2);
|
||||||
|
buffer.writeInt16BE(encoded, 0);
|
||||||
|
return buffer;
|
||||||
|
}));
|
||||||
|
const unescapedData = Buffer.concat([Buffer.alloc(4), tempBytes, Buffer.alloc(6)]);
|
||||||
|
const packet = Buffer.concat([Buffer.from([0]), unescapedData]);
|
||||||
|
return Buffer.concat([Buffer.from([2, 2, packet.length, 1, 1]), packet, Buffer.from([0])]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const controlPacket = (): Buffer => {
|
||||||
|
const packet = Buffer.alloc(16);
|
||||||
|
packet[2] = 0b00000010;
|
||||||
|
packet[3] = 0b00000010;
|
||||||
|
return Buffer.concat([Buffer.from([2, 1, 1, 1]), packet, Buffer.from([0])]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { KwbClient, KwbConfigFlow, KwbIntegration, KwbMapper, createKwbDiscoveryDescriptor, kwbProfile, type IKwbSnapshot, type TKwbRawData } from '../../ts/integrations/kwb/index.js';
|
||||||
|
|
||||||
|
const rawData: TKwbRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'kwb-device-1',
|
||||||
|
name: "KWB Easyfire Device",
|
||||||
|
manufacturer: "KWB Easyfire",
|
||||||
|
model: "KWB Easyfire local integration",
|
||||||
|
serialNumber: 'kwb-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "kwb" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual KWB Easyfire candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createKwbDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'kwb-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'kwb-device-1', name: "KWB Easyfire Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("kwb");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new KwbConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('kwb-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps KWB Easyfire raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new KwbClient({ name: "KWB Easyfire Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = KwbMapper.toSnapshotFromRaw({ name: "KWB Easyfire Runtime" }, rawData);
|
||||||
|
const devices = KwbMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = KwbMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("kwb");
|
||||||
|
expect(devices[0].manufacturer).toEqual("KWB Easyfire");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "kwb" && entityArg.platform === "sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes KWB Easyfire runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new KwbIntegration();
|
||||||
|
expect(integration.status).toEqual("read-only-runtime");
|
||||||
|
expect(kwbProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(kwbProfile.metadata.requirements).toEqual([
|
||||||
|
"pykwb==0.0.8",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "KWB Easyfire Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "kwb", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "kwb", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as IKwbSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("KWB Easyfire Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "kwb", service: kwbProfile.controlServices?.[0] || '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,77 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { LacrosseClient, LacrosseConfigFlow, LacrosseIntegration, LacrosseMapper, createLacrosseDiscoveryDescriptor, lacrosseProfile, type ILacrosseSnapshot, type TLacrosseRawData } from '../../ts/integrations/lacrosse/index.js';
|
||||||
|
|
||||||
|
const rawData: TLacrosseRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'lacrosse-device-1',
|
||||||
|
name: "LaCrosse Device",
|
||||||
|
manufacturer: "LaCrosse",
|
||||||
|
model: "LaCrosse local integration",
|
||||||
|
serialNumber: 'lacrosse-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "lacrosse" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual LaCrosse candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createLacrosseDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'lacrosse-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'lacrosse-device-1', name: "LaCrosse Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("lacrosse");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new LacrosseConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('lacrosse-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps LaCrosse raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new LacrosseClient({ name: "LaCrosse Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = LacrosseMapper.toSnapshotFromRaw({ name: "LaCrosse Runtime" }, rawData);
|
||||||
|
const devices = LacrosseMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = LacrosseMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("lacrosse");
|
||||||
|
expect(devices[0].manufacturer).toEqual("LaCrosse");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "lacrosse" && entityArg.platform === "sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes LaCrosse runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new LacrosseIntegration();
|
||||||
|
expect(integration.status).toEqual("read-only-runtime");
|
||||||
|
expect(lacrosseProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(lacrosseProfile.metadata.requirements).toEqual([
|
||||||
|
"pylacrosse==0.4",
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(lacrosseProfile.metadata.localApi)).toContain('pyserial');
|
||||||
|
expect(JSON.stringify(lacrosseProfile.metadata.localApi)).toContain('RFC2217');
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "LaCrosse Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "lacrosse", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "lacrosse", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as ILacrosseSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("LaCrosse Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "lacrosse", service: lacrosseProfile.controlServices?.[0] || '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,215 @@
|
|||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { LametricClient, LametricConfigFlow, LametricIntegration, LametricMapper, createLametricDiscoveryDescriptor, lametricProfile, type ILametricSnapshot, type TLametricRawData } from '../../ts/integrations/lametric/index.js';
|
||||||
|
|
||||||
|
const readBody = async (requestArg: IncomingMessage): Promise<string> => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of requestArg) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks).toString('utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
const json = (responseArg: ServerResponse, valueArg: unknown, statusArg = 200): void => {
|
||||||
|
responseArg.statusCode = statusArg;
|
||||||
|
responseArg.setHeader('content-type', 'application/json');
|
||||||
|
responseArg.end(JSON.stringify(valueArg));
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawData: TLametricRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'lametric-device-1',
|
||||||
|
name: "LaMetric Device",
|
||||||
|
manufacturer: "LaMetric",
|
||||||
|
model: "LaMetric local integration",
|
||||||
|
serialNumber: 'lametric-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "button", state: true, attributes: { domain: "lametric" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
const deviceData = {
|
||||||
|
id: 'cloud-device-1',
|
||||||
|
name: 'Office LaMetric',
|
||||||
|
serial_number: 'SA150600000100W00BS9',
|
||||||
|
mode: 'manual',
|
||||||
|
model: 'LM 37X8',
|
||||||
|
os_version: '2.3.0',
|
||||||
|
update_available: { version: '2.3.1' },
|
||||||
|
audio: { volume: 69, volume_range: { min: 0, max: 100 }, volume_limit: { min: 0, max: 80 } },
|
||||||
|
bluetooth: { active: true, available: true, discoverable: false, mac: '58:63:56:23:95:6C', name: 'Office LaMetric', pairable: true },
|
||||||
|
display: { brightness: 67, brightness_mode: 'auto', brightness_range: { min: 0, max: 100 }, brightness_limit: { min: 2, max: 75 }, width: 37, height: 8, type: 'mixed' },
|
||||||
|
wifi: { active: true, available: true, essid: 'office', ip: '192.168.1.50', address: '58:63:56:10:D6:1F', strength: 88, mode: 'dhcp', netmask: '255.255.255.0' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const startLametricServer = async (): Promise<{ url: string; requests: Array<{ method?: string; path: string; body?: unknown }>; close(): Promise<void> }> => {
|
||||||
|
const requests: Array<{ method?: string; path: string; body?: unknown }> = [];
|
||||||
|
const server = createServer((requestArg: IncomingMessage, responseArg: ServerResponse) => {
|
||||||
|
void (async () => {
|
||||||
|
const url = new URL(requestArg.url || '/', 'http://127.0.0.1');
|
||||||
|
const bodyText = ['POST', 'PUT'].includes(requestArg.method || '') ? await readBody(requestArg) : undefined;
|
||||||
|
const body = bodyText ? JSON.parse(bodyText) : undefined;
|
||||||
|
requests.push({ method: requestArg.method, path: url.pathname, body });
|
||||||
|
|
||||||
|
expect(requestArg.headers.authorization).toEqual(`Basic ${Buffer.from('dev:api-key').toString('base64')}`);
|
||||||
|
|
||||||
|
if (url.pathname === '/api/v2/device' && requestArg.method === 'GET') {
|
||||||
|
json(responseArg, deviceData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/v2/device/display' && requestArg.method === 'PUT') {
|
||||||
|
json(responseArg, { success: { data: { ...deviceData.display, ...body }, path: url.pathname } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/v2/device/audio' && requestArg.method === 'PUT') {
|
||||||
|
json(responseArg, { success: { data: { ...deviceData.audio, ...body }, path: url.pathname } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/v2/device/bluetooth' && requestArg.method === 'PUT') {
|
||||||
|
json(responseArg, { success: { data: { ...deviceData.bluetooth, ...body }, path: url.pathname } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/v2/device/apps/next' && requestArg.method === 'PUT') {
|
||||||
|
json(responseArg, { success: { data: {}, path: url.pathname } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/v2/device/notifications/current' && requestArg.method === 'GET') {
|
||||||
|
json(responseArg, { id: 7, priority: 'info', model: { frames: [{ text: 'Now' }] } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/v2/device/notifications/7' && requestArg.method === 'DELETE') {
|
||||||
|
json(responseArg, { success: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/api/v2/device/notifications' && requestArg.method === 'POST') {
|
||||||
|
json(responseArg, { success: { id: 9 } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
responseArg.statusCode = 404;
|
||||||
|
responseArg.end('{}');
|
||||||
|
})().catch((errorArg) => {
|
||||||
|
responseArg.statusCode = 500;
|
||||||
|
responseArg.end(String(errorArg));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const address = server.address();
|
||||||
|
const port = typeof address === 'object' && address ? address.port : 0;
|
||||||
|
return {
|
||||||
|
url: `http://127.0.0.1:${port}`,
|
||||||
|
requests,
|
||||||
|
close: async () => new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve())),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual LaMetric candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createLametricDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'lametric-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'lametric-device-1', name: "LaMetric Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("lametric");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new LametricConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('lametric-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps LaMetric raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new LametricClient({ name: "LaMetric Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = LametricMapper.toSnapshotFromRaw({ name: "LaMetric Runtime" }, rawData);
|
||||||
|
const devices = LametricMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = LametricMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("lametric");
|
||||||
|
expect(devices[0].manufacturer).toEqual("LaMetric");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "lametric" && entityArg.platform === "button")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reads and controls LaMetric through the local Device API', async () => {
|
||||||
|
const server = await startLametricServer();
|
||||||
|
try {
|
||||||
|
const client = new LametricClient({ url: server.url, protocol: 'http', apiKey: 'api-key', timeoutMs: 1000 });
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
const runtime = await new LametricIntegration().setup({ url: server.url, protocol: 'http', apiKey: 'api-key', timeoutMs: 1000 }, {});
|
||||||
|
const entities = await runtime.entities();
|
||||||
|
const brightness = entities.find((entityArg) => entityArg.attributes?.key === 'brightness')!;
|
||||||
|
const brightnessMode = entities.find((entityArg) => entityArg.attributes?.key === 'brightness_mode')!;
|
||||||
|
const volume = entities.find((entityArg) => entityArg.attributes?.key === 'volume')!;
|
||||||
|
const bluetooth = entities.find((entityArg) => entityArg.attributes?.key === 'bluetooth')!;
|
||||||
|
const nextApp = entities.find((entityArg) => entityArg.attributes?.key === 'app_next')!;
|
||||||
|
const dismissCurrent = entities.find((entityArg) => entityArg.attributes?.key === 'dismiss_current')!;
|
||||||
|
|
||||||
|
const brightnessResult = await runtime.callService!({ domain: 'number', service: 'set_value', target: { entityId: brightness.id }, data: { value: 42 } });
|
||||||
|
const modeResult = await runtime.callService!({ domain: 'select', service: 'select_option', target: { entityId: brightnessMode.id }, data: { option: 'manual' } });
|
||||||
|
const volumeResult = await runtime.callService!({ domain: 'number', service: 'set_value', target: { entityId: volume.id }, data: { value: 20 } });
|
||||||
|
const bluetoothResult = await runtime.callService!({ domain: 'switch', service: 'turn_off', target: { entityId: bluetooth.id } });
|
||||||
|
const nextResult = await runtime.callService!({ domain: 'button', service: 'press', target: { entityId: nextApp.id } });
|
||||||
|
const dismissResult = await runtime.callService!({ domain: 'button', service: 'press', target: { entityId: dismissCurrent.id } });
|
||||||
|
const messageResult = await runtime.callService!({ domain: 'lametric', service: 'message', target: {}, data: { message: 'Hello', icon: '7956', cycles: 2, sound: 'win' } });
|
||||||
|
const chartResult = await runtime.callService!({ domain: 'lametric', service: 'chart', target: {}, data: { data: [1, 2, 3], priority: 'warning' } });
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.source).toEqual('http');
|
||||||
|
expect(snapshot.device.serialNumber).toEqual('SA150600000100W00BS9');
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'rssi')?.state).toEqual(88);
|
||||||
|
expect(entities.find((entityArg) => entityArg.attributes?.key === 'update')?.attributes?.latestVersion).toEqual('2.3.1');
|
||||||
|
expect(brightnessResult.success).toBeTrue();
|
||||||
|
expect(modeResult.success).toBeTrue();
|
||||||
|
expect(volumeResult.success).toBeTrue();
|
||||||
|
expect(bluetoothResult.success).toBeTrue();
|
||||||
|
expect(nextResult.success).toBeTrue();
|
||||||
|
expect(dismissResult.success).toBeTrue();
|
||||||
|
expect(messageResult.success).toBeTrue();
|
||||||
|
expect(chartResult.success).toBeTrue();
|
||||||
|
expect(server.requests.some((requestArg) => requestArg.path === '/api/v2/device/display' && (requestArg.body as Record<string, unknown>)?.brightness === 42)).toBeTrue();
|
||||||
|
expect(server.requests.some((requestArg) => requestArg.path === '/api/v2/device/display' && (requestArg.body as Record<string, unknown>)?.brightness_mode === 'manual')).toBeTrue();
|
||||||
|
expect(server.requests.some((requestArg) => requestArg.path === '/api/v2/device/audio' && (requestArg.body as Record<string, unknown>)?.volume === 20)).toBeTrue();
|
||||||
|
expect(server.requests.some((requestArg) => requestArg.path === '/api/v2/device/bluetooth' && (requestArg.body as Record<string, unknown>)?.active === false)).toBeTrue();
|
||||||
|
expect(server.requests.some((requestArg) => requestArg.path === '/api/v2/device/apps/next')).toBeTrue();
|
||||||
|
expect(server.requests.some((requestArg) => requestArg.path === '/api/v2/device/notifications/7' && requestArg.method === 'DELETE')).toBeTrue();
|
||||||
|
expect(server.requests.some((requestArg) => requestArg.path === '/api/v2/device/notifications' && requestArg.method === 'POST' && JSON.stringify(requestArg.body).includes('Hello'))).toBeTrue();
|
||||||
|
expect(server.requests.some((requestArg) => requestArg.path === '/api/v2/device/notifications' && requestArg.method === 'POST' && JSON.stringify(requestArg.body).includes('chartData'))).toBeTrue();
|
||||||
|
await runtime.destroy();
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes LaMetric runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new LametricIntegration();
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(lametricProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(lametricProfile.metadata.requirements).toEqual([
|
||||||
|
"demetriek==1.3.0",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "LaMetric Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "lametric", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "lametric", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as ILametricSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("LaMetric Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "lametric", service: lametricProfile.controlServices?.[0] || 'turn_on', target: {} });
|
||||||
|
expect(command.success).toBeFalse();
|
||||||
|
expect(command.error!).toContain('Static snapshots/manual data are read-only');
|
||||||
|
await runtime.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { LandisgyrHeatMeterClient, LandisgyrHeatMeterConfigFlow, LandisgyrHeatMeterIntegration, LandisgyrHeatMeterMapper, createLandisgyrHeatMeterDiscoveryDescriptor, landisgyrHeatMeterProfile, type ILandisgyrHeatMeterSnapshot, type TLandisgyrHeatMeterRawData } from '../../ts/integrations/landisgyr_heat_meter/index.js';
|
||||||
|
|
||||||
|
const rawData: TLandisgyrHeatMeterRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'landisgyr_heat_meter-device-1',
|
||||||
|
name: "Landis+Gyr Heat Meter Device",
|
||||||
|
manufacturer: "Landis+Gyr Heat Meter",
|
||||||
|
model: "Landis+Gyr Heat Meter local integration",
|
||||||
|
serialNumber: 'landisgyr_heat_meter-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "landisgyr_heat_meter" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
const heatMeterFile = `/LUGCUH50\n6.8(12.345*GJ)6.26(00123.456*m3)9.21(OWNER-1)6.26*01(100.100*m3)6.8*01(11.100*GJ)F(00000000)9.20(66153690)6.35(60*m)6.6(25.5*kW)6.6*01(20.5*kW)6.33(3.2*m3ph)9.4(70.1*C&40.2*C)6.31(1200*h)6.32(5*h)6.32*01(4*h)6.36(2026-01-01)6.33*01(2.2*m3ph)9.4*01(65.1*C&35.2*C)6.36*02(2026-02-01)9.36(2026-03-04&05:06:07)9.24(4.0*m3ph)9.1(SET&FW)9.31(1100*h)!\n`;
|
||||||
|
|
||||||
|
tap.test('matches manual Landis+Gyr Heat Meter candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createLandisgyrHeatMeterDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'landisgyr_heat_meter-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'landisgyr_heat_meter-device-1', name: "Landis+Gyr Heat Meter Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("landisgyr_heat_meter");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new LandisgyrHeatMeterConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('landisgyr_heat_meter-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Landis+Gyr Heat Meter raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new LandisgyrHeatMeterClient({ name: "Landis+Gyr Heat Meter Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = LandisgyrHeatMeterMapper.toSnapshotFromRaw({ name: "Landis+Gyr Heat Meter Runtime" }, rawData);
|
||||||
|
const devices = LandisgyrHeatMeterMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = LandisgyrHeatMeterMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("landisgyr_heat_meter");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Landis+Gyr Heat Meter");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "landisgyr_heat_meter" && entityArg.platform === "sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reads documented ultraheat-api file snapshots and parses meter sensors', async () => {
|
||||||
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'landisgyr-'));
|
||||||
|
const filePath = path.join(directory, 'LUGCUH50_dummy.txt');
|
||||||
|
await fs.writeFile(filePath, heatMeterFile, 'utf8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new LandisgyrHeatMeterClient({ filePath, name: 'Utility Heat Meter' });
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
const runtime = await new LandisgyrHeatMeterIntegration().setup({ filePath, name: 'Utility Heat Meter' }, {});
|
||||||
|
const entities = await runtime.entities();
|
||||||
|
const refresh = await runtime.callService!({ domain: 'landisgyr_heat_meter', service: 'refresh', target: {} });
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.source).toEqual('file');
|
||||||
|
expect(snapshot.device.model).toEqual('LUGCUH50');
|
||||||
|
expect(snapshot.device.serialNumber).toEqual('66153690');
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'heat_usage_gj')?.state).toEqual(12.345);
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'volume_usage_m3')?.state).toEqual(123.456);
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'meter_date_time')?.state).toEqual('2026-03-04T05:06:07.000Z');
|
||||||
|
expect(entities.find((entityArg) => entityArg.attributes?.key === 'device_number')?.state).toEqual('66153690');
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
await runtime.destroy();
|
||||||
|
} finally {
|
||||||
|
await fs.rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Landis+Gyr Heat Meter runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new LandisgyrHeatMeterIntegration();
|
||||||
|
expect(integration.status).toEqual("read-only-runtime");
|
||||||
|
expect(landisgyrHeatMeterProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(landisgyrHeatMeterProfile.metadata.requirements).toEqual([
|
||||||
|
"ultraheat-api==0.5.7",
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(landisgyrHeatMeterProfile.metadata.localApi)).toContain('pyserial');
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Landis+Gyr Heat Meter Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "landisgyr_heat_meter", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "landisgyr_heat_meter", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as ILandisgyrHeatMeterSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Landis+Gyr Heat Meter Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "landisgyr_heat_meter", service: landisgyrHeatMeterProfile.controlServices?.[0] || '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,79 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantLannouncerIntegration, LannouncerClient, LannouncerConfigFlow, LannouncerIntegration, LannouncerMapper, createLannouncerDiscoveryDescriptor, lannouncerProfile, type ILannouncerSnapshot, type TLannouncerRawData } from '../../ts/integrations/lannouncer/index.js';
|
||||||
|
|
||||||
|
const rawData: TLannouncerRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'lannouncer-device-1',
|
||||||
|
name: "LANnouncer Device",
|
||||||
|
manufacturer: "LANnouncer",
|
||||||
|
model: "LANnouncer local integration",
|
||||||
|
serialNumber: 'lannouncer-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "lannouncer" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual LANnouncer candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createLannouncerDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'lannouncer-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'lannouncer-device-1', name: "LANnouncer Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("lannouncer");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new LannouncerConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('lannouncer-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps LANnouncer raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new LannouncerClient({ name: "LANnouncer Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = LannouncerMapper.toSnapshotFromRaw({ name: "LANnouncer Runtime" }, rawData);
|
||||||
|
const devices = LannouncerMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = LannouncerMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("lannouncer");
|
||||||
|
expect(devices[0].manufacturer).toEqual("LANnouncer");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "lannouncer" && entityArg.platform === "sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes LANnouncer runtime, HA alias, and unsupported control without executor', async () => {
|
||||||
|
const integration = new LannouncerIntegration();
|
||||||
|
const alias = new HomeAssistantLannouncerIntegration();
|
||||||
|
expect(alias instanceof LannouncerIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual("lannouncer");
|
||||||
|
expect(integration.status).toEqual("read-only-runtime");
|
||||||
|
expect(lannouncerProfile.metadata.configFlow).toEqual(false);
|
||||||
|
expect(lannouncerProfile.metadata.requirements).toEqual([]);
|
||||||
|
const localApi = lannouncerProfile.metadata.localApi as { status: string; explicitUnsupported: string[] };
|
||||||
|
expect(localApi.status).toContain('no native live client');
|
||||||
|
expect(localApi.explicitUnsupported.some((itemArg) => itemArg.includes('integration_removed repair issue'))).toBeTrue();
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "LANnouncer Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "lannouncer", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "lannouncer", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as ILannouncerSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("LANnouncer Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "lannouncer", service: lannouncerProfile.controlServices?.[0] || '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,198 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as plugins from '../../ts/plugins.js';
|
||||||
|
import { HomeAssistantLcnIntegration, LcnClient, LcnConfigFlow, LcnIntegration, LcnMapper, createLcnDiscoveryDescriptor, lcnProfile, type ILcnSnapshot, type TLcnRawData } from '../../ts/integrations/lcn/index.js';
|
||||||
|
|
||||||
|
const rawData: TLcnRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'lcn-device-1',
|
||||||
|
name: "LCN Device",
|
||||||
|
manufacturer: "LCN",
|
||||||
|
model: "LCN local integration",
|
||||||
|
serialNumber: 'lcn-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "lcn" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual LCN candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createLcnDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'lcn-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'lcn-device-1', name: "LCN Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("lcn");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new LcnConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('lcn-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps LCN raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new LcnClient({ name: "LCN Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = LcnMapper.toSnapshotFromRaw({ name: "LCN Runtime" }, rawData);
|
||||||
|
const devices = LcnMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = LcnMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("lcn");
|
||||||
|
expect(devices[0].manufacturer).toEqual("LCN");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "lcn" && entityArg.platform === "binary_sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes LCN runtime, HA alias, and unsupported control without executor', async () => {
|
||||||
|
const integration = new LcnIntegration();
|
||||||
|
const alias = new HomeAssistantLcnIntegration();
|
||||||
|
expect(alias instanceof LcnIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual("lcn");
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(lcnProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(lcnProfile.metadata.requirements).toEqual([
|
||||||
|
"pypck==0.9.11",
|
||||||
|
"lcn-frontend==0.2.7",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "LCN Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "lcn", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "lcn", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as ILcnSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("LCN Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "lcn", service: lcnProfile.controlServices?.[0] || 'turn_on', target: {} });
|
||||||
|
expect(command.success).toBeFalse();
|
||||||
|
expect(command.error!).toContain('requires an injected client.execute() or commandExecutor');
|
||||||
|
await runtime.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reads native LCN PCHK status over TCP', async () => {
|
||||||
|
const server = await startPchkTestServer();
|
||||||
|
try {
|
||||||
|
const client = new LcnClient({ host: '127.0.0.1', port: server.port, username: 'lcn', password: 'secret', timeoutMs: 1000, name: 'LCN PCHK Test' });
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.source).toEqual('tcp');
|
||||||
|
expect(snapshot.device.model).toEqual('LCN-PCHK');
|
||||||
|
expect(snapshot.entities.some((entityArg) => entityArg.id === 'pchk_connection' && entityArg.state === true)).toBeTrue();
|
||||||
|
expect(server.received.includes('lcn')).toBeTrue();
|
||||||
|
expect(server.received.includes('secret')).toBeTrue();
|
||||||
|
expect(server.received.includes('!CHD')).toBeTrue();
|
||||||
|
expect(server.received.includes('!OM1P')).toBeTrue();
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('sends native addressed LCN PCK command over TCP', async () => {
|
||||||
|
const server = await startPchkTestServer();
|
||||||
|
try {
|
||||||
|
const client = new LcnClient({ host: '127.0.0.1', port: server.port, username: 'lcn', password: 'secret', timeoutMs: 1000, postCommandWaitMs: 1 });
|
||||||
|
const result = await client.execute({ domain: 'lcn', service: 'pck', target: {}, data: { pck: 'A1DI050000', segmentId: 0, moduleId: 10 } });
|
||||||
|
|
||||||
|
await waitForReceived(server, '>M000010.A1DI050000');
|
||||||
|
expect(result.success).toBeTrue();
|
||||||
|
expect(server.received.includes('>M000010.A1DI050000')).toBeTrue();
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('uses native LCN client through integration runtime for host configs', async () => {
|
||||||
|
const server = await startPchkTestServer();
|
||||||
|
const runtime = await new LcnIntegration().setup({ host: '127.0.0.1', port: server.port, username: 'lcn', password: 'secret', timeoutMs: 1000 }, {});
|
||||||
|
try {
|
||||||
|
const status = await runtime.callService!({ domain: 'lcn', service: 'status', target: {} });
|
||||||
|
const snapshot = status.data as ILcnSnapshot;
|
||||||
|
|
||||||
|
expect(status.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.source).toEqual('tcp');
|
||||||
|
} finally {
|
||||||
|
await runtime.destroy();
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IPchkTestServer {
|
||||||
|
port: number;
|
||||||
|
received: string[];
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPchkTestServer(): Promise<IPchkTestServer> {
|
||||||
|
const received: string[] = [];
|
||||||
|
const sockets = new Set<plugins.net.Socket>();
|
||||||
|
const server = plugins.net.createServer((socket) => {
|
||||||
|
sockets.add(socket);
|
||||||
|
socket.once('close', () => sockets.delete(socket));
|
||||||
|
socket.write('Username:\n');
|
||||||
|
let buffer = '';
|
||||||
|
socket.on('data', (chunkArg) => {
|
||||||
|
buffer += chunkArg.toString('utf8');
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.replace(/\r$/, '');
|
||||||
|
if (!line) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
received.push(line);
|
||||||
|
if (line === 'lcn') {
|
||||||
|
socket.write('Password:\n');
|
||||||
|
} else if (line === 'secret') {
|
||||||
|
socket.write('OK\n$io:#LCN:connected\n');
|
||||||
|
} else if (line === '!CHD') {
|
||||||
|
socket.write('(dec-mode)\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
server.off('error', reject);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === 'string') {
|
||||||
|
throw new Error('LCN test server did not bind to a TCP port.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
port: address.port,
|
||||||
|
received,
|
||||||
|
close: async () => {
|
||||||
|
for (const socket of sockets) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForReceived(serverArg: IPchkTestServer, lineArg: string): Promise<void> {
|
||||||
|
for (let index = 0; index < 20; index++) {
|
||||||
|
if (serverArg.received.includes(lineArg)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { HomeAssistantLd2410BleIntegration, Ld2410BleClient, Ld2410BleConfigFlow, Ld2410BleIntegration, Ld2410BleMapper, createLd2410BleDiscoveryDescriptor, ld2410BleProfile, type ILd2410BleSnapshot, type TLd2410BleRawData } from '../../ts/integrations/ld2410_ble/index.js';
|
||||||
|
|
||||||
|
const rawData: TLd2410BleRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'ld2410_ble-device-1',
|
||||||
|
name: "LD2410 BLE Device",
|
||||||
|
manufacturer: "LD2410 BLE",
|
||||||
|
model: "LD2410 BLE local integration",
|
||||||
|
serialNumber: 'ld2410_ble-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "ld2410_ble" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual LD2410 BLE candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createLd2410BleDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'ld2410_ble-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'ld2410_ble-device-1', name: "LD2410 BLE Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("ld2410_ble");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new Ld2410BleConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('ld2410_ble-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps LD2410 BLE raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new Ld2410BleClient({ name: "LD2410 BLE Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = Ld2410BleMapper.toSnapshotFromRaw({ name: "LD2410 BLE Runtime" }, rawData);
|
||||||
|
const devices = Ld2410BleMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = Ld2410BleMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("ld2410_ble");
|
||||||
|
expect(devices[0].manufacturer).toEqual("LD2410 BLE");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "ld2410_ble" && entityArg.platform === "binary_sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes LD2410 BLE runtime, HA alias, and unsupported control without executor', async () => {
|
||||||
|
const integration = new Ld2410BleIntegration();
|
||||||
|
const alias = new HomeAssistantLd2410BleIntegration();
|
||||||
|
expect(alias instanceof Ld2410BleIntegration).toBeTrue();
|
||||||
|
expect(alias.domain).toEqual("ld2410_ble");
|
||||||
|
expect(integration.status).toEqual("read-only-runtime");
|
||||||
|
expect(ld2410BleProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(ld2410BleProfile.metadata.requirements).toEqual([
|
||||||
|
"bluetooth-data-tools==1.28.4",
|
||||||
|
"ld2410-ble==0.1.1",
|
||||||
|
]);
|
||||||
|
const localApi = ld2410BleProfile.metadata.localApi as { status: string; explicitUnsupported: string[] };
|
||||||
|
expect(localApi.status).toContain('unavailable BLE stack');
|
||||||
|
expect(localApi.explicitUnsupported.some((itemArg) => itemArg.includes('bleak_retry_connector') && itemArg.includes('ld2410-ble'))).toBeTrue();
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "LD2410 BLE Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "ld2410_ble", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "ld2410_ble", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as ILd2410BleSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("LD2410 BLE Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "ld2410_ble", service: ld2410BleProfile.controlServices?.[0] || '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,77 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { LeaoneClient, LeaoneConfigFlow, LeaoneIntegration, LeaoneMapper, createLeaoneDiscoveryDescriptor, leaoneProfile, type ILeaoneSnapshot, type TLeaoneRawData } from '../../ts/integrations/leaone/index.js';
|
||||||
|
|
||||||
|
const rawData: TLeaoneRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'leaone-device-1',
|
||||||
|
name: "LeaOne Device",
|
||||||
|
manufacturer: "LeaOne",
|
||||||
|
model: "LeaOne local integration",
|
||||||
|
serialNumber: 'leaone-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "sensor", state: true, attributes: { domain: "leaone" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual LeaOne candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createLeaoneDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'leaone-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'leaone-device-1', name: "LeaOne Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("leaone");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new LeaoneConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('leaone-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps LeaOne raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new LeaoneClient({ name: "LeaOne Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = LeaoneMapper.toSnapshotFromRaw({ name: "LeaOne Runtime" }, rawData);
|
||||||
|
const devices = LeaoneMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = LeaoneMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("leaone");
|
||||||
|
expect(devices[0].manufacturer).toEqual("LeaOne");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "leaone" && entityArg.platform === "sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes LeaOne runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new LeaoneIntegration();
|
||||||
|
expect(integration.status).toEqual("read-only-runtime");
|
||||||
|
expect(leaoneProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(leaoneProfile.metadata.requirements).toEqual([
|
||||||
|
"leaone-ble==0.3.0",
|
||||||
|
]);
|
||||||
|
expect((leaoneProfile.metadata.localApi as { status: string }).status).toContain('leaone-ble');
|
||||||
|
expect(((leaoneProfile.metadata.localApi as { explicitUnsupported: string[] }).explicitUnsupported).some((itemArg) => itemArg.includes('BLE scanning'))).toBeTrue();
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "LeaOne Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "leaone", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "leaone", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as ILeaoneSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("LeaOne Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "leaone", service: leaoneProfile.controlServices?.[0] || '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,78 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { LedBleClient, LedBleConfigFlow, LedBleIntegration, LedBleMapper, createLedBleDiscoveryDescriptor, ledBleProfile, type ILedBleSnapshot, type TLedBleRawData } from '../../ts/integrations/led_ble/index.js';
|
||||||
|
|
||||||
|
const rawData: TLedBleRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'led_ble-device-1',
|
||||||
|
name: "LED BLE Device",
|
||||||
|
manufacturer: "LED BLE",
|
||||||
|
model: "LED BLE local integration",
|
||||||
|
serialNumber: 'led_ble-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "light", state: true, attributes: { domain: "led_ble" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual LED BLE candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createLedBleDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'led_ble-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'led_ble-device-1', name: "LED BLE Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("led_ble");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new LedBleConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('led_ble-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps LED BLE raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new LedBleClient({ name: "LED BLE Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = LedBleMapper.toSnapshotFromRaw({ name: "LED BLE Runtime" }, rawData);
|
||||||
|
const devices = LedBleMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = LedBleMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("led_ble");
|
||||||
|
expect(devices[0].manufacturer).toEqual("LED BLE");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "led_ble" && entityArg.platform === "light")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes LED BLE runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new LedBleIntegration();
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(ledBleProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(ledBleProfile.metadata.requirements).toEqual([
|
||||||
|
"bluetooth-data-tools==1.28.4",
|
||||||
|
"led-ble==1.1.8",
|
||||||
|
]);
|
||||||
|
expect((ledBleProfile.metadata.localApi as { status: string }).status).toContain('led-ble');
|
||||||
|
expect(((ledBleProfile.metadata.localApi as { explicitUnsupported: string[] }).explicitUnsupported).some((itemArg) => itemArg.includes('GATT'))).toBeTrue();
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "LED BLE Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "led_ble", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "led_ble", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as ILedBleSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("LED BLE Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "led_ble", service: ledBleProfile.controlServices?.[0] || '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,188 @@
|
|||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { LektricoClient, LektricoConfigFlow, LektricoIntegration, LektricoMapper, createLektricoDiscoveryDescriptor, lektricoProfile, type ILektricoSnapshot, type TLektricoRawData } from '../../ts/integrations/lektrico/index.js';
|
||||||
|
|
||||||
|
const readBody = async (requestArg: IncomingMessage): Promise<string> => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of requestArg) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks).toString('utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
const json = (responseArg: ServerResponse, valueArg: unknown, statusArg = 200): void => {
|
||||||
|
responseArg.statusCode = statusArg;
|
||||||
|
responseArg.setHeader('content-type', 'application/json');
|
||||||
|
responseArg.end(JSON.stringify(valueArg));
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawData: TLektricoRawData = {
|
||||||
|
device: {
|
||||||
|
id: 'lektrico-device-1',
|
||||||
|
name: "Lektrico Charging Station Device",
|
||||||
|
manufacturer: "Lektrico Charging Station",
|
||||||
|
model: "Lektrico Charging Station local integration",
|
||||||
|
serialNumber: 'lektrico-serial-1',
|
||||||
|
},
|
||||||
|
entities: [
|
||||||
|
{ id: 'status', name: 'Status', platform: "binary_sensor", state: true, attributes: { domain: "lektrico" } },
|
||||||
|
],
|
||||||
|
online: true,
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('matches manual Lektrico Charging Station candidates and creates config flow output', async () => {
|
||||||
|
const descriptor = createLektricoDiscoveryDescriptor();
|
||||||
|
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'lektrico-manual-match');
|
||||||
|
const result = await matcher!.matches({ source: 'manual', id: 'lektrico-device-1', name: "Lektrico Charging Station Device", metadata: { rawData } }, {});
|
||||||
|
|
||||||
|
expect(result.matched).toBeTrue();
|
||||||
|
expect(result.candidate?.integrationDomain).toEqual("lektrico");
|
||||||
|
|
||||||
|
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||||
|
expect(validation.matched).toBeTrue();
|
||||||
|
|
||||||
|
const done = await (await new LektricoConfigFlow().start(result.candidate!, {})).submit!({});
|
||||||
|
expect(done.kind).toEqual('done');
|
||||||
|
expect(done.config?.uniqueId).toEqual('lektrico-device-1');
|
||||||
|
expect(done.config?.rawData).toEqual(rawData);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('maps Lektrico Charging Station raw snapshots to runtime devices and entities', async () => {
|
||||||
|
const client = new LektricoClient({ name: "Lektrico Charging Station Runtime", rawData });
|
||||||
|
const snapshot = await client.getSnapshot();
|
||||||
|
const mappedSnapshot = LektricoMapper.toSnapshotFromRaw({ name: "Lektrico Charging Station Runtime" }, rawData);
|
||||||
|
const devices = LektricoMapper.toDevices(mappedSnapshot);
|
||||||
|
const entities = LektricoMapper.toEntities(mappedSnapshot);
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(mappedSnapshot.source).toEqual('manual');
|
||||||
|
expect(devices[0].integrationDomain).toEqual("lektrico");
|
||||||
|
expect(devices[0].manufacturer).toEqual("Lektrico Charging Station");
|
||||||
|
expect(entities.some((entityArg) => entityArg.integrationDomain === "lektrico" && entityArg.platform === "binary_sensor")).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('reads Lektrico charger snapshots over local HTTP JSON-RPC and writes mapped commands', async () => {
|
||||||
|
const requests: Array<{ method?: string; url?: string; body?: Record<string, unknown> }> = [];
|
||||||
|
const server = createServer((requestArg: IncomingMessage, responseArg: ServerResponse) => {
|
||||||
|
void (async () => {
|
||||||
|
const url = new URL(requestArg.url || '/', 'http://127.0.0.1');
|
||||||
|
let body: Record<string, unknown> | undefined;
|
||||||
|
if (requestArg.method === 'POST') {
|
||||||
|
body = JSON.parse(await readBody(requestArg)) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
requests.push({ method: requestArg.method, url: url.pathname, body });
|
||||||
|
|
||||||
|
if (requestArg.method === 'GET' && url.pathname === '/rpc/Device_id.Get') {
|
||||||
|
json(responseArg, { device_id: '3p22k_500006' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestArg.method === 'GET' && url.pathname === '/rpc/charger_config.get') {
|
||||||
|
json(responseArg, { serial_number: 500006, board_revision: 'E' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestArg.method === 'GET' && url.pathname === '/rpc/charger_info.get') {
|
||||||
|
json(responseArg, {
|
||||||
|
currents: [1, 2, 3],
|
||||||
|
voltages: [230, 231, 232],
|
||||||
|
fw_version: '1.45',
|
||||||
|
extended_charger_state: 'C',
|
||||||
|
session_energy: 1200,
|
||||||
|
charging_time: 60,
|
||||||
|
instant_power: 4200,
|
||||||
|
temperature: 39.8,
|
||||||
|
total_charged_energy: 18,
|
||||||
|
has_active_errors: false,
|
||||||
|
state_machine_e_activated: false,
|
||||||
|
user_current: 32,
|
||||||
|
current_limit_reason: 3,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestArg.method === 'GET' && url.pathname === '/rpc/app_config.get') {
|
||||||
|
json(responseArg, { headless: false, install_current: 32, led_max_brightness: 100 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestArg.method === 'GET' && url.pathname === '/rpc/dynamic_current.get') {
|
||||||
|
json(responseArg, { dynamic_current: 16, relay_mode: 3 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestArg.method === 'POST' && url.pathname === '/rpc') {
|
||||||
|
json(responseArg, { id: body?.id, src: '3p22k_500006', dst: 'HASS', result: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
json(responseArg, { error: 'not found' }, 404);
|
||||||
|
})().catch((errorArg) => {
|
||||||
|
responseArg.statusCode = 500;
|
||||||
|
responseArg.end(String(errorArg));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
const port = typeof address === 'object' && address ? address.port : 0;
|
||||||
|
const url = `http://127.0.0.1:${port}`;
|
||||||
|
const client = new LektricoClient({ url, timeoutMs: 1000 });
|
||||||
|
const snapshot = await client.getSnapshot(true);
|
||||||
|
const runtime = await new LektricoIntegration().setup({ url, timeoutMs: 1000 }, {});
|
||||||
|
const entities = await runtime.entities();
|
||||||
|
const dynamicLimit = entities.find((entityArg) => entityArg.attributes?.key === 'dynamic_limit')!;
|
||||||
|
const authentication = entities.find((entityArg) => entityArg.attributes?.key === 'authentication')!;
|
||||||
|
const forceSinglePhase = entities.find((entityArg) => entityArg.attributes?.key === 'force_single_phase')!;
|
||||||
|
const chargeStart = entities.find((entityArg) => entityArg.attributes?.key === 'charge_start')!;
|
||||||
|
|
||||||
|
const dynamicResult = await runtime.callService!({ domain: 'number', service: 'set_value', target: { entityId: dynamicLimit.id }, data: { value: 20 } });
|
||||||
|
const authResult = await runtime.callService!({ domain: 'switch', service: 'turn_off', target: { entityId: authentication.id } });
|
||||||
|
const forceResult = await runtime.callService!({ domain: 'switch', service: 'turn_on', target: { entityId: forceSinglePhase.id } });
|
||||||
|
const chargeResult = await runtime.callService!({ domain: 'button', service: 'press', target: { entityId: chargeStart.id } });
|
||||||
|
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect(snapshot.source).toEqual('http');
|
||||||
|
expect(snapshot.device.serialNumber).toEqual('500006');
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'state')?.state).toEqual('charging');
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'power')?.state).toEqual(4200);
|
||||||
|
expect(snapshot.entities.find((entityArg) => entityArg.id === 'dynamic_limit')?.state).toEqual(16);
|
||||||
|
expect(dynamicResult.success).toBeTrue();
|
||||||
|
expect(authResult.success).toBeTrue();
|
||||||
|
expect(forceResult.success).toBeTrue();
|
||||||
|
expect(chargeResult.success).toBeTrue();
|
||||||
|
expect(requests.some((requestArg) => requestArg.method === 'GET' && requestArg.url === '/rpc/Device_id.Get')).toBeTrue();
|
||||||
|
expect(requests.some((requestArg) => requestArg.body?.method === 'dynamic_current.set' && (requestArg.body.params as Record<string, unknown>).dynamic_current === 20)).toBeTrue();
|
||||||
|
expect(requests.some((requestArg) => requestArg.body?.method === 'app_config.set' && (requestArg.body.params as Record<string, unknown>).config_key === 'headless' && (requestArg.body.params as Record<string, unknown>).config_value === true)).toBeTrue();
|
||||||
|
expect(requests.some((requestArg) => requestArg.body?.method === 'dynamic_current.Set' && (requestArg.body.params as Record<string, unknown>).relay_mode === 1)).toBeTrue();
|
||||||
|
expect(requests.some((requestArg) => requestArg.body?.method === 'charge.start')).toBeTrue();
|
||||||
|
await runtime.destroy();
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('exposes Lektrico Charging Station runtime and unsupported control without executor', async () => {
|
||||||
|
const integration = new LektricoIntegration();
|
||||||
|
expect(integration.status).toEqual("control-runtime");
|
||||||
|
expect(lektricoProfile.metadata.configFlow).toEqual(true);
|
||||||
|
expect(lektricoProfile.metadata.requirements).toEqual([
|
||||||
|
"lektricowifi==0.1",
|
||||||
|
]);
|
||||||
|
expect((lektricoProfile.metadata.localApi as { status: string }).status).toContain('Native local HTTP JSON-RPC is implemented');
|
||||||
|
|
||||||
|
const runtime = await integration.setup({ name: "Lektrico Charging Station Runtime", rawData }, {});
|
||||||
|
const statusResult = await runtime.callService!({ domain: "lektrico", service: 'status', target: {} });
|
||||||
|
const refresh = await runtime.callService!({ domain: "lektrico", service: 'refresh', target: {} });
|
||||||
|
const snapshot = statusResult.data as ILektricoSnapshot;
|
||||||
|
|
||||||
|
expect(statusResult.success).toBeTrue();
|
||||||
|
expect(refresh.success).toBeTrue();
|
||||||
|
expect(snapshot.online).toBeTrue();
|
||||||
|
expect((await runtime.devices())[0].name).toEqual("Lektrico Charging Station Device");
|
||||||
|
|
||||||
|
const command = await runtime.callService!({ domain: "lektrico", service: lektricoProfile.controlServices?.[0] || '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();
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user