Add native local bridge and media integrations

This commit is contained in:
2026-05-07 16:30:59 +00:00
parent d030af1832
commit 7fc9c73ba6
70 changed files with 12036 additions and 177 deletions
+49
View File
@@ -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();
+88
View File
@@ -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();
+61
View File
@@ -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();
+61
View File
@@ -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();
+55
View File
@@ -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();
+69
View File
@@ -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();
+63
View File
@@ -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();
+91
View File
@@ -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();