Add native local bridge and media integrations
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { createBondDiscoveryDescriptor } from '../../ts/integrations/bond/index.js';
|
||||
|
||||
tap.test('matches Bond zeroconf records', async () => {
|
||||
const descriptor = createBondDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'bond-zeroconf-match');
|
||||
const result = await matcher!.matches({
|
||||
name: 'ZZ12345._bond._tcp.local.',
|
||||
type: '_bond._tcp.local.',
|
||||
host: 'bond-zz12345.local',
|
||||
port: 80,
|
||||
txt: {
|
||||
bondid: 'ZZ12345',
|
||||
target: 'zermatt',
|
||||
},
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.normalizedDeviceId).toEqual('ZZ12345');
|
||||
expect(result.candidate?.host).toEqual('bond-zz12345.local');
|
||||
});
|
||||
|
||||
tap.test('matches Bond DHCP leases from Home Assistant manifest patterns', async () => {
|
||||
const descriptor = createBondDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'bond-dhcp-match');
|
||||
const result = await matcher!.matches({
|
||||
hostname: 'bond-zz12345',
|
||||
ip: '192.168.1.45',
|
||||
macaddress: '3c:6a:2c:1a:2b:3c',
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.normalizedDeviceId).toEqual('ZZ12345');
|
||||
expect(result.candidate?.source).toEqual('dhcp');
|
||||
});
|
||||
|
||||
tap.test('validates manual Bond candidates with local endpoints', async () => {
|
||||
const descriptor = createBondDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'bond-manual-match');
|
||||
const validator = descriptor.getValidators()[0];
|
||||
const match = await matcher!.matches({ host: '192.168.1.46', accessToken: 'token', name: 'Patio Bond' }, {});
|
||||
const validation = await validator.validate(match.candidate!, {});
|
||||
|
||||
expect(match.matched).toBeTrue();
|
||||
expect(validation.matched).toBeTrue();
|
||||
expect(match.candidate?.metadata?.accessToken).toEqual('token');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,88 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { BondMapper, bondActions, bondDeviceTypes } from '../../ts/integrations/bond/index.js';
|
||||
|
||||
const snapshot = BondMapper.toSnapshot({
|
||||
config: { host: '192.168.1.45', accessToken: 'token' },
|
||||
source: 'http',
|
||||
online: true,
|
||||
rawData: {
|
||||
version: {
|
||||
bondid: 'ZZ12345',
|
||||
make: 'Olibra',
|
||||
target: 'zermatt',
|
||||
fw_ver: '3.25.1',
|
||||
},
|
||||
bridge: {
|
||||
name: 'Patio Bond',
|
||||
location: 'Patio',
|
||||
},
|
||||
devices: [
|
||||
{
|
||||
deviceId: 'fan-device',
|
||||
attrs: {
|
||||
name: 'Living Room Fan',
|
||||
type: bondDeviceTypes.ceilingFan,
|
||||
actions: [bondActions.turnOn, bondActions.turnOff, bondActions.setSpeed, bondActions.setDirection, bondActions.turnLightOn, bondActions.turnLightOff, bondActions.setBrightness, bondActions.toggleLight, bondActions.stop, bondActions.preset],
|
||||
},
|
||||
props: {
|
||||
max_speed: 3,
|
||||
trust_state: true,
|
||||
},
|
||||
state: {
|
||||
power: 1,
|
||||
speed: 2,
|
||||
direction: 1,
|
||||
light: 1,
|
||||
brightness: 50,
|
||||
},
|
||||
},
|
||||
{
|
||||
deviceId: 'shade-device',
|
||||
attrs: {
|
||||
name: 'Office Shade',
|
||||
type: bondDeviceTypes.motorizedShades,
|
||||
actions: [bondActions.open, bondActions.close, bondActions.hold, bondActions.setPosition],
|
||||
},
|
||||
state: {
|
||||
open: 1,
|
||||
position: 25,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
tap.test('maps Bond snapshots to hub/device definitions and entities', async () => {
|
||||
const devices = BondMapper.toDevices(snapshot);
|
||||
const entities = BondMapper.toEntities(snapshot);
|
||||
|
||||
expect(devices[0].id).toEqual('bond.hub.zz12345');
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'bond.device.zz12345_fan_device')).toBeTrue();
|
||||
expect(entities.find((entityArg) => entityArg.id === 'fan.living_room_fan')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'fan.living_room_fan')?.attributes?.percentage).toEqual(50);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'light.living_room_fan')?.attributes?.brightness).toEqual(128);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'cover.office_shade')?.attributes?.currentPosition).toEqual(75);
|
||||
expect(entities.some((entityArg) => entityArg.id === 'button.living_room_fan_button_preset')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('maps Home Assistant-style services to Bond local API commands', async () => {
|
||||
const fanCommand = BondMapper.commandForService(snapshot, {
|
||||
domain: 'fan',
|
||||
service: 'set_percentage',
|
||||
target: { entityId: 'fan.living_room_fan' },
|
||||
data: { percentage: 100 },
|
||||
});
|
||||
const coverCommand = BondMapper.commandForService(snapshot, {
|
||||
domain: 'cover',
|
||||
service: 'set_cover_position',
|
||||
target: { entityId: 'cover.office_shade' },
|
||||
data: { position: 80 },
|
||||
});
|
||||
|
||||
expect(fanCommand?.action).toEqual(bondActions.setSpeed);
|
||||
expect(fanCommand?.argument).toEqual(3);
|
||||
expect(coverCommand?.action).toEqual(bondActions.setPosition);
|
||||
expect(coverCommand?.argument).toEqual(20);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { BondConfigFlow, BondIntegration, BondMapper, bondActions, bondDeviceTypes, type IBondCommandRequest } from '../../ts/integrations/bond/index.js';
|
||||
|
||||
const snapshot = BondMapper.toSnapshot({
|
||||
config: {},
|
||||
source: 'manual',
|
||||
online: true,
|
||||
rawData: {
|
||||
version: { bondid: 'ZZ12345', make: 'Olibra', target: 'zermatt' },
|
||||
bridge: { name: 'Patio Bond' },
|
||||
devices: [{
|
||||
deviceId: 'relay-device',
|
||||
attrs: {
|
||||
name: 'Generic Relay',
|
||||
type: bondDeviceTypes.genericDevice,
|
||||
actions: [bondActions.turnOn, bondActions.turnOff],
|
||||
},
|
||||
state: { power: 0 },
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
tap.test('does not fake control success for snapshot-only Bond configs', async () => {
|
||||
const runtime = await new BondIntegration().setup({ snapshot }, {});
|
||||
const status = await runtime.callService!({ domain: 'bond', service: 'snapshot', target: {} });
|
||||
const control = await runtime.callService!({ domain: 'switch', service: 'turn_on', target: { entityId: 'switch.generic_relay' } });
|
||||
|
||||
expect(status.success).toBeTrue();
|
||||
expect(control.success).toBeFalse();
|
||||
expect(control.error!.includes('Static snapshots/manual data are read-only')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('uses injected commandExecutor for Bond controls', async () => {
|
||||
const calls: IBondCommandRequest[] = [];
|
||||
const runtime = await new BondIntegration().setup({
|
||||
snapshot,
|
||||
commandExecutor: {
|
||||
execute: async (commandArg) => {
|
||||
calls.push(commandArg);
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
}, {});
|
||||
const result = await runtime.callService!({ domain: 'switch', service: 'turn_off', target: { entityId: 'switch.generic_relay' } });
|
||||
|
||||
expect(result.success).toBeTrue();
|
||||
expect(calls[0].deviceId).toEqual('relay-device');
|
||||
expect(calls[0].action).toEqual(bondActions.turnOff);
|
||||
});
|
||||
|
||||
tap.test('builds Bond config from manual config flow input', async () => {
|
||||
const flow = new BondConfigFlow();
|
||||
const step = await flow.start({ source: 'manual', host: '192.168.1.45', name: 'Patio Bond' }, {});
|
||||
const done = await step.submit!({ accessToken: 'token' });
|
||||
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.host).toEqual('192.168.1.45');
|
||||
expect(done.config?.accessToken).toEqual('token');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createServer, type Socket } from 'node:net';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DenonClient } from '../../ts/integrations/denon/index.js';
|
||||
|
||||
tap.test('reads Denon telnet state and sends control commands', async () => {
|
||||
const commands: string[] = [];
|
||||
const server = createServer((socketArg) => {
|
||||
let buffer = '';
|
||||
socketArg.setEncoding('ascii');
|
||||
socketArg.on('data', (chunkArg) => {
|
||||
buffer += String(chunkArg);
|
||||
const parts = buffer.split('\r');
|
||||
buffer = parts.pop() || '';
|
||||
for (const part of parts) {
|
||||
handleCommand(part.trim(), socketArg, commands);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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 DenonClient({ host: '127.0.0.1', port, timeoutMs: 500, responseIdleMs: 50 });
|
||||
const snapshot = await client.getSnapshot();
|
||||
await client.execute({ command: 'set_volume', volumeLevel: 0.5 });
|
||||
await client.execute({ command: 'select_source', source: 'TV' });
|
||||
|
||||
expect(snapshot.online).toBeTrue();
|
||||
expect(snapshot.receiverInfo.name).toEqual('Living Room Denon');
|
||||
expect(snapshot.state.state).toEqual('playing');
|
||||
expect(snapshot.state.volume).toEqual(27);
|
||||
expect(snapshot.state.volumeLevel).toEqual(0.45);
|
||||
expect(snapshot.state.muted).toBeFalse();
|
||||
expect(snapshot.state.sourceName).toEqual('Media server');
|
||||
expect(snapshot.state.mediaTitle).toContain('Track One');
|
||||
expect(commands.includes('MV30')).toBeTrue();
|
||||
expect(commands.includes('SITV')).toBeTrue();
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
const handleCommand = (commandArg: string, socketArg: Socket, commandsArg: string[]): void => {
|
||||
commandsArg.push(commandArg);
|
||||
const responses: Record<string, string[]> = {
|
||||
'NSFRN ?': ['NSFRN Living Room Denon'],
|
||||
'SSFUN ?': ['SSFUNCD CD', 'SSFUNSERVER Media server', 'SSFUNTV TV'],
|
||||
'SSSOD ?': ['SSSODV.AUX DEL'],
|
||||
'PW?': ['PWON'],
|
||||
'MV?': ['MVMAX 60', 'MV27'],
|
||||
'MU?': ['MUOFF'],
|
||||
'SI?': ['SISERVER'],
|
||||
NSE: ['NSE0Track One', 'NSE1XArtist One', 'NSE2XAlbum One'],
|
||||
};
|
||||
for (const response of responses[commandArg] || []) {
|
||||
socketArg.write(`${response}\r`, 'ascii');
|
||||
}
|
||||
};
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,73 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DenonIntegration, createDenonDiscoveryDescriptor, type IDenonSnapshot } from '../../ts/integrations/denon/index.js';
|
||||
|
||||
const snapshot: IDenonSnapshot = {
|
||||
receiverInfo: {
|
||||
name: 'Static Denon',
|
||||
manufacturer: 'Denon',
|
||||
modelName: 'DRA-N5',
|
||||
serialNumber: 'ABC12345',
|
||||
},
|
||||
state: {
|
||||
power: 'PWSTANDBY',
|
||||
state: 'off',
|
||||
volumeMax: 60,
|
||||
muted: true,
|
||||
source: 'CD',
|
||||
sourceName: 'CD',
|
||||
sourceList: ['CD', 'TV'],
|
||||
},
|
||||
sourceMap: { CD: 'CD', TV: 'TV' },
|
||||
online: true,
|
||||
available: true,
|
||||
source: 'manual',
|
||||
};
|
||||
|
||||
tap.test('matches manual Denon entries and creates config flow output', async () => {
|
||||
const descriptor = createDenonDiscoveryDescriptor();
|
||||
const manualMatcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'denon-manual-match');
|
||||
const match = await manualMatcher!.matches({ name: 'Denon import', snapshot }, {});
|
||||
|
||||
expect(match.matched).toBeTrue();
|
||||
expect(match.normalizedDeviceId).toEqual('ABC12345');
|
||||
expect(match.candidate?.integrationDomain).toEqual('denon');
|
||||
|
||||
const validation = await descriptor.getValidators()[0].validate(match.candidate!, {});
|
||||
expect(validation.matched).toBeTrue();
|
||||
|
||||
const done = await (await new DenonIntegration().configFlow.start(match.candidate!, {})).submit!({});
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.name).toEqual('Static Denon');
|
||||
expect(done.config?.port).toEqual(23);
|
||||
expect(done.config?.snapshot?.state.muted).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('does not report command success for read-only static snapshots', async () => {
|
||||
const runtime = await new DenonIntegration().setup({ snapshot }, {});
|
||||
const result = await runtime.callService?.({ domain: 'media_player', service: 'turn_off', target: { entityId: 'media_player.static_denon' }, data: {} });
|
||||
|
||||
expect(result?.success).toBeFalse();
|
||||
expect(result?.error).toContain('Static snapshots/manual data are read-only');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
tap.test('delegates Denon commands to an injected executor', async () => {
|
||||
const commands: unknown[] = [];
|
||||
const runtime = await new DenonIntegration().setup({
|
||||
snapshot,
|
||||
commandExecutor: {
|
||||
execute: async (requestArg) => {
|
||||
commands.push(requestArg);
|
||||
return [];
|
||||
},
|
||||
},
|
||||
}, {});
|
||||
|
||||
const result = await runtime.callService?.({ domain: 'media_player', service: 'turn_on', target: {}, data: {} });
|
||||
|
||||
expect(result?.success).toBeTrue();
|
||||
expect((commands[0] as { rawCommand?: string }).rawCommand).toEqual('PWON');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DenonMapper, type IDenonSnapshot } from '../../ts/integrations/denon/index.js';
|
||||
|
||||
const snapshot: IDenonSnapshot = {
|
||||
receiverInfo: {
|
||||
host: '192.168.1.60',
|
||||
port: 23,
|
||||
name: 'Living Denon',
|
||||
manufacturer: 'Denon',
|
||||
modelName: 'DRA-N5',
|
||||
serialNumber: 'ABC12345',
|
||||
},
|
||||
state: {
|
||||
power: 'PWON',
|
||||
state: 'playing',
|
||||
volume: 27,
|
||||
volumeMax: 60,
|
||||
volumeLevel: 0.45,
|
||||
muted: false,
|
||||
source: 'SERVER',
|
||||
sourceName: 'Media server',
|
||||
sourceList: ['CD', 'Media server', 'TV'],
|
||||
mediaTitle: 'Track One\nArtist One',
|
||||
mediaMode: true,
|
||||
},
|
||||
sourceMap: { CD: 'CD', 'Media server': 'SERVER', TV: 'TV' },
|
||||
online: true,
|
||||
available: true,
|
||||
source: 'telnet',
|
||||
};
|
||||
|
||||
tap.test('maps Denon snapshots to canonical devices', async () => {
|
||||
const devices = DenonMapper.toDevices(snapshot);
|
||||
|
||||
expect(devices[0].id).toEqual('denon.receiver.abc12345');
|
||||
expect(devices[0].manufacturer).toEqual('Denon');
|
||||
expect(devices[0].state.some((stateArg) => stateArg.featureId === 'source' && stateArg.value === 'Media server')).toBeTrue();
|
||||
expect(devices[0].state.some((stateArg) => stateArg.featureId === 'volume' && stateArg.value === 45)).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('maps Denon snapshots to media player, sensor, and mute entities', async () => {
|
||||
const entities = DenonMapper.toEntities(snapshot);
|
||||
const media = entities.find((entityArg) => entityArg.id === 'media_player.living_denon');
|
||||
const source = entities.find((entityArg) => entityArg.id === 'sensor.living_denon_source');
|
||||
const mute = entities.find((entityArg) => entityArg.id === 'switch.living_denon_mute');
|
||||
|
||||
expect(media?.platform).toEqual('media_player');
|
||||
expect(media?.state).toEqual('playing');
|
||||
expect(media?.attributes?.volumeLevel).toEqual(0.45);
|
||||
expect(media?.attributes?.source).toEqual('Media server');
|
||||
expect(source?.state).toEqual('Media server');
|
||||
expect(mute?.state).toBeFalse();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,115 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { ElgatoClient, ElgatoIntegration, ElgatoMapper, type IElgatoCommandRequest } from '../../ts/integrations/elgato/index.js';
|
||||
|
||||
tap.test('reads Elgato snapshots and executes light commands over native local HTTP', async () => {
|
||||
const requests: Array<{ url: string; method?: string; body: string }> = [];
|
||||
const server = createServer((requestArg, responseArg) => {
|
||||
const chunks: Buffer[] = [];
|
||||
requestArg.on('data', (chunkArg) => chunks.push(Buffer.from(chunkArg)));
|
||||
requestArg.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf8');
|
||||
requests.push({ url: requestArg.url || '', method: requestArg.method, body });
|
||||
responseArg.setHeader('content-type', 'application/json');
|
||||
|
||||
if (requestArg.url === '/elgato/accessory-info' && requestArg.method === 'GET') {
|
||||
responseArg.end(JSON.stringify({ productName: 'Elgato Key Light', serialNumber: 'ELG123', displayName: 'Desk Key Light', firmwareVersion: '1.0.3', firmwareBuildNumber: 42, hardwareBoardType: 7, macAddress: 'AABBCCDDEEFF', features: ['lights'] }));
|
||||
return;
|
||||
}
|
||||
if (requestArg.url === '/elgato/lights/settings' && requestArg.method === 'GET') {
|
||||
responseArg.end(JSON.stringify({ colorChangeDurationMs: 100, powerOnBehavior: 1, powerOnBrightness: 40, switchOffDurationMs: 300, switchOnDurationMs: 300 }));
|
||||
return;
|
||||
}
|
||||
if (requestArg.url === '/elgato/lights' && requestArg.method === 'GET') {
|
||||
responseArg.end(JSON.stringify({ numberOfLights: 1, lights: [{ on: 1, brightness: 35, temperature: 200 }] }));
|
||||
return;
|
||||
}
|
||||
if (requestArg.url === '/elgato/lights' && requestArg.method === 'PUT') {
|
||||
responseArg.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
if (requestArg.url === '/elgato/identify' && requestArg.method === 'POST') {
|
||||
responseArg.end(JSON.stringify({ ok: true }));
|
||||
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 client = new ElgatoClient({ host: '127.0.0.1', port, timeoutMs: 1000 });
|
||||
const snapshot = await client.getSnapshot();
|
||||
const light = await client.setLight({ on: true, brightness: 50, colorTemperatureKelvin: 5000 });
|
||||
const identify = await client.identify();
|
||||
|
||||
expect(snapshot.online).toBeTrue();
|
||||
expect(snapshot.source).toEqual('http');
|
||||
expect(snapshot.device.serialNumber).toEqual('ELG123');
|
||||
expect(snapshot.device.macAddress).toEqual('aa:bb:cc:dd:ee:ff');
|
||||
expect(snapshot.light.colorTemperatureKelvin).toEqual(5000);
|
||||
expect(light.ok).toBeTrue();
|
||||
expect(identify.ok).toBeTrue();
|
||||
const putBody = JSON.parse(requests.find((requestArg) => requestArg.url === '/elgato/lights' && requestArg.method === 'PUT')!.body) as { lights: Array<Record<string, unknown>> };
|
||||
expect(putBody.lights[0].on).toEqual(1);
|
||||
expect(putBody.lights[0].brightness).toEqual(50);
|
||||
expect(putBody.lights[0].temperature).toEqual(200);
|
||||
expect(requests.some((requestArg) => requestArg.url === '/elgato/identify' && requestArg.method === 'POST')).toBeTrue();
|
||||
await client.destroy();
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('does not report live light command success without HTTP transport or executor', async () => {
|
||||
const snapshot = ElgatoMapper.toSnapshot({
|
||||
config: { name: 'Static Key Light' },
|
||||
rawData: {
|
||||
info: { productName: 'Elgato Key Light', serialNumber: 'ELGSTATIC', displayName: 'Static Key Light' },
|
||||
settings: { colorChangeDurationMs: 100, powerOnBehavior: 1, powerOnBrightness: 40, switchOffDurationMs: 300, switchOnDurationMs: 300 },
|
||||
state: { on: 1, brightness: 40, temperature: 200 },
|
||||
},
|
||||
online: true,
|
||||
source: 'manual',
|
||||
});
|
||||
const runtime = await new ElgatoIntegration().setup({ snapshot }, {});
|
||||
const result = await runtime.callService?.({ domain: 'light', service: 'turn_off', target: { entityId: 'light.static_key_light' }, data: {} });
|
||||
|
||||
expect(result?.success).toBeFalse();
|
||||
expect(result?.error).toContain('Static snapshots/manual data are read-only');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
tap.test('models Elgato commands through an injected executor', async () => {
|
||||
const commands: IElgatoCommandRequest[] = [];
|
||||
const snapshot = ElgatoMapper.toSnapshot({
|
||||
config: { name: 'Executor Key Light' },
|
||||
rawData: {
|
||||
info: { productName: 'Elgato Key Light', serialNumber: 'ELGEXEC', displayName: 'Executor Key Light' },
|
||||
settings: { colorChangeDurationMs: 100, powerOnBehavior: 1, powerOnBrightness: 40, switchOffDurationMs: 300, switchOnDurationMs: 300 },
|
||||
state: { on: 1, brightness: 40, temperature: 200 },
|
||||
},
|
||||
online: true,
|
||||
source: 'manual',
|
||||
});
|
||||
const runtime = await new ElgatoIntegration().setup({
|
||||
snapshot,
|
||||
commandExecutor: {
|
||||
execute: async (requestArg) => {
|
||||
commands.push(requestArg);
|
||||
return { ok: true, accepted: requestArg.action };
|
||||
},
|
||||
},
|
||||
}, {});
|
||||
|
||||
const result = await runtime.callService?.({ domain: 'button', service: 'press', target: { entityId: 'button.executor_key_light_identify' } });
|
||||
|
||||
expect(result?.success).toBeTrue();
|
||||
expect(commands[0].action).toEqual('identify');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,76 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { ElgatoConfigFlow, ElgatoMapper, createElgatoDiscoveryDescriptor, type IElgatoRawData } from '../../ts/integrations/elgato/index.js';
|
||||
|
||||
tap.test('matches Elgato zeroconf records and creates config flow output', async () => {
|
||||
const descriptor = createElgatoDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'elgato-zeroconf-match');
|
||||
const result = await matcher!.matches({
|
||||
type: '_elg._tcp.local.',
|
||||
name: 'Office Key Light._elg._tcp.local.',
|
||||
host: 'key-light.local',
|
||||
port: 9123,
|
||||
properties: { id: 'AABBCCDDEEFF', name: 'Office Key Light' },
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.integrationDomain).toEqual('elgato');
|
||||
expect(result.candidate?.host).toEqual('key-light.local');
|
||||
expect(result.candidate?.macAddress).toEqual('aa:bb:cc:dd:ee:ff');
|
||||
|
||||
const done = await (await new ElgatoConfigFlow().start(result.candidate!, {})).submit!({});
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.host).toEqual('key-light.local');
|
||||
expect(done.config?.port).toEqual(9123);
|
||||
expect(done.config?.macAddress).toEqual('aa:bb:cc:dd:ee:ff');
|
||||
});
|
||||
|
||||
tap.test('matches DHCP registered-device Elgato candidates', async () => {
|
||||
const descriptor = createElgatoDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'elgato-dhcp-match');
|
||||
const result = await matcher!.matches({
|
||||
ip: '192.0.2.22',
|
||||
macaddress: 'AABBCCDDEEFF',
|
||||
hostname: 'elgato-key-light-aabbcc',
|
||||
registeredDevices: true,
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.source).toEqual('dhcp');
|
||||
expect(result.candidate?.host).toEqual('192.0.2.22');
|
||||
expect(result.candidate?.port).toEqual(9123);
|
||||
expect(result.normalizedDeviceId).toEqual('aa:bb:cc:dd:ee:ff');
|
||||
});
|
||||
|
||||
tap.test('matches manual snapshots without requiring live HTTP', async () => {
|
||||
const rawData: Partial<IElgatoRawData> = {
|
||||
info: { productName: 'Elgato Key Light', serialNumber: 'ELG123', displayName: 'Manual Key Light', firmwareVersion: '1.0.3', firmwareBuildNumber: 42, hardwareBoardType: 7 },
|
||||
settings: { colorChangeDurationMs: 100, powerOnBehavior: 1, powerOnBrightness: 40, switchOffDurationMs: 300, switchOnDurationMs: 300 },
|
||||
state: { on: 1, brightness: 35, temperature: 200 },
|
||||
};
|
||||
const snapshot = ElgatoMapper.toSnapshot({ config: { name: 'Manual Key Light' }, rawData, online: true, source: 'manual' });
|
||||
const descriptor = createElgatoDiscoveryDescriptor();
|
||||
const manualMatcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'elgato-manual-match');
|
||||
const match = await manualMatcher!.matches({ name: 'Manual import', snapshot }, {});
|
||||
|
||||
expect(match.matched).toBeTrue();
|
||||
expect(match.candidate?.metadata?.snapshot).toEqual(snapshot);
|
||||
const validation = await descriptor.getValidators()[0].validate(match.candidate!, {});
|
||||
expect(validation.matched).toBeTrue();
|
||||
|
||||
const done = await (await new ElgatoConfigFlow().start(match.candidate!, {})).submit!({});
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.snapshot?.light.on).toBeTrue();
|
||||
expect(done.config?.snapshot?.device.serialNumber).toEqual('ELG123');
|
||||
});
|
||||
|
||||
tap.test('rejects non-Elgato and unusable candidates', async () => {
|
||||
const descriptor = createElgatoDiscoveryDescriptor();
|
||||
const manualMatcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'elgato-manual-match');
|
||||
const generic = await manualMatcher!.matches({ name: 'Generic light' }, {});
|
||||
expect(generic.matched).toBeFalse();
|
||||
|
||||
const validation = await descriptor.getValidators()[0].validate({ source: 'manual', name: 'Elgato without host' }, {});
|
||||
expect(validation.matched).toBeFalse();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { ElgatoMapper, type IElgatoRawData } from '../../ts/integrations/elgato/index.js';
|
||||
|
||||
const rawData: Partial<IElgatoRawData> = {
|
||||
info: {
|
||||
productName: 'Elgato Key Light Mini',
|
||||
serialNumber: 'ELG123',
|
||||
displayName: 'Desk Key Light',
|
||||
firmwareVersion: '1.0.3',
|
||||
firmwareBuildNumber: 42,
|
||||
hardwareBoardType: 7,
|
||||
macAddress: 'AABBCCDDEEFF',
|
||||
},
|
||||
settings: {
|
||||
colorChangeDurationMs: 100,
|
||||
powerOnBehavior: 1,
|
||||
powerOnBrightness: 40,
|
||||
switchOffDurationMs: 300,
|
||||
switchOnDurationMs: 300,
|
||||
battery: {
|
||||
bypass: 1,
|
||||
energySaving: { enable: 0, minimumBatteryLevel: 20, disableWifi: 0, adjustBrightness: { enable: 1, brightness: 25 } },
|
||||
},
|
||||
},
|
||||
state: { on: 1, brightness: 50, temperature: 200 },
|
||||
battery: { level: 88, status: 2, powerSource: 1, currentBatteryVoltage: 4100, inputChargeVoltage: 5000, inputChargeCurrent: 1000 },
|
||||
};
|
||||
|
||||
tap.test('maps Elgato raw API data to snapshots, devices, entities, and battery settings', async () => {
|
||||
const snapshot = ElgatoMapper.toSnapshot({ config: { host: 'key-light.local' }, rawData, online: true, source: 'manual' });
|
||||
const devices = ElgatoMapper.toDevices(snapshot);
|
||||
const entities = ElgatoMapper.toEntities(snapshot);
|
||||
|
||||
expect(snapshot.device.serialNumber).toEqual('ELG123');
|
||||
expect(snapshot.device.macAddress).toEqual('aa:bb:cc:dd:ee:ff');
|
||||
expect(snapshot.light.on).toBeTrue();
|
||||
expect(snapshot.light.brightness255).toEqual(128);
|
||||
expect(snapshot.light.colorTemperatureKelvin).toEqual(5000);
|
||||
expect(snapshot.light.supportedColorModes).toEqual(['color_temp']);
|
||||
expect(snapshot.battery?.level).toEqual(88);
|
||||
expect(snapshot.battery?.chargePower).toEqual(5);
|
||||
expect(snapshot.battery?.bypass).toBeTrue();
|
||||
expect(snapshot.battery?.energySavingEnabled).toBeFalse();
|
||||
expect(devices[0].id).toEqual('elgato.light.elg123');
|
||||
expect(devices[0].metadata?.serialNumber).toEqual('ELG123');
|
||||
expect(entities.find((entityArg) => entityArg.attributes?.key === 'light')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.attributes?.key === 'battery')?.state).toEqual(88);
|
||||
expect(entities.find((entityArg) => entityArg.attributes?.key === 'bypass')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.attributes?.key === 'identify')?.available).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('maps Home Assistant style services to Elgato command shapes', async () => {
|
||||
const snapshot = ElgatoMapper.toSnapshot({ config: { host: 'key-light.local' }, rawData, online: true, source: 'manual' });
|
||||
const turnOn = ElgatoMapper.commandForService(snapshot, { domain: 'light', service: 'turn_on', target: { entityId: 'light.desk_key_light' }, data: { brightness: 128, color_temp_kelvin: 5000 } });
|
||||
const turnOff = ElgatoMapper.commandForService(snapshot, { domain: 'light', service: 'turn_off', target: { entityId: 'light.desk_key_light' }, data: {} });
|
||||
const identify = ElgatoMapper.commandForService(snapshot, { domain: 'button', service: 'press', target: { entityId: 'button.desk_key_light_identify' } });
|
||||
const bypassOff = ElgatoMapper.commandForService(snapshot, { domain: 'switch', service: 'turn_off', target: { entityId: 'switch.desk_key_light_bypass' } });
|
||||
|
||||
expect(turnOn?.action).toEqual('set_light');
|
||||
expect(turnOn?.on).toBeTrue();
|
||||
expect(turnOn?.brightness).toEqual(50);
|
||||
expect(turnOn?.colorTemperatureKelvin).toEqual(5000);
|
||||
expect(turnOff?.on).toBeFalse();
|
||||
expect(identify?.action).toEqual('identify');
|
||||
expect(bypassOff?.action).toEqual('battery_bypass');
|
||||
expect(bypassOff?.enabled).toBeFalse();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,52 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { HarmonyConfigFlow, createHarmonyDiscoveryDescriptor } from '../../ts/integrations/harmony/index.js';
|
||||
|
||||
tap.test('matches Harmony Hub SSDP advertisements from the upstream manifest', async () => {
|
||||
const descriptor = createHarmonyDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const result = await matcher.matches({
|
||||
deviceType: 'urn:myharmony-com:device:harmony:1',
|
||||
manufacturer: 'Logitech',
|
||||
friendlyName: 'Living Room Harmony',
|
||||
location: 'http://192.168.1.40:5224/description.xml',
|
||||
usn: 'uuid:harmony-hub-123::urn:myharmony-com:device:harmony:1',
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.confidence).toEqual('certain');
|
||||
expect(result.candidate?.integrationDomain).toEqual('harmony');
|
||||
expect(result.candidate?.host).toEqual('192.168.1.40');
|
||||
expect(result.candidate?.port).toEqual(8088);
|
||||
expect(result.candidate?.manufacturer).toEqual('Logitech');
|
||||
expect(result.candidate?.metadata?.ssdpDeviceType).toEqual('urn:myharmony-com:device:harmony:1');
|
||||
});
|
||||
|
||||
tap.test('matches manual Harmony Hub host entries', async () => {
|
||||
const descriptor = createHarmonyDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[1];
|
||||
const result = await matcher.matches({
|
||||
host: '192.168.1.41',
|
||||
name: 'Bedroom Hub',
|
||||
remoteId: '987654321',
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.host).toEqual('192.168.1.41');
|
||||
expect(result.candidate?.port).toEqual(8088);
|
||||
expect(result.normalizedDeviceId).toEqual('987654321');
|
||||
});
|
||||
|
||||
tap.test('creates local-first config flow entries', async () => {
|
||||
const flow = new HarmonyConfigFlow();
|
||||
const step = await flow.start({ source: 'ssdp', host: '192.168.1.42', name: 'Office Hub', metadata: { remoteId: 'hub-42' } }, {});
|
||||
const done = await step.submit?.({ host: '192.168.1.42', defaultActivity: 'Watch TV' });
|
||||
|
||||
expect(done?.kind).toEqual('done');
|
||||
expect(done?.config?.host).toEqual('192.168.1.42');
|
||||
expect(done?.config?.port).toEqual(8088);
|
||||
expect(done?.config?.remoteId).toEqual('hub-42');
|
||||
expect(done?.config?.defaultActivity).toEqual('Watch TV');
|
||||
expect(done?.config?.delaySecs).toEqual(0.4);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,63 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { HarmonyClient, HarmonyMapper, type IHarmonyConfig } from '../../ts/integrations/harmony/index.js';
|
||||
|
||||
const config: IHarmonyConfig = {
|
||||
host: '192.168.1.40',
|
||||
name: 'Living Room Harmony',
|
||||
remoteId: '123456789',
|
||||
state: {
|
||||
available: true,
|
||||
currentActivity: 'Watch TV',
|
||||
lastActivity: 'Listen to Music',
|
||||
},
|
||||
hubConfig: {
|
||||
global: {
|
||||
timeStampHash: 'abc;123456789',
|
||||
},
|
||||
activity: [
|
||||
{ id: '-1', label: 'PowerOff' },
|
||||
{ id: '111', label: 'Watch TV' },
|
||||
{ id: '222', label: 'Listen to Music' },
|
||||
],
|
||||
device: [
|
||||
{
|
||||
id: '333',
|
||||
label: 'Television',
|
||||
manufacturer: 'Sony',
|
||||
model: 'TV',
|
||||
controlGroup: [
|
||||
{ name: 'Volume', function: [{ name: 'VolumeUp' }, { name: 'VolumeDown' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
tap.test('normalizes upstream-shaped hub config snapshots', async () => {
|
||||
const snapshot = await new HarmonyClient(config).getSnapshot();
|
||||
|
||||
expect(snapshot.deviceInfo.name).toEqual('Living Room Harmony');
|
||||
expect(snapshot.activities.map((activityArg) => activityArg.label)).toEqual(['PowerOff', 'Watch TV', 'Listen to Music']);
|
||||
expect(snapshot.devices[0].commands?.map((commandArg) => commandArg.name)).toEqual(['VolumeUp', 'VolumeDown']);
|
||||
expect(snapshot.state.currentActivityId).toEqual('111');
|
||||
});
|
||||
|
||||
tap.test('maps Harmony snapshots to media and select entities', async () => {
|
||||
const snapshot = await new HarmonyClient(config).getSnapshot();
|
||||
const devices = HarmonyMapper.toDevices(snapshot);
|
||||
const entities = HarmonyMapper.toEntities(snapshot);
|
||||
|
||||
expect(devices[0].id).toEqual('harmony.device.123456789');
|
||||
expect(devices[0].online).toBeTrue();
|
||||
expect(devices[0].state.some((stateArg) => stateArg.featureId === 'activity' && stateArg.value === 'Watch TV')).toBeTrue();
|
||||
expect((devices[0].metadata?.activities as string[]).includes('Listen to Music')).toBeTrue();
|
||||
|
||||
expect(entities[0].platform).toEqual('media_player');
|
||||
expect(entities[0].state).toEqual('on');
|
||||
expect(entities[0].attributes?.currentActivity).toEqual('Watch TV');
|
||||
expect(entities[1].platform).toEqual('select');
|
||||
expect(entities[1].state).toEqual('Watch TV');
|
||||
expect(entities[1].attributes?.options).toEqual(['power_off', 'Listen to Music', 'Watch TV']);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,91 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { HarmonyIntegration, type IHarmonyCommand, type IHarmonyConfig } from '../../ts/integrations/harmony/index.js';
|
||||
|
||||
const baseConfig = (commandsArg: IHarmonyCommand[] = []): IHarmonyConfig => ({
|
||||
host: '192.168.1.40',
|
||||
name: 'Living Room Harmony',
|
||||
remoteId: '123456789',
|
||||
defaultActivity: 'Watch TV',
|
||||
snapshot: {
|
||||
deviceInfo: {
|
||||
id: '123456789',
|
||||
name: 'Living Room Harmony',
|
||||
host: '192.168.1.40',
|
||||
remoteId: '123456789',
|
||||
},
|
||||
state: {
|
||||
available: true,
|
||||
currentActivity: 'PowerOff',
|
||||
currentActivityId: '-1',
|
||||
lastActivity: 'Listen to Music',
|
||||
},
|
||||
activities: [
|
||||
{ id: '-1', label: 'PowerOff', isPowerOff: true },
|
||||
{ id: '111', label: 'Watch TV' },
|
||||
{ id: '222', label: 'Listen to Music' },
|
||||
],
|
||||
devices: [
|
||||
{
|
||||
id: '333',
|
||||
label: 'Television',
|
||||
commands: [{ name: 'VolumeUp' }, { name: 'Mute' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
executor: async (commandArg) => {
|
||||
commandsArg.push(commandArg);
|
||||
},
|
||||
});
|
||||
|
||||
tap.test('models Harmony activity and remote commands through an injected executor', async () => {
|
||||
const commands: IHarmonyCommand[] = [];
|
||||
const runtime = await new HarmonyIntegration().setup(baseConfig(commands), {});
|
||||
|
||||
expect((await runtime.callService?.({ domain: 'remote', service: 'turn_on', target: {}, data: { activity: 'Watch TV' } }))?.success).toBeTrue();
|
||||
expect(commands[0].action).toEqual('start_activity');
|
||||
expect(commands[0].activityId).toEqual('111');
|
||||
expect(commands[0].activity).toEqual('Watch TV');
|
||||
|
||||
expect((await runtime.callService?.({ domain: 'remote', service: 'send_command', target: {}, data: { device: 'Television', command: ['VolumeUp', 'Mute'], num_repeats: 2, hold_secs: 0.25 } }))?.success).toBeTrue();
|
||||
expect(commands[1].action).toEqual('send_command');
|
||||
expect(commands[1].deviceId).toEqual('333');
|
||||
expect(commands[1].numRepeats).toEqual(2);
|
||||
expect(commands[1].holdSecs).toEqual(0.25);
|
||||
expect(commands[1].sequence?.map((stepArg) => stepArg.command)).toEqual(['VolumeUp', 'Mute', 'VolumeUp', 'Mute']);
|
||||
|
||||
expect((await runtime.callService?.({ domain: 'harmony', service: 'change_channel', target: {}, data: { channel: 101 } }))?.success).toBeTrue();
|
||||
expect(commands[2].action).toEqual('change_channel');
|
||||
expect(commands[2].channel).toEqual(101);
|
||||
|
||||
expect((await runtime.callService?.({ domain: 'select', service: 'select_option', target: {}, data: { option: 'power_off' } }))?.success).toBeTrue();
|
||||
expect(commands[3].action).toEqual('power_off');
|
||||
expect(commands[3].activity).toEqual('PowerOff');
|
||||
});
|
||||
|
||||
tap.test('returns explicit unsupported errors without an executor', async () => {
|
||||
const runtime = await new HarmonyIntegration().setup({ host: '192.168.1.40', activities: [{ id: '111', label: 'Watch TV' }] }, {});
|
||||
const result = await runtime.callService?.({ domain: 'remote', service: 'turn_on', target: {}, data: { activity: 'Watch TV' } });
|
||||
|
||||
expect(result?.success).toBeFalse();
|
||||
expect(result?.error).toContain('requires an injected client/executor');
|
||||
});
|
||||
|
||||
tap.test('honors explicit Previous Active Activity over the configured default', async () => {
|
||||
const commands: IHarmonyCommand[] = [];
|
||||
const runtime = await new HarmonyIntegration().setup(baseConfig(commands), {});
|
||||
const result = await runtime.callService?.({ domain: 'remote', service: 'turn_on', target: {}, data: { activity: 'Previous Active Activity' } });
|
||||
|
||||
expect(result?.success).toBeTrue();
|
||||
expect(commands[0].activity).toEqual('Listen to Music');
|
||||
expect(commands[0].activityId).toEqual('222');
|
||||
});
|
||||
|
||||
tap.test('does not implement cloud-backed sync', async () => {
|
||||
const runtime = await new HarmonyIntegration().setup(baseConfig([]), {});
|
||||
const result = await runtime.callService?.({ domain: 'harmony', service: 'sync', target: {} });
|
||||
|
||||
expect(result?.success).toBeFalse();
|
||||
expect(result?.error).toContain('cloud-backed');
|
||||
});
|
||||
|
||||
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 { HuaweiLteClient, HuaweiLteIntegration, HuaweiLteMapper, type IHuaweiLteRawData } from '../../ts/integrations/huawei_lte/index.js';
|
||||
|
||||
const xmlResponse = (itemsArg: Record<string, string | number | boolean>): string => {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?><response>${Object.entries(itemsArg).map(([key, value]) => `<${key}>${value}</${key}>`).join('')}</response>`;
|
||||
};
|
||||
|
||||
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');
|
||||
};
|
||||
|
||||
tap.test('reads Huawei LTE snapshots over native local HTTP and writes real POST commands', async () => {
|
||||
const requests: Array<{ method?: string; url?: string; body?: string }> = [];
|
||||
const server = createServer((requestArg: IncomingMessage, responseArg: ServerResponse) => {
|
||||
void (async () => {
|
||||
const body = requestArg.method === 'POST' ? await readBody(requestArg) : undefined;
|
||||
requests.push({ method: requestArg.method, url: requestArg.url, body });
|
||||
responseArg.setHeader('content-type', 'application/xml');
|
||||
responseArg.setHeader('__RequestVerificationToken', 'token-next');
|
||||
const url = new URL(requestArg.url || '/', 'http://127.0.0.1');
|
||||
|
||||
if (url.pathname === '/') {
|
||||
responseArg.setHeader('content-type', 'text/html');
|
||||
responseArg.end('<html><head><meta name="csrf_token" content="token-1"></head></html>');
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/device/information') {
|
||||
responseArg.end(xmlResponse({ DeviceName: 'B535-232', SerialNumber: 'LIVE123', SoftwareVersion: '12.0.5.1', MacAddress1: 'AABBCCDDEEFF' }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/device/basic_information') {
|
||||
responseArg.end(xmlResponse({ devicename: 'B535-232' }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/device/signal') {
|
||||
responseArg.end(xmlResponse({ rsrp: '-90dBm', rsrq: '-11dB', sinr: '20dB' }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/dialup/mobile-dataswitch' && requestArg.method === 'GET') {
|
||||
responseArg.end(xmlResponse({ dataswitch: '1' }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/monitoring/status') {
|
||||
responseArg.end(xmlResponse({ ConnectionStatus: '901', WifiStatus: '1', CurrentWifiUser: '2' }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/monitoring/check-notifications') {
|
||||
responseArg.end(xmlResponse({ UnreadMessage: '1', SmsStorageFull: '0' }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/monitoring/traffic-statistics') {
|
||||
responseArg.end(xmlResponse({ CurrentDownloadRate: '128', CurrentUploadRate: '64', TotalDownload: '4096', TotalUpload: '8192' }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/monitoring/month_statistics') {
|
||||
responseArg.end(xmlResponse({ CurrentMonthDownload: '1000', CurrentMonthUpload: '2000' }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/net/current-plmn') {
|
||||
responseArg.end(xmlResponse({ FullName: 'Example Mobile', Numeric: '00101', State: '0' }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/net/net-mode' && requestArg.method === 'GET') {
|
||||
responseArg.end(xmlResponse({ NetworkMode: '03', NetworkBand: '3fffffff', LTEBand: '7fffffffffffffff' }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/sms/sms-count') {
|
||||
responseArg.end(xmlResponse({ LocalUnread: '1', LocalInbox: '3', SimMax: '50' }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/wlan/wifi-feature-switch') {
|
||||
responseArg.end(xmlResponse({ wifi24g_switch_enable: '1', wifi5g_enabled: '1' }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/lan/HostInfo') {
|
||||
responseArg.end('<?xml version="1.0" encoding="UTF-8"?><response><Hosts><Host><MacAddress>11:22:33:44:55:66</MacAddress><HostName>phone</HostName><IpAddress>192.168.8.10</IpAddress><Active>1</Active><InterfaceType>Wireless</InterfaceType></Host></Hosts></response>');
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/wlan/multi-basic-settings') {
|
||||
if (requestArg.method === 'GET') {
|
||||
responseArg.end('<?xml version="1.0" encoding="UTF-8"?><response><Ssids><Ssid><Index>0</Index><WifiEnable>1</WifiEnable><WifiSsid>Main</WifiSsid><WifiMac>AA:BB:CC:DD:EE:FF</WifiMac><wifiisguestnetwork>0</wifiisguestnetwork></Ssid><Ssid><Index>1</Index><WifiEnable>1</WifiEnable><WifiSsid>Guest</WifiSsid><WifiMac>AA:BB:CC:DD:EE:00</WifiMac><wifiisguestnetwork>1</wifiisguestnetwork></Ssid></Ssids></response>');
|
||||
return;
|
||||
}
|
||||
expect(body).toContain('<WifiEnable>0</WifiEnable>');
|
||||
responseArg.end('<response>OK</response>');
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/dialup/mobile-dataswitch' && requestArg.method === 'POST') {
|
||||
expect(body).toContain('<dataswitch>0</dataswitch>');
|
||||
responseArg.end('<response>OK</response>');
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/api/sms/send-sms') {
|
||||
expect(body).toContain('<Phone>+123</Phone>');
|
||||
expect(body).toContain('<Content>hello</Content>');
|
||||
responseArg.end('<response>OK</response>');
|
||||
return;
|
||||
}
|
||||
responseArg.statusCode = 404;
|
||||
responseArg.end('<error><code>100002</code><message>No support</message></error>');
|
||||
})().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 client = new HuaweiLteClient({ url: `http://127.0.0.1:${port}/`, timeoutMs: 1000 });
|
||||
const snapshot = await client.getSnapshot();
|
||||
await client.execute({ action: 'set_mobile_dataswitch', enabled: false });
|
||||
await client.execute({ action: 'set_wifi_guest_network', enabled: false });
|
||||
await client.execute({ action: 'send_sms', phoneNumbers: ['+123'], message: 'hello' });
|
||||
|
||||
expect(snapshot.online).toBeTrue();
|
||||
expect(snapshot.source).toEqual('http');
|
||||
expect(snapshot.device.serialNumber).toEqual('LIVE123');
|
||||
expect(snapshot.device.macAddresses[0]).toEqual('aa:bb:cc:dd:ee:00');
|
||||
expect(snapshot.signal.rsrp).toEqual(-90);
|
||||
expect(snapshot.connection.mobileDataEnabled).toBeTrue();
|
||||
expect(snapshot.connection.guestWifiEnabled).toBeTrue();
|
||||
expect(snapshot.hosts[0].hostname).toEqual('phone');
|
||||
expect(requests.some((requestArg) => requestArg.url === '/api/device/information')).toBeTrue();
|
||||
expect(requests.some((requestArg) => requestArg.url === '/api/dialup/mobile-dataswitch' && requestArg.method === 'POST')).toBeTrue();
|
||||
expect(requests.some((requestArg) => requestArg.url === '/api/sms/send-sms' && requestArg.method === 'POST')).toBeTrue();
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('does not report live command success for static snapshots', async () => {
|
||||
const rawData: Partial<IHuaweiLteRawData> = {
|
||||
deviceInformation: { DeviceName: 'Static LTE', SerialNumber: 'STATIC123' },
|
||||
dialupMobileDataswitch: { dataswitch: '1' },
|
||||
monitoringStatus: { ConnectionStatus: '901' },
|
||||
};
|
||||
const snapshot = HuaweiLteMapper.toSnapshot({ config: { name: 'Static LTE' }, rawData, online: true, source: 'manual' });
|
||||
const runtime = await new HuaweiLteIntegration().setup({ snapshot }, {});
|
||||
const result = await runtime.callService?.({ domain: 'switch', service: 'turn_off', target: { entityId: 'switch.static_lte_mobile_data' }, data: {} });
|
||||
|
||||
expect(result?.success).toBeFalse();
|
||||
expect(result?.error).toContain('Static snapshots/manual data are read-only');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
tap.test('delegates Huawei LTE commands to an injected executor', async () => {
|
||||
const commands: unknown[] = [];
|
||||
const rawData: Partial<IHuaweiLteRawData> = {
|
||||
deviceInformation: { DeviceName: 'Executor LTE', SerialNumber: 'EXEC123' },
|
||||
dialupMobileDataswitch: { dataswitch: '1' },
|
||||
monitoringStatus: { ConnectionStatus: '901' },
|
||||
};
|
||||
const snapshot = HuaweiLteMapper.toSnapshot({ config: { name: 'Executor LTE' }, rawData, online: true, source: 'manual' });
|
||||
const runtime = await new HuaweiLteIntegration().setup({
|
||||
snapshot,
|
||||
commandExecutor: {
|
||||
execute: async (requestArg) => {
|
||||
commands.push(requestArg);
|
||||
return { accepted: true };
|
||||
},
|
||||
},
|
||||
}, {});
|
||||
|
||||
const result = await runtime.callService?.({ domain: 'huawei_lte', service: 'reboot', target: {}, data: {} });
|
||||
|
||||
expect(result?.success).toBeTrue();
|
||||
expect((commands[0] as { action?: string }).action).toEqual('reboot');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,78 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { HuaweiLteConfigFlow, HuaweiLteMapper, createHuaweiLteDiscoveryDescriptor, type IHuaweiLteRawData } from '../../ts/integrations/huawei_lte/index.js';
|
||||
|
||||
tap.test('matches Home Assistant Huawei LTE SSDP records and creates config flow output', async () => {
|
||||
const descriptor = createHuaweiLteDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'huawei_lte-ssdp-match');
|
||||
const result = await matcher!.matches({
|
||||
st: 'urn:schemas-upnp-org:device:InternetGatewayDevice:1',
|
||||
location: 'http://192.168.8.1/rootDesc.xml',
|
||||
upnp: {
|
||||
deviceType: 'urn:schemas-upnp-org:device:InternetGatewayDevice:1',
|
||||
manufacturer: 'Huawei Technologies Co., Ltd.',
|
||||
friendlyName: 'B535-232',
|
||||
modelName: 'B535-232',
|
||||
serialNumber: 'ABC123456789',
|
||||
presentationURL: 'http://192.168.8.1/',
|
||||
udn: 'uuid:huawei-b535',
|
||||
},
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.confidence).toEqual('certain');
|
||||
expect(result.candidate?.integrationDomain).toEqual('huawei_lte');
|
||||
expect(result.candidate?.host).toEqual('192.168.8.1');
|
||||
expect(result.candidate?.port).toEqual(80);
|
||||
expect(result.candidate?.metadata?.url).toEqual('http://192.168.8.1/');
|
||||
|
||||
const done = await (await new HuaweiLteConfigFlow().start(result.candidate!, {})).submit!({ username: 'admin', password: 'secret' });
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.url).toEqual('http://192.168.8.1/');
|
||||
expect(done.config?.username).toEqual('admin');
|
||||
expect(done.config?.password).toEqual('secret');
|
||||
expect(done.config?.uniqueId).toEqual('ABC123456789');
|
||||
});
|
||||
|
||||
tap.test('matches manual snapshots without requiring a live router during setup', async () => {
|
||||
const rawData: Partial<IHuaweiLteRawData> = {
|
||||
deviceInformation: {
|
||||
DeviceName: 'B818-263',
|
||||
SerialNumber: 'SNAP123',
|
||||
SoftwareVersion: '11.0.1.1',
|
||||
MacAddress1: 'AABBCCDDEEFF',
|
||||
},
|
||||
monitoringStatus: {
|
||||
ConnectionStatus: '901',
|
||||
WifiStatus: '1',
|
||||
},
|
||||
dialupMobileDataswitch: {
|
||||
dataswitch: '1',
|
||||
},
|
||||
};
|
||||
const snapshot = HuaweiLteMapper.toSnapshot({ config: { name: 'Snapshot LTE' }, rawData, online: true, source: 'manual' });
|
||||
const descriptor = createHuaweiLteDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'huawei_lte-manual-match');
|
||||
const match = await matcher!.matches({ metadata: { snapshot } }, {});
|
||||
|
||||
expect(match.matched).toBeTrue();
|
||||
expect(match.candidate?.host).toBeUndefined();
|
||||
expect(match.candidate?.metadata?.snapshot).toEqual(snapshot);
|
||||
|
||||
const done = await (await new HuaweiLteConfigFlow().start(match.candidate!, {})).submit!({});
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.snapshot?.device.serialNumber).toEqual('SNAP123');
|
||||
expect(done.config?.host).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('rejects Huawei-looking candidates without a usable local source', async () => {
|
||||
const validation = await createHuaweiLteDiscoveryDescriptor().getValidators()[0].validate({
|
||||
source: 'manual',
|
||||
integrationDomain: 'huawei_lte',
|
||||
name: 'Huawei without URL',
|
||||
}, {});
|
||||
|
||||
expect(validation.matched).toBeFalse();
|
||||
expect(validation.reason).toEqual('Huawei LTE candidate lacks a host, URL, injected client, snapshot, or raw data.');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,118 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { HuaweiLteMapper, type IHuaweiLteRawData } from '../../ts/integrations/huawei_lte/index.js';
|
||||
|
||||
const rawData: Partial<IHuaweiLteRawData> = {
|
||||
deviceInformation: {
|
||||
DeviceName: 'B535-232',
|
||||
SerialNumber: 'HW123456',
|
||||
HardwareVersion: 'WL1B535M',
|
||||
SoftwareVersion: '12.0.5.1',
|
||||
MacAddress1: 'AA:BB:CC:DD:EE:FF',
|
||||
},
|
||||
deviceSignal: {
|
||||
rsrp: '-91dBm',
|
||||
rsrq: '-12dB',
|
||||
sinr: '18dB',
|
||||
nrrsrp: '-83dBm',
|
||||
},
|
||||
dialupMobileDataswitch: {
|
||||
dataswitch: '1',
|
||||
},
|
||||
monitoringStatus: {
|
||||
ConnectionStatus: '901',
|
||||
WifiStatus: '1',
|
||||
CurrentWifiUser: '3',
|
||||
BatteryPercent: '78',
|
||||
},
|
||||
monitoringTrafficStatistics: {
|
||||
CurrentDownload: '1024',
|
||||
CurrentUpload: '2048',
|
||||
CurrentDownloadRate: '128',
|
||||
CurrentUploadRate: '64',
|
||||
TotalDownload: '4096',
|
||||
TotalUpload: '8192',
|
||||
},
|
||||
monitoringMonthStatistics: {
|
||||
CurrentMonthDownload: '1000',
|
||||
CurrentMonthUpload: '2000',
|
||||
},
|
||||
monitoringCheckNotifications: {
|
||||
UnreadMessage: '2',
|
||||
SmsStorageFull: '0',
|
||||
},
|
||||
netCurrentPlmn: {
|
||||
FullName: 'Example Mobile',
|
||||
Numeric: '00101',
|
||||
State: '0',
|
||||
},
|
||||
netNetMode: {
|
||||
NetworkMode: '03',
|
||||
},
|
||||
smsSmsCount: {
|
||||
LocalUnread: '2',
|
||||
LocalInbox: '10',
|
||||
SimMax: '50',
|
||||
},
|
||||
lanHostInfo: {
|
||||
Hosts: {
|
||||
Host: [
|
||||
{ MacAddress: '11:22:33:44:55:66', HostName: 'phone', IpAddress: '192.168.8.10', Active: '1', InterfaceType: 'Wireless' },
|
||||
{ MacAddress: '22:33:44:55:66:77', HostName: 'nas', IpAddress: '192.168.8.20', Active: '0', InterfaceType: 'Ethernet' },
|
||||
],
|
||||
},
|
||||
},
|
||||
wlanWifiFeatureSwitch: {
|
||||
wifi24g_switch_enable: '1',
|
||||
wifi5g_enabled: '0',
|
||||
},
|
||||
wlanWifiGuestNetworkSwitch: {
|
||||
WifiEnable: '1',
|
||||
WifiSsid: 'Guest LTE',
|
||||
},
|
||||
};
|
||||
|
||||
tap.test('maps Huawei LTE raw API data to snapshot, devices, and entities', async () => {
|
||||
const snapshot = HuaweiLteMapper.toSnapshot({ config: { url: 'http://192.168.8.1/' }, rawData, online: true, source: 'manual' });
|
||||
const devices = HuaweiLteMapper.toDevices(snapshot);
|
||||
const entities = HuaweiLteMapper.toEntities(snapshot);
|
||||
|
||||
expect(snapshot.device.serialNumber).toEqual('HW123456');
|
||||
expect(snapshot.device.macAddresses[0]).toEqual('aa:bb:cc:dd:ee:ff');
|
||||
expect(snapshot.connection.mobileConnected).toBeTrue();
|
||||
expect(snapshot.connection.mobileDataEnabled).toBeTrue();
|
||||
expect(snapshot.connection.wifi5ghzEnabled).toBeFalse();
|
||||
expect(snapshot.signal.rsrp).toEqual(-91);
|
||||
expect(snapshot.signal.nrrsrp).toEqual(-83);
|
||||
expect(snapshot.traffic.currentDownloadRate).toEqual(128);
|
||||
expect(snapshot.sms.unread).toEqual(2);
|
||||
expect(snapshot.network.operatorName).toEqual('Example Mobile');
|
||||
expect(snapshot.hosts.length).toEqual(2);
|
||||
expect(snapshot.hosts[0].active).toBeTrue();
|
||||
expect(snapshot.capabilities.mobileDataSwitch).toBeTrue();
|
||||
expect(snapshot.capabilities.sendSms).toBeTrue();
|
||||
expect(devices[0].id).toEqual('huawei_lte.router.hw123456');
|
||||
expect(devices[0].metadata?.softwareVersion).toEqual('12.0.5.1');
|
||||
expect(entities.find((entityArg) => entityArg.attributes?.key === 'mobile_data')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.attributes?.key === 'wifi_guest_network')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.attributes?.key === 'preferred_network_mode')?.state).toEqual('03');
|
||||
expect(entities.find((entityArg) => entityArg.attributes?.item === 'rsrp')?.state).toEqual(-91);
|
||||
});
|
||||
|
||||
tap.test('maps Home Assistant style services to real Huawei LTE command shapes', async () => {
|
||||
const snapshot = HuaweiLteMapper.toSnapshot({ config: { url: 'http://192.168.8.1/' }, rawData, online: true, source: 'manual' });
|
||||
const mobileDataOff = HuaweiLteMapper.commandForService(snapshot, { domain: 'switch', service: 'turn_off', target: { entityId: 'switch.b535_mobile_data' }, data: {} });
|
||||
const guestOn = HuaweiLteMapper.commandForService(snapshot, { domain: 'switch', service: 'turn_on', target: { entityId: 'switch.b535_wifi_guest_network' }, data: {} });
|
||||
const sms = HuaweiLteMapper.commandForService(snapshot, { domain: 'huawei_lte', service: 'send_sms', target: {}, data: { phone_numbers: ['+123'], message: 'hello' } });
|
||||
const networkMode = HuaweiLteMapper.commandForService(snapshot, { domain: 'select', service: 'select_option', target: { entityId: 'select.b535_preferred_network_mode' }, data: { option: '00' } });
|
||||
|
||||
expect(mobileDataOff?.action).toEqual('set_mobile_dataswitch');
|
||||
expect(mobileDataOff?.enabled).toBeFalse();
|
||||
expect(guestOn?.action).toEqual('set_wifi_guest_network');
|
||||
expect(guestOn?.enabled).toBeTrue();
|
||||
expect(sms?.action).toEqual('send_sms');
|
||||
expect(sms?.phoneNumbers?.[0]).toEqual('+123');
|
||||
expect(networkMode?.action).toEqual('set_net_mode');
|
||||
expect(networkMode?.networkMode).toEqual('00');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { HyperionClient, HyperionIntegration, HyperionMapper, type IHyperionCommandRequest, type IHyperionJsonRpcRequest, type IHyperionRawData } from '../../ts/integrations/hyperion/index.js';
|
||||
|
||||
const rawData: IHyperionRawData = {
|
||||
sysInfo: { hyperion: { id: 'static-hyperion', version: '2.0.17' } },
|
||||
serverInfo: {
|
||||
instance: [{ instance: 0, friendly_name: 'Static Hyperion', running: true }],
|
||||
components: [{ name: 'ALL', enabled: true }, { name: 'LEDDEVICE', enabled: true }],
|
||||
effects: [{ name: 'Rainbow swirl' }],
|
||||
adjustment: [{ id: 'default', brightness: 75 }],
|
||||
priorities: [{ priority: 128, componentId: 'COLOR', origin: 'smarthome.exchange', visible: true, value: { RGB: [5, 6, 7] } }],
|
||||
},
|
||||
};
|
||||
|
||||
const responseFor = (payloadArg: IHyperionJsonRpcRequest): Record<string, unknown> => {
|
||||
if (payloadArg.command === 'authorize' && payloadArg.subcommand === 'tokenRequired') {
|
||||
return { command: 'authorize-tokenRequired', info: { required: false }, success: true, tan: payloadArg.tan };
|
||||
}
|
||||
if (payloadArg.command === 'sysinfo') {
|
||||
return { command: 'sysinfo', info: { hyperion: { id: 'http-hyperion', version: '2.0.17' }, system: { hostName: 'hyperion-host' } }, success: true, tan: payloadArg.tan };
|
||||
}
|
||||
if (payloadArg.command === 'serverinfo') {
|
||||
return { command: 'serverinfo-getInfo', info: rawData.serverInfo, success: true, tan: payloadArg.tan };
|
||||
}
|
||||
if (payloadArg.command === 'instance-data') {
|
||||
return { command: 'instance-data-getImageSnapshot', info: { data: 'aW1hZ2U=', format: payloadArg.format || 'PNG', width: 1, height: 1 }, success: true, tan: payloadArg.tan };
|
||||
}
|
||||
return { command: payloadArg.command, success: true, tan: payloadArg.tan };
|
||||
};
|
||||
|
||||
tap.test('reads Hyperion snapshots over local HTTP JSON-RPC and sends commands', async () => {
|
||||
const requests: IHyperionJsonRpcRequest[] = [];
|
||||
const server = createServer((requestArg, responseArg) => {
|
||||
let body = '';
|
||||
requestArg.on('data', (chunkArg) => { body += chunkArg.toString('utf8'); });
|
||||
requestArg.on('end', () => {
|
||||
const payload = JSON.parse(body) as IHyperionJsonRpcRequest;
|
||||
requests.push(payload);
|
||||
responseArg.setHeader('content-type', 'application/json');
|
||||
responseArg.end(JSON.stringify(responseFor(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 HyperionClient({ host: '127.0.0.1', port, transport: 'http', timeoutMs: 1000 });
|
||||
const snapshot = await client.getSnapshot();
|
||||
const color = await client.execute({ action: 'set_color', instance: 0, color: [1, 2, 3], priority: 128, origin: 'smarthome.exchange' });
|
||||
const image = await client.execute({ action: 'get_image_snapshot', instance: 0, format: 'PNG' });
|
||||
|
||||
expect(snapshot.online).toBeTrue();
|
||||
expect(snapshot.source).toEqual('http');
|
||||
expect(snapshot.device.id).toEqual('http-hyperion');
|
||||
expect(snapshot.instances[0].name).toEqual('Static Hyperion');
|
||||
expect((color as { command?: string }).command).toEqual('color');
|
||||
expect((image as { format?: string }).format).toEqual('PNG');
|
||||
expect(requests.some((requestArg) => requestArg.command === 'authorize' && requestArg.subcommand === 'tokenRequired')).toBeTrue();
|
||||
expect(requests.some((requestArg) => requestArg.command === 'color' && (requestArg.color as number[])[0] === 1)).toBeTrue();
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('does not report live command success for static snapshots without transport', async () => {
|
||||
const snapshot = HyperionMapper.toSnapshot({ config: { name: 'Static Hyperion' }, rawData, online: true, source: 'manual' });
|
||||
const runtime = await new HyperionIntegration().setup({ snapshot }, {});
|
||||
const result = await runtime.callService!({ domain: 'light', service: 'turn_off', target: { entityId: 'light.static_hyperion_light' } });
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.error).toContain('Static snapshots/manual data are read-only');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
tap.test('delegates Hyperion commands to an injected executor', async () => {
|
||||
const commands: IHyperionCommandRequest[] = [];
|
||||
const snapshot = HyperionMapper.toSnapshot({ config: { name: 'Executor Hyperion' }, rawData, online: true, source: 'manual' });
|
||||
const runtime = await new HyperionIntegration().setup({
|
||||
snapshot,
|
||||
commandExecutor: {
|
||||
execute: async (requestArg) => {
|
||||
commands.push(requestArg);
|
||||
return { accepted: true };
|
||||
},
|
||||
},
|
||||
}, {});
|
||||
|
||||
const result = await runtime.callService!({ domain: 'light', service: 'turn_on', target: { entityId: 'light.executor_hyperion_light' }, data: { rgb_color: [9, 8, 7] } });
|
||||
|
||||
expect(result.success).toBeTrue();
|
||||
expect(commands[0].action).toEqual('set_color');
|
||||
expect(commands[0].color).toEqual([9, 8, 7]);
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,68 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { HyperionConfigFlow, HyperionMapper, createHyperionDiscoveryDescriptor, type IHyperionRawData } from '../../ts/integrations/hyperion/index.js';
|
||||
|
||||
const rawData: IHyperionRawData = {
|
||||
sysInfo: { hyperion: { id: 'hyperion-uuid', version: '2.0.17' } },
|
||||
serverInfo: {
|
||||
hostname: 'hyperion-host',
|
||||
instance: [{ instance: 0, friendly_name: 'Living Hyperion', running: true }],
|
||||
components: [{ name: 'ALL', enabled: true }, { name: 'LEDDEVICE', enabled: true }],
|
||||
effects: [{ name: 'Rainbow swirl' }],
|
||||
adjustment: [{ id: 'default', brightness: 80 }],
|
||||
priorities: [{ priority: 128, componentId: 'COLOR', origin: 'smarthome.exchange', visible: true, value: { RGB: [1, 2, 3] } }],
|
||||
},
|
||||
};
|
||||
|
||||
tap.test('matches Hyperion SSDP records and creates TCP JSON config', async () => {
|
||||
const descriptor = createHyperionDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hyperion-ssdp-match');
|
||||
const result = await matcher!.matches({
|
||||
ssdp_location: 'http://192.0.2.20:8090/description.xml',
|
||||
ssdp_st: 'urn:hyperion-project.org:device:basic:1',
|
||||
manufacturer: 'Hyperion Open Source Ambient Lighting',
|
||||
serialNumber: 'f9aab089-f85a-55cf-b7c1-222a72faebe9',
|
||||
ports: { jsonServer: '19444', sslServer: '8092' },
|
||||
friendlyName: 'Hyperion (192.0.2.20)',
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.normalizedDeviceId).toEqual('f9aab089-f85a-55cf-b7c1-222a72faebe9');
|
||||
expect(result.candidate?.host).toEqual('192.0.2.20');
|
||||
expect(result.candidate?.port).toEqual(19444);
|
||||
expect(result.candidate?.metadata?.webPort).toEqual(8090);
|
||||
|
||||
const done = await (await new HyperionConfigFlow().start(result.candidate!, {})).submit!({});
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.transport).toEqual('tcp');
|
||||
expect(done.config?.host).toEqual('192.0.2.20');
|
||||
expect(done.config?.jsonPort).toEqual(19444);
|
||||
expect(done.config?.webPort).toEqual(8090);
|
||||
});
|
||||
|
||||
tap.test('matches manual Hyperion snapshots without requiring live transport', async () => {
|
||||
const snapshot = HyperionMapper.toSnapshot({ config: { name: 'Manual Hyperion' }, rawData, online: true, source: 'manual' });
|
||||
const descriptor = createHyperionDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hyperion-manual-match');
|
||||
const result = await matcher!.matches({ name: 'Manual Hyperion', snapshot }, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.metadata?.snapshot).toEqual(snapshot);
|
||||
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||
expect(validation.matched).toBeTrue();
|
||||
|
||||
const done = await (await new HyperionConfigFlow().start(result.candidate!, {})).submit!({});
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.snapshot?.device.id).toEqual('hyperion-uuid');
|
||||
});
|
||||
|
||||
tap.test('rejects candidates without Hyperion hints or usable data', async () => {
|
||||
const descriptor = createHyperionDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'hyperion-manual-match');
|
||||
const result = await matcher!.matches({ name: 'Generic JSON service' }, {});
|
||||
expect(result.matched).toBeFalse();
|
||||
|
||||
const validation = await descriptor.getValidators()[0].validate({ source: 'manual', name: 'Hyperion without endpoint' }, {});
|
||||
expect(validation.matched).toBeFalse();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,47 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { HyperionMapper, type IHyperionRawData } from '../../ts/integrations/hyperion/index.js';
|
||||
|
||||
const rawData: IHyperionRawData = {
|
||||
sysInfo: { hyperion: { id: 'hyperion-uuid', version: '2.0.17' } },
|
||||
serverInfo: {
|
||||
hostname: 'hyperion-host',
|
||||
instance: [{ instance: 0, friendly_name: 'Living Hyperion', running: true }],
|
||||
components: [{ name: 'ALL', enabled: true }, { name: 'LEDDEVICE', enabled: true }],
|
||||
effects: [{ name: 'Rainbow swirl' }, { name: 'Warm mood blobs' }],
|
||||
adjustment: [{ id: 'default', brightness: 50 }],
|
||||
priorities: [{ priority: 128, componentId: 'EFFECT', origin: 'smarthome.exchange', owner: 'Rainbow swirl', visible: true }],
|
||||
},
|
||||
};
|
||||
|
||||
tap.test('maps Hyperion snapshots to devices and entities', async () => {
|
||||
const snapshot = HyperionMapper.toSnapshot({ config: { host: 'hyperion.local', priority: 128 }, rawData, online: true, source: 'manual' });
|
||||
const devices = HyperionMapper.toDevices(snapshot);
|
||||
const entities = HyperionMapper.toEntities(snapshot);
|
||||
const light = entities.find((entityArg) => entityArg.platform === 'light');
|
||||
const visiblePriority = entities.find((entityArg) => entityArg.id.includes('visible_priority'));
|
||||
const ledSwitch = entities.find((entityArg) => entityArg.id.includes('component_leddevice'));
|
||||
|
||||
expect(devices[0].id).toEqual('hyperion.instance.hyperion_uuid.0');
|
||||
expect(devices[0].online).toBeTrue();
|
||||
expect(light?.state).toEqual('on');
|
||||
expect(light?.attributes?.effect).toEqual('Rainbow swirl');
|
||||
expect(light?.attributes?.brightness).toEqual(128);
|
||||
expect(visiblePriority?.state).toEqual('Rainbow swirl');
|
||||
expect(ledSwitch?.state).toEqual('on');
|
||||
});
|
||||
|
||||
tap.test('maps Home Assistant-style services to Hyperion JSON commands', async () => {
|
||||
const snapshot = HyperionMapper.toSnapshot({ config: { host: 'hyperion.local', priority: 128 }, rawData, online: true, source: 'manual' });
|
||||
const lightCommands = HyperionMapper.commandsForService(snapshot, { domain: 'light', service: 'turn_on', target: { entityId: 'light.living_hyperion_light' }, data: { rgb_color: [10, 20, 30] } });
|
||||
const switchCommands = HyperionMapper.commandsForService(snapshot, { domain: 'switch', service: 'turn_off', target: { entityId: 'switch.living_hyperion_component_leddevice' } });
|
||||
const snapshotCommand = HyperionMapper.commandForService(snapshot, { domain: 'hyperion', service: 'get_image_snapshot', target: {}, data: { instance: 0, format: 'JPG' } });
|
||||
|
||||
expect(lightCommands[0].action).toEqual('set_color');
|
||||
expect(lightCommands[0].color).toEqual([10, 20, 30]);
|
||||
expect(switchCommands[0].action).toEqual('set_component');
|
||||
expect(switchCommands[0].component).toEqual('LEDDEVICE');
|
||||
expect(snapshotCommand?.action).toEqual('get_image_snapshot');
|
||||
expect(snapshotCommand?.format).toEqual('JPG');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
Reference in New Issue
Block a user