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();
|
||||
+12
@@ -16,6 +16,7 @@ import { AsuswrtIntegration } from './integrations/asuswrt/index.js';
|
||||
import { BleboxIntegration } from './integrations/blebox/index.js';
|
||||
import { BluetoothLeTrackerIntegration } from './integrations/bluetooth_le_tracker/index.js';
|
||||
import { BluesoundIntegration } from './integrations/bluesound/index.js';
|
||||
import { BondIntegration } from './integrations/bond/index.js';
|
||||
import { BoschShcIntegration } from './integrations/bosch_shc/index.js';
|
||||
import { BraviatvIntegration } from './integrations/braviatv/index.js';
|
||||
import { BrotherIntegration } from './integrations/brother/index.js';
|
||||
@@ -23,6 +24,7 @@ import { BroadlinkIntegration } from './integrations/broadlink/index.js';
|
||||
import { CastIntegration } from './integrations/cast/index.js';
|
||||
import { DaikinIntegration } from './integrations/daikin/index.js';
|
||||
import { DeconzIntegration } from './integrations/deconz/index.js';
|
||||
import { DenonIntegration } from './integrations/denon/index.js';
|
||||
import { DenonavrIntegration } from './integrations/denonavr/index.js';
|
||||
import { DevoloHomeNetworkIntegration } from './integrations/devolo_home_network/index.js';
|
||||
import { DirectvIntegration } from './integrations/directv/index.js';
|
||||
@@ -31,16 +33,20 @@ import { DlnaDmrIntegration } from './integrations/dlna_dmr/index.js';
|
||||
import { DoorbirdIntegration } from './integrations/doorbird/index.js';
|
||||
import { DsmrIntegration } from './integrations/dsmr/index.js';
|
||||
import { DunehdIntegration } from './integrations/dunehd/index.js';
|
||||
import { ElgatoIntegration } from './integrations/elgato/index.js';
|
||||
import { EsphomeIntegration } from './integrations/esphome/index.js';
|
||||
import { ForkedDaapdIntegration } from './integrations/forked_daapd/index.js';
|
||||
import { FrontierSiliconIntegration } from './integrations/frontier_silicon/index.js';
|
||||
import { FritzIntegration } from './integrations/fritz/index.js';
|
||||
import { GlancesIntegration } from './integrations/glances/index.js';
|
||||
import { Go2rtcIntegration } from './integrations/go2rtc/index.js';
|
||||
import { HarmonyIntegration } from './integrations/harmony/index.js';
|
||||
import { HeosIntegration } from './integrations/heos/index.js';
|
||||
import { HikvisionIntegration } from './integrations/hikvision/index.js';
|
||||
import { HomekitControllerIntegration } from './integrations/homekit_controller/index.js';
|
||||
import { HomematicIntegration } from './integrations/homematic/index.js';
|
||||
import { HuaweiLteIntegration } from './integrations/huawei_lte/index.js';
|
||||
import { HyperionIntegration } from './integrations/hyperion/index.js';
|
||||
import { IppIntegration } from './integrations/ipp/index.js';
|
||||
import { JellyfinIntegration } from './integrations/jellyfin/index.js';
|
||||
import { KnxIntegration } from './integrations/knx/index.js';
|
||||
@@ -102,6 +108,7 @@ export const integrations = [
|
||||
new BleboxIntegration(),
|
||||
new BluetoothLeTrackerIntegration(),
|
||||
new BluesoundIntegration(),
|
||||
new BondIntegration(),
|
||||
new BoschShcIntegration(),
|
||||
new BraviatvIntegration(),
|
||||
new BrotherIntegration(),
|
||||
@@ -109,6 +116,7 @@ export const integrations = [
|
||||
new CastIntegration(),
|
||||
new DaikinIntegration(),
|
||||
new DeconzIntegration(),
|
||||
new DenonIntegration(),
|
||||
new DenonavrIntegration(),
|
||||
new DevoloHomeNetworkIntegration(),
|
||||
new DirectvIntegration(),
|
||||
@@ -117,16 +125,20 @@ export const integrations = [
|
||||
new DoorbirdIntegration(),
|
||||
new DsmrIntegration(),
|
||||
new DunehdIntegration(),
|
||||
new ElgatoIntegration(),
|
||||
new EsphomeIntegration(),
|
||||
new ForkedDaapdIntegration(),
|
||||
new FrontierSiliconIntegration(),
|
||||
new FritzIntegration(),
|
||||
new GlancesIntegration(),
|
||||
new Go2rtcIntegration(),
|
||||
new HarmonyIntegration(),
|
||||
new HeosIntegration(),
|
||||
new HikvisionIntegration(),
|
||||
new HomekitControllerIntegration(),
|
||||
new HomematicIntegration(),
|
||||
new HuaweiLteIntegration(),
|
||||
new HyperionIntegration(),
|
||||
new HueIntegration(),
|
||||
new IppIntegration(),
|
||||
new JellyfinIntegration(),
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -0,0 +1,270 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { BondMapper } from './bond.mapper.js';
|
||||
import type { IBondCommandRequest, IBondConfig, IBondDeviceRaw, IBondHttpRefreshResult, IBondRawData, IBondSnapshot } from './bond.types.js';
|
||||
import { bondActions, bondDefaultPort, bondDefaultTimeoutMs } from './bond.types.js';
|
||||
|
||||
export class BondApiError extends Error {}
|
||||
export class BondApiConnectionError extends BondApiError {}
|
||||
export class BondApiAuthorizationError extends BondApiError {}
|
||||
export class BondUnsupportedCommandError extends BondApiError {}
|
||||
|
||||
interface IBondEndpoint {
|
||||
protocol: 'http' | 'https';
|
||||
host: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export class BondClient {
|
||||
private currentSnapshot?: IBondSnapshot;
|
||||
|
||||
constructor(private readonly config: IBondConfig) {}
|
||||
|
||||
public static async fetchToken(hostArg: string, portArg = bondDefaultPort, timeoutMsArg = bondDefaultTimeoutMs): Promise<string | undefined> {
|
||||
const client = new BondClient({ host: hostArg, port: portArg, timeoutMs: timeoutMsArg });
|
||||
const response = await client.requestJson<Record<string, unknown>>('/v2/token', { allowMissingToken: true });
|
||||
return typeof response.token === 'string' && response.token ? response.token : undefined;
|
||||
}
|
||||
|
||||
public async getSnapshot(forceRefreshArg = false): Promise<IBondSnapshot> {
|
||||
if (!forceRefreshArg && this.currentSnapshot) {
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
if (!forceRefreshArg && this.config.snapshot) {
|
||||
this.currentSnapshot = BondMapper.toSnapshot({ config: this.config, source: 'snapshot', online: this.config.snapshot.online });
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
if (this.config.client) {
|
||||
try {
|
||||
this.currentSnapshot = await this.snapshotFromClient();
|
||||
} catch (errorArg) {
|
||||
this.currentSnapshot = BondMapper.offlineSnapshot(this.config, this.errorMessage(errorArg));
|
||||
}
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
if (!forceRefreshArg && this.hasManualData()) {
|
||||
this.currentSnapshot = BondMapper.toSnapshot({ config: this.config, online: this.config.online ?? true, source: 'manual' });
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
if (this.hasEndpoint()) {
|
||||
if (!this.accessToken()) {
|
||||
this.currentSnapshot = BondMapper.offlineSnapshot(this.config, 'Bond local HTTP snapshots require config.accessToken or config.token.');
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
try {
|
||||
this.currentSnapshot = await this.fetchSnapshot();
|
||||
} catch (errorArg) {
|
||||
this.currentSnapshot = BondMapper.offlineSnapshot(this.config, this.errorMessage(errorArg));
|
||||
}
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
this.currentSnapshot = BondMapper.offlineSnapshot(this.config, 'No Bond local HTTP endpoint, injected client, snapshot, or manual data is configured.');
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
public async refresh(): Promise<IBondHttpRefreshResult> {
|
||||
try {
|
||||
this.currentSnapshot = undefined;
|
||||
const liveCapable = Boolean(this.config.client || (this.hasEndpoint() && this.accessToken()));
|
||||
const snapshot = await this.getSnapshot(liveCapable);
|
||||
const success = liveCapable && snapshot.online && !snapshot.error;
|
||||
return { success, snapshot, error: success ? undefined : snapshot.error || 'Bond refresh requires a live host/token or injected client.', data: { source: snapshot.source } };
|
||||
} catch (errorArg) {
|
||||
const error = this.errorMessage(errorArg);
|
||||
const snapshot = BondMapper.offlineSnapshot(this.config, error);
|
||||
this.currentSnapshot = snapshot;
|
||||
return { success: false, snapshot: this.cloneSnapshot(snapshot), error };
|
||||
}
|
||||
}
|
||||
|
||||
public async fetchSnapshot(): Promise<IBondSnapshot> {
|
||||
if (!this.hasEndpoint()) {
|
||||
throw new BondApiConnectionError('Bond local HTTP snapshot requires config.host or config.url.');
|
||||
}
|
||||
if (!this.accessToken()) {
|
||||
throw new BondApiAuthorizationError('Bond local HTTP snapshot requires config.accessToken or config.token.');
|
||||
}
|
||||
|
||||
const version = await this.requestJson<Record<string, unknown>>('/v2/sys/version');
|
||||
const deviceIndex = await this.requestJson<Record<string, unknown>>('/v2/devices');
|
||||
const devices: IBondDeviceRaw[] = [];
|
||||
for (const deviceId of this.deviceIds(deviceIndex)) {
|
||||
const [attrs, props, state] = await Promise.all([
|
||||
this.requestJson<Record<string, unknown>>(`/v2/devices/${encodeURIComponent(deviceId)}`),
|
||||
this.requestJson<Record<string, unknown>>(`/v2/devices/${encodeURIComponent(deviceId)}/properties`),
|
||||
this.requestJson<Record<string, unknown>>(`/v2/devices/${encodeURIComponent(deviceId)}/state`),
|
||||
]);
|
||||
devices.push({ deviceId, attrs, props, state });
|
||||
}
|
||||
|
||||
const bridge = await this.requestJson<Record<string, unknown>>('/v2/bridge').catch(() => undefined);
|
||||
const rawData: IBondRawData = {
|
||||
version,
|
||||
bridge,
|
||||
deviceIndex,
|
||||
devices,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
return BondMapper.toSnapshot({ config: this.config, rawData, online: true, source: 'http' });
|
||||
}
|
||||
|
||||
public async execute(commandArg: IBondCommandRequest): Promise<unknown> {
|
||||
if (this.config.commandExecutor) {
|
||||
return this.config.commandExecutor.execute(commandArg);
|
||||
}
|
||||
if (this.config.client?.execute) {
|
||||
return this.config.client.execute(commandArg);
|
||||
}
|
||||
if (!this.hasEndpoint() || !this.accessToken()) {
|
||||
throw new BondApiConnectionError('Bond commands require config.host/config.url and accessToken/token, an injected client.execute, or commandExecutor. Static snapshots/manual data are read-only.');
|
||||
}
|
||||
|
||||
if (commandArg.action === bondActions.setStateBelief || commandArg.statePatch) {
|
||||
const data = commandArg.statePatch || objectArgument(commandArg.argument) || {};
|
||||
const result = await this.requestJson(`/v2/devices/${encodeURIComponent(commandArg.deviceId)}/state`, { method: 'PATCH', body: data });
|
||||
this.currentSnapshot = undefined;
|
||||
return result;
|
||||
}
|
||||
|
||||
const body = commandArg.argument === undefined ? {} : { argument: commandArg.argument };
|
||||
const result = await this.requestJson(`/v2/devices/${encodeURIComponent(commandArg.deviceId)}/actions/${encodeURIComponent(commandArg.action)}`, { method: 'PUT', body });
|
||||
this.currentSnapshot = undefined;
|
||||
return result;
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.config.client?.destroy?.();
|
||||
}
|
||||
|
||||
private async snapshotFromClient(): Promise<IBondSnapshot> {
|
||||
const client = this.config.client;
|
||||
if (!client) {
|
||||
throw new BondApiConnectionError('No Bond client configured.');
|
||||
}
|
||||
if (client.getSnapshot) {
|
||||
const result = await client.getSnapshot();
|
||||
if (this.isSnapshot(result)) {
|
||||
return BondMapper.toSnapshot({ config: { ...this.config, snapshot: result }, source: 'client', online: result.online });
|
||||
}
|
||||
return BondMapper.toSnapshot({ config: this.config, rawData: result, online: true, source: 'client' });
|
||||
}
|
||||
if (client.getRawData) {
|
||||
return BondMapper.toSnapshot({ config: this.config, rawData: await client.getRawData(), online: true, source: 'client' });
|
||||
}
|
||||
throw new BondApiConnectionError('Bond client must expose getSnapshot() or getRawData().');
|
||||
}
|
||||
|
||||
private async requestJson<TValue = unknown>(pathArg: string, optionsArg: { method?: 'GET' | 'PUT' | 'PATCH'; body?: unknown; allowMissingToken?: boolean } = {}): Promise<TValue> {
|
||||
const endpoint = this.endpoint();
|
||||
const token = this.accessToken();
|
||||
if (!endpoint) {
|
||||
throw new BondApiConnectionError('Bond request requires config.host or config.url.');
|
||||
}
|
||||
if (!token && !optionsArg.allowMissingToken) {
|
||||
throw new BondApiAuthorizationError('Bond request requires config.accessToken or config.token.');
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.config.timeoutMs || bondDefaultTimeoutMs);
|
||||
const headers: Record<string, string> = {
|
||||
'BOND-UUID': this.messageId(),
|
||||
};
|
||||
if (token) {
|
||||
headers['BOND-Token'] = token;
|
||||
}
|
||||
if (optionsArg.body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl(endpoint)}${pathArg}`, {
|
||||
method: optionsArg.method || 'GET',
|
||||
headers,
|
||||
body: optionsArg.body === undefined ? undefined : JSON.stringify(optionsArg.body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new BondApiAuthorizationError(`Bond API authorization failed with HTTP ${response.status}.`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new BondApiError(`Bond API request failed with HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
return (text ? JSON.parse(text) : {}) as TValue;
|
||||
} catch (errorArg) {
|
||||
if (errorArg instanceof BondApiError) {
|
||||
throw errorArg;
|
||||
}
|
||||
if (errorArg instanceof Error && errorArg.name === 'AbortError') {
|
||||
throw new BondApiConnectionError(`Bond API request timed out after ${this.config.timeoutMs || bondDefaultTimeoutMs}ms.`);
|
||||
}
|
||||
throw new BondApiConnectionError(this.errorMessage(errorArg));
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private endpoint(): IBondEndpoint | undefined {
|
||||
const value = this.config.url || this.config.host;
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `http://${value}`);
|
||||
return {
|
||||
protocol: url.protocol === 'https:' ? 'https' : 'http',
|
||||
host: url.hostname,
|
||||
port: this.config.port || (url.port ? Number(url.port) : undefined),
|
||||
};
|
||||
} catch {
|
||||
return { protocol: 'http', host: value, port: this.config.port };
|
||||
}
|
||||
}
|
||||
|
||||
private baseUrl(endpointArg: IBondEndpoint): string {
|
||||
const port = endpointArg.port && !(endpointArg.protocol === 'http' && endpointArg.port === 80) && !(endpointArg.protocol === 'https' && endpointArg.port === 443) ? `:${endpointArg.port}` : '';
|
||||
return `${endpointArg.protocol}://${endpointArg.host}${port}`;
|
||||
}
|
||||
|
||||
private accessToken(): string | undefined {
|
||||
return stringValue(this.config.accessToken) || stringValue(this.config.token);
|
||||
}
|
||||
|
||||
private hasEndpoint(): boolean {
|
||||
return Boolean(this.config.host || this.config.url);
|
||||
}
|
||||
|
||||
private hasManualData(): boolean {
|
||||
return Boolean(this.config.rawData || this.config.snapshot);
|
||||
}
|
||||
|
||||
private deviceIds(deviceIndexArg: Record<string, unknown>): string[] {
|
||||
return Object.entries(deviceIndexArg)
|
||||
.filter(([keyArg, valueArg]) => !keyArg.startsWith('_') && valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg))
|
||||
.map(([keyArg]) => keyArg);
|
||||
}
|
||||
|
||||
private isSnapshot(valueArg: unknown): valueArg is IBondSnapshot {
|
||||
return Boolean(valueArg && typeof valueArg === 'object' && 'hub' in valueArg && 'devices' in valueArg);
|
||||
}
|
||||
|
||||
private cloneSnapshot(snapshotArg: IBondSnapshot): IBondSnapshot {
|
||||
return JSON.parse(JSON.stringify(snapshotArg)) as IBondSnapshot;
|
||||
}
|
||||
|
||||
private messageId(): string {
|
||||
return `a0${plugins.crypto.randomBytes(7).toString('hex')}`.slice(0, 16).toLowerCase();
|
||||
}
|
||||
|
||||
private errorMessage(errorArg: unknown): string {
|
||||
return errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||
}
|
||||
}
|
||||
|
||||
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
|
||||
const objectArgument = (valueArg: unknown): Record<string, unknown> | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import { BondClient } from './bond.classes.client.js';
|
||||
import type { IBondConfig, IBondRawData, IBondSnapshot } from './bond.types.js';
|
||||
import { bondDefaultPort, bondDefaultTimeoutMs } from './bond.types.js';
|
||||
|
||||
export class BondConfigFlow implements IConfigFlow<IBondConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IBondConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Configure Bond',
|
||||
description: 'Configure a local Bond Bridge endpoint. Discovery candidates need an access token unless a live token is available from the bridge setup window or snapshot/client data is supplied.',
|
||||
fields: [
|
||||
{ name: 'host', label: 'Host', type: 'text' },
|
||||
{ name: 'port', label: 'Port', type: 'number' },
|
||||
{ name: 'accessToken', label: 'Access token', type: 'password' },
|
||||
{ name: 'name', label: 'Name', type: 'text' },
|
||||
],
|
||||
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
|
||||
};
|
||||
}
|
||||
|
||||
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IBondConfig>> {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const parsed = parseEndpoint(this.stringValue(valuesArg.host) || this.stringMetadata(metadata, 'url'));
|
||||
const snapshot = snapshotValue(metadata.snapshot);
|
||||
const rawData = rawDataValue(metadata.rawData);
|
||||
const host = parsed?.host || this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.hub.host;
|
||||
const port = this.numberValue(valuesArg.port) || parsed?.port || candidateArg.port || snapshot?.hub.port || bondDefaultPort;
|
||||
let accessToken = this.stringValue(valuesArg.accessToken)
|
||||
|| this.stringValue(valuesArg.token)
|
||||
|| this.stringMetadata(metadata, 'accessToken')
|
||||
|| this.stringMetadata(metadata, 'token');
|
||||
const hasManualData = Boolean(snapshot || rawData || metadata.client);
|
||||
const discoveryProtocol = this.stringMetadata(metadata, 'discoveryProtocol') || candidateArg.source;
|
||||
|
||||
if (host && !accessToken && !hasManualData && ['zeroconf', 'dhcp', 'mdns'].includes(discoveryProtocol)) {
|
||||
accessToken = await BondClient.fetchToken(host, port, 1500).catch(() => undefined);
|
||||
}
|
||||
|
||||
if (!host && !hasManualData) {
|
||||
return { kind: 'error', title: 'Bond setup failed', error: 'Bond host, injected client, snapshot, or manual raw data is required.' };
|
||||
}
|
||||
if (!this.validPort(port)) {
|
||||
return { kind: 'error', title: 'Bond setup failed', error: 'Bond port must be between 1 and 65535.' };
|
||||
}
|
||||
if (host && !accessToken && !hasManualData) {
|
||||
return { kind: 'error', title: 'Bond setup failed', error: 'Bond access token is required for local bridge HTTP access.' };
|
||||
}
|
||||
|
||||
const config: IBondConfig = {
|
||||
host,
|
||||
port,
|
||||
accessToken,
|
||||
timeoutMs: bondDefaultTimeoutMs,
|
||||
name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.hub.name || this.stringMetadata(metadata, 'name'),
|
||||
manufacturer: candidateArg.manufacturer || snapshot?.hub.manufacturer || 'Olibra',
|
||||
model: candidateArg.model || snapshot?.hub.model || snapshot?.hub.target,
|
||||
uniqueId: candidateArg.id || snapshot?.hub.id || (host ? `${host}:${port}` : undefined),
|
||||
bondId: candidateArg.id || snapshot?.hub.id,
|
||||
snapshot,
|
||||
rawData,
|
||||
client: metadata.client as IBondConfig['client'],
|
||||
commandExecutor: metadata.commandExecutor as IBondConfig['commandExecutor'],
|
||||
};
|
||||
|
||||
return { kind: 'done', title: 'Bond configured', config };
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim().replace(/\/$/, '') : undefined;
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown): number | undefined {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return Math.round(valueArg);
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const parsed = Number(valueArg);
|
||||
return Number.isFinite(parsed) ? Math.round(parsed) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private stringMetadata(metadataArg: Record<string, unknown>, keyArg: string): string | undefined {
|
||||
return this.stringValue(metadataArg[keyArg]);
|
||||
}
|
||||
|
||||
private validPort(valueArg: number): boolean {
|
||||
return Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
|
||||
}
|
||||
}
|
||||
|
||||
const parseEndpoint = (valueArg: string | undefined): { host: string; port?: number } | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg) ? valueArg : `http://${valueArg}`);
|
||||
return { host: url.hostname, port: url.port ? Number(url.port) : undefined };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const snapshotValue = (valueArg: unknown): IBondSnapshot | undefined => valueArg && typeof valueArg === 'object' && 'hub' in valueArg && 'devices' in valueArg ? valueArg as IBondSnapshot : undefined;
|
||||
|
||||
const rawDataValue = (valueArg: unknown): Partial<IBondRawData> | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Partial<IBondRawData> : undefined;
|
||||
@@ -1,29 +1,111 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { BondClient } from './bond.classes.client.js';
|
||||
import { BondConfigFlow } from './bond.classes.configflow.js';
|
||||
import { createBondDiscoveryDescriptor } from './bond.discovery.js';
|
||||
import { BondMapper } from './bond.mapper.js';
|
||||
import type { IBondConfig } from './bond.types.js';
|
||||
import { bondDefaultPort, bondDhcpMacPrefixes, bondDisplayName, bondDomain, bondZeroconfType } from './bond.types.js';
|
||||
|
||||
export class HomeAssistantBondIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "bond",
|
||||
displayName: "Bond",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/bond",
|
||||
"upstreamDomain": "bond",
|
||||
"integrationType": "hub",
|
||||
"iotClass": "local_push",
|
||||
"requirements": [
|
||||
"bond-async==0.2.1"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@bdraco",
|
||||
"@prystupa",
|
||||
"@joshs85",
|
||||
"@marciogranzotto"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class BondIntegration extends BaseIntegration<IBondConfig> {
|
||||
public readonly domain = bondDomain;
|
||||
public readonly displayName = bondDisplayName;
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createBondDiscoveryDescriptor();
|
||||
public readonly configFlow = new BondConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/bond',
|
||||
upstreamDomain: bondDomain,
|
||||
integrationType: 'hub',
|
||||
iotClass: 'local_push',
|
||||
requirements: ['bond-async==0.2.1'],
|
||||
dependencies: [] as string[],
|
||||
afterDependencies: [] as string[],
|
||||
codeowners: ['@bdraco', '@prystupa', '@joshs85', '@marciogranzotto'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/bond',
|
||||
zeroconf: [bondZeroconfType],
|
||||
dhcp: [
|
||||
{ hostname: 'bond-*', macaddress: `${bondDhcpMacPrefixes[0]}*` },
|
||||
{ hostname: 'bond-*', macaddress: `${bondDhcpMacPrefixes[1]}*` },
|
||||
],
|
||||
discovery: {
|
||||
zeroconf: bondZeroconfType,
|
||||
dhcp: 'bond-* leases with 3C6A2C1* or F44E38* MAC prefixes',
|
||||
manual: true,
|
||||
},
|
||||
runtime: {
|
||||
type: 'control-runtime',
|
||||
polling: 'local Bond Bridge HTTP /v2 snapshots with BPUP-compatible local_push modeling',
|
||||
services: ['snapshot', 'status', 'refresh', 'fan', 'light', 'cover', 'switch', 'button', 'raw Bond action', 'state belief patch'],
|
||||
controls: true,
|
||||
},
|
||||
localApi: {
|
||||
implemented: [
|
||||
'GET /v2/sys/version',
|
||||
'GET /v2/token during setup-window token discovery',
|
||||
'GET /v2/bridge when available',
|
||||
'GET /v2/devices and per-device attrs/properties/state snapshots',
|
||||
'PUT /v2/devices/{device_id}/actions/{action}',
|
||||
'PATCH /v2/devices/{device_id}/state for tracked-state belief updates',
|
||||
'snapshot/manual-data and injected client/executor operation',
|
||||
],
|
||||
explicitUnsupported: [
|
||||
'cloud Bond account APIs',
|
||||
'pretending service calls succeeded without host/token, injected client.execute, or commandExecutor',
|
||||
'native BPUP UDP subscription sockets in this runtime layer; snapshots stay HTTP/manual/client based',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
public async setup(configArg: IBondConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new BondRuntime(new BondClient({ port: bondDefaultPort, ...configArg }));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantBondIntegration extends BondIntegration {}
|
||||
|
||||
class BondRuntime implements IIntegrationRuntime {
|
||||
public domain = bondDomain;
|
||||
|
||||
constructor(private readonly client: BondClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return BondMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return BondMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
try {
|
||||
if (requestArg.domain === bondDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
|
||||
return { success: true, data: await this.client.getSnapshot() };
|
||||
}
|
||||
if (requestArg.domain === bondDomain && requestArg.service === 'refresh') {
|
||||
const result = await this.client.refresh();
|
||||
return { success: result.success, error: result.error, data: result.snapshot || result.data };
|
||||
}
|
||||
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
const command = BondMapper.commandForService(snapshot, requestArg);
|
||||
if (!command) {
|
||||
return { success: false, error: `Unsupported Bond service or target: ${requestArg.domain}.${requestArg.service}` };
|
||||
}
|
||||
const data = await this.client.execute(command);
|
||||
return { success: true, data: data ?? command };
|
||||
} catch (errorArg) {
|
||||
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import { BondMapper } from './bond.mapper.js';
|
||||
import type { IBondDhcpRecord, IBondManualEntry, IBondRawData, IBondSnapshot, IBondZeroconfRecord } from './bond.types.js';
|
||||
import { bondDefaultPort, bondDhcpHostnamePrefix, bondDhcpMacPrefixes, bondDisplayName, bondDomain, bondZeroconfType } from './bond.types.js';
|
||||
|
||||
export class BondZeroconfMatcher implements IDiscoveryMatcher<IBondZeroconfRecord> {
|
||||
public id = 'bond-zeroconf-match';
|
||||
public source = 'mdns' as const;
|
||||
public description = 'Recognize Bond _bond._tcp.local zeroconf advertisements from the Home Assistant manifest.';
|
||||
|
||||
public async matches(recordArg: IBondZeroconfRecord): Promise<IDiscoveryMatch> {
|
||||
const txt = normalizeKeys({ ...recordArg.txt, ...recordArg.properties });
|
||||
const type = normalizeMdnsType(recordArg.type || recordArg.serviceType || '');
|
||||
const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0];
|
||||
const name = cleanServiceName(recordArg.name) || stringValue(txt.name) || bondDisplayName;
|
||||
const bondId = normalizeBondId(stringValue(txt.bondid) || stringValue(txt.id) || cleanServiceName(recordArg.name));
|
||||
const matched = type === normalizeMdnsType(bondZeroconfType) || type.includes('_bond') || recordArg.metadata?.bond === true;
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'mDNS record is not a Bond _bond advertisement.' };
|
||||
}
|
||||
return {
|
||||
matched: true,
|
||||
confidence: host && bondId ? 'certain' : host ? 'high' : 'medium',
|
||||
reason: 'mDNS service matches _bond._tcp.local.',
|
||||
normalizedDeviceId: bondId || recordArg.name || host,
|
||||
candidate: {
|
||||
source: 'mdns',
|
||||
integrationDomain: bondDomain,
|
||||
id: bondId || recordArg.name || host,
|
||||
host,
|
||||
port: recordArg.port || bondDefaultPort,
|
||||
name,
|
||||
manufacturer: 'Olibra',
|
||||
model: txt.model || txt.target || 'Bond Bridge',
|
||||
metadata: {
|
||||
...recordArg.metadata,
|
||||
bond: true,
|
||||
discoveryProtocol: 'zeroconf',
|
||||
mdnsType: type,
|
||||
mdnsName: recordArg.name,
|
||||
txt,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class BondDhcpMatcher implements IDiscoveryMatcher<IBondDhcpRecord> {
|
||||
public id = 'bond-dhcp-match';
|
||||
public source = 'dhcp' as const;
|
||||
public description = 'Recognize Bond DHCP leases with bond-* hostnames and 3C6A2C1/F44E38 MAC prefixes from the Home Assistant manifest.';
|
||||
|
||||
public async matches(recordArg: IBondDhcpRecord): Promise<IDiscoveryMatch> {
|
||||
const hostname = stringValue(recordArg.hostname) || stringValue(recordArg.hostName) || '';
|
||||
const normalizedHostname = hostname.toLowerCase();
|
||||
const normalizedMac = normalizeMacPrefix(recordArg.macaddress || recordArg.macAddress);
|
||||
const hostMatches = normalizedHostname.startsWith(bondDhcpHostnamePrefix);
|
||||
const macMatches = Boolean(normalizedMac && bondDhcpMacPrefixes.some((prefixArg) => normalizedMac.startsWith(prefixArg)));
|
||||
const matched = (hostMatches && macMatches) || recordArg.metadata?.bond === true;
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'DHCP lease does not match Bond hostname and MAC prefixes.' };
|
||||
}
|
||||
const bondId = normalizeBondId(hostMatches ? hostname.slice(bondDhcpHostnamePrefix.length) : undefined);
|
||||
const host = recordArg.ip || recordArg.address;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: host && bondId && macMatches ? 'certain' : host ? 'high' : 'medium',
|
||||
reason: 'DHCP lease matches Bond hostname and MAC prefixes.',
|
||||
normalizedDeviceId: bondId || normalizedMac || host,
|
||||
candidate: {
|
||||
source: 'dhcp',
|
||||
integrationDomain: bondDomain,
|
||||
id: bondId || normalizedMac || host,
|
||||
host,
|
||||
port: bondDefaultPort,
|
||||
name: bondId ? `Bond ${bondId}` : bondDisplayName,
|
||||
manufacturer: 'Olibra',
|
||||
macAddress: BondMapper.normalizeMac(normalizedMac),
|
||||
model: 'Bond Bridge',
|
||||
metadata: {
|
||||
...recordArg.metadata,
|
||||
bond: true,
|
||||
discoveryProtocol: 'dhcp',
|
||||
hostname,
|
||||
macAddress: BondMapper.normalizeMac(normalizedMac),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class BondManualMatcher implements IDiscoveryMatcher<IBondManualEntry> {
|
||||
public id = 'bond-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual Bond host/token, snapshot, injected client, and raw-data setup entries.';
|
||||
|
||||
public async matches(inputArg: IBondManualEntry): Promise<IDiscoveryMatch> {
|
||||
const metadata = inputArg.metadata || {};
|
||||
const parsed = parseEndpoint(inputArg.url || inputArg.host);
|
||||
const snapshot = snapshotValue(inputArg.snapshot || metadata.snapshot);
|
||||
const rawData = rawDataValue(inputArg.rawData || metadata.rawData);
|
||||
const hasManualData = Boolean(snapshot || rawData || inputArg.client || metadata.client);
|
||||
const text = [inputArg.name, inputArg.manufacturer, inputArg.model, metadata.name, metadata.manufacturer, metadata.model].filter(Boolean).join(' ').toLowerCase();
|
||||
const matched = Boolean(inputArg.host || inputArg.url || inputArg.accessToken || inputArg.token || metadata.bond || hasManualData || text.includes('bond'));
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Bond setup hints.' };
|
||||
}
|
||||
const host = parsed?.host || (inputArg.url ? undefined : inputArg.host) || snapshot?.hub.host;
|
||||
const port = inputArg.port || parsed?.port || snapshot?.hub.port || bondDefaultPort;
|
||||
const id = inputArg.id || inputArg.uniqueId || inputArg.bondId || snapshot?.hub.id || (host ? `${host}:${port}` : undefined);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: host || hasManualData ? 'high' : 'medium',
|
||||
reason: 'Manual entry can start Bond setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: bondDomain,
|
||||
id,
|
||||
host,
|
||||
port,
|
||||
name: inputArg.name || snapshot?.hub.name || bondDisplayName,
|
||||
manufacturer: inputArg.manufacturer || snapshot?.hub.manufacturer || 'Olibra',
|
||||
model: inputArg.model || snapshot?.hub.model || snapshot?.hub.target,
|
||||
metadata: {
|
||||
...metadata,
|
||||
bond: true,
|
||||
discoveryProtocol: 'manual',
|
||||
accessToken: inputArg.accessToken || inputArg.token || metadata.accessToken || metadata.token,
|
||||
snapshot,
|
||||
rawData,
|
||||
client: inputArg.client || metadata.client,
|
||||
commandExecutor: inputArg.commandExecutor || metadata.commandExecutor,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class BondCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'bond-candidate-validator';
|
||||
public description = 'Validate Bond candidates from zeroconf, DHCP, and manual setup.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const snapshot = snapshotValue(metadata.snapshot);
|
||||
const rawData = rawDataValue(metadata.rawData);
|
||||
const mdnsType = typeof metadata.mdnsType === 'string' ? metadata.mdnsType.toLowerCase() : '';
|
||||
const discoveryProtocol = typeof metadata.discoveryProtocol === 'string' ? metadata.discoveryProtocol : undefined;
|
||||
const text = [candidateArg.integrationDomain, candidateArg.name, candidateArg.manufacturer, candidateArg.model, mdnsType].filter(Boolean).join(' ').toLowerCase();
|
||||
const matched = candidateArg.integrationDomain === bondDomain
|
||||
|| metadata.bond === true
|
||||
|| text.includes('bond')
|
||||
|| mdnsType.includes('_bond')
|
||||
|| discoveryProtocol === 'dhcp';
|
||||
const hasUsableSource = Boolean(candidateArg.host || snapshot || rawData || metadata.client);
|
||||
const normalizedDeviceId = candidateArg.id || snapshot?.hub.id || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || bondDefaultPort}` : undefined);
|
||||
if (!matched || !hasUsableSource) {
|
||||
return {
|
||||
matched: false,
|
||||
confidence: matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Bond candidate lacks a host, injected client, snapshot, or manual raw data.' : 'Candidate is not Bond.',
|
||||
normalizedDeviceId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
matched: true,
|
||||
confidence: candidateArg.host && normalizedDeviceId ? 'certain' : candidateArg.host ? 'high' : 'medium',
|
||||
reason: 'Candidate has Bond metadata and a usable local endpoint, client, snapshot, or manual raw data.',
|
||||
normalizedDeviceId,
|
||||
candidate: {
|
||||
...candidateArg,
|
||||
integrationDomain: bondDomain,
|
||||
port: candidateArg.port || bondDefaultPort,
|
||||
manufacturer: candidateArg.manufacturer || 'Olibra',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createBondDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: bondDomain, displayName: bondDisplayName })
|
||||
.addMatcher(new BondZeroconfMatcher())
|
||||
.addMatcher(new BondDhcpMatcher())
|
||||
.addMatcher(new BondManualMatcher())
|
||||
.addValidator(new BondCandidateValidator());
|
||||
};
|
||||
|
||||
const normalizeMdnsType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.$/, '');
|
||||
|
||||
const cleanServiceName = (valueArg: string | undefined): string | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
return valueArg.replace(/\._.*$/u, '').replace(/\.local\.?$/i, '').trim() || undefined;
|
||||
};
|
||||
|
||||
const normalizeKeys = (recordArg: Record<string, string | undefined>): Record<string, string | undefined> => {
|
||||
const normalized: Record<string, string | undefined> = {};
|
||||
for (const [key, value] of Object.entries(recordArg)) {
|
||||
normalized[key.toLowerCase()] = value;
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const normalizeBondId = (valueArg: string | undefined): string | undefined => valueArg?.replace(/^bond-/i, '').replace(/\..*$/, '').toUpperCase() || undefined;
|
||||
|
||||
const normalizeMacPrefix = (valueArg: unknown): string | undefined => typeof valueArg === 'string' ? valueArg.replace(/[^0-9a-f]/gi, '').toUpperCase() : undefined;
|
||||
|
||||
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
|
||||
const parseEndpoint = (valueArg: string | undefined): { host: string; port?: number } | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg) ? valueArg : `http://${valueArg}`);
|
||||
return { host: url.hostname, port: url.port ? Number(url.port) : undefined };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const snapshotValue = (valueArg: unknown): IBondSnapshot | undefined => valueArg && typeof valueArg === 'object' && 'hub' in valueArg && 'devices' in valueArg ? valueArg as IBondSnapshot : undefined;
|
||||
|
||||
const rawDataValue = (valueArg: unknown): Partial<IBondRawData> | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Partial<IBondRawData> : undefined;
|
||||
@@ -0,0 +1,910 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
|
||||
import type {
|
||||
IBondCommandRequest,
|
||||
IBondConfig,
|
||||
IBondDeviceRaw,
|
||||
IBondDeviceSnapshot,
|
||||
IBondRawData,
|
||||
IBondSnapshot,
|
||||
TBondSnapshotSource,
|
||||
} from './bond.types.js';
|
||||
import { bondActions, bondDefaultPort, bondDeviceTypes, bondDisplayName, bondDomain } from './bond.types.js';
|
||||
|
||||
interface IBondSnapshotOptions {
|
||||
config: IBondConfig;
|
||||
rawData?: Partial<IBondRawData>;
|
||||
online?: boolean;
|
||||
source?: TBondSnapshotSource;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface IBondEntityDefinition {
|
||||
key: string;
|
||||
platform: TEntityPlatform;
|
||||
name: string;
|
||||
state: unknown;
|
||||
subDevice?: string;
|
||||
available?: boolean;
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface IBondButtonDescription {
|
||||
key: string;
|
||||
name: string;
|
||||
mutuallyExclusive?: string;
|
||||
argument?: number;
|
||||
}
|
||||
|
||||
const buttonStepSize = 10;
|
||||
|
||||
const buttonDescriptions: IBondButtonDescription[] = [
|
||||
{ key: bondActions.togglePower, name: 'Toggle Power', mutuallyExclusive: bondActions.turnOn },
|
||||
{ key: bondActions.toggleLight, name: 'Toggle Light', mutuallyExclusive: bondActions.turnLightOn },
|
||||
{ key: bondActions.increaseBrightness, name: 'Increase Brightness', mutuallyExclusive: bondActions.setBrightness, argument: buttonStepSize },
|
||||
{ key: bondActions.decreaseBrightness, name: 'Decrease Brightness', mutuallyExclusive: bondActions.setBrightness, argument: buttonStepSize },
|
||||
{ key: bondActions.toggleUpLight, name: 'Toggle Up Light', mutuallyExclusive: bondActions.turnUpLightOn },
|
||||
{ key: bondActions.toggleDownLight, name: 'Toggle Down Light', mutuallyExclusive: bondActions.turnDownLightOn },
|
||||
{ key: bondActions.startDimmer, name: 'Start Dimmer', mutuallyExclusive: bondActions.setBrightness },
|
||||
{ key: bondActions.toggleLightTemp, name: 'Toggle Light Temperature' },
|
||||
{ key: bondActions.startUpLightDimmer, name: 'Start Up Light Dimmer', mutuallyExclusive: bondActions.setUpLightBrightness },
|
||||
{ key: bondActions.startDownLightDimmer, name: 'Start Down Light Dimmer', mutuallyExclusive: bondActions.setDownLightBrightness },
|
||||
{ key: bondActions.startIncreasingBrightness, name: 'Start Increasing Brightness', mutuallyExclusive: bondActions.setBrightness },
|
||||
{ key: bondActions.startDecreasingBrightness, name: 'Start Decreasing Brightness', mutuallyExclusive: bondActions.setBrightness },
|
||||
{ key: bondActions.increaseUpLightBrightness, name: 'Increase Up Light Brightness', mutuallyExclusive: bondActions.setUpLightBrightness, argument: buttonStepSize },
|
||||
{ key: bondActions.decreaseUpLightBrightness, name: 'Decrease Up Light Brightness', mutuallyExclusive: bondActions.setUpLightBrightness, argument: buttonStepSize },
|
||||
{ key: bondActions.increaseDownLightBrightness, name: 'Increase Down Light Brightness', mutuallyExclusive: bondActions.setDownLightBrightness, argument: buttonStepSize },
|
||||
{ key: bondActions.decreaseDownLightBrightness, name: 'Decrease Down Light Brightness', mutuallyExclusive: bondActions.setDownLightBrightness, argument: buttonStepSize },
|
||||
{ key: bondActions.cycleUpLightBrightness, name: 'Cycle Up Light Brightness', mutuallyExclusive: bondActions.setUpLightBrightness, argument: buttonStepSize },
|
||||
{ key: bondActions.cycleDownLightBrightness, name: 'Cycle Down Light Brightness', mutuallyExclusive: bondActions.setDownLightBrightness, argument: buttonStepSize },
|
||||
{ key: bondActions.cycleBrightness, name: 'Cycle Brightness', mutuallyExclusive: bondActions.setBrightness, argument: buttonStepSize },
|
||||
{ key: bondActions.increaseSpeed, name: 'Increase Speed', mutuallyExclusive: bondActions.setSpeed, argument: 1 },
|
||||
{ key: bondActions.decreaseSpeed, name: 'Decrease Speed', mutuallyExclusive: bondActions.setSpeed, argument: 1 },
|
||||
{ key: bondActions.toggleDirection, name: 'Toggle Direction', mutuallyExclusive: bondActions.setDirection },
|
||||
{ key: bondActions.increaseTemperature, name: 'Increase Temperature', argument: 1 },
|
||||
{ key: bondActions.decreaseTemperature, name: 'Decrease Temperature', argument: 1 },
|
||||
{ key: bondActions.increaseFlame, name: 'Increase Flame', argument: buttonStepSize },
|
||||
{ key: bondActions.decreaseFlame, name: 'Decrease Flame', argument: buttonStepSize },
|
||||
{ key: bondActions.toggleOpen, name: 'Toggle Open', mutuallyExclusive: bondActions.open },
|
||||
{ key: bondActions.increasePosition, name: 'Increase Position', mutuallyExclusive: bondActions.setPosition, argument: buttonStepSize },
|
||||
{ key: bondActions.decreasePosition, name: 'Decrease Position', mutuallyExclusive: bondActions.setPosition, argument: buttonStepSize },
|
||||
{ key: bondActions.openNext, name: 'Open Next' },
|
||||
{ key: bondActions.closeNext, name: 'Close Next' },
|
||||
];
|
||||
|
||||
const stopButtonDescription: IBondButtonDescription = { key: bondActions.stop, name: 'Stop Actions' };
|
||||
const presetButtonDescription: IBondButtonDescription = { key: bondActions.preset, name: 'Preset' };
|
||||
|
||||
export class BondMapper {
|
||||
public static toSnapshot(optionsArg: IBondSnapshotOptions): IBondSnapshot {
|
||||
if (optionsArg.config.snapshot && !optionsArg.rawData) {
|
||||
return this.normalizeSnapshot(this.clone(optionsArg.config.snapshot), optionsArg.config, optionsArg.source || 'snapshot', optionsArg.error);
|
||||
}
|
||||
|
||||
const rawData = this.rawData(optionsArg.config, optionsArg.rawData);
|
||||
const version = rawData.version || {};
|
||||
const devices = this.devices(rawData.devices || []);
|
||||
const hubId = stringValue(version.bondid) || optionsArg.config.bondId || optionsArg.config.uniqueId || optionsArg.config.host || 'bond';
|
||||
const isBridge = this.isBridgeFromSerial(hubId);
|
||||
const hubName = optionsArg.config.name
|
||||
|| (isBridge ? stringValue(rawData.bridge?.name) : devices[0]?.name)
|
||||
|| stringValue(rawData.bridge?.name)
|
||||
|| (hubId !== 'bond' ? `Bond ${hubId}` : bondDisplayName);
|
||||
const online = optionsArg.online ?? optionsArg.config.online ?? Boolean(rawData.version || rawData.devices?.length || optionsArg.source === 'http' || optionsArg.source === 'client');
|
||||
const source = optionsArg.source || (rawData.version ? 'http' : Object.keys(rawData).length ? 'manual' : 'runtime');
|
||||
|
||||
const snapshot: IBondSnapshot = {
|
||||
hub: {
|
||||
id: hubId,
|
||||
name: hubName,
|
||||
host: endpointHost(optionsArg.config.host || optionsArg.config.url),
|
||||
port: endpointPort(optionsArg.config.host || optionsArg.config.url, optionsArg.config.port),
|
||||
manufacturer: stringValue(version.make) || optionsArg.config.manufacturer || 'Olibra',
|
||||
model: stringValue(version.model) || optionsArg.config.model,
|
||||
target: stringValue(version.target),
|
||||
firmware: stringValue(version.fw_ver),
|
||||
hardware: stringValue(version.mcu_ver),
|
||||
location: isBridge ? stringValue(rawData.bridge?.location) : devices[0]?.location,
|
||||
isBridge,
|
||||
version,
|
||||
bridge: rawData.bridge,
|
||||
},
|
||||
devices,
|
||||
capabilities: {
|
||||
localControl: this.hasLocalControl(optionsArg.config),
|
||||
pushUpdates: true,
|
||||
fan: devices.some((deviceArg) => this.isFan(deviceArg)),
|
||||
cover: devices.some((deviceArg) => this.isCover(deviceArg)),
|
||||
light: devices.some((deviceArg) => this.isLightDevice(deviceArg) || this.supportsLight(deviceArg) || this.isFireplace(deviceArg)),
|
||||
fireplace: devices.some((deviceArg) => this.isFireplace(deviceArg)),
|
||||
switch: devices.some((deviceArg) => this.isGeneric(deviceArg)),
|
||||
buttons: devices.some((deviceArg) => this.buttonEntities(deviceArg).length > 0),
|
||||
},
|
||||
rawData: Object.keys(rawData).length ? rawData : undefined,
|
||||
online,
|
||||
updatedAt: rawData.fetchedAt || new Date().toISOString(),
|
||||
source,
|
||||
error: optionsArg.error,
|
||||
};
|
||||
|
||||
return this.normalizeSnapshot(snapshot, optionsArg.config, source, optionsArg.error);
|
||||
}
|
||||
|
||||
public static toDevices(snapshotArg: IBondSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [{
|
||||
id: this.hubDeviceId(snapshotArg),
|
||||
integrationDomain: bondDomain,
|
||||
name: snapshotArg.hub.name,
|
||||
protocol: snapshotArg.hub.host ? 'http' : 'unknown',
|
||||
manufacturer: snapshotArg.hub.manufacturer,
|
||||
model: snapshotArg.hub.target || snapshotArg.hub.model || 'Bond Bridge',
|
||||
online: snapshotArg.online,
|
||||
features: [
|
||||
{ id: 'online', capability: 'sensor', name: 'Online', readable: true, writable: false },
|
||||
{ id: 'device_count', capability: 'sensor', name: 'Device count', readable: true, writable: false },
|
||||
],
|
||||
state: [
|
||||
{ featureId: 'online', value: snapshotArg.online, updatedAt },
|
||||
{ featureId: 'device_count', value: snapshotArg.devices.length, updatedAt },
|
||||
],
|
||||
metadata: this.cleanAttributes({
|
||||
bondId: snapshotArg.hub.id,
|
||||
host: snapshotArg.hub.host,
|
||||
port: snapshotArg.hub.port,
|
||||
firmware: snapshotArg.hub.firmware,
|
||||
hardware: snapshotArg.hub.hardware,
|
||||
target: snapshotArg.hub.target,
|
||||
location: snapshotArg.hub.location,
|
||||
source: snapshotArg.source,
|
||||
error: snapshotArg.error,
|
||||
}),
|
||||
}];
|
||||
|
||||
for (const device of snapshotArg.devices) {
|
||||
const features = this.featuresForDevice(snapshotArg, device);
|
||||
devices.push({
|
||||
id: this.deviceId(snapshotArg, device),
|
||||
integrationDomain: bondDomain,
|
||||
name: device.name,
|
||||
protocol: snapshotArg.hub.host ? 'http' : 'unknown',
|
||||
manufacturer: snapshotArg.hub.manufacturer,
|
||||
model: [device.brandingProfile, device.template].filter(Boolean).join(' ') || this.deviceTypeName(device.type),
|
||||
online: snapshotArg.online,
|
||||
features,
|
||||
state: this.stateForDevice(device, features, updatedAt),
|
||||
metadata: this.cleanAttributes({
|
||||
bondId: snapshotArg.hub.id,
|
||||
rawDeviceId: device.deviceId,
|
||||
type: device.type,
|
||||
location: device.location,
|
||||
template: device.template,
|
||||
brandingProfile: device.brandingProfile,
|
||||
trustState: device.trustState,
|
||||
supportedActions: device.supportedActions,
|
||||
viaDevice: this.hubDeviceId(snapshotArg),
|
||||
}),
|
||||
});
|
||||
}
|
||||
return devices;
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IBondSnapshot): IIntegrationEntity[] {
|
||||
const entities: IIntegrationEntity[] = [];
|
||||
for (const device of snapshotArg.devices) {
|
||||
if (this.isFan(device)) {
|
||||
entities.push(this.entity(snapshotArg, device, {
|
||||
key: 'fan',
|
||||
platform: 'fan',
|
||||
name: device.name,
|
||||
state: onOffState(device.state.power),
|
||||
attributes: {
|
||||
percentage: this.fanPercentage(device),
|
||||
speed: numberValue(device.state.speed),
|
||||
maxSpeed: this.maxSpeed(device),
|
||||
direction: directionName(device.state.direction),
|
||||
breeze: Array.isArray(device.state.breeze) ? Boolean(device.state.breeze[0]) : undefined,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
if (this.isCover(device)) {
|
||||
entities.push(this.entity(snapshotArg, device, {
|
||||
key: 'cover',
|
||||
platform: 'cover',
|
||||
name: device.name,
|
||||
state: coverState(device.state.open),
|
||||
attributes: {
|
||||
currentPosition: typeof device.state.position === 'number' ? this.bondToHomeAssistantPosition(device.state.position) : undefined,
|
||||
bondPosition: device.state.position,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
if (this.isGeneric(device)) {
|
||||
entities.push(this.entity(snapshotArg, device, {
|
||||
key: 'switch',
|
||||
platform: 'switch',
|
||||
name: device.name,
|
||||
state: onOffState(device.state.power),
|
||||
}));
|
||||
}
|
||||
|
||||
entities.push(...this.lightEntities(snapshotArg, device));
|
||||
entities.push(...this.buttonEntities(device).map((buttonArg) => this.entity(snapshotArg, device, {
|
||||
key: `button_${slug(buttonArg.key)}`,
|
||||
platform: 'button',
|
||||
name: `${device.name} ${buttonArg.name}`,
|
||||
state: 'available',
|
||||
subDevice: buttonArg.key.toLowerCase(),
|
||||
attributes: {
|
||||
action: buttonArg.key,
|
||||
argument: buttonArg.argument,
|
||||
},
|
||||
})));
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static commandForService(snapshotArg: IBondSnapshot, requestArg: IServiceCallRequest): IBondCommandRequest | undefined {
|
||||
if (requestArg.domain === bondDomain && ['action', 'execute_action', 'send_action'].includes(requestArg.service)) {
|
||||
const deviceId = stringValue(requestArg.data?.deviceId) || stringValue(requestArg.data?.device_id) || this.deviceForTarget(snapshotArg, requestArg)?.deviceId;
|
||||
const action = stringValue(requestArg.data?.action);
|
||||
if (!deviceId || !action) {
|
||||
return undefined;
|
||||
}
|
||||
const argument = requestArg.data && 'argument' in requestArg.data ? requestArg.data.argument : undefined;
|
||||
return { deviceId, action, argument, service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
|
||||
if (requestArg.domain === bondDomain && requestArg.service === 'set_state_belief') {
|
||||
const deviceId = stringValue(requestArg.data?.deviceId) || stringValue(requestArg.data?.device_id) || this.deviceForTarget(snapshotArg, requestArg)?.deviceId;
|
||||
const statePatch = recordValue(requestArg.data?.statePatch) || recordValue(requestArg.data?.state) || undefined;
|
||||
if (!deviceId || !statePatch) {
|
||||
return undefined;
|
||||
}
|
||||
return { deviceId, action: bondActions.setStateBelief, statePatch, service: `${requestArg.domain}.${requestArg.service}`, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
|
||||
const entity = this.entityForTarget(snapshotArg, requestArg);
|
||||
const device = entity ? this.deviceByRawId(snapshotArg, stringValue(entity.attributes?.deviceId)) : this.deviceForTarget(snapshotArg, requestArg);
|
||||
if (!device) {
|
||||
return undefined;
|
||||
}
|
||||
const service = `${requestArg.domain}.${requestArg.service}`;
|
||||
|
||||
const serviceDomain = requestArg.domain === bondDomain && entity ? entity.platform : requestArg.domain;
|
||||
|
||||
if (serviceDomain === 'button' && requestArg.service === 'press') {
|
||||
const action = stringValue(entity?.attributes?.action) || stringValue(requestArg.data?.action);
|
||||
if (!action || !this.hasAction(device, action)) {
|
||||
return undefined;
|
||||
}
|
||||
const argument = entity?.attributes?.argument ?? requestArg.data?.argument;
|
||||
return { deviceId: device.deviceId, action, argument, service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
|
||||
if (serviceDomain === 'fan') {
|
||||
return this.fanCommand(device, requestArg, service);
|
||||
}
|
||||
if (serviceDomain === 'light') {
|
||||
return this.lightCommand(device, entity, requestArg, service);
|
||||
}
|
||||
if (serviceDomain === 'cover') {
|
||||
return this.coverCommand(device, requestArg, service);
|
||||
}
|
||||
if (serviceDomain === 'switch') {
|
||||
return this.switchCommand(device, requestArg, service);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public static offlineSnapshot(configArg: IBondConfig, errorArg: string): IBondSnapshot {
|
||||
return this.toSnapshot({ config: configArg, online: false, source: 'runtime', error: errorArg });
|
||||
}
|
||||
|
||||
public static normalizeMac(valueArg: unknown): string | undefined {
|
||||
if (typeof valueArg !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const hex = valueArg.replace(/[^0-9a-f]/gi, '').toUpperCase();
|
||||
if (hex.length < 6) {
|
||||
return undefined;
|
||||
}
|
||||
return hex.length === 12 ? hex.match(/.{1,2}/g)?.join(':') : hex;
|
||||
}
|
||||
|
||||
public static isBridgeFromSerial(serialArg: string | undefined): boolean {
|
||||
if (!serialArg) {
|
||||
return true;
|
||||
}
|
||||
return /^[A-C]\w*$/i.test(serialArg) || /^Z[ZX]\w*$/i.test(serialArg) || /^ZP\w*$/i.test(serialArg);
|
||||
}
|
||||
|
||||
public static deviceId(snapshotArg: IBondSnapshot, deviceArg: IBondDeviceSnapshot): string {
|
||||
return `bond.device.${slug(`${snapshotArg.hub.id}_${deviceArg.deviceId}`)}`;
|
||||
}
|
||||
|
||||
public static hubDeviceId(snapshotArg: IBondSnapshot): string {
|
||||
return `bond.hub.${slug(snapshotArg.hub.id)}`;
|
||||
}
|
||||
|
||||
private static normalizeSnapshot(snapshotArg: IBondSnapshot, configArg: IBondConfig, sourceArg: TBondSnapshotSource, errorArg?: string): IBondSnapshot {
|
||||
const localControl = this.hasLocalControl(configArg);
|
||||
return {
|
||||
...snapshotArg,
|
||||
hub: {
|
||||
...snapshotArg.hub,
|
||||
host: snapshotArg.hub.host || endpointHost(configArg.host || configArg.url),
|
||||
port: snapshotArg.hub.port || endpointPort(configArg.host || configArg.url, configArg.port),
|
||||
},
|
||||
capabilities: {
|
||||
...snapshotArg.capabilities,
|
||||
localControl,
|
||||
},
|
||||
source: sourceArg,
|
||||
error: errorArg ?? snapshotArg.error,
|
||||
};
|
||||
}
|
||||
|
||||
private static rawData(configArg: IBondConfig, rawDataArg: Partial<IBondRawData> | undefined): IBondRawData {
|
||||
const rawData = rawDataArg || configArg.rawData || {};
|
||||
return {
|
||||
version: rawData.version,
|
||||
bridge: rawData.bridge,
|
||||
deviceIndex: rawData.deviceIndex,
|
||||
devices: rawData.devices,
|
||||
fetchedAt: rawData.fetchedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private static devices(devicesArg: IBondDeviceRaw[]): IBondDeviceSnapshot[] {
|
||||
return devicesArg.map((deviceArg) => {
|
||||
const attrs = deviceArg.attrs || {};
|
||||
const props = deviceArg.props || {};
|
||||
const state = deviceArg.state || {};
|
||||
const supportedActions = Array.isArray(attrs.actions) ? attrs.actions.filter((actionArg) => typeof actionArg === 'string') : [];
|
||||
return {
|
||||
deviceId: deviceArg.deviceId,
|
||||
name: stringValue(attrs.name) || `Bond Device ${deviceArg.deviceId}`,
|
||||
type: stringValue(attrs.type) || bondDeviceTypes.genericDevice,
|
||||
location: stringValue(attrs.location),
|
||||
template: stringValue(attrs.template),
|
||||
brandingProfile: stringValue(props.branding_profile),
|
||||
trustState: props.trust_state === true,
|
||||
supportedActions,
|
||||
attrs,
|
||||
props,
|
||||
state,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private static featuresForDevice(snapshotArg: IBondSnapshot, deviceArg: IBondDeviceSnapshot): plugins.shxInterfaces.data.IDeviceFeature[] {
|
||||
const writable = snapshotArg.capabilities.localControl;
|
||||
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [];
|
||||
if (this.isFan(deviceArg) || this.isGeneric(deviceArg) || this.isFireplace(deviceArg)) {
|
||||
features.push({ id: 'power', capability: 'switch', name: 'Power', readable: true, writable });
|
||||
}
|
||||
if (this.isFan(deviceArg) && this.hasAction(deviceArg, bondActions.setSpeed)) {
|
||||
features.push({ id: 'speed', capability: 'fan', name: 'Speed', readable: true, writable });
|
||||
}
|
||||
if (this.isFan(deviceArg) && this.hasAction(deviceArg, bondActions.setDirection)) {
|
||||
features.push({ id: 'direction', capability: 'fan', name: 'Direction', readable: true, writable });
|
||||
}
|
||||
if (this.supportsLight(deviceArg) || this.isLightDevice(deviceArg)) {
|
||||
features.push({ id: 'light', capability: 'light', name: 'Light', readable: true, writable });
|
||||
}
|
||||
if ((this.supportsLight(deviceArg) || this.isLightDevice(deviceArg)) && this.hasAction(deviceArg, bondActions.setBrightness)) {
|
||||
features.push({ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable, unit: '%' });
|
||||
}
|
||||
if (this.isFireplace(deviceArg)) {
|
||||
features.push({ id: 'flame', capability: 'light', name: 'Flame', readable: true, writable, unit: '%' });
|
||||
}
|
||||
if (this.isCover(deviceArg)) {
|
||||
features.push({ id: 'open', capability: 'cover', name: 'Open', readable: true, writable });
|
||||
if (this.hasAction(deviceArg, bondActions.setPosition)) {
|
||||
features.push({ id: 'position', capability: 'cover', name: 'Position', readable: true, writable, unit: '%' });
|
||||
}
|
||||
}
|
||||
if (features.length === 0) {
|
||||
features.push({ id: 'state', capability: 'sensor', name: 'State', readable: true, writable: false });
|
||||
}
|
||||
return features;
|
||||
}
|
||||
|
||||
private static stateForDevice(deviceArg: IBondDeviceSnapshot, featuresArg: plugins.shxInterfaces.data.IDeviceFeature[], updatedAtArg: string): plugins.shxInterfaces.data.IDeviceState[] {
|
||||
return featuresArg.map((featureArg) => ({
|
||||
featureId: featureArg.id,
|
||||
value: this.deviceStateValue(this.featureValue(deviceArg, featureArg.id)),
|
||||
updatedAt: updatedAtArg,
|
||||
}));
|
||||
}
|
||||
|
||||
private static featureValue(deviceArg: IBondDeviceSnapshot, featureArg: string): unknown {
|
||||
switch (featureArg) {
|
||||
case 'power':
|
||||
return boolState(deviceArg.state.power);
|
||||
case 'speed':
|
||||
return numberValue(deviceArg.state.speed) ?? null;
|
||||
case 'direction':
|
||||
return directionName(deviceArg.state.direction) ?? null;
|
||||
case 'light':
|
||||
return boolState(deviceArg.state.light);
|
||||
case 'brightness':
|
||||
return numberValue(deviceArg.state.brightness) ?? null;
|
||||
case 'flame':
|
||||
return numberValue(deviceArg.state.flame) ?? null;
|
||||
case 'open':
|
||||
return boolState(deviceArg.state.open);
|
||||
case 'position':
|
||||
return typeof deviceArg.state.position === 'number' ? this.bondToHomeAssistantPosition(deviceArg.state.position) : null;
|
||||
default:
|
||||
return deviceArg.state;
|
||||
}
|
||||
}
|
||||
|
||||
private static entity(snapshotArg: IBondSnapshot, deviceArg: IBondDeviceSnapshot, definitionArg: IBondEntityDefinition): IIntegrationEntity {
|
||||
const subDeviceId = definitionArg.subDevice || definitionArg.key;
|
||||
const baseId = slug(deviceArg.name) || slug(deviceArg.deviceId);
|
||||
const entityId = `${definitionArg.platform}.${baseId}${definitionArg.key === definitionArg.platform ? '' : `_${slug(definitionArg.key)}`}`;
|
||||
return {
|
||||
id: entityId,
|
||||
uniqueId: `${snapshotArg.hub.id}_${deviceArg.deviceId}_${slug(subDeviceId)}`,
|
||||
integrationDomain: bondDomain,
|
||||
deviceId: this.deviceId(snapshotArg, deviceArg),
|
||||
platform: definitionArg.platform,
|
||||
name: definitionArg.name,
|
||||
state: definitionArg.state,
|
||||
available: definitionArg.available ?? snapshotArg.online,
|
||||
attributes: this.cleanAttributes({
|
||||
bondId: snapshotArg.hub.id,
|
||||
deviceId: deviceArg.deviceId,
|
||||
type: deviceArg.type,
|
||||
subDevice: definitionArg.subDevice,
|
||||
assumedState: snapshotArg.hub.isBridge && !deviceArg.trustState,
|
||||
supportedActions: deviceArg.supportedActions,
|
||||
...definitionArg.attributes,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private static lightEntities(snapshotArg: IBondSnapshot, deviceArg: IBondDeviceSnapshot): IIntegrationEntity[] {
|
||||
const entities: IIntegrationEntity[] = [];
|
||||
if (this.isFan(deviceArg) && this.supportsLight(deviceArg) && !(this.supportsUpLight(deviceArg) && this.supportsDownLight(deviceArg))) {
|
||||
entities.push(this.lightEntity(snapshotArg, deviceArg, 'light', 'Light', onOffState(deviceArg.state.light), {
|
||||
brightness: this.homeAssistantBrightness(deviceArg.state.brightness),
|
||||
brightnessPercent: deviceArg.state.brightness,
|
||||
}));
|
||||
}
|
||||
if (this.isFan(deviceArg) && this.supportsUpLight(deviceArg)) {
|
||||
entities.push(this.lightEntity(snapshotArg, deviceArg, 'up_light', 'Up Light', onOffState(deviceArg.state.up_light || deviceArg.state.light)));
|
||||
}
|
||||
if (this.isFan(deviceArg) && this.supportsDownLight(deviceArg)) {
|
||||
entities.push(this.lightEntity(snapshotArg, deviceArg, 'down_light', 'Down Light', onOffState(deviceArg.state.down_light || deviceArg.state.light)));
|
||||
}
|
||||
if (this.isFireplace(deviceArg)) {
|
||||
entities.push(this.lightEntity(snapshotArg, deviceArg, 'fireplace', 'Fireplace', onOffState(deviceArg.state.power), {
|
||||
brightness: this.homeAssistantBrightness(deviceArg.state.flame),
|
||||
flamePercent: deviceArg.state.flame,
|
||||
}));
|
||||
if (this.supportsLight(deviceArg)) {
|
||||
entities.push(this.lightEntity(snapshotArg, deviceArg, 'light', 'Light', onOffState(deviceArg.state.light), {
|
||||
brightness: this.homeAssistantBrightness(deviceArg.state.brightness),
|
||||
brightnessPercent: deviceArg.state.brightness,
|
||||
}));
|
||||
}
|
||||
}
|
||||
if (this.isLightDevice(deviceArg)) {
|
||||
entities.push(this.lightEntity(snapshotArg, deviceArg, 'light', 'Light', onOffState(deviceArg.state.light), {
|
||||
brightness: this.homeAssistantBrightness(deviceArg.state.brightness),
|
||||
brightnessPercent: deviceArg.state.brightness,
|
||||
}, deviceArg.name));
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static lightEntity(snapshotArg: IBondSnapshot, deviceArg: IBondDeviceSnapshot, keyArg: string, suffixArg: string, stateArg: unknown, attributesArg: Record<string, unknown> = {}, nameArg?: string): IIntegrationEntity {
|
||||
return this.entity(snapshotArg, deviceArg, {
|
||||
key: keyArg === 'light' ? 'light' : `light_${keyArg}`,
|
||||
platform: 'light',
|
||||
name: nameArg || `${deviceArg.name} ${suffixArg}`,
|
||||
state: stateArg,
|
||||
subDevice: keyArg,
|
||||
attributes: attributesArg,
|
||||
});
|
||||
}
|
||||
|
||||
private static buttonEntities(deviceArg: IBondDeviceSnapshot): IBondButtonDescription[] {
|
||||
const buttons = buttonDescriptions.filter((descriptionArg) => this.hasAction(deviceArg, descriptionArg.key) && (!descriptionArg.mutuallyExclusive || !this.hasAction(deviceArg, descriptionArg.mutuallyExclusive)));
|
||||
if (buttons.length > 0 && this.hasAction(deviceArg, stopButtonDescription.key)) {
|
||||
buttons.push(stopButtonDescription);
|
||||
}
|
||||
if (this.hasAction(deviceArg, presetButtonDescription.key)) {
|
||||
buttons.push(presetButtonDescription);
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
|
||||
private static fanCommand(deviceArg: IBondDeviceSnapshot, requestArg: IServiceCallRequest, serviceArg: string): IBondCommandRequest | undefined {
|
||||
if (requestArg.service === 'turn_on') {
|
||||
const presetMode = stringValue(requestArg.data?.preset_mode);
|
||||
if (presetMode?.toLowerCase() === 'breeze' && this.hasAction(deviceArg, bondActions.breezeOn)) {
|
||||
return { deviceId: deviceArg.deviceId, action: bondActions.breezeOn, service: serviceArg, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
const percentage = numberValue(requestArg.data?.percentage);
|
||||
if (percentage !== undefined) {
|
||||
return this.fanPercentageCommand(deviceArg, percentage, serviceArg, requestArg);
|
||||
}
|
||||
return this.actionCommand(deviceArg, bondActions.turnOn, serviceArg, requestArg);
|
||||
}
|
||||
if (requestArg.service === 'turn_off') {
|
||||
return this.actionCommand(deviceArg, bondActions.turnOff, serviceArg, requestArg);
|
||||
}
|
||||
if (requestArg.service === 'set_percentage') {
|
||||
const percentage = numberValue(requestArg.data?.percentage);
|
||||
return percentage === undefined ? undefined : this.fanPercentageCommand(deviceArg, percentage, serviceArg, requestArg);
|
||||
}
|
||||
if (requestArg.service === 'set_direction') {
|
||||
const direction = stringValue(requestArg.data?.direction)?.toLowerCase();
|
||||
const bondDirection = direction === 'reverse' ? -1 : 1;
|
||||
return this.actionCommand(deviceArg, bondActions.setDirection, serviceArg, requestArg, bondDirection);
|
||||
}
|
||||
if (requestArg.service === 'set_fan_speed_tracked_state') {
|
||||
const speed = numberValue(requestArg.data?.speed ?? requestArg.data?.percentage);
|
||||
if (speed === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (speed === 0) {
|
||||
return { deviceId: deviceArg.deviceId, action: bondActions.setStateBelief, statePatch: { power: 0 }, service: serviceArg, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
return { deviceId: deviceArg.deviceId, action: bondActions.setStateBelief, statePatch: { power: 1, speed: this.percentageToBondSpeed(deviceArg, speed) }, service: serviceArg, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static fanPercentageCommand(deviceArg: IBondDeviceSnapshot, percentageArg: number, serviceArg: string, requestArg: IServiceCallRequest): IBondCommandRequest | undefined {
|
||||
if (percentageArg <= 0) {
|
||||
return this.actionCommand(deviceArg, bondActions.turnOff, serviceArg, requestArg);
|
||||
}
|
||||
return this.actionCommand(deviceArg, bondActions.setSpeed, serviceArg, requestArg, this.percentageToBondSpeed(deviceArg, percentageArg));
|
||||
}
|
||||
|
||||
private static lightCommand(deviceArg: IBondDeviceSnapshot, entityArg: IIntegrationEntity | undefined, requestArg: IServiceCallRequest, serviceArg: string): IBondCommandRequest | undefined {
|
||||
const key = stringValue(entityArg?.attributes?.subDevice) || this.lightKeyFromEntity(entityArg) || 'light';
|
||||
if (requestArg.service === 'turn_on') {
|
||||
const brightness = numberValue(requestArg.data?.brightness);
|
||||
if (brightness !== undefined) {
|
||||
if (key === 'fireplace') {
|
||||
return this.actionCommand(deviceArg, bondActions.setFlame, serviceArg, requestArg, this.homeAssistantToPercent(brightness));
|
||||
}
|
||||
return this.actionCommand(deviceArg, this.brightnessActionForLightKey(key), serviceArg, requestArg, this.homeAssistantToPercent(brightness));
|
||||
}
|
||||
return this.actionCommand(deviceArg, this.turnOnActionForLightKey(key), serviceArg, requestArg);
|
||||
}
|
||||
if (requestArg.service === 'turn_off') {
|
||||
return this.actionCommand(deviceArg, this.turnOffActionForLightKey(key), serviceArg, requestArg);
|
||||
}
|
||||
if (requestArg.service === 'set_light_power_tracked_state') {
|
||||
const power = booleanValue(requestArg.data?.power_state);
|
||||
return power === undefined ? undefined : { deviceId: deviceArg.deviceId, action: bondActions.setStateBelief, statePatch: key === 'fireplace' ? { power: Number(power) } : { light: Number(power) }, service: serviceArg, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (requestArg.service === 'set_light_brightness_tracked_state') {
|
||||
const brightness = numberValue(requestArg.data?.brightness);
|
||||
return brightness === undefined ? undefined : { deviceId: deviceArg.deviceId, action: bondActions.setStateBelief, statePatch: { brightness: this.homeAssistantToPercent(brightness) }, service: serviceArg, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static coverCommand(deviceArg: IBondDeviceSnapshot, requestArg: IServiceCallRequest, serviceArg: string): IBondCommandRequest | undefined {
|
||||
if (requestArg.service === 'open_cover') {
|
||||
return this.actionCommand(deviceArg, bondActions.open, serviceArg, requestArg);
|
||||
}
|
||||
if (requestArg.service === 'close_cover') {
|
||||
return this.actionCommand(deviceArg, bondActions.close, serviceArg, requestArg);
|
||||
}
|
||||
if (requestArg.service === 'stop_cover' || requestArg.service === 'stop_cover_tilt') {
|
||||
return this.actionCommand(deviceArg, bondActions.hold, serviceArg, requestArg);
|
||||
}
|
||||
if (requestArg.service === 'open_cover_tilt') {
|
||||
return this.actionCommand(deviceArg, bondActions.tiltOpen, serviceArg, requestArg);
|
||||
}
|
||||
if (requestArg.service === 'close_cover_tilt') {
|
||||
return this.actionCommand(deviceArg, bondActions.tiltClose, serviceArg, requestArg);
|
||||
}
|
||||
if (requestArg.service === 'set_cover_position') {
|
||||
const position = numberValue(requestArg.data?.position);
|
||||
return position === undefined ? undefined : this.actionCommand(deviceArg, bondActions.setPosition, serviceArg, requestArg, this.homeAssistantToBondPosition(position));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static switchCommand(deviceArg: IBondDeviceSnapshot, requestArg: IServiceCallRequest, serviceArg: string): IBondCommandRequest | undefined {
|
||||
if (requestArg.service === 'turn_on') {
|
||||
return this.actionCommand(deviceArg, bondActions.turnOn, serviceArg, requestArg);
|
||||
}
|
||||
if (requestArg.service === 'turn_off') {
|
||||
return this.actionCommand(deviceArg, bondActions.turnOff, serviceArg, requestArg);
|
||||
}
|
||||
if (requestArg.service === 'set_switch_power_tracked_state') {
|
||||
const power = booleanValue(requestArg.data?.power_state);
|
||||
return power === undefined ? undefined : { deviceId: deviceArg.deviceId, action: bondActions.setStateBelief, statePatch: { power: Number(power) }, service: serviceArg, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static actionCommand(deviceArg: IBondDeviceSnapshot, actionArg: string, serviceArg: string, requestArg: IServiceCallRequest, argumentArg?: unknown): IBondCommandRequest | undefined {
|
||||
if (!this.hasAction(deviceArg, actionArg)) {
|
||||
return undefined;
|
||||
}
|
||||
return { deviceId: deviceArg.deviceId, action: actionArg, argument: argumentArg, service: serviceArg, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
|
||||
private static entityForTarget(snapshotArg: IBondSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined {
|
||||
const entityId = requestArg.target.entityId;
|
||||
if (!entityId) {
|
||||
return undefined;
|
||||
}
|
||||
return this.toEntities(snapshotArg).find((entityArg) => entityArg.id === entityId || entityArg.uniqueId === entityId);
|
||||
}
|
||||
|
||||
private static deviceForTarget(snapshotArg: IBondSnapshot, requestArg: IServiceCallRequest): IBondDeviceSnapshot | undefined {
|
||||
const dataDeviceId = stringValue(requestArg.data?.deviceId) || stringValue(requestArg.data?.device_id);
|
||||
if (dataDeviceId) {
|
||||
return this.deviceByRawId(snapshotArg, dataDeviceId);
|
||||
}
|
||||
const target = requestArg.target.deviceId;
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
return snapshotArg.devices.find((deviceArg) => target === deviceArg.deviceId || target === this.deviceId(snapshotArg, deviceArg));
|
||||
}
|
||||
|
||||
private static deviceByRawId(snapshotArg: IBondSnapshot, deviceIdArg: string | undefined): IBondDeviceSnapshot | undefined {
|
||||
return snapshotArg.devices.find((deviceArg) => deviceArg.deviceId === deviceIdArg);
|
||||
}
|
||||
|
||||
private static hasLocalControl(configArg: IBondConfig): boolean {
|
||||
return Boolean(((configArg.host || configArg.url) && (configArg.accessToken || configArg.token)) || configArg.commandExecutor || configArg.client?.execute);
|
||||
}
|
||||
|
||||
private static hasAction(deviceArg: IBondDeviceSnapshot, actionArg: string): boolean {
|
||||
return deviceArg.supportedActions.includes(actionArg);
|
||||
}
|
||||
|
||||
private static isFan(deviceArg: IBondDeviceSnapshot): boolean {
|
||||
return deviceArg.type === bondDeviceTypes.ceilingFan;
|
||||
}
|
||||
|
||||
private static isCover(deviceArg: IBondDeviceSnapshot): boolean {
|
||||
return deviceArg.type === bondDeviceTypes.motorizedShades;
|
||||
}
|
||||
|
||||
private static isFireplace(deviceArg: IBondDeviceSnapshot): boolean {
|
||||
return deviceArg.type === bondDeviceTypes.fireplace;
|
||||
}
|
||||
|
||||
private static isLightDevice(deviceArg: IBondDeviceSnapshot): boolean {
|
||||
return deviceArg.type === bondDeviceTypes.light;
|
||||
}
|
||||
|
||||
private static isGeneric(deviceArg: IBondDeviceSnapshot): boolean {
|
||||
return deviceArg.type === bondDeviceTypes.genericDevice;
|
||||
}
|
||||
|
||||
private static supportsLight(deviceArg: IBondDeviceSnapshot): boolean {
|
||||
return this.hasAction(deviceArg, bondActions.turnLightOn) || this.hasAction(deviceArg, bondActions.turnLightOff);
|
||||
}
|
||||
|
||||
private static supportsUpLight(deviceArg: IBondDeviceSnapshot): boolean {
|
||||
return this.hasAction(deviceArg, bondActions.turnUpLightOn) || this.hasAction(deviceArg, bondActions.turnUpLightOff);
|
||||
}
|
||||
|
||||
private static supportsDownLight(deviceArg: IBondDeviceSnapshot): boolean {
|
||||
return this.hasAction(deviceArg, bondActions.turnDownLightOn) || this.hasAction(deviceArg, bondActions.turnDownLightOff);
|
||||
}
|
||||
|
||||
private static maxSpeed(deviceArg: IBondDeviceSnapshot): number {
|
||||
return numberValue(deviceArg.props.max_speed) || 3;
|
||||
}
|
||||
|
||||
private static percentageToBondSpeed(deviceArg: IBondDeviceSnapshot, percentageArg: number): number {
|
||||
const maxSpeed = this.maxSpeed(deviceArg);
|
||||
return Math.min(maxSpeed, Math.max(1, Math.ceil(1 + ((maxSpeed - 1) * percentageArg) / 100)));
|
||||
}
|
||||
|
||||
private static fanPercentage(deviceArg: IBondDeviceSnapshot): number {
|
||||
const power = boolState(deviceArg.state.power);
|
||||
const speed = numberValue(deviceArg.state.speed);
|
||||
if (!power || !speed) {
|
||||
return 0;
|
||||
}
|
||||
const maxSpeed = this.maxSpeed(deviceArg);
|
||||
return Math.min(100, Math.max(0, Math.round(((speed - 1) * 100) / Math.max(1, maxSpeed - 1))));
|
||||
}
|
||||
|
||||
private static bondToHomeAssistantPosition(positionArg: number): number {
|
||||
return Math.abs(positionArg - 100);
|
||||
}
|
||||
|
||||
private static homeAssistantToBondPosition(positionArg: number): number {
|
||||
return 100 - positionArg;
|
||||
}
|
||||
|
||||
private static homeAssistantBrightness(valueArg: unknown): number | undefined {
|
||||
const brightness = numberValue(valueArg);
|
||||
return brightness === undefined ? undefined : Math.round((brightness * 255) / 100);
|
||||
}
|
||||
|
||||
private static homeAssistantToPercent(valueArg: number): number {
|
||||
return Math.round((valueArg * 100) / 255);
|
||||
}
|
||||
|
||||
private static lightKeyFromEntity(entityArg: IIntegrationEntity | undefined): string | undefined {
|
||||
if (!entityArg) {
|
||||
return undefined;
|
||||
}
|
||||
if (entityArg.id.includes('up_light')) {
|
||||
return 'up_light';
|
||||
}
|
||||
if (entityArg.id.includes('down_light')) {
|
||||
return 'down_light';
|
||||
}
|
||||
if (entityArg.id.includes('fireplace')) {
|
||||
return 'fireplace';
|
||||
}
|
||||
return 'light';
|
||||
}
|
||||
|
||||
private static turnOnActionForLightKey(keyArg: string): string {
|
||||
if (keyArg === 'up_light') {
|
||||
return bondActions.turnUpLightOn;
|
||||
}
|
||||
if (keyArg === 'down_light') {
|
||||
return bondActions.turnDownLightOn;
|
||||
}
|
||||
if (keyArg === 'fireplace') {
|
||||
return bondActions.turnOn;
|
||||
}
|
||||
return bondActions.turnLightOn;
|
||||
}
|
||||
|
||||
private static turnOffActionForLightKey(keyArg: string): string {
|
||||
if (keyArg === 'up_light') {
|
||||
return bondActions.turnUpLightOff;
|
||||
}
|
||||
if (keyArg === 'down_light') {
|
||||
return bondActions.turnDownLightOff;
|
||||
}
|
||||
if (keyArg === 'fireplace') {
|
||||
return bondActions.turnOff;
|
||||
}
|
||||
return bondActions.turnLightOff;
|
||||
}
|
||||
|
||||
private static brightnessActionForLightKey(keyArg: string): string {
|
||||
if (keyArg === 'up_light') {
|
||||
return bondActions.setUpLightBrightness;
|
||||
}
|
||||
if (keyArg === 'down_light') {
|
||||
return bondActions.setDownLightBrightness;
|
||||
}
|
||||
return bondActions.setBrightness;
|
||||
}
|
||||
|
||||
private static deviceTypeName(typeArg: string): string {
|
||||
const names: Record<string, string> = {
|
||||
[bondDeviceTypes.ceilingFan]: 'Ceiling Fan',
|
||||
[bondDeviceTypes.motorizedShades]: 'Motorized Shades',
|
||||
[bondDeviceTypes.fireplace]: 'Fireplace',
|
||||
[bondDeviceTypes.airConditioner]: 'Air Conditioner',
|
||||
[bondDeviceTypes.garageDoor]: 'Garage Door',
|
||||
[bondDeviceTypes.bidet]: 'Bidet',
|
||||
[bondDeviceTypes.light]: 'Light',
|
||||
[bondDeviceTypes.genericDevice]: 'Generic Device',
|
||||
};
|
||||
return names[typeArg] || `Bond ${typeArg}`;
|
||||
}
|
||||
|
||||
private static cleanAttributes(attributesArg: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(attributesArg)) {
|
||||
if (value !== undefined) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static clone<TValue>(valueArg: TValue): TValue {
|
||||
return JSON.parse(JSON.stringify(valueArg)) as TValue;
|
||||
}
|
||||
|
||||
private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
|
||||
if (valueArg === undefined || valueArg === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean') {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'object' && !Array.isArray(valueArg)) {
|
||||
return valueArg as Record<string, unknown>;
|
||||
}
|
||||
return String(valueArg);
|
||||
}
|
||||
}
|
||||
|
||||
const endpointHost = (valueArg: string | undefined): string | undefined => parseEndpoint(valueArg)?.host || valueArg?.replace(/^https?:\/\//i, '').replace(/\/.*/, '').replace(/:\d+$/, '');
|
||||
|
||||
const endpointPort = (valueArg: string | undefined, fallbackArg?: number): number | undefined => parseEndpoint(valueArg)?.port || fallbackArg || (valueArg ? bondDefaultPort : undefined);
|
||||
|
||||
const parseEndpoint = (valueArg: string | undefined): { host: string; port?: number } | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg) ? valueArg : `http://${valueArg}`);
|
||||
return { host: url.hostname, port: url.port ? Number(url.port) : undefined };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const slug = (valueArg: unknown): string => String(valueArg ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'bond';
|
||||
|
||||
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
|
||||
const numberValue = (valueArg: unknown): number | undefined => {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const parsed = Number(valueArg);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const booleanValue = (valueArg: unknown): boolean | undefined => {
|
||||
if (typeof valueArg === 'boolean') {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'number') {
|
||||
return valueArg !== 0;
|
||||
}
|
||||
if (typeof valueArg === 'string') {
|
||||
const normalized = valueArg.trim().toLowerCase();
|
||||
if (['true', 'yes', 'on', '1'].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (['false', 'no', 'off', '0'].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const recordValue = (valueArg: unknown): Record<string, unknown> | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
|
||||
|
||||
const boolState = (valueArg: unknown): boolean | undefined => booleanValue(valueArg);
|
||||
|
||||
const onOffState = (valueArg: unknown): string => {
|
||||
const value = boolState(valueArg);
|
||||
return value === undefined ? 'unknown' : value ? 'on' : 'off';
|
||||
};
|
||||
|
||||
const coverState = (valueArg: unknown): string => {
|
||||
const value = boolState(valueArg);
|
||||
return value === undefined ? 'unknown' : value ? 'open' : 'closed';
|
||||
};
|
||||
|
||||
const directionName = (valueArg: unknown): string | undefined => {
|
||||
const value = numberValue(valueArg);
|
||||
if (value === 1) {
|
||||
return 'forward';
|
||||
}
|
||||
if (value === -1) {
|
||||
return 'reverse';
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -1,4 +1,280 @@
|
||||
export interface IHomeAssistantBondConfig {
|
||||
// TODO: replace with the TypeScript-native config for bond.
|
||||
export const bondDomain = 'bond';
|
||||
export const bondDisplayName = 'Bond';
|
||||
export const bondDefaultPort = 80;
|
||||
export const bondDefaultTimeoutMs = 10000;
|
||||
export const bondZeroconfType = '_bond._tcp.local.';
|
||||
export const bondDhcpHostnamePrefix = 'bond-';
|
||||
export const bondDhcpMacPrefixes = ['3C6A2C1', 'F44E38'] as const;
|
||||
|
||||
export type TBondSnapshotSource = 'http' | 'client' | 'manual' | 'snapshot' | 'runtime' | 'executor';
|
||||
|
||||
export const bondDeviceTypes = {
|
||||
ceilingFan: 'CF',
|
||||
motorizedShades: 'MS',
|
||||
fireplace: 'FP',
|
||||
airConditioner: 'AC',
|
||||
garageDoor: 'GD',
|
||||
bidet: 'BD',
|
||||
light: 'LT',
|
||||
genericDevice: 'GX',
|
||||
} as const;
|
||||
|
||||
export const bondActions = {
|
||||
stop: 'Stop',
|
||||
turnOn: 'TurnOn',
|
||||
turnOff: 'TurnOff',
|
||||
togglePower: 'TogglePower',
|
||||
setTimer: 'SetTimer',
|
||||
switchMode: 'SwitchMode',
|
||||
setStateBelief: 'state',
|
||||
turnLightOn: 'TurnLightOn',
|
||||
turnLightOff: 'TurnLightOff',
|
||||
toggleLight: 'ToggleLight',
|
||||
setBrightness: 'SetBrightness',
|
||||
increaseBrightness: 'IncreaseBrightness',
|
||||
decreaseBrightness: 'DecreaseBrightness',
|
||||
startDimmer: 'StartDimmer',
|
||||
turnUpLightOn: 'TurnUpLightOn',
|
||||
turnDownLightOn: 'TurnDownLightOn',
|
||||
turnUpLightOff: 'TurnUpLightOff',
|
||||
turnDownLightOff: 'TurnDownLightOff',
|
||||
toggleUpLight: 'ToggleUpLight',
|
||||
toggleDownLight: 'ToggleDownLight',
|
||||
startUpLightDimmer: 'StartUpLightDimmer',
|
||||
startDownLightDimmer: 'StartDownLightDimmer',
|
||||
startIncreasingBrightness: 'StartIncreasingBrightness',
|
||||
startDecreasingBrightness: 'StartDecreasingBrightness',
|
||||
setUpLightBrightness: 'SetUpLightBrightness',
|
||||
setDownLightBrightness: 'SetDownLightBrightness',
|
||||
increaseUpLightBrightness: 'IncreaseUpLightBrightness',
|
||||
decreaseUpLightBrightness: 'DecreaseUpLightBrightness',
|
||||
increaseDownLightBrightness: 'IncreaseDownLightBrightness',
|
||||
decreaseDownLightBrightness: 'DecreaseDownLightBrightness',
|
||||
cycleUpLightBrightness: 'CycleUpLightBrightness',
|
||||
cycleDownLightBrightness: 'CycleDownLightBrightness',
|
||||
cycleBrightness: 'CycleBrightness',
|
||||
breezeOff: 'BreezeOff',
|
||||
breezeOn: 'BreezeOn',
|
||||
decreaseSpeed: 'DecreaseSpeed',
|
||||
increaseSpeed: 'IncreaseSpeed',
|
||||
setBreeze: 'SetBreeze',
|
||||
setDirection: 'SetDirection',
|
||||
setSpeed: 'SetSpeed',
|
||||
toggleDirection: 'ToggleDirection',
|
||||
toggleLightTemp: 'ToggleLightTemp',
|
||||
increaseTemperature: 'IncreaseTemperature',
|
||||
decreaseTemperature: 'DecreaseTemperature',
|
||||
setFpFan: 'SetFpFan',
|
||||
turnFpFanOn: 'TurnFpFanOn',
|
||||
turnFpFanOff: 'TurnFpFanOff',
|
||||
increaseFlame: 'IncreaseFlame',
|
||||
decreaseFlame: 'DecreaseFlame',
|
||||
setFlame: 'SetFlame',
|
||||
close: 'Close',
|
||||
closeNext: 'CloseNext',
|
||||
decreasePosition: 'DecreasePosition',
|
||||
hold: 'Hold',
|
||||
increasePosition: 'IncreasePosition',
|
||||
open: 'Open',
|
||||
openNext: 'OpenNext',
|
||||
preset: 'Preset',
|
||||
setPosition: 'SetPosition',
|
||||
tiltClose: 'TiltClose',
|
||||
tiltOpen: 'TiltOpen',
|
||||
toggleOpen: 'ToggleOpen',
|
||||
} as const;
|
||||
|
||||
export type TBondAction = typeof bondActions[keyof typeof bondActions] | string;
|
||||
export type TBondDeviceType = typeof bondDeviceTypes[keyof typeof bondDeviceTypes] | string;
|
||||
|
||||
export interface IBondVersionInfo {
|
||||
bondid?: string;
|
||||
target?: string;
|
||||
model?: string;
|
||||
make?: string;
|
||||
fw_ver?: string;
|
||||
mcu_ver?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IBondBridgeInfo {
|
||||
name?: string;
|
||||
location?: string;
|
||||
bluelight?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IBondDeviceAttributes {
|
||||
name?: string;
|
||||
type?: TBondDeviceType;
|
||||
location?: string;
|
||||
template?: string;
|
||||
actions?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IBondDeviceProperties {
|
||||
max_speed?: number;
|
||||
trust_state?: boolean;
|
||||
branding_profile?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IBondDeviceState {
|
||||
power?: number | boolean;
|
||||
light?: number | boolean;
|
||||
up_light?: number | boolean;
|
||||
down_light?: number | boolean;
|
||||
brightness?: number;
|
||||
flame?: number;
|
||||
speed?: number;
|
||||
direction?: number;
|
||||
breeze?: unknown[];
|
||||
open?: number | boolean;
|
||||
position?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IBondDeviceRaw {
|
||||
deviceId: string;
|
||||
attrs?: IBondDeviceAttributes;
|
||||
props?: IBondDeviceProperties;
|
||||
state?: IBondDeviceState;
|
||||
}
|
||||
|
||||
export interface IBondRawData {
|
||||
version?: IBondVersionInfo;
|
||||
bridge?: IBondBridgeInfo;
|
||||
deviceIndex?: Record<string, unknown>;
|
||||
devices?: IBondDeviceRaw[];
|
||||
fetchedAt?: string;
|
||||
}
|
||||
|
||||
export interface IBondHubSnapshot {
|
||||
id: string;
|
||||
name: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
manufacturer: string;
|
||||
model?: string;
|
||||
target?: string;
|
||||
firmware?: string;
|
||||
hardware?: string;
|
||||
location?: string;
|
||||
isBridge: boolean;
|
||||
version: IBondVersionInfo;
|
||||
bridge?: IBondBridgeInfo;
|
||||
}
|
||||
|
||||
export interface IBondDeviceSnapshot {
|
||||
deviceId: string;
|
||||
name: string;
|
||||
type: TBondDeviceType;
|
||||
location?: string;
|
||||
template?: string;
|
||||
brandingProfile?: string;
|
||||
trustState: boolean;
|
||||
supportedActions: string[];
|
||||
attrs: IBondDeviceAttributes;
|
||||
props: IBondDeviceProperties;
|
||||
state: IBondDeviceState;
|
||||
}
|
||||
|
||||
export interface IBondCapabilities {
|
||||
localControl: boolean;
|
||||
pushUpdates: boolean;
|
||||
fan: boolean;
|
||||
cover: boolean;
|
||||
light: boolean;
|
||||
fireplace: boolean;
|
||||
switch: boolean;
|
||||
buttons: boolean;
|
||||
}
|
||||
|
||||
export interface IBondSnapshot {
|
||||
hub: IBondHubSnapshot;
|
||||
devices: IBondDeviceSnapshot[];
|
||||
capabilities: IBondCapabilities;
|
||||
rawData?: IBondRawData;
|
||||
online: boolean;
|
||||
updatedAt: string;
|
||||
source: TBondSnapshotSource;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface IBondCommandRequest {
|
||||
deviceId: string;
|
||||
action: TBondAction;
|
||||
argument?: unknown;
|
||||
statePatch?: Record<string, unknown>;
|
||||
service?: string;
|
||||
target?: Record<string, unknown>;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IBondCommandExecutor {
|
||||
execute(requestArg: IBondCommandRequest): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface IBondClientLike {
|
||||
getSnapshot?: () => Promise<IBondSnapshot | Partial<IBondRawData>>;
|
||||
getRawData?: () => Promise<Partial<IBondRawData>>;
|
||||
execute?: (requestArg: IBondCommandRequest) => Promise<unknown>;
|
||||
destroy?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface IBondConfig {
|
||||
host?: string;
|
||||
url?: string;
|
||||
port?: number;
|
||||
accessToken?: string;
|
||||
token?: string;
|
||||
timeoutMs?: number;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
uniqueId?: string;
|
||||
bondId?: string;
|
||||
snapshot?: IBondSnapshot;
|
||||
rawData?: Partial<IBondRawData>;
|
||||
client?: IBondClientLike;
|
||||
commandExecutor?: IBondCommandExecutor;
|
||||
online?: boolean;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantBondConfig extends IBondConfig {}
|
||||
|
||||
export interface IBondManualEntry extends IBondConfig {
|
||||
id?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IBondZeroconfRecord {
|
||||
type?: string;
|
||||
serviceType?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
hostname?: string;
|
||||
addresses?: string[];
|
||||
port?: number;
|
||||
txt?: Record<string, string | undefined>;
|
||||
properties?: Record<string, string | undefined>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IBondDhcpRecord {
|
||||
hostname?: string;
|
||||
hostName?: string;
|
||||
ip?: string;
|
||||
address?: string;
|
||||
macaddress?: string;
|
||||
macAddress?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IBondHttpRefreshResult {
|
||||
success: boolean;
|
||||
snapshot?: IBondSnapshot;
|
||||
error?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './bond.classes.client.js';
|
||||
export * from './bond.classes.configflow.js';
|
||||
export * from './bond.classes.integration.js';
|
||||
export * from './bond.discovery.js';
|
||||
export * from './bond.mapper.js';
|
||||
export * from './bond.types.js';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -0,0 +1,620 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type {
|
||||
IDenonClientLike,
|
||||
IDenonCommandRequest,
|
||||
IDenonConfig,
|
||||
IDenonRawCommandRequest,
|
||||
IDenonSnapshot,
|
||||
TDenonSnapshotSource,
|
||||
} from './denon.types.js';
|
||||
import {
|
||||
denonDefaultName,
|
||||
denonDefaultPort,
|
||||
denonDefaultSourceMap,
|
||||
denonDefaultTimeoutMs,
|
||||
denonMediaModeCodes,
|
||||
} from './denon.types.js';
|
||||
|
||||
export class DenonClientError extends Error {
|
||||
constructor(messageArg: string) {
|
||||
super(messageArg);
|
||||
this.name = 'DenonClientError';
|
||||
}
|
||||
}
|
||||
|
||||
export class DenonTransportError extends DenonClientError {
|
||||
constructor(messageArg: string) {
|
||||
super(messageArg);
|
||||
this.name = 'DenonTransportError';
|
||||
}
|
||||
}
|
||||
|
||||
export class DenonClient {
|
||||
private currentSnapshot?: IDenonSnapshot;
|
||||
|
||||
constructor(private readonly config: IDenonConfig) {
|
||||
this.currentSnapshot = config.snapshot ? this.normalizeSnapshot(this.cloneValue(config.snapshot), 'snapshot') : undefined;
|
||||
}
|
||||
|
||||
public async getSnapshot(): Promise<IDenonSnapshot> {
|
||||
if (this.config.snapshot) {
|
||||
this.currentSnapshot = this.normalizeSnapshot(this.cloneValue(this.config.snapshot), 'snapshot');
|
||||
return this.cloneValue(this.currentSnapshot);
|
||||
}
|
||||
|
||||
if (this.config.client?.getSnapshot || this.config.client?.snapshot) {
|
||||
this.currentSnapshot = this.normalizeSnapshot(await this.snapshotFromClient(this.config.client), 'client');
|
||||
return this.cloneValue(this.currentSnapshot);
|
||||
}
|
||||
|
||||
if (this.config.host || this.config.commandExecutor) {
|
||||
try {
|
||||
this.currentSnapshot = await this.fetchSnapshot();
|
||||
} catch (errorArg) {
|
||||
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
|
||||
}
|
||||
return this.cloneValue(this.currentSnapshot);
|
||||
}
|
||||
|
||||
this.currentSnapshot = this.offlineSnapshot('Denon refresh requires config.host, config.snapshot, injected client, or commandExecutor.');
|
||||
return this.cloneValue(this.currentSnapshot);
|
||||
}
|
||||
|
||||
public async refresh(): Promise<IDenonSnapshot> {
|
||||
return this.getSnapshot();
|
||||
}
|
||||
|
||||
public async validateConnection(): Promise<IDenonSnapshot> {
|
||||
const snapshot = await this.fetchSnapshot();
|
||||
if (!snapshot.online) {
|
||||
throw new DenonTransportError(snapshot.error || 'Denon receiver did not return live telnet state.');
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public async execute(requestArg: IDenonCommandRequest): Promise<unknown> {
|
||||
if (this.config.client?.execute) {
|
||||
return this.config.client.execute(requestArg);
|
||||
}
|
||||
|
||||
const rawCommand = this.rawCommandFromRequest(requestArg);
|
||||
if (!rawCommand) {
|
||||
throw new DenonClientError(`Unsupported Denon command: ${requestArg.command}`);
|
||||
}
|
||||
|
||||
if (this.config.commandExecutor) {
|
||||
return this.config.commandExecutor.execute(this.rawRequest(rawCommand));
|
||||
}
|
||||
if (!this.config.host) {
|
||||
throw new DenonTransportError('Denon telnet commands require config.host, injected client.execute, or commandExecutor. Static snapshots/manual data are read-only.');
|
||||
}
|
||||
|
||||
return this.sendRawCommand(rawCommand);
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
|
||||
private async fetchSnapshot(): Promise<IDenonSnapshot> {
|
||||
const source = this.config.commandExecutor && !this.config.host ? 'executor' : 'telnet';
|
||||
const session = this.config.commandExecutor ? undefined : await DenonTelnetSession.connect({
|
||||
host: this.requireHost(),
|
||||
port: this.port(),
|
||||
timeoutMs: this.timeoutMs(),
|
||||
responseIdleMs: this.responseIdleMs(),
|
||||
});
|
||||
|
||||
try {
|
||||
const request = async (commandArg: string): Promise<string[]> => {
|
||||
if (this.config.commandExecutor) {
|
||||
return this.executorResultToLines(await this.config.commandExecutor.execute(this.rawRequest(commandArg)));
|
||||
}
|
||||
return session!.request(commandArg);
|
||||
};
|
||||
|
||||
const setup = await this.readSources(request);
|
||||
const power = firstLine(await request('PW?'));
|
||||
const volumeLines = await request('MV?');
|
||||
const volumeMax = this.volumeMax(volumeLines) || 60;
|
||||
const volume = this.volume(volumeLines);
|
||||
const muted = firstLine(await request('MU?')) === 'MUON';
|
||||
const sourceCode = this.stripPrefix(firstLine(await request('SI?')), 'SI');
|
||||
const sourceName = this.sourceName(setup.sourceMap, sourceCode);
|
||||
const mediaMode = denonMediaModeCodes.has(sourceCode || '');
|
||||
const mediaInfo = mediaMode
|
||||
? this.mediaInfo(await request('NSE'))
|
||||
: sourceName;
|
||||
|
||||
return this.normalizeSnapshot({
|
||||
receiverInfo: {
|
||||
host: this.config.host,
|
||||
port: this.port(),
|
||||
name: setup.name || this.config.name || denonDefaultName,
|
||||
manufacturer: this.config.manufacturer || 'Denon',
|
||||
modelName: this.config.model,
|
||||
serialNumber: this.config.serialNumber,
|
||||
},
|
||||
state: {
|
||||
power,
|
||||
state: this.mediaState(power, mediaMode),
|
||||
volume,
|
||||
volumeMax,
|
||||
volumeLevel: typeof volume === 'number' ? Math.max(0, Math.min(1, volume / volumeMax)) : undefined,
|
||||
muted,
|
||||
source: sourceCode,
|
||||
sourceName,
|
||||
sourceList: Object.keys(setup.sourceMap).sort(),
|
||||
mediaTitle: mediaInfo,
|
||||
mediaInfo,
|
||||
mediaMode,
|
||||
supportedFeatures: this.supportedFeatures(mediaMode),
|
||||
},
|
||||
sourceMap: setup.sourceMap,
|
||||
online: true,
|
||||
available: true,
|
||||
source: source,
|
||||
}, source);
|
||||
} finally {
|
||||
await session?.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async readSources(requestArg: (commandArg: string) => Promise<string[]>): Promise<{ name?: string; sourceMap: Record<string, string> }> {
|
||||
const configuredSourceMap = { ...denonDefaultSourceMap, ...this.config.sourceMap };
|
||||
let sourceMap = configuredSourceMap;
|
||||
const name = this.stripPrefix(firstLine(await requestArg('NSFRN ?')), 'NSFRN ') || this.config.name;
|
||||
const sourceLines = await requestArg('SSFUN ?');
|
||||
|
||||
if (sourceLines.length) {
|
||||
const parsedSourceMap: Record<string, string> = {};
|
||||
for (const line of sourceLines) {
|
||||
const sourceLine = this.stripPrefix(line, 'SSFUN');
|
||||
if (!sourceLine) {
|
||||
continue;
|
||||
}
|
||||
const [source, configuredName] = splitFirst(sourceLine.trim(), ' ');
|
||||
parsedSourceMap[configuredName || source] = source;
|
||||
}
|
||||
if (Object.keys(parsedSourceMap).length) {
|
||||
sourceMap = { ...parsedSourceMap, ...this.config.sourceMap };
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of await requestArg('SSSOD ?')) {
|
||||
const deletedLine = this.stripPrefix(line, 'SSSOD') || '';
|
||||
const [source, status] = splitFirst(deletedLine.trim(), ' ');
|
||||
if (status !== 'DEL') {
|
||||
continue;
|
||||
}
|
||||
for (const [prettyName, sourceCode] of Object.entries(sourceMap)) {
|
||||
if (sourceCode === source) {
|
||||
delete sourceMap[prettyName];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { name, sourceMap };
|
||||
}
|
||||
|
||||
private rawCommandFromRequest(requestArg: IDenonCommandRequest): string | undefined {
|
||||
if (requestArg.command === 'raw') {
|
||||
return requestArg.rawCommand;
|
||||
}
|
||||
if (requestArg.command === 'turn_on') {
|
||||
return 'PWON';
|
||||
}
|
||||
if (requestArg.command === 'turn_off') {
|
||||
return 'PWSTANDBY';
|
||||
}
|
||||
if (requestArg.command === 'volume_up') {
|
||||
return 'MVUP';
|
||||
}
|
||||
if (requestArg.command === 'volume_down') {
|
||||
return 'MVDOWN';
|
||||
}
|
||||
if (requestArg.command === 'set_volume') {
|
||||
return this.setVolumeCommand(requestArg.volumeLevel);
|
||||
}
|
||||
if (requestArg.command === 'mute') {
|
||||
return `MU${requestArg.muted ? 'ON' : 'OFF'}`;
|
||||
}
|
||||
if (requestArg.command === 'select_source') {
|
||||
return this.selectSourceCommand(requestArg.source);
|
||||
}
|
||||
if (requestArg.command === 'play') {
|
||||
return 'NS9A';
|
||||
}
|
||||
if (requestArg.command === 'pause') {
|
||||
return 'NS9B';
|
||||
}
|
||||
if (requestArg.command === 'stop') {
|
||||
return 'NS9C';
|
||||
}
|
||||
if (requestArg.command === 'next_track') {
|
||||
return 'NS9D';
|
||||
}
|
||||
if (requestArg.command === 'previous_track') {
|
||||
return 'NS9E';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private setVolumeCommand(volumeLevelArg: number | undefined): string {
|
||||
if (typeof volumeLevelArg !== 'number' || !Number.isFinite(volumeLevelArg)) {
|
||||
throw new DenonClientError('Denon set_volume requires data.volume_level.');
|
||||
}
|
||||
const volumeMax = this.currentSnapshot?.state.volumeMax || this.config.snapshot?.state.volumeMax || 60;
|
||||
const volume = Math.max(0, Math.min(volumeMax, Math.round(volumeLevelArg * volumeMax)));
|
||||
return `MV${String(volume).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
private selectSourceCommand(sourceArg: string | undefined): string {
|
||||
if (!sourceArg) {
|
||||
throw new DenonClientError('Denon select_source requires data.source.');
|
||||
}
|
||||
const sourceMap = {
|
||||
...denonDefaultSourceMap,
|
||||
...this.currentSnapshot?.sourceMap,
|
||||
...this.config.snapshot?.sourceMap,
|
||||
...this.config.sourceMap,
|
||||
};
|
||||
const sourceCode = sourceMap[sourceArg] || sourceMap[sourceArg.toUpperCase()] || sourceArg;
|
||||
if (!sourceCode) {
|
||||
throw new DenonClientError(`Denon source is not available: ${sourceArg}`);
|
||||
}
|
||||
return `SI${sourceCode}`;
|
||||
}
|
||||
|
||||
private async sendRawCommand(commandArg: string): Promise<string[]> {
|
||||
const session = await DenonTelnetSession.connect({
|
||||
host: this.requireHost(),
|
||||
port: this.port(),
|
||||
timeoutMs: this.timeoutMs(),
|
||||
responseIdleMs: this.responseIdleMs(),
|
||||
});
|
||||
try {
|
||||
return await session.request(commandArg);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
private rawRequest(commandArg: string): IDenonRawCommandRequest {
|
||||
return {
|
||||
command: 'raw',
|
||||
rawCommand: commandArg,
|
||||
host: this.config.host,
|
||||
port: this.port(),
|
||||
timeoutMs: this.timeoutMs(),
|
||||
};
|
||||
}
|
||||
|
||||
private async snapshotFromClient(clientArg: IDenonClientLike): Promise<IDenonSnapshot> {
|
||||
const result = clientArg.getSnapshot ? await clientArg.getSnapshot() : clientArg.snapshot ? await clientArg.snapshot() : undefined;
|
||||
if (!result) {
|
||||
throw new DenonClientError('Denon client must expose getSnapshot() or snapshot().');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private normalizeSnapshot(snapshotArg: IDenonSnapshot, sourceArg: TDenonSnapshotSource): IDenonSnapshot {
|
||||
const receiverInfo = {
|
||||
host: snapshotArg.receiverInfo.host || this.config.host,
|
||||
port: snapshotArg.receiverInfo.port || this.port(),
|
||||
name: snapshotArg.receiverInfo.name || this.config.name || denonDefaultName,
|
||||
manufacturer: snapshotArg.receiverInfo.manufacturer || this.config.manufacturer || 'Denon',
|
||||
modelName: snapshotArg.receiverInfo.modelName || this.config.model,
|
||||
serialNumber: snapshotArg.receiverInfo.serialNumber || this.config.serialNumber,
|
||||
};
|
||||
const sourceMap = { ...denonDefaultSourceMap, ...snapshotArg.sourceMap, ...this.config.sourceMap };
|
||||
const sourceName = snapshotArg.state.sourceName || this.sourceName(sourceMap, snapshotArg.state.source);
|
||||
const volumeMax = snapshotArg.state.volumeMax || 60;
|
||||
const volumeLevel = typeof snapshotArg.state.volumeLevel === 'number'
|
||||
? Math.max(0, Math.min(1, snapshotArg.state.volumeLevel))
|
||||
: typeof snapshotArg.state.volume === 'number'
|
||||
? Math.max(0, Math.min(1, snapshotArg.state.volume / volumeMax))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...snapshotArg,
|
||||
receiverInfo,
|
||||
state: {
|
||||
...snapshotArg.state,
|
||||
sourceName,
|
||||
sourceList: snapshotArg.state.sourceList || Object.keys(sourceMap).sort(),
|
||||
volumeMax,
|
||||
volumeLevel,
|
||||
state: snapshotArg.state.state || this.mediaState(snapshotArg.state.power, snapshotArg.state.mediaMode),
|
||||
supportedFeatures: snapshotArg.state.supportedFeatures || this.supportedFeatures(Boolean(snapshotArg.state.mediaMode)),
|
||||
},
|
||||
sourceMap,
|
||||
online: snapshotArg.online,
|
||||
available: snapshotArg.available,
|
||||
source: sourceArg,
|
||||
lastUpdated: snapshotArg.lastUpdated || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private offlineSnapshot(errorArg: string): IDenonSnapshot {
|
||||
return this.normalizeSnapshot({
|
||||
receiverInfo: {
|
||||
host: this.config.host,
|
||||
port: this.port(),
|
||||
name: this.config.name || denonDefaultName,
|
||||
manufacturer: this.config.manufacturer || 'Denon',
|
||||
modelName: this.config.model,
|
||||
serialNumber: this.config.serialNumber,
|
||||
},
|
||||
state: {
|
||||
power: 'PWSTANDBY',
|
||||
state: 'unavailable',
|
||||
volumeMax: 60,
|
||||
sourceList: Object.keys({ ...denonDefaultSourceMap, ...this.config.sourceMap }).sort(),
|
||||
supportedFeatures: this.supportedFeatures(false),
|
||||
},
|
||||
sourceMap: { ...denonDefaultSourceMap, ...this.config.sourceMap },
|
||||
online: false,
|
||||
available: false,
|
||||
source: 'runtime',
|
||||
error: errorArg,
|
||||
}, 'runtime');
|
||||
}
|
||||
|
||||
private supportedFeatures(mediaModeArg: boolean): string[] {
|
||||
const features = ['volume_set', 'volume_mute', 'turn_on', 'turn_off', 'select_source'];
|
||||
return mediaModeArg ? [...features, 'pause', 'stop', 'previous_track', 'next_track', 'play'] : features;
|
||||
}
|
||||
|
||||
private mediaState(powerArg: string | undefined, mediaModeArg: boolean | undefined): string {
|
||||
if (powerArg === 'PWSTANDBY') {
|
||||
return 'off';
|
||||
}
|
||||
if (powerArg === 'PWON') {
|
||||
return mediaModeArg ? 'playing' : 'on';
|
||||
}
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
private volumeMax(linesArg: string[]): number | undefined {
|
||||
for (const line of linesArg) {
|
||||
if (!line.startsWith('MVMAX')) {
|
||||
continue;
|
||||
}
|
||||
const value = this.numberValue(line.replace(/^MVMAX\s*/i, '').slice(0, 2));
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private volume(linesArg: string[]): number | undefined {
|
||||
for (const line of linesArg) {
|
||||
if (line.startsWith('MVMAX') || !line.startsWith('MV')) {
|
||||
continue;
|
||||
}
|
||||
return this.parseVolumeValue(line.replace(/^MV/i, ''));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private parseVolumeValue(valueArg: string): number | undefined {
|
||||
const normalized = valueArg.trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
const value = this.numberValue(normalized);
|
||||
if (typeof value !== 'number') {
|
||||
return undefined;
|
||||
}
|
||||
return normalized.length >= 3 && !normalized.includes('.') ? value / 10 : value;
|
||||
}
|
||||
|
||||
private numberValue(valueArg: string): number | undefined {
|
||||
const value = Number(valueArg);
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
private mediaInfo(linesArg: string[]): string | undefined {
|
||||
const answerCodes = ['NSE0', 'NSE1X', 'NSE2X', 'NSE3X', 'NSE4', 'NSE5', 'NSE6', 'NSE7', 'NSE8'];
|
||||
const lines = linesArg.map((lineArg, indexArg) => this.stripPrefix(lineArg, answerCodes[indexArg] || '')).filter(Boolean);
|
||||
return lines.length ? lines.join('\n') : undefined;
|
||||
}
|
||||
|
||||
private sourceName(sourceMapArg: Record<string, string>, sourceArg: string | undefined): string | undefined {
|
||||
if (!sourceArg) {
|
||||
return undefined;
|
||||
}
|
||||
for (const [prettyName, sourceCode] of Object.entries(sourceMapArg)) {
|
||||
if (sourceCode === sourceArg) {
|
||||
return prettyName;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private stripPrefix(valueArg: string | undefined, prefixArg: string): string | undefined {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
return valueArg.startsWith(prefixArg) ? valueArg.slice(prefixArg.length).trim() || undefined : valueArg.trim() || undefined;
|
||||
}
|
||||
|
||||
private executorResultToLines(valueArg: unknown): string[] {
|
||||
if (Array.isArray(valueArg)) {
|
||||
return valueArg.map((itemArg) => String(itemArg).trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof valueArg === 'string') {
|
||||
return valueArg.split(/\r?\n|\r/).map((lineArg) => lineArg.trim()).filter(Boolean);
|
||||
}
|
||||
if (valueArg && typeof valueArg === 'object') {
|
||||
const result = valueArg as { lines?: unknown; response?: unknown; data?: unknown };
|
||||
if (Array.isArray(result.lines)) {
|
||||
return result.lines.map((itemArg) => String(itemArg).trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof result.response === 'string') {
|
||||
return this.executorResultToLines(result.response);
|
||||
}
|
||||
if (typeof result.data === 'string') {
|
||||
return this.executorResultToLines(result.data);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private port(): number {
|
||||
return this.config.port || denonDefaultPort;
|
||||
}
|
||||
|
||||
private timeoutMs(): number {
|
||||
return this.config.timeoutMs || denonDefaultTimeoutMs;
|
||||
}
|
||||
|
||||
private responseIdleMs(): number {
|
||||
return this.config.responseIdleMs || Math.min(200, Math.max(20, Math.floor(this.timeoutMs() / 5)));
|
||||
}
|
||||
|
||||
private requireHost(): string {
|
||||
if (!this.config.host) {
|
||||
throw new DenonTransportError('Denon telnet transport requires config.host.');
|
||||
}
|
||||
return this.config.host;
|
||||
}
|
||||
|
||||
private cloneValue<TValue>(valueArg: TValue): TValue {
|
||||
return JSON.parse(JSON.stringify(valueArg)) as TValue;
|
||||
}
|
||||
|
||||
private errorMessage(errorArg: unknown): string {
|
||||
return errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||
}
|
||||
}
|
||||
|
||||
class DenonTelnetSession {
|
||||
private buffer = '';
|
||||
private pending?: {
|
||||
lines: string[];
|
||||
resolve: (valueArg: string[]) => void;
|
||||
reject: (errorArg: Error) => void;
|
||||
idleTimer: ReturnType<typeof setTimeout>;
|
||||
timeoutTimer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
private closed = false;
|
||||
|
||||
private constructor(
|
||||
private readonly socket: plugins.net.Socket,
|
||||
private readonly options: { host: string; port: number; timeoutMs: number; responseIdleMs: number },
|
||||
) {}
|
||||
|
||||
public static async connect(optionsArg: { host: string; port: number; timeoutMs: number; responseIdleMs: number }): Promise<DenonTelnetSession> {
|
||||
const socket = plugins.net.createConnection({ host: optionsArg.host, port: optionsArg.port });
|
||||
const session = new DenonTelnetSession(socket, optionsArg);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (errorArg?: Error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
socket.removeListener('connect', onConnect);
|
||||
socket.removeListener('error', onError);
|
||||
socket.removeListener('timeout', onTimeout);
|
||||
if (errorArg) {
|
||||
socket.destroy();
|
||||
reject(errorArg);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
const onConnect = () => finish();
|
||||
const onError = (errorArg: Error) => finish(errorArg);
|
||||
const onTimeout = () => finish(new DenonTransportError(`Timed out connecting to Denon receiver at ${optionsArg.host}:${optionsArg.port}.`));
|
||||
socket.setEncoding('ascii');
|
||||
socket.setTimeout(optionsArg.timeoutMs, onTimeout);
|
||||
socket.once('connect', onConnect);
|
||||
socket.once('error', onError);
|
||||
socket.once('timeout', onTimeout);
|
||||
});
|
||||
|
||||
socket.setTimeout(0);
|
||||
socket.on('data', (chunkArg) => session.onData(String(chunkArg)));
|
||||
socket.on('error', (errorArg) => session.rejectPending(errorArg));
|
||||
socket.on('close', () => {
|
||||
session.closed = true;
|
||||
session.rejectPending(new DenonTransportError('Denon telnet connection closed.'));
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
public async request(commandArg: string): Promise<string[]> {
|
||||
if (this.closed) {
|
||||
throw new DenonTransportError('Denon telnet connection is closed.');
|
||||
}
|
||||
if (this.pending) {
|
||||
throw new DenonTransportError('Denon telnet session already has a pending request.');
|
||||
}
|
||||
|
||||
return new Promise<string[]>((resolve, reject) => {
|
||||
const finish = (errorArg?: Error, linesArg: string[] = []) => {
|
||||
const pending = this.pending;
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.idleTimer);
|
||||
clearTimeout(pending.timeoutTimer);
|
||||
this.pending = undefined;
|
||||
if (errorArg) {
|
||||
reject(errorArg);
|
||||
return;
|
||||
}
|
||||
resolve(linesArg);
|
||||
};
|
||||
const idleTimer = setTimeout(() => finish(undefined, this.pending?.lines || []), this.options.responseIdleMs);
|
||||
const timeoutTimer = setTimeout(() => finish(new DenonTransportError(`Denon telnet command ${commandArg} timed out after ${this.options.timeoutMs}ms.`)), this.options.timeoutMs);
|
||||
this.pending = { lines: [], resolve: (valueArg) => finish(undefined, valueArg), reject: (errorArg) => finish(errorArg), idleTimer, timeoutTimer };
|
||||
this.socket.write(`${commandArg}\r`, 'ascii');
|
||||
});
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.closed = true;
|
||||
this.socket.removeAllListeners();
|
||||
this.socket.destroy();
|
||||
this.rejectPending(new DenonTransportError('Denon telnet connection closed.'));
|
||||
}
|
||||
|
||||
private onData(chunkArg: string): void {
|
||||
this.buffer += chunkArg;
|
||||
const lines = this.buffer.split(/\r?\n|\r/);
|
||||
this.buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
const value = line.trim();
|
||||
if (value) {
|
||||
this.pending?.lines.push(value);
|
||||
}
|
||||
}
|
||||
if (this.pending) {
|
||||
clearTimeout(this.pending.idleTimer);
|
||||
this.pending.idleTimer = setTimeout(() => this.pending?.resolve(this.pending.lines), this.options.responseIdleMs);
|
||||
}
|
||||
}
|
||||
|
||||
private rejectPending(errorArg: Error): void {
|
||||
this.pending?.reject(errorArg);
|
||||
}
|
||||
}
|
||||
|
||||
const firstLine = (linesArg: string[]): string | undefined => {
|
||||
return linesArg[0];
|
||||
};
|
||||
|
||||
const splitFirst = (valueArg: string, separatorArg: string): [string, string | undefined] => {
|
||||
const index = valueArg.indexOf(separatorArg);
|
||||
if (index < 0) {
|
||||
return [valueArg, undefined];
|
||||
}
|
||||
return [valueArg.slice(0, index), valueArg.slice(index + separatorArg.length).trim() || undefined];
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import type { IDenonConfig, IDenonSnapshot } from './denon.types.js';
|
||||
import { denonDefaultName, denonDefaultPort } from './denon.types.js';
|
||||
|
||||
export class DenonConfigFlow implements IConfigFlow<IDenonConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IDenonConfig>> {
|
||||
void contextArg;
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const snapshot = metadata.snapshot as IDenonSnapshot | undefined;
|
||||
const hasInjectedRuntime = Boolean(snapshot || metadata.client || metadata.commandExecutor);
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect Denon Network Receiver',
|
||||
description: hasInjectedRuntime
|
||||
? 'Configure the Denon receiver using the discovered snapshot or injected runtime.'
|
||||
: 'Configure the local Denon receiver telnet endpoint.',
|
||||
fields: [
|
||||
{ name: 'host', label: 'Host', type: 'text', required: !hasInjectedRuntime },
|
||||
{ name: 'port', label: 'Telnet port', type: 'number' },
|
||||
{ name: 'name', label: 'Name', type: 'text' },
|
||||
{ name: 'model', label: 'Model', type: 'text' },
|
||||
],
|
||||
submit: async (valuesArg) => {
|
||||
const port = numberValue(valuesArg.port) || candidateArg.port || snapshot?.receiverInfo.port || denonDefaultPort;
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'Denon receiver configured',
|
||||
config: {
|
||||
host: stringValue(valuesArg.host) || candidateArg.host || snapshot?.receiverInfo.host,
|
||||
port,
|
||||
name: stringValue(valuesArg.name) || candidateArg.name || snapshot?.receiverInfo.name || denonDefaultName,
|
||||
manufacturer: candidateArg.manufacturer || snapshot?.receiverInfo.manufacturer || 'Denon',
|
||||
model: stringValue(valuesArg.model) || candidateArg.model || snapshot?.receiverInfo.modelName,
|
||||
serialNumber: candidateArg.serialNumber || snapshot?.receiverInfo.serialNumber,
|
||||
sourceMap: metadata.sourceMap as IDenonConfig['sourceMap'],
|
||||
snapshot,
|
||||
client: metadata.client as IDenonConfig['client'],
|
||||
commandExecutor: metadata.commandExecutor as IDenonConfig['commandExecutor'],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const stringValue = (valueArg: unknown): string | undefined => {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
};
|
||||
|
||||
const numberValue = (valueArg: unknown): number | undefined => {
|
||||
const value = typeof valueArg === 'number' ? valueArg : typeof valueArg === 'string' && valueArg.trim() ? Number(valueArg) : undefined;
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
};
|
||||
@@ -1,22 +1,172 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { DenonClient } from './denon.classes.client.js';
|
||||
import { DenonConfigFlow } from './denon.classes.configflow.js';
|
||||
import { createDenonDiscoveryDescriptor } from './denon.discovery.js';
|
||||
import { DenonMapper } from './denon.mapper.js';
|
||||
import type { IDenonCommandRequest, IDenonConfig, TDenonCommand } from './denon.types.js';
|
||||
import { denonDomain } from './denon.types.js';
|
||||
|
||||
export class HomeAssistantDenonIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "denon",
|
||||
displayName: "Denon Network Receivers",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/denon",
|
||||
"upstreamDomain": "denon",
|
||||
"iotClass": "local_polling",
|
||||
"qualityScale": "legacy",
|
||||
"requirements": [],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": []
|
||||
},
|
||||
});
|
||||
export class DenonIntegration extends BaseIntegration<IDenonConfig> {
|
||||
public readonly domain = denonDomain;
|
||||
public readonly displayName = 'Denon Network Receivers';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createDenonDiscoveryDescriptor();
|
||||
public readonly configFlow = new DenonConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/denon',
|
||||
upstreamDomain: denonDomain,
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_polling',
|
||||
qualityScale: 'legacy',
|
||||
requirements: [],
|
||||
dependencies: [],
|
||||
afterDependencies: [],
|
||||
codeowners: [],
|
||||
configFlow: true,
|
||||
runtime: {
|
||||
type: 'control-runtime',
|
||||
polling: 'local Denon telnet protocol',
|
||||
services: ['snapshot', 'refresh', 'media_player controls'],
|
||||
controls: true,
|
||||
},
|
||||
localApi: {
|
||||
implemented: [
|
||||
'manual host setup',
|
||||
'telnet snapshot polling via PW?, MV?, MU?, SI?, NSFRN?, SSFUN?, SSSOD?, and NSE',
|
||||
'power, volume, mute, source selection, and media transport telnet commands',
|
||||
],
|
||||
explicitUnsupported: [
|
||||
'claiming live command success without config.host, injected client.execute, or commandExecutor',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
public async setup(configArg: IDenonConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new DenonRuntime(new DenonClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantDenonIntegration extends DenonIntegration {}
|
||||
|
||||
class DenonRuntime implements IIntegrationRuntime {
|
||||
public domain = denonDomain;
|
||||
|
||||
constructor(private readonly client: DenonClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return DenonMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return DenonMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
try {
|
||||
if (requestArg.domain === denonDomain) {
|
||||
return this.callDenonService(requestArg);
|
||||
}
|
||||
if (requestArg.domain !== 'media_player') {
|
||||
return { success: false, error: `Unsupported Denon service domain: ${requestArg.domain}` };
|
||||
}
|
||||
const command = this.commandFromService(requestArg);
|
||||
if (!command) {
|
||||
return { success: false, error: `Unsupported Denon media_player service: ${requestArg.service}` };
|
||||
}
|
||||
const request: IDenonCommandRequest = {
|
||||
command,
|
||||
source: this.stringData(requestArg, 'source'),
|
||||
volumeLevel: this.numberData(requestArg, 'volume_level'),
|
||||
muted: this.boolData(requestArg, 'is_volume_muted') ?? this.boolData(requestArg, 'mute'),
|
||||
};
|
||||
return { success: true, data: await this.client.execute(request) };
|
||||
} catch (errorArg) {
|
||||
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
|
||||
private async callDenonService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'snapshot' || requestArg.service === 'status') {
|
||||
return { success: true, data: await this.client.getSnapshot() };
|
||||
}
|
||||
if (requestArg.service === 'refresh' || requestArg.service === 'reload') {
|
||||
const snapshot = await this.client.refresh();
|
||||
return snapshot.online
|
||||
? { success: true, data: snapshot }
|
||||
: { success: false, error: snapshot.error || 'Denon refresh requires config.host, config.snapshot, injected client, or commandExecutor.', data: snapshot };
|
||||
}
|
||||
if (requestArg.service === 'raw_command') {
|
||||
const rawCommand = this.stringData(requestArg, 'command') || this.stringData(requestArg, 'rawCommand');
|
||||
if (!rawCommand) {
|
||||
return { success: false, error: 'Denon raw_command requires data.command.' };
|
||||
}
|
||||
return { success: true, data: await this.client.execute({ command: 'raw', rawCommand }) };
|
||||
}
|
||||
return { success: false, error: `Unsupported Denon service: ${requestArg.service}` };
|
||||
}
|
||||
|
||||
private commandFromService(requestArg: IServiceCallRequest): TDenonCommand | undefined {
|
||||
if (requestArg.service === 'turn_on') {
|
||||
return 'turn_on';
|
||||
}
|
||||
if (requestArg.service === 'turn_off') {
|
||||
return 'turn_off';
|
||||
}
|
||||
if (requestArg.service === 'volume_up') {
|
||||
return 'volume_up';
|
||||
}
|
||||
if (requestArg.service === 'volume_down') {
|
||||
return 'volume_down';
|
||||
}
|
||||
if (requestArg.service === 'volume_set') {
|
||||
return 'set_volume';
|
||||
}
|
||||
if (requestArg.service === 'volume_mute') {
|
||||
return 'mute';
|
||||
}
|
||||
if (requestArg.service === 'select_source') {
|
||||
return 'select_source';
|
||||
}
|
||||
if (requestArg.service === 'media_play' || requestArg.service === 'play') {
|
||||
return 'play';
|
||||
}
|
||||
if (requestArg.service === 'media_pause' || requestArg.service === 'pause') {
|
||||
return 'pause';
|
||||
}
|
||||
if (requestArg.service === 'media_stop' || requestArg.service === 'stop') {
|
||||
return 'stop';
|
||||
}
|
||||
if (requestArg.service === 'media_next_track' || requestArg.service === 'next_track') {
|
||||
return 'next_track';
|
||||
}
|
||||
if (requestArg.service === 'media_previous_track' || requestArg.service === 'previous_track') {
|
||||
return 'previous_track';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
private numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
return typeof value === 'number' ? value : undefined;
|
||||
}
|
||||
|
||||
private boolData(requestArg: IServiceCallRequest, keyArg: string): boolean | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import type { IDenonManualEntry, IDenonSnapshot } from './denon.types.js';
|
||||
import { denonDefaultName, denonDefaultPort, denonDomain } from './denon.types.js';
|
||||
|
||||
export class DenonManualMatcher implements IDiscoveryMatcher<IDenonManualEntry> {
|
||||
public id = 'denon-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual Denon Network Receiver setup entries.';
|
||||
|
||||
public async matches(inputArg: IDenonManualEntry): Promise<IDiscoveryMatch> {
|
||||
const metadata = inputArg.metadata || {};
|
||||
const snapshot = inputArg.snapshot || (metadata.snapshot as IDenonSnapshot | undefined);
|
||||
const matched = Boolean(
|
||||
inputArg.host
|
||||
|| snapshot
|
||||
|| inputArg.commandExecutor
|
||||
|| inputArg.client
|
||||
|| metadata.denon
|
||||
|| inputArg.manufacturer?.toLowerCase().includes('denon')
|
||||
|| inputArg.model?.toLowerCase().includes('denon')
|
||||
);
|
||||
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Denon Network Receiver setup hints.' };
|
||||
}
|
||||
|
||||
const id = inputArg.id || snapshot?.receiverInfo.serialNumber || inputArg.serialNumber || inputArg.host;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: inputArg.host || snapshot ? 'high' : 'medium',
|
||||
reason: 'Manual entry can start Denon Network Receiver setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: denonDomain,
|
||||
id,
|
||||
host: inputArg.host || snapshot?.receiverInfo.host,
|
||||
port: inputArg.port || snapshot?.receiverInfo.port || denonDefaultPort,
|
||||
name: snapshot?.receiverInfo.name || inputArg.name || denonDefaultName,
|
||||
manufacturer: inputArg.manufacturer || snapshot?.receiverInfo.manufacturer || 'Denon',
|
||||
model: inputArg.model || snapshot?.receiverInfo.modelName,
|
||||
serialNumber: inputArg.serialNumber || snapshot?.receiverInfo.serialNumber,
|
||||
metadata: {
|
||||
...metadata,
|
||||
snapshot,
|
||||
sourceMap: inputArg.sourceMap || metadata.sourceMap,
|
||||
client: inputArg.client || metadata.client,
|
||||
commandExecutor: inputArg.commandExecutor || metadata.commandExecutor,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class DenonCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'denon-candidate-validator';
|
||||
public description = 'Validate Denon Network Receiver candidate metadata.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const snapshot = metadata.snapshot as IDenonSnapshot | undefined;
|
||||
const matched = candidateArg.integrationDomain === denonDomain
|
||||
|| Boolean(candidateArg.host)
|
||||
|| Boolean(snapshot)
|
||||
|| Boolean(metadata.commandExecutor)
|
||||
|| Boolean(metadata.client)
|
||||
|| candidateArg.manufacturer?.toLowerCase().includes('denon') === true
|
||||
|| candidateArg.model?.toLowerCase().includes('denon') === true;
|
||||
|
||||
return {
|
||||
matched,
|
||||
confidence: matched && (candidateArg.host || snapshot) ? 'high' : matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Candidate has usable Denon Network Receiver setup data.' : 'Candidate is not a Denon Network Receiver.',
|
||||
candidate: matched ? candidateArg : undefined,
|
||||
normalizedDeviceId: candidateArg.id || candidateArg.serialNumber || snapshot?.receiverInfo.serialNumber || candidateArg.host,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createDenonDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: denonDomain, displayName: 'Denon Network Receivers' })
|
||||
.addMatcher(new DenonManualMatcher())
|
||||
.addValidator(new DenonCandidateValidator());
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity } from '../../core/types.js';
|
||||
import type { IDenonReceiverInfo, IDenonSnapshot } from './denon.types.js';
|
||||
import { denonDefaultName, denonDomain } from './denon.types.js';
|
||||
|
||||
export class DenonMapper {
|
||||
public static toDevices(snapshotArg: IDenonSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.lastUpdated || new Date().toISOString();
|
||||
return [{
|
||||
id: this.deviceId(snapshotArg),
|
||||
integrationDomain: denonDomain,
|
||||
name: this.receiverName(snapshotArg.receiverInfo),
|
||||
protocol: 'unknown',
|
||||
manufacturer: snapshotArg.receiverInfo.manufacturer || 'Denon',
|
||||
model: snapshotArg.receiverInfo.modelName,
|
||||
online: snapshotArg.online && snapshotArg.available,
|
||||
features: [
|
||||
{ id: 'power', capability: 'media', name: 'Power', readable: true, writable: true },
|
||||
{ id: 'source', capability: 'media', name: 'Source', readable: true, writable: true },
|
||||
{ id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true, unit: '%' },
|
||||
{ id: 'muted', capability: 'media', name: 'Muted', readable: true, writable: true },
|
||||
{ id: 'media_title', capability: 'media', name: 'Media title', readable: true, writable: false },
|
||||
],
|
||||
state: [
|
||||
{ featureId: 'power', value: this.powerState(snapshotArg), updatedAt },
|
||||
{ featureId: 'source', value: snapshotArg.state.sourceName || snapshotArg.state.source || null, updatedAt },
|
||||
{ featureId: 'volume', value: typeof snapshotArg.state.volumeLevel === 'number' ? Math.round(snapshotArg.state.volumeLevel * 100) : null, updatedAt },
|
||||
{ featureId: 'muted', value: snapshotArg.state.muted ?? null, updatedAt },
|
||||
{ featureId: 'media_title', value: snapshotArg.state.mediaTitle || null, updatedAt },
|
||||
],
|
||||
metadata: this.cleanAttributes({
|
||||
host: snapshotArg.receiverInfo.host,
|
||||
port: snapshotArg.receiverInfo.port,
|
||||
protocol: 'telnet',
|
||||
serialNumber: snapshotArg.receiverInfo.serialNumber,
|
||||
source: snapshotArg.source,
|
||||
error: snapshotArg.error,
|
||||
}),
|
||||
}];
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IDenonSnapshot): IIntegrationEntity[] {
|
||||
const entityBase = this.entityBase(snapshotArg);
|
||||
const deviceId = this.deviceId(snapshotArg);
|
||||
const entities: IIntegrationEntity[] = [{
|
||||
id: `media_player.${entityBase}`,
|
||||
uniqueId: `denon_${this.uniqueBase(snapshotArg)}_media_player`,
|
||||
integrationDomain: denonDomain,
|
||||
deviceId,
|
||||
platform: 'media_player',
|
||||
name: this.receiverName(snapshotArg.receiverInfo),
|
||||
state: this.mediaState(snapshotArg),
|
||||
attributes: this.cleanAttributes({
|
||||
deviceClass: 'receiver',
|
||||
power: snapshotArg.state.power,
|
||||
volumeLevel: snapshotArg.state.volumeLevel,
|
||||
volume: snapshotArg.state.volume,
|
||||
volumeMax: snapshotArg.state.volumeMax,
|
||||
isVolumeMuted: snapshotArg.state.muted,
|
||||
source: snapshotArg.state.sourceName || snapshotArg.state.source,
|
||||
sourceCode: snapshotArg.state.source,
|
||||
sourceList: snapshotArg.state.sourceList,
|
||||
mediaTitle: snapshotArg.state.mediaTitle,
|
||||
mediaContentType: snapshotArg.state.mediaMode ? 'music' : undefined,
|
||||
supportedFeatures: snapshotArg.state.supportedFeatures,
|
||||
error: snapshotArg.error,
|
||||
}),
|
||||
available: snapshotArg.online && snapshotArg.available,
|
||||
}, {
|
||||
id: `sensor.${entityBase}_source`,
|
||||
uniqueId: `denon_${this.uniqueBase(snapshotArg)}_source`,
|
||||
integrationDomain: denonDomain,
|
||||
deviceId,
|
||||
platform: 'sensor',
|
||||
name: `${this.receiverName(snapshotArg.receiverInfo)} Source`,
|
||||
state: snapshotArg.state.sourceName || snapshotArg.state.source || 'unknown',
|
||||
attributes: this.cleanAttributes({ sourceCode: snapshotArg.state.source }),
|
||||
available: snapshotArg.online && snapshotArg.available,
|
||||
}];
|
||||
|
||||
if (typeof snapshotArg.state.muted === 'boolean') {
|
||||
entities.push({
|
||||
id: `switch.${entityBase}_mute`,
|
||||
uniqueId: `denon_${this.uniqueBase(snapshotArg)}_mute`,
|
||||
integrationDomain: denonDomain,
|
||||
deviceId,
|
||||
platform: 'switch',
|
||||
name: `${this.receiverName(snapshotArg.receiverInfo)} Mute`,
|
||||
state: snapshotArg.state.muted,
|
||||
attributes: {},
|
||||
available: snapshotArg.online && snapshotArg.available,
|
||||
});
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static deviceId(snapshotArg: IDenonSnapshot): string {
|
||||
return `denon.receiver.${this.uniqueBase(snapshotArg)}`;
|
||||
}
|
||||
|
||||
public static slug(valueArg: string): string {
|
||||
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || denonDomain;
|
||||
}
|
||||
|
||||
private static mediaState(snapshotArg: IDenonSnapshot): string {
|
||||
if (!snapshotArg.online || !snapshotArg.available) {
|
||||
return 'unavailable';
|
||||
}
|
||||
if (this.powerState(snapshotArg) === 'off') {
|
||||
return 'off';
|
||||
}
|
||||
const state = snapshotArg.state.state?.toLowerCase();
|
||||
if (state === 'playing' || state === 'paused' || state === 'idle') {
|
||||
return state;
|
||||
}
|
||||
return 'on';
|
||||
}
|
||||
|
||||
private static powerState(snapshotArg: IDenonSnapshot): string {
|
||||
if (!snapshotArg.online || !snapshotArg.available) {
|
||||
return 'unavailable';
|
||||
}
|
||||
return snapshotArg.state.power === 'PWSTANDBY' || snapshotArg.state.state === 'off' ? 'off' : 'on';
|
||||
}
|
||||
|
||||
private static deviceIdentity(snapshotArg: IDenonSnapshot): string {
|
||||
return snapshotArg.receiverInfo.serialNumber || snapshotArg.receiverInfo.host || this.receiverName(snapshotArg.receiverInfo);
|
||||
}
|
||||
|
||||
private static entityBase(snapshotArg: IDenonSnapshot): string {
|
||||
return this.slug(this.receiverName(snapshotArg.receiverInfo));
|
||||
}
|
||||
|
||||
private static uniqueBase(snapshotArg: IDenonSnapshot): string {
|
||||
return this.slug(this.deviceIdentity(snapshotArg));
|
||||
}
|
||||
|
||||
private static receiverName(infoArg: IDenonReceiverInfo): string {
|
||||
return infoArg.name || infoArg.modelName || denonDefaultName;
|
||||
}
|
||||
|
||||
private static cleanAttributes(attributesArg: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,154 @@
|
||||
export interface IHomeAssistantDenonConfig {
|
||||
// TODO: replace with the TypeScript-native config for denon.
|
||||
[key: string]: unknown;
|
||||
export const denonDomain = 'denon';
|
||||
export const denonDefaultName = 'Music station';
|
||||
export const denonDefaultPort = 23;
|
||||
export const denonDefaultTimeoutMs = 1500;
|
||||
|
||||
export type TDenonSnapshotSource = 'telnet' | 'snapshot' | 'manual' | 'client' | 'executor' | 'runtime';
|
||||
|
||||
export type TDenonPowerState = 'PWON' | 'PWSTANDBY' | string;
|
||||
|
||||
export type TDenonMediaState = 'on' | 'off' | 'playing' | 'paused' | 'idle' | 'unavailable' | string;
|
||||
|
||||
export type TDenonCommand =
|
||||
| 'turn_on'
|
||||
| 'turn_off'
|
||||
| 'volume_up'
|
||||
| 'volume_down'
|
||||
| 'set_volume'
|
||||
| 'mute'
|
||||
| 'select_source'
|
||||
| 'play'
|
||||
| 'pause'
|
||||
| 'stop'
|
||||
| 'next_track'
|
||||
| 'previous_track'
|
||||
| 'raw';
|
||||
|
||||
export interface IDenonConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
timeoutMs?: number;
|
||||
responseIdleMs?: number;
|
||||
sourceMap?: Record<string, string>;
|
||||
snapshot?: IDenonSnapshot;
|
||||
client?: IDenonClientLike;
|
||||
commandExecutor?: IDenonCommandExecutor;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantDenonConfig extends IDenonConfig {}
|
||||
|
||||
export interface IDenonClientLike {
|
||||
getSnapshot?(): Promise<IDenonSnapshot>;
|
||||
snapshot?(): Promise<IDenonSnapshot>;
|
||||
execute?(requestArg: IDenonCommandRequest): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface IDenonCommandExecutor {
|
||||
execute(requestArg: IDenonRawCommandRequest): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface IDenonReceiverInfo {
|
||||
host?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
modelName?: string;
|
||||
serialNumber?: string;
|
||||
}
|
||||
|
||||
export interface IDenonMediaPlayerState {
|
||||
power?: TDenonPowerState;
|
||||
state?: TDenonMediaState;
|
||||
volume?: number;
|
||||
volumeMax?: number;
|
||||
volumeLevel?: number;
|
||||
muted?: boolean;
|
||||
source?: string;
|
||||
sourceName?: string;
|
||||
sourceList?: string[];
|
||||
mediaTitle?: string;
|
||||
mediaInfo?: string;
|
||||
mediaMode?: boolean;
|
||||
supportedFeatures?: string[];
|
||||
}
|
||||
|
||||
export interface IDenonSnapshot {
|
||||
receiverInfo: IDenonReceiverInfo;
|
||||
state: IDenonMediaPlayerState;
|
||||
sourceMap?: Record<string, string>;
|
||||
online: boolean;
|
||||
available: boolean;
|
||||
source: TDenonSnapshotSource;
|
||||
lastUpdated?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface IDenonCommandRequest {
|
||||
command: TDenonCommand;
|
||||
source?: string;
|
||||
volumeLevel?: number;
|
||||
muted?: boolean;
|
||||
rawCommand?: string;
|
||||
}
|
||||
|
||||
export interface IDenonRawCommandRequest {
|
||||
command: 'raw';
|
||||
rawCommand: string;
|
||||
host?: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface IDenonManualEntry {
|
||||
host?: string;
|
||||
port?: number;
|
||||
id?: string;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
sourceMap?: Record<string, string>;
|
||||
snapshot?: IDenonSnapshot;
|
||||
client?: IDenonClientLike;
|
||||
commandExecutor?: IDenonCommandExecutor;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const denonNormalInputs: Record<string, string> = {
|
||||
Cd: 'CD',
|
||||
Dvd: 'DVD',
|
||||
'Blue ray': 'BD',
|
||||
TV: 'TV',
|
||||
'Satellite / Cable': 'SAT/CBL',
|
||||
Game: 'GAME',
|
||||
Game2: 'GAME2',
|
||||
'Video Aux': 'V.AUX',
|
||||
Dock: 'DOCK',
|
||||
};
|
||||
|
||||
export const denonMediaModes: Record<string, string> = {
|
||||
Tuner: 'TUNER',
|
||||
'Media server': 'SERVER',
|
||||
'Ipod dock': 'IPOD',
|
||||
'Net/USB': 'NET/USB',
|
||||
Rapsody: 'RHAPSODY',
|
||||
Napster: 'NAPSTER',
|
||||
Pandora: 'PANDORA',
|
||||
LastFM: 'LASTFM',
|
||||
Flickr: 'FLICKR',
|
||||
Favorites: 'FAVORITES',
|
||||
'Internet Radio': 'IRADIO',
|
||||
'USB/IPOD': 'USB/IPOD',
|
||||
USB: 'USB',
|
||||
};
|
||||
|
||||
export const denonDefaultSourceMap: Record<string, string> = {
|
||||
...denonNormalInputs,
|
||||
...denonMediaModes,
|
||||
};
|
||||
|
||||
export const denonMediaModeCodes = new Set(Object.values(denonMediaModes));
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './denon.classes.client.js';
|
||||
export * from './denon.classes.configflow.js';
|
||||
export * from './denon.classes.integration.js';
|
||||
export * from './denon.discovery.js';
|
||||
export * from './denon.mapper.js';
|
||||
export * from './denon.types.js';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -0,0 +1,420 @@
|
||||
import { ElgatoMapper } from './elgato.mapper.js';
|
||||
import type {
|
||||
IElgatoBatteryInfo,
|
||||
IElgatoClientLike,
|
||||
IElgatoCommandRequest,
|
||||
IElgatoConfig,
|
||||
IElgatoInfo,
|
||||
IElgatoRawData,
|
||||
IElgatoRefreshResult,
|
||||
IElgatoSettings,
|
||||
IElgatoSnapshot,
|
||||
IElgatoState,
|
||||
} from './elgato.types.js';
|
||||
import { elgatoDefaultPort, elgatoDefaultTimeoutMs } from './elgato.types.js';
|
||||
|
||||
export class ElgatoApiError extends Error {}
|
||||
export class ElgatoApiConnectionError extends ElgatoApiError {}
|
||||
export class ElgatoApiAuthorizationError extends ElgatoApiError {}
|
||||
export class ElgatoApiValidationError extends ElgatoApiError {}
|
||||
|
||||
export class ElgatoClient {
|
||||
private currentSnapshot?: IElgatoSnapshot;
|
||||
|
||||
constructor(private readonly config: IElgatoConfig) {}
|
||||
|
||||
public async getSnapshot(forceRefreshArg = false): Promise<IElgatoSnapshot> {
|
||||
if (!forceRefreshArg && this.currentSnapshot) {
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
if (!forceRefreshArg && this.config.snapshot) {
|
||||
this.currentSnapshot = ElgatoMapper.toSnapshot({ config: this.config, source: 'snapshot', online: this.config.snapshot.online });
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
if (this.config.client) {
|
||||
try {
|
||||
this.currentSnapshot = await this.snapshotFromClient(this.config.client);
|
||||
} catch (errorArg) {
|
||||
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
|
||||
}
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
if (!forceRefreshArg && this.hasManualData()) {
|
||||
this.currentSnapshot = ElgatoMapper.toSnapshot({ config: this.config, online: this.config.online ?? true, source: 'manual' });
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
if (this.config.host || this.config.url) {
|
||||
try {
|
||||
this.currentSnapshot = await this.fetchSnapshot();
|
||||
} catch (errorArg) {
|
||||
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
|
||||
}
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
if (this.config.snapshot) {
|
||||
this.currentSnapshot = ElgatoMapper.toSnapshot({ config: this.config, source: 'snapshot', online: this.config.snapshot.online });
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
if (this.hasManualData()) {
|
||||
this.currentSnapshot = ElgatoMapper.toSnapshot({ config: this.config, online: this.config.online ?? true, source: 'manual' });
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
this.currentSnapshot = this.offlineSnapshot('No Elgato HTTP endpoint, client, snapshot, or manual data is configured.');
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
public async refresh(): Promise<IElgatoRefreshResult> {
|
||||
try {
|
||||
this.currentSnapshot = undefined;
|
||||
const hasLiveTransport = Boolean(this.config.host || this.config.url || this.config.client);
|
||||
const snapshot = await this.getSnapshot(hasLiveTransport);
|
||||
const success = snapshot.online && !snapshot.error;
|
||||
return { success, snapshot, error: success ? undefined : snapshot.error, data: { source: snapshot.source } };
|
||||
} catch (errorArg) {
|
||||
const error = this.errorMessage(errorArg);
|
||||
const snapshot = this.offlineSnapshot(error);
|
||||
this.currentSnapshot = snapshot;
|
||||
return { success: false, snapshot: this.cloneSnapshot(snapshot), error };
|
||||
}
|
||||
}
|
||||
|
||||
public async validateConnection(): Promise<IElgatoInfo> {
|
||||
return this.info();
|
||||
}
|
||||
|
||||
public async fetchSnapshot(): Promise<IElgatoSnapshot> {
|
||||
const [info, settings, state] = await Promise.all([
|
||||
this.info(),
|
||||
this.settings(),
|
||||
this.state(),
|
||||
]);
|
||||
const battery = settings.battery ? await this.battery().catch(() => undefined) : undefined;
|
||||
return ElgatoMapper.toSnapshot({
|
||||
config: this.config,
|
||||
rawData: { info, settings, state, battery, fetchedAt: new Date().toISOString() },
|
||||
online: true,
|
||||
source: 'http',
|
||||
});
|
||||
}
|
||||
|
||||
public async info(): Promise<IElgatoInfo> {
|
||||
const data = await this.requestJson<IElgatoInfo>('accessory-info');
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new ElgatoApiConnectionError('Elgato accessory-info response was not an object.');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
public async settings(): Promise<IElgatoSettings> {
|
||||
const data = await this.requestJson<IElgatoSettings>('lights/settings');
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new ElgatoApiConnectionError('Elgato lights/settings response was not an object.');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
public async state(): Promise<IElgatoState> {
|
||||
const data = await this.requestJson<Record<string, unknown>>('lights');
|
||||
const lights = Array.isArray(data.lights) ? data.lights : undefined;
|
||||
const state = lights?.[0];
|
||||
if (!state || typeof state !== 'object') {
|
||||
throw new ElgatoApiConnectionError('Elgato lights response did not contain lights[0].');
|
||||
}
|
||||
return state as IElgatoState;
|
||||
}
|
||||
|
||||
public async battery(): Promise<IElgatoBatteryInfo> {
|
||||
const data = await this.requestJson<IElgatoBatteryInfo>('battery-info');
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new ElgatoApiConnectionError('Elgato battery-info response was not an object.');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
public async setLight(commandArg: Partial<IElgatoCommandRequest>): Promise<{ ok: true; light: Record<string, number> }> {
|
||||
const light: Record<string, number> = {};
|
||||
if (commandArg.on !== undefined) {
|
||||
light.on = commandArg.on ? 1 : 0;
|
||||
}
|
||||
|
||||
const brightness = commandArg.brightness ?? (commandArg.brightness255 !== undefined ? Math.round((this.clamp(commandArg.brightness255, 0, 255) / 255) * 100) : undefined);
|
||||
if (brightness !== undefined) {
|
||||
if (brightness < 0 || brightness > 100) {
|
||||
throw new ElgatoApiValidationError('Brightness must be between 0 and 100.');
|
||||
}
|
||||
light.brightness = Math.round(brightness);
|
||||
}
|
||||
|
||||
const hue = commandArg.hue;
|
||||
const saturation = commandArg.saturation;
|
||||
const temperature = commandArg.temperature ?? ElgatoMapper.kelvinToMired(commandArg.colorTemperatureKelvin);
|
||||
if (temperature !== undefined && (hue !== undefined || saturation !== undefined)) {
|
||||
throw new ElgatoApiValidationError('Cannot set color temperature together with hue or saturation.');
|
||||
}
|
||||
if (hue !== undefined) {
|
||||
if (hue < 0 || hue > 360) {
|
||||
throw new ElgatoApiValidationError('Hue must be between 0 and 360.');
|
||||
}
|
||||
light.hue = hue;
|
||||
}
|
||||
if (saturation !== undefined) {
|
||||
if (saturation < 0 || saturation > 100) {
|
||||
throw new ElgatoApiValidationError('Saturation must be between 0 and 100.');
|
||||
}
|
||||
light.saturation = saturation;
|
||||
}
|
||||
if (temperature !== undefined) {
|
||||
if (temperature < 143 || temperature > 344) {
|
||||
throw new ElgatoApiValidationError('Color temperature mired value must be between 143 and 344.');
|
||||
}
|
||||
light.temperature = Math.round(temperature);
|
||||
}
|
||||
if (!Object.keys(light).length) {
|
||||
throw new ElgatoApiValidationError('No light parameters were provided.');
|
||||
}
|
||||
|
||||
await this.requestJson('lights', { method: 'PUT', data: { numberOfLights: 1, lights: [light] } });
|
||||
this.currentSnapshot = undefined;
|
||||
return { ok: true, light };
|
||||
}
|
||||
|
||||
public async identify(): Promise<{ ok: true }> {
|
||||
await this.requestJson('identify', { method: 'POST' });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
public async restart(): Promise<{ ok: true }> {
|
||||
await this.requestJson('restart', { method: 'POST' });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
public async batteryBypass(onArg: boolean): Promise<{ ok: true; enabled: boolean }> {
|
||||
await this.requestJson('/elgato/lights/settings', { method: 'PUT', data: { battery: { bypass: onArg ? 1 : 0 } } });
|
||||
this.currentSnapshot = undefined;
|
||||
return { ok: true, enabled: onArg };
|
||||
}
|
||||
|
||||
public async energySaving(onArg: boolean): Promise<{ ok: true; enabled: boolean }> {
|
||||
const settings = await this.settings();
|
||||
const energySaving = { ...(settings.battery?.energySaving || {}), enable: onArg ? 1 : 0 };
|
||||
await this.requestJson('/elgato/lights/settings', { method: 'PUT', data: { battery: { energySaving } } });
|
||||
this.currentSnapshot = undefined;
|
||||
return { ok: true, enabled: onArg };
|
||||
}
|
||||
|
||||
public async execute(commandArg: IElgatoCommandRequest): Promise<unknown> {
|
||||
if (this.config.commandExecutor) {
|
||||
return this.config.commandExecutor.execute(commandArg);
|
||||
}
|
||||
if (this.config.client && !this.config.host && !this.config.url) {
|
||||
return this.executeWithClient(this.config.client, commandArg);
|
||||
}
|
||||
if (commandArg.action === 'refresh') {
|
||||
return this.refresh();
|
||||
}
|
||||
if (!this.config.host && !this.config.url) {
|
||||
throw new ElgatoApiConnectionError('Elgato commands require config.host/config.url, an injected client, or commandExecutor. Static snapshots/manual data are read-only.');
|
||||
}
|
||||
if (commandArg.action === 'set_light') {
|
||||
return this.setLight(commandArg);
|
||||
}
|
||||
if (commandArg.action === 'identify') {
|
||||
return this.identify();
|
||||
}
|
||||
if (commandArg.action === 'restart') {
|
||||
return this.restart();
|
||||
}
|
||||
if (commandArg.action === 'battery_bypass') {
|
||||
if (commandArg.enabled === undefined) {
|
||||
throw new ElgatoApiValidationError('battery_bypass requires an enabled value.');
|
||||
}
|
||||
return this.batteryBypass(commandArg.enabled);
|
||||
}
|
||||
if (commandArg.action === 'energy_saving') {
|
||||
if (commandArg.enabled === undefined) {
|
||||
throw new ElgatoApiValidationError('energy_saving requires an enabled value.');
|
||||
}
|
||||
return this.energySaving(commandArg.enabled);
|
||||
}
|
||||
throw new ElgatoApiError(`Unsupported Elgato command: ${commandArg.action}`);
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.config.client?.destroy?.();
|
||||
}
|
||||
|
||||
private async executeWithClient(clientArg: IElgatoClientLike, commandArg: IElgatoCommandRequest): Promise<unknown> {
|
||||
if (clientArg.execute) {
|
||||
return clientArg.execute(commandArg);
|
||||
}
|
||||
if (commandArg.action === 'refresh') {
|
||||
return this.refresh();
|
||||
}
|
||||
if (commandArg.action === 'set_light' && clientArg.light) {
|
||||
return clientArg.light(commandArg);
|
||||
}
|
||||
if (commandArg.action === 'identify' && clientArg.identify) {
|
||||
return clientArg.identify();
|
||||
}
|
||||
if (commandArg.action === 'restart' && clientArg.restart) {
|
||||
return clientArg.restart();
|
||||
}
|
||||
if (commandArg.action === 'battery_bypass' && clientArg.batteryBypass && commandArg.enabled !== undefined) {
|
||||
return clientArg.batteryBypass(commandArg.enabled);
|
||||
}
|
||||
if (commandArg.action === 'energy_saving' && clientArg.energySaving && commandArg.enabled !== undefined) {
|
||||
return clientArg.energySaving(commandArg.enabled);
|
||||
}
|
||||
throw new ElgatoApiConnectionError('Elgato command is not available on the injected client and no HTTP endpoint or commandExecutor is configured.');
|
||||
}
|
||||
|
||||
private async snapshotFromClient(clientArg: IElgatoClientLike): Promise<IElgatoSnapshot> {
|
||||
if (clientArg.getSnapshot) {
|
||||
const result = await clientArg.getSnapshot();
|
||||
if (isElgatoSnapshot(result)) {
|
||||
return ElgatoMapper.toSnapshot({ config: { ...this.config, snapshot: result }, source: 'client', online: result.online });
|
||||
}
|
||||
return ElgatoMapper.toSnapshot({ config: this.config, rawData: result, online: true, source: 'client' });
|
||||
}
|
||||
if (clientArg.getRawData) {
|
||||
return ElgatoMapper.toSnapshot({ config: this.config, rawData: await clientArg.getRawData(), online: true, source: 'client' });
|
||||
}
|
||||
|
||||
const [info, settings, state] = await Promise.all([
|
||||
clientArg.info ? clientArg.info() : undefined,
|
||||
clientArg.settings ? clientArg.settings() : undefined,
|
||||
clientArg.state ? clientArg.state() : undefined,
|
||||
]);
|
||||
const battery = clientArg.battery ? await clientArg.battery().catch(() => undefined) : undefined;
|
||||
if (!info && !settings && !state && !battery) {
|
||||
throw new ElgatoApiConnectionError('Elgato client must expose getSnapshot(), getRawData(), or at least one raw API getter.');
|
||||
}
|
||||
return ElgatoMapper.toSnapshot({ config: this.config, rawData: { info, settings, state, battery }, online: true, source: 'client' });
|
||||
}
|
||||
|
||||
private async requestJson<TValue = Record<string, unknown>>(pathArg: string, optionsArg: IElgatoRequestOptions = {}): Promise<TValue> {
|
||||
const response = await this.request(pathArg, { ...optionsArg, headers: { accept: 'application/json', ...optionsArg.headers } });
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return {} as TValue;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as TValue;
|
||||
} catch (errorArg) {
|
||||
throw new ElgatoApiConnectionError(`Unable to parse Elgato JSON from ${pathArg}: ${this.errorMessage(errorArg)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async request(pathArg: string, optionsArg: IElgatoRequestOptions = {}): Promise<Response> {
|
||||
if (!this.config.host && !this.config.url) {
|
||||
throw new ElgatoApiConnectionError('Elgato HTTP requests require config.host or config.url.');
|
||||
}
|
||||
const url = this.url(pathArg);
|
||||
const headers = new Headers({
|
||||
'user-agent': 'SmartHomeExchangeElgato',
|
||||
...optionsArg.headers,
|
||||
});
|
||||
let body: BodyInit | undefined;
|
||||
if (optionsArg.data !== undefined) {
|
||||
headers.set('content-type', 'application/json');
|
||||
body = JSON.stringify(optionsArg.data);
|
||||
}
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
method: optionsArg.method || 'GET',
|
||||
headers,
|
||||
body,
|
||||
}, this.config.timeoutMs || elgatoDefaultTimeoutMs);
|
||||
return this.checkedResponse(response, pathArg);
|
||||
}
|
||||
|
||||
private async checkedResponse(responseArg: Response, pathArg: string): Promise<Response> {
|
||||
if (!responseArg.ok) {
|
||||
const text = await responseArg.text().catch(() => '');
|
||||
if (responseArg.status === 401 || responseArg.status === 403) {
|
||||
throw new ElgatoApiAuthorizationError('Elgato local API authorization failed.');
|
||||
}
|
||||
throw new ElgatoApiConnectionError(`Elgato endpoint ${pathArg} failed with HTTP ${responseArg.status}${text ? `: ${text}` : ''}`);
|
||||
}
|
||||
return responseArg;
|
||||
}
|
||||
|
||||
private async fetchWithTimeout(urlArg: string, initArg: RequestInit, timeoutMsArg: number): Promise<Response> {
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(() => abortController.abort(), timeoutMsArg);
|
||||
try {
|
||||
return await globalThis.fetch(urlArg, { ...initArg, signal: abortController.signal });
|
||||
} catch (errorArg) {
|
||||
throw new ElgatoApiConnectionError(`Connection to ${urlArg} failed: ${this.errorMessage(errorArg)}`);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
private url(pathArg: string): string {
|
||||
if (/^https?:\/\//i.test(pathArg)) {
|
||||
return pathArg;
|
||||
}
|
||||
const normalizedPath = pathArg.startsWith('/elgato/') ? pathArg : `/elgato/${pathArg.replace(/^\/+/, '')}`;
|
||||
return `${this.baseUrl()}${normalizedPath}`;
|
||||
}
|
||||
|
||||
private baseUrl(): string {
|
||||
return endpointBaseUrl(this.config);
|
||||
}
|
||||
|
||||
private hasManualData(): boolean {
|
||||
return Boolean(this.config.rawData || this.config.info || this.config.settings || this.config.state || this.config.battery);
|
||||
}
|
||||
|
||||
private offlineSnapshot(errorArg: string): IElgatoSnapshot {
|
||||
return ElgatoMapper.toSnapshot({
|
||||
config: this.config,
|
||||
online: false,
|
||||
source: 'runtime',
|
||||
error: errorArg,
|
||||
});
|
||||
}
|
||||
|
||||
private errorMessage(errorArg: unknown): string {
|
||||
return errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||
}
|
||||
|
||||
private cloneSnapshot(snapshotArg: IElgatoSnapshot): IElgatoSnapshot {
|
||||
return JSON.parse(JSON.stringify(snapshotArg)) as IElgatoSnapshot;
|
||||
}
|
||||
|
||||
private clamp(valueArg: number, minArg: number, maxArg: number): number {
|
||||
return Math.min(maxArg, Math.max(minArg, valueArg));
|
||||
}
|
||||
}
|
||||
|
||||
interface IElgatoRequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT';
|
||||
headers?: HeadersInit;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
const endpointBaseUrl = (configArg: Pick<IElgatoConfig, 'host' | 'url' | 'port'>): string => {
|
||||
const configured = configArg.url || configArg.host || 'localhost';
|
||||
if (/^https?:\/\//i.test(configured)) {
|
||||
const url = new URL(configured);
|
||||
const port = configArg.port || (url.port ? Number(url.port) : undefined);
|
||||
const defaultPort = url.protocol === 'https:' ? 443 : 80;
|
||||
return `${url.protocol}//${hostForUrl(url.hostname)}${port && port !== defaultPort ? `:${port}` : ''}`;
|
||||
}
|
||||
const port = configArg.port || elgatoDefaultPort;
|
||||
return `http://${hostForUrl(configured)}${port !== 80 ? `:${port}` : ''}`;
|
||||
};
|
||||
|
||||
const hostForUrl = (hostArg: string): string => hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
|
||||
|
||||
const isElgatoSnapshot = (valueArg: unknown): valueArg is IElgatoSnapshot => {
|
||||
return Boolean(valueArg && typeof valueArg === 'object' && 'device' in valueArg && 'light' in valueArg && 'capabilities' in valueArg);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import type { IElgatoConfig, IElgatoRawData, IElgatoSnapshot } from './elgato.types.js';
|
||||
import { elgatoDefaultPort, elgatoDefaultTimeoutMs } from './elgato.types.js';
|
||||
|
||||
export class ElgatoConfigFlow implements IConfigFlow<IElgatoConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IElgatoConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Configure Elgato Light',
|
||||
description: 'Configure a local Elgato Key Light endpoint, or use snapshot/manual data from the discovery candidate.',
|
||||
fields: [
|
||||
{ name: 'host', label: 'Host', type: 'text' },
|
||||
{ name: 'port', label: 'Port', type: 'number' },
|
||||
{ name: 'name', label: 'Name', type: 'text' },
|
||||
{ name: 'serialNumber', label: 'Serial number', type: 'text' },
|
||||
{ name: 'macAddress', label: 'MAC address', type: 'text' },
|
||||
],
|
||||
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
|
||||
};
|
||||
}
|
||||
|
||||
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IElgatoConfig>> {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const parsed = parseEndpoint(this.stringValue(valuesArg.host) || this.stringMetadata(metadata, 'url'));
|
||||
const snapshot = snapshotValue(metadata.snapshot);
|
||||
const rawData = rawDataValue(metadata.rawData);
|
||||
const host = parsed?.host || this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.device.host;
|
||||
const port = this.numberValue(valuesArg.port) || candidateArg.port || parsed?.port || snapshot?.device.port || elgatoDefaultPort;
|
||||
const hasManualData = Boolean(snapshot || rawData || metadata.client);
|
||||
|
||||
if (!host && !hasManualData) {
|
||||
return { kind: 'error', title: 'Elgato setup failed', error: 'Elgato host, injected client, snapshot, or manual data is required.' };
|
||||
}
|
||||
if (!this.validPort(port)) {
|
||||
return { kind: 'error', title: 'Elgato setup failed', error: 'Elgato port must be between 1 and 65535.' };
|
||||
}
|
||||
|
||||
const serialNumber = this.stringValue(valuesArg.serialNumber) || candidateArg.serialNumber || snapshot?.device.serialNumber || this.stringMetadata(metadata, 'serialNumber');
|
||||
const macAddress = this.stringValue(valuesArg.macAddress) || candidateArg.macAddress || snapshot?.device.macAddress || this.stringMetadata(metadata, 'macAddress');
|
||||
const config: IElgatoConfig = {
|
||||
host,
|
||||
port,
|
||||
timeoutMs: elgatoDefaultTimeoutMs,
|
||||
name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.device.name || this.stringMetadata(metadata, 'name'),
|
||||
manufacturer: candidateArg.manufacturer || snapshot?.device.manufacturer,
|
||||
model: candidateArg.model || snapshot?.device.model,
|
||||
uniqueId: candidateArg.id || snapshot?.device.id || serialNumber || macAddress || (host ? `${host}:${port}` : undefined),
|
||||
serialNumber,
|
||||
macAddress,
|
||||
snapshot,
|
||||
rawData,
|
||||
client: metadata.client as IElgatoConfig['client'],
|
||||
commandExecutor: metadata.commandExecutor as IElgatoConfig['commandExecutor'],
|
||||
};
|
||||
|
||||
return { kind: 'done', title: 'Elgato Light configured', config };
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim().replace(/\/$/, '') : undefined;
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown): number | undefined {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return Math.round(valueArg);
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const parsed = Number(valueArg);
|
||||
return Number.isFinite(parsed) ? Math.round(parsed) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private stringMetadata(metadataArg: Record<string, unknown>, keyArg: string): string | undefined {
|
||||
return this.stringValue(metadataArg[keyArg]);
|
||||
}
|
||||
|
||||
private validPort(valueArg: number): boolean {
|
||||
return Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
|
||||
}
|
||||
}
|
||||
|
||||
const parseEndpoint = (valueArg: string | undefined): { host: string; port?: number } | undefined => {
|
||||
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const url = new URL(valueArg);
|
||||
return { host: url.hostname, port: url.port ? Number(url.port) : undefined };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const snapshotValue = (valueArg: unknown): IElgatoSnapshot | undefined => {
|
||||
return valueArg && typeof valueArg === 'object' && 'device' in valueArg && 'light' in valueArg ? valueArg as IElgatoSnapshot : undefined;
|
||||
};
|
||||
|
||||
const rawDataValue = (valueArg: unknown): Partial<IElgatoRawData> | undefined => {
|
||||
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Partial<IElgatoRawData> : undefined;
|
||||
};
|
||||
@@ -1,27 +1,108 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { ElgatoClient } from './elgato.classes.client.js';
|
||||
import { ElgatoConfigFlow } from './elgato.classes.configflow.js';
|
||||
import { createElgatoDiscoveryDescriptor } from './elgato.discovery.js';
|
||||
import { ElgatoMapper } from './elgato.mapper.js';
|
||||
import type { IElgatoConfig } from './elgato.types.js';
|
||||
import { elgatoDefaultPort, elgatoDisplayName, elgatoDomain, elgatoZeroconfType } from './elgato.types.js';
|
||||
|
||||
export class HomeAssistantElgatoIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "elgato",
|
||||
displayName: "Elgato Light",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/elgato",
|
||||
"upstreamDomain": "elgato",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_polling",
|
||||
"qualityScale": "platinum",
|
||||
"requirements": [
|
||||
"elgato==5.1.2"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@frenck"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class ElgatoIntegration extends BaseIntegration<IElgatoConfig> {
|
||||
public readonly domain = elgatoDomain;
|
||||
public readonly displayName = elgatoDisplayName;
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createElgatoDiscoveryDescriptor();
|
||||
public readonly configFlow = new ElgatoConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/elgato',
|
||||
upstreamDomain: elgatoDomain,
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_polling',
|
||||
qualityScale: 'platinum',
|
||||
requirements: ['elgato==5.1.2'],
|
||||
dependencies: [] as string[],
|
||||
afterDependencies: [] as string[],
|
||||
codeowners: ['@frenck'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/elgato',
|
||||
dhcp: [{ registered_devices: true }],
|
||||
zeroconf: [elgatoZeroconfType],
|
||||
discovery: {
|
||||
zeroconf: elgatoZeroconfType,
|
||||
dhcp: 'DHCP registered-device candidates and obvious Elgato/Key Light hostnames are recognized for local host updates.',
|
||||
manual: true,
|
||||
},
|
||||
runtime: {
|
||||
type: 'control-runtime',
|
||||
polling: `local HTTP Elgato Light API on port ${elgatoDefaultPort}: /elgato/accessory-info, /elgato/lights/settings, /elgato/lights, and /elgato/battery-info`,
|
||||
services: ['snapshot', 'status', 'refresh', 'light.turn_on', 'light.turn_off', 'identify', 'restart', 'battery_bypass', 'energy_saving'],
|
||||
controls: true,
|
||||
},
|
||||
localApi: {
|
||||
implemented: [
|
||||
'Elgato local HTTP accessory-info/settings/lights/battery-info snapshots compatible with the upstream elgato==5.1.2 client',
|
||||
'PUT /elgato/lights commands for on/off, brightness, color temperature, and HS color',
|
||||
'POST /elgato/identify and /elgato/restart button commands',
|
||||
'zeroconf, DHCP, and manual discovery plus config-flow output for local HTTP setup',
|
||||
'snapshot/manual-data and injected client/executor operation',
|
||||
],
|
||||
explicitUnsupported: [
|
||||
'cloud or Stream Deck account APIs',
|
||||
'fake live command success without a configured HTTP endpoint, injected client, or command executor',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
public async setup(configArg: IElgatoConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new ElgatoRuntime(new ElgatoClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantElgatoIntegration extends ElgatoIntegration {}
|
||||
|
||||
class ElgatoRuntime implements IIntegrationRuntime {
|
||||
public domain = elgatoDomain;
|
||||
|
||||
constructor(private readonly client: ElgatoClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return ElgatoMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return ElgatoMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
try {
|
||||
if (requestArg.domain === elgatoDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
return { success: true, data: requestArg.service === 'status' ? snapshot.light : snapshot };
|
||||
}
|
||||
if (requestArg.domain === elgatoDomain && requestArg.service === 'refresh') {
|
||||
const result = await this.client.refresh();
|
||||
return { success: result.success, error: result.error, data: result.snapshot || result.data };
|
||||
}
|
||||
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
const command = ElgatoMapper.commandForService(snapshot, requestArg);
|
||||
if (!command) {
|
||||
return { success: false, error: `Unsupported Elgato service: ${requestArg.domain}.${requestArg.service}` };
|
||||
}
|
||||
const data = await this.client.execute(command);
|
||||
const ok = data && typeof data === 'object' && 'ok' in data ? Boolean((data as { ok?: unknown }).ok) : true;
|
||||
return { success: ok, data };
|
||||
} catch (errorArg) {
|
||||
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import { ElgatoMapper } from './elgato.mapper.js';
|
||||
import type { IElgatoDhcpRecord, IElgatoManualEntry, IElgatoMdnsRecord, IElgatoRawData, IElgatoSnapshot } from './elgato.types.js';
|
||||
import { elgatoDefaultPort, elgatoDisplayName, elgatoDomain, elgatoManufacturer, elgatoZeroconfType } from './elgato.types.js';
|
||||
|
||||
export class ElgatoZeroconfMatcher implements IDiscoveryMatcher<IElgatoMdnsRecord> {
|
||||
public id = 'elgato-zeroconf-match';
|
||||
public source = 'mdns' as const;
|
||||
public description = 'Recognize Elgato _elg._tcp.local zeroconf advertisements from the Home Assistant manifest.';
|
||||
|
||||
public async matches(recordArg: IElgatoMdnsRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
const txt = normalizeKeys({ ...recordArg.txt, ...recordArg.properties });
|
||||
const type = normalizeMdnsType(recordArg.type || recordArg.serviceType || '');
|
||||
const host = recordArg.host || recordArg.hostname || recordArg.addresses?.[0];
|
||||
const mac = ElgatoMapper.normalizeMac(txt.id || txt.mac || txt.macaddress);
|
||||
const name = stringValue(txt.name) || cleanServiceName(recordArg.name) || elgatoDisplayName;
|
||||
const matched = type === normalizeMdnsType(elgatoZeroconfType) || type.includes('_elg._tcp') || recordArg.metadata?.elgato === true;
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'mDNS record is not an Elgato _elg advertisement.' };
|
||||
}
|
||||
return {
|
||||
matched: true,
|
||||
confidence: host && mac ? 'certain' : host ? 'high' : 'medium',
|
||||
reason: 'mDNS service matches _elg._tcp.local.',
|
||||
normalizedDeviceId: mac || recordArg.name || host,
|
||||
candidate: {
|
||||
source: 'mdns',
|
||||
integrationDomain: elgatoDomain,
|
||||
id: mac || recordArg.name || host,
|
||||
host,
|
||||
port: recordArg.port || elgatoDefaultPort,
|
||||
name,
|
||||
manufacturer: elgatoManufacturer,
|
||||
model: txt.model || txt.product || elgatoDisplayName,
|
||||
macAddress: mac,
|
||||
metadata: {
|
||||
...recordArg.metadata,
|
||||
elgato: true,
|
||||
discoveryProtocol: 'zeroconf',
|
||||
mdnsType: type,
|
||||
mdnsName: recordArg.name,
|
||||
txt,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ElgatoDhcpMatcher implements IDiscoveryMatcher<IElgatoDhcpRecord> {
|
||||
public id = 'elgato-dhcp-match';
|
||||
public source = 'dhcp' as const;
|
||||
public description = 'Recognize DHCP candidates for already-known Elgato devices and obvious Elgato hostnames.';
|
||||
|
||||
public async matches(recordArg: IElgatoDhcpRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
const metadata = recordArg.metadata || {};
|
||||
const host = recordArg.ip || recordArg.address || recordArg.host;
|
||||
const hostname = recordArg.hostname || recordArg.hostName || stringValue(metadata.hostname);
|
||||
const mac = ElgatoMapper.normalizeMac(recordArg.macaddress || recordArg.macAddress || stringValue(metadata.macAddress));
|
||||
const text = [recordArg.name, recordArg.manufacturer, recordArg.model, hostname, metadata.name, metadata.manufacturer, metadata.model].filter(Boolean).join(' ').toLowerCase();
|
||||
const matched = Boolean(metadata.elgato)
|
||||
|| recordArg.registeredDevices === true
|
||||
|| recordArg.registered_devices === true
|
||||
|| metadata.registeredDevices === true
|
||||
|| metadata.registered_devices === true
|
||||
|| text.includes('elgato')
|
||||
|| text.includes('key light')
|
||||
|| text.includes('keylight');
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'DHCP record does not contain Elgato hints.' };
|
||||
}
|
||||
const id = mac || (host ? `${host}:${elgatoDefaultPort}` : hostname);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: host && mac ? 'high' : host ? 'medium' : 'low',
|
||||
reason: mac ? 'DHCP record has Elgato hints and a MAC address.' : 'DHCP record has Elgato hostname or registered-device metadata.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'dhcp',
|
||||
integrationDomain: elgatoDomain,
|
||||
id,
|
||||
host,
|
||||
port: elgatoDefaultPort,
|
||||
name: recordArg.name || hostname || elgatoDisplayName,
|
||||
manufacturer: recordArg.manufacturer || elgatoManufacturer,
|
||||
model: recordArg.model || elgatoDisplayName,
|
||||
macAddress: mac,
|
||||
metadata: {
|
||||
...metadata,
|
||||
elgato: true,
|
||||
discoveryProtocol: 'dhcp',
|
||||
hostname,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ElgatoManualMatcher implements IDiscoveryMatcher<IElgatoManualEntry> {
|
||||
public id = 'elgato-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual Elgato host, snapshot, raw-data, client, and command-executor setup entries.';
|
||||
|
||||
public async matches(inputArg: IElgatoManualEntry, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
const metadata = inputArg.metadata || {};
|
||||
const parsed = parseEndpoint(inputArg.url || inputArg.host);
|
||||
const snapshot = snapshotValue(inputArg.snapshot || metadata.snapshot);
|
||||
const rawData = rawDataValue(inputArg.rawData || metadata.rawData || rawDataFromInput(inputArg));
|
||||
const hasManualData = Boolean(snapshot || rawData || inputArg.client || metadata.client);
|
||||
const text = [inputArg.name, inputArg.manufacturer, inputArg.model, metadata.name, metadata.manufacturer, metadata.model].filter(Boolean).join(' ').toLowerCase();
|
||||
const matched = Boolean(inputArg.host || inputArg.url || inputArg.serialNumber || inputArg.macAddress || metadata.elgato || hasManualData || text.includes('elgato') || text.includes('key light'));
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Elgato setup hints.' };
|
||||
}
|
||||
const host = parsed?.host || (inputArg.url ? undefined : inputArg.host) || snapshot?.device.host;
|
||||
const port = inputArg.port || parsed?.port || snapshot?.device.port || elgatoDefaultPort;
|
||||
const id = inputArg.id || inputArg.uniqueId || inputArg.serialNumber || inputArg.macAddress || snapshot?.device.id || snapshot?.device.serialNumber || snapshot?.device.macAddress || (host ? `${host}:${port}` : undefined);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: host || hasManualData ? 'high' : 'medium',
|
||||
reason: 'Manual entry can start Elgato setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: elgatoDomain,
|
||||
id,
|
||||
host,
|
||||
port,
|
||||
name: inputArg.name || snapshot?.device.name || elgatoDisplayName,
|
||||
manufacturer: inputArg.manufacturer || snapshot?.device.manufacturer || elgatoManufacturer,
|
||||
model: inputArg.model || snapshot?.device.model,
|
||||
serialNumber: inputArg.serialNumber || snapshot?.device.serialNumber,
|
||||
macAddress: inputArg.macAddress || snapshot?.device.macAddress,
|
||||
metadata: {
|
||||
...metadata,
|
||||
elgato: true,
|
||||
discoveryProtocol: 'manual',
|
||||
snapshot,
|
||||
rawData,
|
||||
client: inputArg.client || metadata.client,
|
||||
commandExecutor: inputArg.commandExecutor || metadata.commandExecutor,
|
||||
serialNumber: inputArg.serialNumber || metadata.serialNumber,
|
||||
macAddress: inputArg.macAddress || metadata.macAddress,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ElgatoCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'elgato-candidate-validator';
|
||||
public description = 'Validate Elgato candidates from zeroconf, DHCP, and manual setup.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const snapshot = snapshotValue(metadata.snapshot);
|
||||
const rawData = rawDataValue(metadata.rawData);
|
||||
const mdnsType = typeof metadata.mdnsType === 'string' ? normalizeMdnsType(metadata.mdnsType) : '';
|
||||
const text = [candidateArg.integrationDomain, candidateArg.name, candidateArg.manufacturer, candidateArg.model, mdnsType, metadata.hostname].filter(Boolean).join(' ').toLowerCase();
|
||||
const matched = candidateArg.integrationDomain === elgatoDomain
|
||||
|| metadata.elgato === true
|
||||
|| mdnsType.includes('_elg._tcp')
|
||||
|| text.includes('elgato')
|
||||
|| text.includes('key light')
|
||||
|| text.includes('keylight');
|
||||
const hasUsableSource = Boolean(candidateArg.host || snapshot || rawData || metadata.client);
|
||||
const normalizedDeviceId = candidateArg.serialNumber || ElgatoMapper.normalizeMac(candidateArg.macAddress) || candidateArg.id || snapshot?.device.serialNumber || snapshot?.device.macAddress || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || elgatoDefaultPort}` : undefined);
|
||||
if (!matched || !hasUsableSource) {
|
||||
return {
|
||||
matched: false,
|
||||
confidence: matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Elgato candidate lacks a host, injected client, snapshot, or manual data.' : 'Candidate is not Elgato.',
|
||||
normalizedDeviceId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
matched: true,
|
||||
confidence: candidateArg.host && normalizedDeviceId ? 'certain' : candidateArg.host ? 'high' : 'medium',
|
||||
reason: 'Candidate has Elgato metadata and a usable local endpoint, client, snapshot, or manual data.',
|
||||
normalizedDeviceId,
|
||||
candidate: {
|
||||
...candidateArg,
|
||||
integrationDomain: elgatoDomain,
|
||||
port: candidateArg.port || elgatoDefaultPort,
|
||||
manufacturer: candidateArg.manufacturer || elgatoManufacturer,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createElgatoDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: elgatoDomain, displayName: elgatoDisplayName })
|
||||
.addMatcher(new ElgatoZeroconfMatcher())
|
||||
.addMatcher(new ElgatoDhcpMatcher())
|
||||
.addMatcher(new ElgatoManualMatcher())
|
||||
.addValidator(new ElgatoCandidateValidator());
|
||||
};
|
||||
|
||||
const normalizeMdnsType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.local\.?$/u, '').replace(/\.$/u, '');
|
||||
|
||||
const cleanServiceName = (valueArg: string | undefined): string | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
return valueArg.replace(/\._.*$/u, '').replace(/\.local\.?$/iu, '').trim() || undefined;
|
||||
};
|
||||
|
||||
const parseEndpoint = (valueArg: string | undefined): { host: string; port?: number } | undefined => {
|
||||
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const url = new URL(valueArg);
|
||||
return { host: url.hostname, port: url.port ? Number(url.port) : undefined };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeKeys = (recordArg: Record<string, string | undefined>): Record<string, string | undefined> => {
|
||||
const normalized: Record<string, string | undefined> = {};
|
||||
for (const [key, value] of Object.entries(recordArg)) {
|
||||
normalized[key.toLowerCase()] = value;
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
|
||||
const snapshotValue = (valueArg: unknown): IElgatoSnapshot | undefined => {
|
||||
return valueArg && typeof valueArg === 'object' && 'device' in valueArg && 'light' in valueArg ? valueArg as IElgatoSnapshot : undefined;
|
||||
};
|
||||
|
||||
const rawDataValue = (valueArg: unknown): Partial<IElgatoRawData> | undefined => {
|
||||
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Partial<IElgatoRawData> : undefined;
|
||||
};
|
||||
|
||||
const rawDataFromInput = (inputArg: IElgatoManualEntry): Partial<IElgatoRawData> | undefined => {
|
||||
const rawData: Partial<IElgatoRawData> = {
|
||||
info: inputArg.info,
|
||||
settings: inputArg.settings,
|
||||
state: inputArg.state,
|
||||
battery: inputArg.battery,
|
||||
};
|
||||
return Object.values(rawData).some(Boolean) ? rawData : undefined;
|
||||
};
|
||||
@@ -0,0 +1,581 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
|
||||
import type {
|
||||
IElgatoBatteryInfo,
|
||||
IElgatoBatterySnapshot,
|
||||
IElgatoCommandRequest,
|
||||
IElgatoConfig,
|
||||
IElgatoDeviceSnapshotInfo,
|
||||
IElgatoInfo,
|
||||
IElgatoLightSnapshot,
|
||||
IElgatoRawData,
|
||||
IElgatoSettings,
|
||||
IElgatoSnapshot,
|
||||
IElgatoState,
|
||||
TElgatoColorMode,
|
||||
TElgatoSnapshotSource,
|
||||
} from './elgato.types.js';
|
||||
import { elgatoDefaultPort, elgatoDisplayName, elgatoDomain, elgatoManufacturer } from './elgato.types.js';
|
||||
|
||||
interface IElgatoSnapshotOptions {
|
||||
config: IElgatoConfig;
|
||||
rawData?: Partial<IElgatoRawData>;
|
||||
online?: boolean;
|
||||
source?: TElgatoSnapshotSource;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface IElgatoEntityDefinition {
|
||||
key: string;
|
||||
platform: TEntityPlatform;
|
||||
name: string;
|
||||
state: unknown;
|
||||
available: boolean;
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const batterySensorDefinitions: Array<{
|
||||
key: string;
|
||||
name: string;
|
||||
value: (batteryArg: IElgatoBatterySnapshot) => unknown;
|
||||
unit?: string;
|
||||
deviceClass?: string;
|
||||
stateClass?: string;
|
||||
}> = [
|
||||
{ key: 'battery', name: 'Battery', value: (batteryArg) => batteryArg.level, unit: '%', deviceClass: 'battery', stateClass: 'measurement' },
|
||||
{ key: 'voltage', name: 'Voltage', value: (batteryArg) => batteryArg.voltage, unit: 'mV', deviceClass: 'voltage', stateClass: 'measurement' },
|
||||
{ key: 'input_charge_current', name: 'Input Charge Current', value: (batteryArg) => batteryArg.inputChargeCurrent, unit: 'mA', deviceClass: 'current', stateClass: 'measurement' },
|
||||
{ key: 'charge_power', name: 'Charge Power', value: (batteryArg) => batteryArg.chargePower, unit: 'W', deviceClass: 'power', stateClass: 'measurement' },
|
||||
{ key: 'input_charge_voltage', name: 'Input Charge Voltage', value: (batteryArg) => batteryArg.inputChargeVoltage, unit: 'mV', deviceClass: 'voltage', stateClass: 'measurement' },
|
||||
];
|
||||
|
||||
export class ElgatoMapper {
|
||||
public static toSnapshot(optionsArg: IElgatoSnapshotOptions): IElgatoSnapshot {
|
||||
if (optionsArg.config.snapshot && !optionsArg.rawData) {
|
||||
return this.normalizeSnapshot(this.clone(optionsArg.config.snapshot), optionsArg.config, optionsArg.source || 'snapshot');
|
||||
}
|
||||
|
||||
const rawData = this.rawData(optionsArg.config, optionsArg.rawData);
|
||||
const online = optionsArg.online ?? optionsArg.config.online ?? this.hasRawData(rawData);
|
||||
const device = this.deviceInfo(optionsArg.config, rawData.info, online);
|
||||
const light = this.light(rawData.info, rawData.settings, rawData.state);
|
||||
const battery = this.battery(rawData.battery, rawData.settings);
|
||||
const capabilities = {
|
||||
localControl: this.hasCommandTransport(optionsArg.config, 'set_light'),
|
||||
colorTemperature: true,
|
||||
color: light.supportedColorModes.includes('hs'),
|
||||
identify: this.hasCommandTransport(optionsArg.config, 'identify'),
|
||||
restart: this.hasCommandTransport(optionsArg.config, 'restart'),
|
||||
battery: Boolean(battery),
|
||||
batterySettings: Boolean(rawData.settings?.battery),
|
||||
};
|
||||
|
||||
return {
|
||||
device,
|
||||
light,
|
||||
battery,
|
||||
capabilities,
|
||||
rawData: this.hasRawData(rawData) ? rawData : undefined,
|
||||
online,
|
||||
updatedAt: rawData.fetchedAt || new Date().toISOString(),
|
||||
source: optionsArg.source || (this.hasRawData(rawData) ? 'manual' : 'runtime'),
|
||||
error: optionsArg.error,
|
||||
};
|
||||
}
|
||||
|
||||
public static toDevices(snapshotArg: IElgatoSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||
{ id: 'power', capability: 'light', name: 'Power', readable: true, writable: snapshotArg.capabilities.localControl },
|
||||
{ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: snapshotArg.capabilities.localControl, unit: '%' },
|
||||
{ id: 'color_temperature', capability: 'light', name: 'Color temperature', readable: true, writable: snapshotArg.capabilities.localControl, unit: 'K' },
|
||||
];
|
||||
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||
{ featureId: 'power', value: snapshotArg.light.on, updatedAt },
|
||||
{ featureId: 'brightness', value: snapshotArg.light.brightness ?? null, updatedAt },
|
||||
{ featureId: 'color_temperature', value: snapshotArg.light.colorTemperatureKelvin ?? null, updatedAt },
|
||||
];
|
||||
|
||||
if (snapshotArg.capabilities.color) {
|
||||
features.push({ id: 'hs_color', capability: 'light', name: 'HS color', readable: true, writable: snapshotArg.capabilities.localControl });
|
||||
state.push({ featureId: 'hs_color', value: { hue: snapshotArg.light.hue ?? null, saturation: snapshotArg.light.saturation ?? null }, updatedAt });
|
||||
}
|
||||
if (snapshotArg.battery) {
|
||||
features.push({ id: 'battery', capability: 'sensor', name: 'Battery', readable: true, writable: false, unit: '%' });
|
||||
state.push({ featureId: 'battery', value: snapshotArg.battery.level ?? null, updatedAt });
|
||||
}
|
||||
features.push({ id: 'identify', capability: 'switch', name: 'Identify', readable: false, writable: snapshotArg.capabilities.identify });
|
||||
features.push({ id: 'restart', capability: 'switch', name: 'Restart', readable: false, writable: snapshotArg.capabilities.restart });
|
||||
|
||||
return [{
|
||||
id: this.deviceId(snapshotArg),
|
||||
integrationDomain: elgatoDomain,
|
||||
name: snapshotArg.device.name,
|
||||
protocol: snapshotArg.device.host ? 'http' : 'unknown',
|
||||
manufacturer: snapshotArg.device.manufacturer,
|
||||
model: snapshotArg.device.model || elgatoDisplayName,
|
||||
online: snapshotArg.online,
|
||||
features,
|
||||
state,
|
||||
metadata: this.cleanAttributes({
|
||||
serialNumber: snapshotArg.device.serialNumber,
|
||||
macAddress: snapshotArg.device.macAddress,
|
||||
firmwareVersion: snapshotArg.device.firmwareVersion,
|
||||
firmwareBuildNumber: snapshotArg.device.firmwareBuildNumber,
|
||||
hardwareBoardType: snapshotArg.device.hardwareBoardType,
|
||||
host: snapshotArg.device.host,
|
||||
port: snapshotArg.device.port,
|
||||
baseUrl: snapshotArg.device.baseUrl,
|
||||
source: snapshotArg.source,
|
||||
error: snapshotArg.error,
|
||||
}),
|
||||
}];
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IElgatoSnapshot): IIntegrationEntity[] {
|
||||
const deviceId = this.deviceId(snapshotArg);
|
||||
const uniqueBase = this.uniqueBase(snapshotArg);
|
||||
const baseName = snapshotArg.device.name;
|
||||
const entities: IIntegrationEntity[] = [];
|
||||
|
||||
entities.push(this.entity(snapshotArg, deviceId, uniqueBase, {
|
||||
key: 'light',
|
||||
platform: 'light',
|
||||
name: baseName,
|
||||
state: snapshotArg.light.on ? 'on' : 'off',
|
||||
available: snapshotArg.online,
|
||||
attributes: {
|
||||
brightness: snapshotArg.light.brightness255,
|
||||
brightnessPercent: snapshotArg.light.brightness,
|
||||
colorMode: snapshotArg.light.colorMode,
|
||||
supportedColorModes: snapshotArg.light.supportedColorModes,
|
||||
colorTempKelvin: snapshotArg.light.colorTemperatureKelvin,
|
||||
colorTempMired: snapshotArg.light.colorTemperatureMired,
|
||||
minColorTempKelvin: snapshotArg.light.minColorTemperatureKelvin,
|
||||
maxColorTempKelvin: snapshotArg.light.maxColorTemperatureKelvin,
|
||||
hsColor: snapshotArg.light.hue !== undefined || snapshotArg.light.saturation !== undefined ? [snapshotArg.light.hue ?? 0, snapshotArg.light.saturation ?? 0] : undefined,
|
||||
supportedFeatures: ['turn_on', 'turn_off', 'brightness', 'color_temp', ...snapshotArg.capabilities.color ? ['hs_color'] : []],
|
||||
source: snapshotArg.source,
|
||||
error: snapshotArg.error,
|
||||
},
|
||||
}));
|
||||
|
||||
if (snapshotArg.battery) {
|
||||
for (const definition of batterySensorDefinitions) {
|
||||
const value = definition.value(snapshotArg.battery);
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
entities.push(this.entity(snapshotArg, deviceId, uniqueBase, {
|
||||
key: definition.key,
|
||||
platform: 'sensor',
|
||||
name: `${baseName} ${definition.name}`,
|
||||
state: value,
|
||||
available: snapshotArg.online,
|
||||
attributes: this.cleanAttributes({ unit: definition.unit, deviceClass: definition.deviceClass, stateClass: definition.stateClass }),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (snapshotArg.capabilities.batterySettings) {
|
||||
entities.push(this.entity(snapshotArg, deviceId, uniqueBase, {
|
||||
key: 'bypass',
|
||||
platform: 'switch',
|
||||
name: `${baseName} Bypass`,
|
||||
state: snapshotArg.battery?.bypass ? 'on' : 'off',
|
||||
available: snapshotArg.online && snapshotArg.battery?.bypass !== undefined,
|
||||
attributes: { entityCategory: 'config' },
|
||||
}));
|
||||
entities.push(this.entity(snapshotArg, deviceId, uniqueBase, {
|
||||
key: 'energy_saving',
|
||||
platform: 'switch',
|
||||
name: `${baseName} Energy Saving`,
|
||||
state: snapshotArg.battery?.energySavingEnabled ? 'on' : 'off',
|
||||
available: snapshotArg.online && snapshotArg.battery?.energySavingEnabled !== undefined,
|
||||
attributes: { entityCategory: 'config' },
|
||||
}));
|
||||
}
|
||||
|
||||
entities.push(this.entity(snapshotArg, deviceId, uniqueBase, {
|
||||
key: 'identify',
|
||||
platform: 'button',
|
||||
name: `${baseName} Identify`,
|
||||
state: 'idle',
|
||||
available: snapshotArg.online && snapshotArg.capabilities.identify,
|
||||
attributes: { entityCategory: 'config', action: 'identify' },
|
||||
}));
|
||||
entities.push(this.entity(snapshotArg, deviceId, uniqueBase, {
|
||||
key: 'restart',
|
||||
platform: 'button',
|
||||
name: `${baseName} Restart`,
|
||||
state: 'idle',
|
||||
available: snapshotArg.online && snapshotArg.capabilities.restart,
|
||||
attributes: { entityCategory: 'config', action: 'restart' },
|
||||
}));
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static commandForService(snapshotArg: IElgatoSnapshot, requestArg: IServiceCallRequest): IElgatoCommandRequest | undefined {
|
||||
if (requestArg.domain === 'light') {
|
||||
if (requestArg.service === 'turn_on') {
|
||||
return this.lightCommand(snapshotArg, requestArg, true);
|
||||
}
|
||||
if (requestArg.service === 'turn_off') {
|
||||
return { action: 'set_light', on: false, service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
}
|
||||
|
||||
if (requestArg.domain === 'button' && requestArg.service === 'press') {
|
||||
return this.buttonCommand(snapshotArg, requestArg);
|
||||
}
|
||||
|
||||
if (requestArg.domain === 'switch') {
|
||||
const enabled = requestArg.service === 'turn_on' ? true : requestArg.service === 'turn_off' ? false : undefined;
|
||||
if (enabled === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const target = this.targetText(requestArg);
|
||||
if (target.includes('bypass')) {
|
||||
return { action: 'battery_bypass', enabled, service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (target.includes('energy')) {
|
||||
return { action: 'energy_saving', enabled, service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
}
|
||||
|
||||
if (requestArg.domain !== elgatoDomain) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (requestArg.service === 'refresh') {
|
||||
return { action: 'refresh', service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (requestArg.service === 'identify') {
|
||||
return { action: 'identify', service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (requestArg.service === 'restart') {
|
||||
return { action: 'restart', service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (requestArg.service === 'turn_on') {
|
||||
return this.lightCommand(snapshotArg, requestArg, true);
|
||||
}
|
||||
if (requestArg.service === 'turn_off') {
|
||||
return { action: 'set_light', on: false, service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (requestArg.service === 'set_light' || requestArg.service === 'light') {
|
||||
return this.lightCommand(snapshotArg, requestArg, this.booleanValue(requestArg.data?.on));
|
||||
}
|
||||
if (requestArg.service === 'battery_bypass') {
|
||||
const enabled = this.booleanValue(requestArg.data?.on ?? requestArg.data?.enabled);
|
||||
return enabled === undefined ? undefined : { action: 'battery_bypass', enabled, service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (requestArg.service === 'energy_saving') {
|
||||
const enabled = this.booleanValue(requestArg.data?.on ?? requestArg.data?.enabled);
|
||||
return enabled === undefined ? undefined : { action: 'energy_saving', enabled, service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public static deviceId(snapshotArg: IElgatoSnapshot): string {
|
||||
return `${elgatoDomain}.light.${this.uniqueBase(snapshotArg)}`;
|
||||
}
|
||||
|
||||
public static uniqueBase(snapshotArg: IElgatoSnapshot): string {
|
||||
return this.slug(snapshotArg.device.serialNumber || snapshotArg.device.macAddress || snapshotArg.device.id || snapshotArg.device.host || snapshotArg.device.name || elgatoDomain);
|
||||
}
|
||||
|
||||
public static slug(valueArg: string): string {
|
||||
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || elgatoDomain;
|
||||
}
|
||||
|
||||
public static normalizeMac(valueArg?: string): string | undefined {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||
return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : valueArg.toLowerCase();
|
||||
}
|
||||
|
||||
public static compactMac(valueArg?: string): string | undefined {
|
||||
return this.normalizeMac(valueArg)?.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
public static miredToKelvin(valueArg: number | undefined): number | undefined {
|
||||
return valueArg && Number.isFinite(valueArg) ? Math.round(1_000_000 / valueArg) : undefined;
|
||||
}
|
||||
|
||||
public static kelvinToMired(valueArg: number | undefined): number | undefined {
|
||||
return valueArg && Number.isFinite(valueArg) ? Math.round(1_000_000 / valueArg) : undefined;
|
||||
}
|
||||
|
||||
private static normalizeSnapshot(snapshotArg: IElgatoSnapshot, configArg: IElgatoConfig, sourceArg: TElgatoSnapshotSource): IElgatoSnapshot {
|
||||
const derived = this.toSnapshot({
|
||||
config: { ...configArg, snapshot: undefined },
|
||||
rawData: snapshotArg.rawData,
|
||||
online: snapshotArg.online,
|
||||
source: sourceArg,
|
||||
error: snapshotArg.error,
|
||||
});
|
||||
return {
|
||||
...derived,
|
||||
...snapshotArg,
|
||||
device: { ...derived.device, ...snapshotArg.device },
|
||||
light: { ...derived.light, ...snapshotArg.light },
|
||||
battery: snapshotArg.battery || derived.battery,
|
||||
capabilities: { ...derived.capabilities, ...snapshotArg.capabilities },
|
||||
rawData: snapshotArg.rawData || derived.rawData,
|
||||
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
|
||||
source: snapshotArg.source || sourceArg,
|
||||
};
|
||||
}
|
||||
|
||||
private static rawData(configArg: IElgatoConfig, rawDataArg?: Partial<IElgatoRawData>): Partial<IElgatoRawData> {
|
||||
return this.cleanAttributes({
|
||||
...configArg.rawData,
|
||||
...rawDataArg,
|
||||
info: rawDataArg?.info || configArg.info || configArg.rawData?.info,
|
||||
settings: rawDataArg?.settings || configArg.settings || configArg.rawData?.settings,
|
||||
state: rawDataArg?.state || configArg.state || configArg.rawData?.state,
|
||||
battery: rawDataArg?.battery || configArg.battery || configArg.rawData?.battery,
|
||||
fetchedAt: rawDataArg?.fetchedAt || configArg.rawData?.fetchedAt,
|
||||
});
|
||||
}
|
||||
|
||||
private static deviceInfo(configArg: IElgatoConfig, infoArg: IElgatoInfo | undefined, onlineArg: boolean): IElgatoDeviceSnapshotInfo {
|
||||
const endpoint = this.endpoint(configArg);
|
||||
const serialNumber = this.stringValue(infoArg?.serialNumber) || configArg.serialNumber;
|
||||
const macAddress = this.normalizeMac(this.stringValue(infoArg?.macAddress) || configArg.macAddress);
|
||||
const model = configArg.model || this.stringValue(infoArg?.productName);
|
||||
const name = configArg.name || this.stringValue(infoArg?.displayName) || serialNumber || endpoint.host || elgatoDisplayName;
|
||||
return this.cleanAttributes({
|
||||
id: configArg.uniqueId || serialNumber || macAddress || (endpoint.host ? `${endpoint.host}:${endpoint.port || elgatoDefaultPort}` : undefined) || name,
|
||||
name,
|
||||
manufacturer: configArg.manufacturer || elgatoManufacturer,
|
||||
model,
|
||||
serialNumber,
|
||||
firmwareVersion: this.stringValue(infoArg?.firmwareVersion),
|
||||
firmwareBuildNumber: this.numberValue(infoArg?.firmwareBuildNumber),
|
||||
hardwareBoardType: this.numberValue(infoArg?.hardwareBoardType),
|
||||
macAddress,
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
baseUrl: endpoint.baseUrl,
|
||||
online: onlineArg,
|
||||
}) as IElgatoDeviceSnapshotInfo;
|
||||
}
|
||||
|
||||
private static light(infoArg: IElgatoInfo | undefined, settingsArg: IElgatoSettings | undefined, stateArg: IElgatoState | undefined): IElgatoLightSnapshot {
|
||||
const productName = this.stringValue(infoArg?.productName) || '';
|
||||
const hue = this.numberValue(stateArg?.hue);
|
||||
const saturation = this.numberValue(stateArg?.saturation);
|
||||
const supportsColor = productName === 'Elgato Light Strip' || productName === 'Elgato Light Strip Pro' || settingsArg?.powerOnHue !== undefined || hue !== undefined;
|
||||
const brightness = this.clamp(this.numberValue(stateArg?.brightness) ?? 0, 0, 100);
|
||||
const temperature = this.numberValue(stateArg?.temperature);
|
||||
const supportedColorModes: TElgatoColorMode[] = supportsColor ? ['color_temp', 'hs'] : ['color_temp'];
|
||||
return this.cleanAttributes({
|
||||
on: this.booleanValue(stateArg?.on) ?? false,
|
||||
brightness,
|
||||
brightness255: Math.round((brightness * 255) / 100),
|
||||
colorMode: hue !== undefined ? 'hs' : 'color_temp',
|
||||
supportedColorModes,
|
||||
colorTemperatureMired: temperature,
|
||||
colorTemperatureKelvin: this.miredToKelvin(temperature),
|
||||
minColorTemperatureKelvin: supportsColor ? 3500 : 2900,
|
||||
maxColorTemperatureKelvin: supportsColor ? 6500 : 7000,
|
||||
hue,
|
||||
saturation,
|
||||
}) as IElgatoLightSnapshot;
|
||||
}
|
||||
|
||||
private static battery(batteryArg: IElgatoBatteryInfo | undefined, settingsArg: IElgatoSettings | undefined): IElgatoBatterySnapshot | undefined {
|
||||
if (!batteryArg && !settingsArg?.battery) {
|
||||
return undefined;
|
||||
}
|
||||
const inputChargeVoltage = this.numberValue(batteryArg?.inputChargeVoltage);
|
||||
const inputChargeCurrent = this.numberValue(batteryArg?.inputChargeCurrent);
|
||||
return this.cleanAttributes({
|
||||
level: this.numberValue(batteryArg?.level),
|
||||
status: this.numberValue(batteryArg?.status),
|
||||
powerSource: this.numberValue(batteryArg?.powerSource),
|
||||
voltage: this.numberValue(batteryArg?.currentBatteryVoltage),
|
||||
inputChargeVoltage,
|
||||
inputChargeCurrent,
|
||||
chargePower: inputChargeVoltage !== undefined && inputChargeCurrent !== undefined ? Math.round((inputChargeVoltage * inputChargeCurrent) / 10_000) / 100 : undefined,
|
||||
bypass: this.booleanValue(settingsArg?.battery?.bypass),
|
||||
energySavingEnabled: this.booleanValue(settingsArg?.battery?.energySaving?.enable),
|
||||
}) as IElgatoBatterySnapshot;
|
||||
}
|
||||
|
||||
private static entity(snapshotArg: IElgatoSnapshot, deviceIdArg: string, uniqueBaseArg: string, definitionArg: IElgatoEntityDefinition): IIntegrationEntity {
|
||||
return {
|
||||
id: `${definitionArg.platform}.${this.slug(definitionArg.name)}`,
|
||||
uniqueId: `${elgatoDomain}_${uniqueBaseArg}_${this.slug(definitionArg.key)}`,
|
||||
integrationDomain: elgatoDomain,
|
||||
deviceId: deviceIdArg,
|
||||
platform: definitionArg.platform,
|
||||
name: definitionArg.name,
|
||||
state: definitionArg.state,
|
||||
attributes: this.cleanAttributes({ key: definitionArg.key, ...definitionArg.attributes }),
|
||||
available: definitionArg.available,
|
||||
};
|
||||
}
|
||||
|
||||
private static lightCommand(snapshotArg: IElgatoSnapshot, requestArg: IServiceCallRequest, onArg: boolean | undefined): IElgatoCommandRequest {
|
||||
const data = requestArg.data || {};
|
||||
const brightnessPercent = this.brightnessPercent(data);
|
||||
const hsColor = this.arrayValue(data.hs_color ?? data.hsColor);
|
||||
const colorTemperatureKelvin = this.numberValue(data.color_temp_kelvin ?? data.colorTempKelvin ?? data.kelvin);
|
||||
const temperature = this.numberValue(data.color_temp ?? data.colorTemp ?? data.temperature);
|
||||
const command: IElgatoCommandRequest = this.cleanAttributes({
|
||||
action: 'set_light',
|
||||
on: onArg,
|
||||
brightness: brightnessPercent,
|
||||
brightness255: this.numberValue(data.brightness),
|
||||
colorTemperatureKelvin,
|
||||
temperature,
|
||||
hue: hsColor ? this.numberValue(hsColor[0]) : this.numberValue(data.hue),
|
||||
saturation: hsColor ? this.numberValue(hsColor[1]) : this.numberValue(data.saturation),
|
||||
service: requestArg.service,
|
||||
target: requestArg.target,
|
||||
data: requestArg.data,
|
||||
}) as IElgatoCommandRequest;
|
||||
|
||||
if (command.brightness !== undefined && command.hue === undefined && command.saturation === undefined && command.temperature === undefined && command.colorTemperatureKelvin === undefined && snapshotArg.capabilities.color && snapshotArg.light.colorMode === 'color_temp') {
|
||||
command.colorTemperatureKelvin = snapshotArg.light.colorTemperatureKelvin;
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
private static buttonCommand(snapshotArg: IElgatoSnapshot, requestArg: IServiceCallRequest): IElgatoCommandRequest | undefined {
|
||||
void snapshotArg;
|
||||
const target = this.targetText(requestArg);
|
||||
if (!target || target.includes('identify')) {
|
||||
return { action: 'identify', service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (target.includes('restart')) {
|
||||
return { action: 'restart', service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static brightnessPercent(dataArg: Record<string, unknown>): number | undefined {
|
||||
const brightnessPct = this.numberValue(dataArg.brightness_pct ?? dataArg.brightnessPct);
|
||||
if (brightnessPct !== undefined) {
|
||||
return Math.round(this.clamp(brightnessPct, 0, 100));
|
||||
}
|
||||
const brightness = this.numberValue(dataArg.brightness);
|
||||
return brightness === undefined ? undefined : Math.round((this.clamp(brightness, 0, 255) / 255) * 100);
|
||||
}
|
||||
|
||||
private static targetText(requestArg: IServiceCallRequest): string {
|
||||
return [requestArg.target?.entityId, requestArg.target?.deviceId, requestArg.data?.entity_id, requestArg.data?.entityId, requestArg.data?.key, requestArg.data?.action].filter(Boolean).join(' ').toLowerCase();
|
||||
}
|
||||
|
||||
private static endpoint(configArg: IElgatoConfig): { host?: string; port?: number; baseUrl?: string } {
|
||||
const parsed = this.parseEndpoint(configArg.url || configArg.host);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
const host = configArg.host;
|
||||
const port = host ? configArg.port || elgatoDefaultPort : configArg.port;
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
baseUrl: host ? `http://${this.hostForUrl(host)}${port && port !== 80 ? `:${port}` : ''}` : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private static parseEndpoint(valueArg: string | undefined): { host: string; port: number; baseUrl: string } | undefined {
|
||||
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const url = new URL(valueArg);
|
||||
const port = url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : elgatoDefaultPort;
|
||||
return { host: url.hostname, port, baseUrl: `${url.protocol}//${this.hostForUrl(url.hostname)}${url.port ? `:${url.port}` : ''}` };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private static hasCommandTransport(configArg: IElgatoConfig, actionArg: IElgatoCommandRequest['action']): boolean {
|
||||
if (configArg.commandExecutor || configArg.host || configArg.url) {
|
||||
return true;
|
||||
}
|
||||
if (actionArg === 'set_light') {
|
||||
return Boolean(configArg.client?.light || configArg.client?.execute);
|
||||
}
|
||||
if (actionArg === 'identify') {
|
||||
return Boolean(configArg.client?.identify || configArg.client?.execute);
|
||||
}
|
||||
if (actionArg === 'restart') {
|
||||
return Boolean(configArg.client?.restart || configArg.client?.execute);
|
||||
}
|
||||
if (actionArg === 'battery_bypass') {
|
||||
return Boolean(configArg.client?.batteryBypass || configArg.client?.execute);
|
||||
}
|
||||
if (actionArg === 'energy_saving') {
|
||||
return Boolean(configArg.client?.energySaving || configArg.client?.execute);
|
||||
}
|
||||
return Boolean(configArg.client?.execute);
|
||||
}
|
||||
|
||||
private static hasRawData(rawDataArg: Partial<IElgatoRawData>): boolean {
|
||||
return Boolean(rawDataArg.info || rawDataArg.settings || rawDataArg.state || rawDataArg.battery);
|
||||
}
|
||||
|
||||
private static stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : typeof valueArg === 'number' ? String(valueArg) : undefined;
|
||||
}
|
||||
|
||||
private static numberValue(valueArg: unknown): number | undefined {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const parsed = Number(valueArg);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static booleanValue(valueArg: unknown): boolean | undefined {
|
||||
if (typeof valueArg === 'boolean') {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'number') {
|
||||
return valueArg !== 0;
|
||||
}
|
||||
if (typeof valueArg === 'string') {
|
||||
const normalized = valueArg.trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static arrayValue(valueArg: unknown): unknown[] | undefined {
|
||||
return Array.isArray(valueArg) ? valueArg : undefined;
|
||||
}
|
||||
|
||||
private static clamp(valueArg: number, minArg: number, maxArg: number): number {
|
||||
return Math.min(maxArg, Math.max(minArg, valueArg));
|
||||
}
|
||||
|
||||
private static hostForUrl(hostArg: string): string {
|
||||
return hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
|
||||
}
|
||||
|
||||
private static cleanAttributes<TRecord extends Record<string, unknown>>(recordArg: TRecord): TRecord {
|
||||
return Object.fromEntries(Object.entries(recordArg).filter(([, valueArg]) => valueArg !== undefined)) as TRecord;
|
||||
}
|
||||
|
||||
private static clone<TValue>(valueArg: TValue): TValue {
|
||||
return JSON.parse(JSON.stringify(valueArg)) as TValue;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,252 @@
|
||||
export interface IHomeAssistantElgatoConfig {
|
||||
// TODO: replace with the TypeScript-native config for elgato.
|
||||
export const elgatoDomain = 'elgato';
|
||||
export const elgatoDisplayName = 'Elgato Light';
|
||||
export const elgatoManufacturer = 'Elgato';
|
||||
export const elgatoDefaultPort = 9123;
|
||||
export const elgatoDefaultTimeoutMs = 8000;
|
||||
export const elgatoZeroconfType = '_elg._tcp.local.';
|
||||
|
||||
export type TElgatoSnapshotSource = 'http' | 'client' | 'manual' | 'snapshot' | 'runtime';
|
||||
export type TElgatoColorMode = 'color_temp' | 'hs';
|
||||
export type TElgatoCommandAction = 'refresh' | 'set_light' | 'identify' | 'restart' | 'battery_bypass' | 'energy_saving';
|
||||
|
||||
export interface IElgatoWifiInfo {
|
||||
frequencyMHz?: number;
|
||||
rssi?: number;
|
||||
ssid?: string;
|
||||
}
|
||||
|
||||
export interface IElgatoInfo {
|
||||
features?: string[];
|
||||
firmwareBuildNumber?: number;
|
||||
firmwareVersion?: string;
|
||||
hardwareBoardType?: number;
|
||||
productName?: string;
|
||||
serialNumber?: string;
|
||||
displayName?: string;
|
||||
macAddress?: string;
|
||||
'wifi-info'?: IElgatoWifiInfo;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IElgatoEnergySavingAdjustBrightnessSettings {
|
||||
brightness?: number;
|
||||
enable?: number | boolean;
|
||||
}
|
||||
|
||||
export interface IElgatoEnergySavingSettings {
|
||||
adjustBrightness?: IElgatoEnergySavingAdjustBrightnessSettings;
|
||||
disableWifi?: number | boolean;
|
||||
enable?: number | boolean;
|
||||
minimumBatteryLevel?: number;
|
||||
}
|
||||
|
||||
export interface IElgatoBatterySettings {
|
||||
bypass?: number | boolean;
|
||||
energySaving?: IElgatoEnergySavingSettings;
|
||||
}
|
||||
|
||||
export interface IElgatoSettings {
|
||||
colorChangeDurationMs?: number;
|
||||
powerOnBehavior?: number;
|
||||
powerOnBrightness?: number;
|
||||
switchOffDurationMs?: number;
|
||||
switchOnDurationMs?: number;
|
||||
battery?: IElgatoBatterySettings | null;
|
||||
powerOnHue?: number;
|
||||
powerOnSaturation?: number;
|
||||
powerOnTemperature?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IElgatoState {
|
||||
on?: number | boolean;
|
||||
brightness?: number;
|
||||
hue?: number | null;
|
||||
saturation?: number | null;
|
||||
temperature?: number | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IElgatoBatteryInfo {
|
||||
powerSource?: number;
|
||||
level?: number;
|
||||
status?: number;
|
||||
currentBatteryVoltage?: number;
|
||||
inputChargeVoltage?: number;
|
||||
inputChargeCurrent?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IElgatoRawData {
|
||||
info?: IElgatoInfo;
|
||||
settings?: IElgatoSettings;
|
||||
state?: IElgatoState;
|
||||
battery?: IElgatoBatteryInfo;
|
||||
fetchedAt?: string;
|
||||
}
|
||||
|
||||
export interface IElgatoDeviceSnapshotInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
manufacturer: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
firmwareVersion?: string;
|
||||
firmwareBuildNumber?: number;
|
||||
hardwareBoardType?: number;
|
||||
macAddress?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export interface IElgatoLightSnapshot {
|
||||
on: boolean;
|
||||
brightness?: number;
|
||||
brightness255?: number;
|
||||
colorMode: TElgatoColorMode;
|
||||
supportedColorModes: TElgatoColorMode[];
|
||||
colorTemperatureMired?: number;
|
||||
colorTemperatureKelvin?: number;
|
||||
minColorTemperatureKelvin: number;
|
||||
maxColorTemperatureKelvin: number;
|
||||
hue?: number;
|
||||
saturation?: number;
|
||||
}
|
||||
|
||||
export interface IElgatoBatterySnapshot {
|
||||
level?: number;
|
||||
status?: number;
|
||||
powerSource?: number;
|
||||
voltage?: number;
|
||||
inputChargeVoltage?: number;
|
||||
inputChargeCurrent?: number;
|
||||
chargePower?: number;
|
||||
bypass?: boolean;
|
||||
energySavingEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IElgatoCapabilities {
|
||||
localControl: boolean;
|
||||
colorTemperature: boolean;
|
||||
color: boolean;
|
||||
identify: boolean;
|
||||
restart: boolean;
|
||||
battery: boolean;
|
||||
batterySettings: boolean;
|
||||
}
|
||||
|
||||
export interface IElgatoSnapshot {
|
||||
device: IElgatoDeviceSnapshotInfo;
|
||||
light: IElgatoLightSnapshot;
|
||||
battery?: IElgatoBatterySnapshot;
|
||||
capabilities: IElgatoCapabilities;
|
||||
rawData?: IElgatoRawData;
|
||||
online: boolean;
|
||||
updatedAt: string;
|
||||
source: TElgatoSnapshotSource;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface IElgatoCommandRequest {
|
||||
action: TElgatoCommandAction;
|
||||
service?: string;
|
||||
target?: {
|
||||
entityId?: string;
|
||||
deviceId?: string;
|
||||
};
|
||||
data?: Record<string, unknown>;
|
||||
on?: boolean;
|
||||
brightness?: number;
|
||||
brightness255?: number;
|
||||
temperature?: number;
|
||||
colorTemperatureKelvin?: number;
|
||||
hue?: number;
|
||||
saturation?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IElgatoRefreshResult {
|
||||
success: boolean;
|
||||
snapshot?: IElgatoSnapshot;
|
||||
error?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IElgatoClientLike {
|
||||
getSnapshot?: () => Promise<IElgatoSnapshot | Partial<IElgatoRawData>>;
|
||||
getRawData?: () => Promise<Partial<IElgatoRawData>>;
|
||||
info?: () => Promise<IElgatoInfo>;
|
||||
settings?: () => Promise<IElgatoSettings>;
|
||||
state?: () => Promise<IElgatoState>;
|
||||
battery?: () => Promise<IElgatoBatteryInfo>;
|
||||
light?: (requestArg: Partial<IElgatoCommandRequest>) => Promise<unknown>;
|
||||
identify?: () => Promise<unknown>;
|
||||
restart?: () => Promise<unknown>;
|
||||
batteryBypass?: (onArg: boolean) => Promise<unknown>;
|
||||
energySaving?: (onArg: boolean) => Promise<unknown>;
|
||||
execute?: (requestArg: IElgatoCommandRequest) => Promise<unknown>;
|
||||
destroy?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface IElgatoCommandExecutor {
|
||||
execute(requestArg: IElgatoCommandRequest): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface IElgatoConfig {
|
||||
host?: string;
|
||||
url?: string;
|
||||
port?: number;
|
||||
timeoutMs?: number;
|
||||
name?: string;
|
||||
uniqueId?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
macAddress?: string;
|
||||
snapshot?: IElgatoSnapshot;
|
||||
rawData?: Partial<IElgatoRawData>;
|
||||
info?: IElgatoInfo;
|
||||
settings?: IElgatoSettings;
|
||||
state?: IElgatoState;
|
||||
battery?: IElgatoBatteryInfo;
|
||||
client?: IElgatoClientLike;
|
||||
commandExecutor?: IElgatoCommandExecutor;
|
||||
online?: boolean;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantElgatoConfig extends IElgatoConfig {}
|
||||
|
||||
export interface IElgatoManualEntry extends IElgatoConfig {
|
||||
id?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IElgatoMdnsRecord {
|
||||
type?: string;
|
||||
serviceType?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
hostname?: string;
|
||||
addresses?: string[];
|
||||
port?: number;
|
||||
txt?: Record<string, string | undefined>;
|
||||
properties?: Record<string, string | undefined>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IElgatoDhcpRecord {
|
||||
ip?: string;
|
||||
address?: string;
|
||||
host?: string;
|
||||
hostname?: string;
|
||||
hostName?: string;
|
||||
macaddress?: string;
|
||||
macAddress?: string;
|
||||
registeredDevices?: boolean;
|
||||
registered_devices?: boolean;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './elgato.classes.client.js';
|
||||
export * from './elgato.classes.configflow.js';
|
||||
export * from './elgato.classes.integration.js';
|
||||
export * from './elgato.discovery.js';
|
||||
export * from './elgato.mapper.js';
|
||||
export * from './elgato.types.js';
|
||||
|
||||
@@ -131,7 +131,6 @@ import { HomeAssistantBlueprintIntegration } from '../blueprint/index.js';
|
||||
import { HomeAssistantBluetoothIntegration } from '../bluetooth/index.js';
|
||||
import { HomeAssistantBluetoothAdaptersIntegration } from '../bluetooth_adapters/index.js';
|
||||
import { HomeAssistantBmwConnectedDriveIntegration } from '../bmw_connected_drive/index.js';
|
||||
import { HomeAssistantBondIntegration } from '../bond/index.js';
|
||||
import { HomeAssistantBoschAlarmIntegration } from '../bosch_alarm/index.js';
|
||||
import { HomeAssistantBrandsIntegration } from '../brands/index.js';
|
||||
import { HomeAssistantBrandtIntegration } from '../brandt/index.js';
|
||||
@@ -220,7 +219,6 @@ import { HomeAssistantDelijnIntegration } from '../delijn/index.js';
|
||||
import { HomeAssistantDelmarvaIntegration } from '../delmarva/index.js';
|
||||
import { HomeAssistantDelugeIntegration } from '../deluge/index.js';
|
||||
import { HomeAssistantDemoIntegration } from '../demo/index.js';
|
||||
import { HomeAssistantDenonIntegration } from '../denon/index.js';
|
||||
import { HomeAssistantDenonRs232Integration } from '../denon_rs232/index.js';
|
||||
import { HomeAssistantDerivativeIntegration } from '../derivative/index.js';
|
||||
import { HomeAssistantDevialetIntegration } from '../devialet/index.js';
|
||||
@@ -282,7 +280,6 @@ import { HomeAssistantEkeybionyxIntegration } from '../ekeybionyx/index.js';
|
||||
import { HomeAssistantElectrasmartIntegration } from '../electrasmart/index.js';
|
||||
import { HomeAssistantElectricKiwiIntegration } from '../electric_kiwi/index.js';
|
||||
import { HomeAssistantElevenlabsIntegration } from '../elevenlabs/index.js';
|
||||
import { HomeAssistantElgatoIntegration } from '../elgato/index.js';
|
||||
import { HomeAssistantEliqonlineIntegration } from '../eliqonline/index.js';
|
||||
import { HomeAssistantElkm1Integration } from '../elkm1/index.js';
|
||||
import { HomeAssistantElmaxIntegration } from '../elmax/index.js';
|
||||
@@ -451,7 +448,6 @@ import { HomeAssistantHannaIntegration } from '../hanna/index.js';
|
||||
import { HomeAssistantHardkernelIntegration } from '../hardkernel/index.js';
|
||||
import { HomeAssistantHardwareIntegration } from '../hardware/index.js';
|
||||
import { HomeAssistantHarmanKardonAvrIntegration } from '../harman_kardon_avr/index.js';
|
||||
import { HomeAssistantHarmonyIntegration } from '../harmony/index.js';
|
||||
import { HomeAssistantHarveyIntegration } from '../harvey/index.js';
|
||||
import { HomeAssistantHassioIntegration } from '../hassio/index.js';
|
||||
import { HomeAssistantHavanaShadeIntegration } from '../havana_shade/index.js';
|
||||
@@ -497,7 +493,6 @@ import { HomeAssistantHpIloIntegration } from '../hp_ilo/index.js';
|
||||
import { HomeAssistantHrEnergyQubeIntegration } from '../hr_energy_qube/index.js';
|
||||
import { HomeAssistantHtml5Integration } from '../html5/index.js';
|
||||
import { HomeAssistantHttpIntegration } from '../http/index.js';
|
||||
import { HomeAssistantHuaweiLteIntegration } from '../huawei_lte/index.js';
|
||||
import { HomeAssistantHueBleIntegration } from '../hue_ble/index.js';
|
||||
import { HomeAssistantHuisbaasjeIntegration } from '../huisbaasje/index.js';
|
||||
import { HomeAssistantHumidifierIntegration } from '../humidifier/index.js';
|
||||
@@ -509,7 +504,6 @@ import { HomeAssistantHusqvarnaAutomowerBleIntegration } from '../husqvarna_auto
|
||||
import { HomeAssistantHuumIntegration } from '../huum/index.js';
|
||||
import { HomeAssistantHvvDeparturesIntegration } from '../hvv_departures/index.js';
|
||||
import { HomeAssistantHydrawiseIntegration } from '../hydrawise/index.js';
|
||||
import { HomeAssistantHyperionIntegration } from '../hyperion/index.js';
|
||||
import { HomeAssistantHypontechIntegration } from '../hypontech/index.js';
|
||||
import { HomeAssistantIalarmIntegration } from '../ialarm/index.js';
|
||||
import { HomeAssistantIammeterIntegration } from '../iammeter/index.js';
|
||||
@@ -1509,7 +1503,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlueprintIntegratio
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothAdaptersIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBmwConnectedDriveIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBondIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBoschAlarmIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBrandtIntegration());
|
||||
@@ -1598,7 +1591,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantDelijnIntegration()
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDelmarvaIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDelugeIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDemoIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDenonIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDenonRs232Integration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDerivativeIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDevialetIntegration());
|
||||
@@ -1660,7 +1652,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantEkeybionyxIntegrati
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantElectrasmartIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantElectricKiwiIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantElevenlabsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantElgatoIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantEliqonlineIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantElkm1Integration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantElmaxIntegration());
|
||||
@@ -1829,7 +1820,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantHannaIntegration())
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHardkernelIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHardwareIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHarmanKardonAvrIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHarmonyIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHarveyIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHassioIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHavanaShadeIntegration());
|
||||
@@ -1875,7 +1865,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantHpIloIntegration())
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHrEnergyQubeIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHtml5Integration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHttpIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHuaweiLteIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHueBleIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHuisbaasjeIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHumidifierIntegration());
|
||||
@@ -1887,7 +1876,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantHusqvarnaAutomowerB
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHuumIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHvvDeparturesIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHydrawiseIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHyperionIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantHypontechIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantIalarmIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantIammeterIntegration());
|
||||
@@ -2756,7 +2744,7 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
|
||||
|
||||
export const generatedHomeAssistantPortCount = 1376;
|
||||
export const generatedHomeAssistantPortCount = 1370;
|
||||
export const handwrittenHomeAssistantPortDomains = [
|
||||
"adguard",
|
||||
"airgradient",
|
||||
@@ -2771,6 +2759,7 @@ export const handwrittenHomeAssistantPortDomains = [
|
||||
"blebox",
|
||||
"bluesound",
|
||||
"bluetooth_le_tracker",
|
||||
"bond",
|
||||
"bosch_shc",
|
||||
"braviatv",
|
||||
"broadlink",
|
||||
@@ -2778,6 +2767,7 @@ export const handwrittenHomeAssistantPortDomains = [
|
||||
"cast",
|
||||
"daikin",
|
||||
"deconz",
|
||||
"denon",
|
||||
"denonavr",
|
||||
"devolo_home_network",
|
||||
"directv",
|
||||
@@ -2786,17 +2776,21 @@ export const handwrittenHomeAssistantPortDomains = [
|
||||
"doorbird",
|
||||
"dsmr",
|
||||
"dunehd",
|
||||
"elgato",
|
||||
"esphome",
|
||||
"forked_daapd",
|
||||
"fritz",
|
||||
"frontier_silicon",
|
||||
"glances",
|
||||
"go2rtc",
|
||||
"harmony",
|
||||
"heos",
|
||||
"hikvision",
|
||||
"homekit_controller",
|
||||
"homematic",
|
||||
"huawei_lte",
|
||||
"hue",
|
||||
"hyperion",
|
||||
"ipp",
|
||||
"jellyfin",
|
||||
"knx",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -0,0 +1,413 @@
|
||||
import {
|
||||
harmonyDefaultDelaySecs,
|
||||
harmonyDefaultHoldSecs,
|
||||
harmonyDefaultRepeats,
|
||||
harmonyDisplayName,
|
||||
harmonyHubPort,
|
||||
harmonyManufacturer,
|
||||
harmonyPowerOffActivity,
|
||||
harmonyPreviousActiveActivity,
|
||||
harmonySelectPowerOffOption,
|
||||
} from './harmony.constants.js';
|
||||
import type {
|
||||
IHarmonyActivity,
|
||||
IHarmonyCommand,
|
||||
IHarmonyCommandContext,
|
||||
IHarmonyConfig,
|
||||
IHarmonyDevice,
|
||||
IHarmonyDeviceCommand,
|
||||
IHarmonyDeviceCommandGroup,
|
||||
IHarmonyDeviceInfo,
|
||||
IHarmonyRemoteCommandStep,
|
||||
IHarmonySnapshot,
|
||||
THarmonyJsonValue,
|
||||
} from './harmony.types.js';
|
||||
|
||||
export class HarmonyUnsupportedProtocolError extends Error {
|
||||
constructor(commandArg: IHarmonyCommand) {
|
||||
super(`Harmony Hub command "${commandArg.action}" requires an injected client/executor. This TypeScript port models local Harmony Hub state and commands but does not implement live aioharmony/websocket transport.`);
|
||||
this.name = 'HarmonyUnsupportedProtocolError';
|
||||
}
|
||||
}
|
||||
|
||||
export class HarmonyUnsupportedCloudOperationError extends Error {
|
||||
constructor() {
|
||||
super('Harmony Hub sync is cloud-backed and is not implemented by this local-first TypeScript port.');
|
||||
this.name = 'HarmonyUnsupportedCloudOperationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class HarmonyClient {
|
||||
private snapshot?: IHarmonySnapshot;
|
||||
|
||||
constructor(private readonly config: IHarmonyConfig) {
|
||||
this.snapshot = config.snapshot ? this.cloneSnapshot(config.snapshot) : undefined;
|
||||
}
|
||||
|
||||
public get defaultActivity(): string | undefined {
|
||||
return this.stringValue(this.config.defaultActivity || this.config.activity);
|
||||
}
|
||||
|
||||
public get delaySecs(): number {
|
||||
return this.positiveNumber(this.config.delaySecs ?? this.config.delay_secs) ?? harmonyDefaultDelaySecs;
|
||||
}
|
||||
|
||||
public get holdSecs(): number {
|
||||
return this.nonNegativeNumber(this.config.holdSecs ?? this.config.hold_secs) ?? harmonyDefaultHoldSecs;
|
||||
}
|
||||
|
||||
public get numRepeats(): number {
|
||||
return this.repeats(this.config.numRepeats ?? this.config.num_repeats);
|
||||
}
|
||||
|
||||
public async getSnapshot(): Promise<IHarmonySnapshot> {
|
||||
if (this.config.client?.getSnapshot) {
|
||||
return this.normalizeSnapshot(await this.config.client.getSnapshot());
|
||||
}
|
||||
this.snapshot = this.normalizeSnapshot(this.snapshot || this.snapshotFromManualConfig());
|
||||
return this.cloneSnapshot(this.snapshot);
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
await this.execute({ action: 'connect', reason: 'connect' });
|
||||
}
|
||||
|
||||
public async startActivity(activityArg: string, reasonArg: IHarmonyCommand['reason'] = 'remote_turn_on'): Promise<void> {
|
||||
const snapshot = await this.getSnapshot();
|
||||
const activity = this.resolveActivity(snapshot, activityArg);
|
||||
if (!activity) {
|
||||
throw new Error(`Harmony Hub activity is not configured: ${activityArg}`);
|
||||
}
|
||||
if (snapshot.state.currentActivity === activity.label || snapshot.state.currentActivityId === activity.id) {
|
||||
return;
|
||||
}
|
||||
await this.execute({
|
||||
action: activity.isPowerOff ? 'power_off' : 'start_activity',
|
||||
reason: reasonArg,
|
||||
activityId: activity.id,
|
||||
activity: activity.label,
|
||||
});
|
||||
}
|
||||
|
||||
public async powerOff(reasonArg: IHarmonyCommand['reason'] = 'remote_turn_off'): Promise<void> {
|
||||
await this.execute({ action: 'power_off', reason: reasonArg, activityId: '-1', activity: harmonyPowerOffActivity });
|
||||
}
|
||||
|
||||
public async sendCommands(
|
||||
commandsArg: string[],
|
||||
deviceArg: string,
|
||||
optionsArg: {
|
||||
numRepeats?: number;
|
||||
delaySecs?: number;
|
||||
holdSecs?: number;
|
||||
reason?: IHarmonyCommand['reason'];
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const snapshot = await this.getSnapshot();
|
||||
const device = this.resolveDevice(snapshot, deviceArg);
|
||||
if (!device) {
|
||||
throw new Error(`Harmony Hub device is not configured: ${deviceArg}`);
|
||||
}
|
||||
const commands = commandsArg.map((commandArg) => commandArg.trim()).filter(Boolean);
|
||||
if (!commands.length) {
|
||||
throw new Error('Harmony Hub remote.send_command requires at least one command.');
|
||||
}
|
||||
const numRepeats = this.repeats(optionsArg.numRepeats);
|
||||
const holdSecs = this.nonNegativeNumber(optionsArg.holdSecs) ?? this.holdSecs;
|
||||
const sequence: IHarmonyRemoteCommandStep[] = [];
|
||||
for (let repeatIndex = 0; repeatIndex < numRepeats; repeatIndex += 1) {
|
||||
for (const command of commands) {
|
||||
sequence.push({ deviceId: device.id, device: device.label, command, holdSecs });
|
||||
}
|
||||
}
|
||||
await this.execute({
|
||||
action: 'send_command',
|
||||
reason: optionsArg.reason || 'remote_send_command',
|
||||
deviceId: device.id,
|
||||
device: device.label,
|
||||
commands,
|
||||
sequence,
|
||||
numRepeats,
|
||||
delaySecs: this.nonNegativeNumber(optionsArg.delaySecs) ?? this.delaySecs,
|
||||
holdSecs,
|
||||
});
|
||||
}
|
||||
|
||||
public async changeChannel(channelArg: number, reasonArg: IHarmonyCommand['reason'] = 'change_channel'): Promise<void> {
|
||||
if (!Number.isInteger(channelArg) || channelArg < 1) {
|
||||
throw new Error('Harmony Hub change_channel requires a positive integer channel.');
|
||||
}
|
||||
await this.execute({ action: 'change_channel', reason: reasonArg, channel: channelArg });
|
||||
}
|
||||
|
||||
public async sync(): Promise<void> {
|
||||
throw new HarmonyUnsupportedCloudOperationError();
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.config.client?.destroy?.();
|
||||
}
|
||||
|
||||
private async execute(commandArg: IHarmonyCommand): Promise<void> {
|
||||
const snapshot = await this.getSnapshot();
|
||||
const context: IHarmonyCommandContext = { config: this.config, snapshot };
|
||||
if (this.config.client?.execute) {
|
||||
await this.config.client.execute(commandArg, context);
|
||||
this.applyCommand(commandArg);
|
||||
return;
|
||||
}
|
||||
if (!this.config.executor) {
|
||||
throw new HarmonyUnsupportedProtocolError(commandArg);
|
||||
}
|
||||
if (typeof this.config.executor === 'function') {
|
||||
await this.config.executor(commandArg, context);
|
||||
} else {
|
||||
await this.config.executor.execute(commandArg, context);
|
||||
}
|
||||
this.applyCommand(commandArg);
|
||||
}
|
||||
|
||||
private applyCommand(commandArg: IHarmonyCommand): void {
|
||||
if (!this.snapshot) {
|
||||
return;
|
||||
}
|
||||
if (commandArg.action === 'start_activity' || commandArg.action === 'power_off') {
|
||||
const currentActivity = commandArg.activity || (commandArg.action === 'power_off' ? harmonyPowerOffActivity : undefined);
|
||||
this.snapshot = this.normalizeSnapshot({
|
||||
...this.snapshot,
|
||||
state: {
|
||||
...this.snapshot.state,
|
||||
available: true,
|
||||
currentActivityId: commandArg.activityId,
|
||||
currentActivity,
|
||||
lastActivity: commandArg.action === 'power_off' ? this.snapshot.state.lastActivity : currentActivity || this.snapshot.state.lastActivity,
|
||||
activityStarting: null,
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private snapshotFromManualConfig(): IHarmonySnapshot {
|
||||
const hubConfig = asRecord(this.config.hubConfig);
|
||||
const global = asRecord(hubConfig?.global);
|
||||
const deviceInfo: IHarmonyDeviceInfo = {
|
||||
...this.config.deviceInfo,
|
||||
id: this.config.deviceInfo?.id || this.config.remoteId || this.config.hubId || this.stringValue(global?.timeStampHash) || this.config.host,
|
||||
name: this.config.deviceInfo?.name || this.config.deviceName || this.config.name || harmonyDisplayName,
|
||||
host: this.config.deviceInfo?.host || this.config.host,
|
||||
port: this.config.deviceInfo?.port || this.config.port || harmonyHubPort,
|
||||
remoteId: this.config.deviceInfo?.remoteId || this.config.remoteId || this.config.hubId,
|
||||
hubId: this.config.deviceInfo?.hubId || this.config.hubId || this.config.remoteId,
|
||||
manufacturer: this.config.deviceInfo?.manufacturer || this.config.manufacturer || harmonyManufacturer,
|
||||
model: this.config.deviceInfo?.model || this.config.model || 'Harmony Hub',
|
||||
softwareVersion: this.config.deviceInfo?.softwareVersion || this.config.deviceInfo?.firmwareVersion,
|
||||
timeStampHash: this.config.deviceInfo?.timeStampHash || this.stringValue(global?.timeStampHash),
|
||||
};
|
||||
const activities = this.normalizeActivities(this.config.activities || asArray(hubConfig?.activity));
|
||||
const currentActivity = this.stringValue(this.config.currentActivity || this.config.state?.currentActivity) || harmonyPowerOffActivity;
|
||||
return {
|
||||
deviceInfo,
|
||||
state: {
|
||||
available: this.config.state?.available ?? Boolean(this.config.client),
|
||||
currentActivity,
|
||||
currentActivityId: this.stringValue(this.config.currentActivityId ?? this.config.state?.currentActivityId) || this.activityIdForLabel(activities, currentActivity),
|
||||
activityStarting: this.config.state?.activityStarting ?? null,
|
||||
lastActivity: this.config.lastActivity || this.config.state?.lastActivity || null,
|
||||
rawState: this.config.state?.rawState,
|
||||
},
|
||||
activities,
|
||||
devices: this.normalizeDevices(this.config.devices || asArray(hubConfig?.device)),
|
||||
hubConfig: this.config.hubConfig as THarmonyJsonValue | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeSnapshot(snapshotArg: IHarmonySnapshot): IHarmonySnapshot {
|
||||
const activities = this.normalizeActivities(snapshotArg.activities);
|
||||
const currentActivity = this.stringValue(snapshotArg.state.currentActivity) || this.activityLabelForId(activities, snapshotArg.state.currentActivityId) || harmonyPowerOffActivity;
|
||||
const deviceInfo: IHarmonyDeviceInfo = {
|
||||
...snapshotArg.deviceInfo,
|
||||
id: snapshotArg.deviceInfo.id || snapshotArg.deviceInfo.remoteId || snapshotArg.deviceInfo.hubId || this.config.remoteId || this.config.hubId || this.config.host,
|
||||
name: snapshotArg.deviceInfo.name || this.config.name || this.config.deviceName || harmonyDisplayName,
|
||||
host: snapshotArg.deviceInfo.host || this.config.host,
|
||||
port: snapshotArg.deviceInfo.port || this.config.port || harmonyHubPort,
|
||||
manufacturer: snapshotArg.deviceInfo.manufacturer || this.config.manufacturer || harmonyManufacturer,
|
||||
model: snapshotArg.deviceInfo.model || this.config.model || 'Harmony Hub',
|
||||
};
|
||||
return {
|
||||
deviceInfo,
|
||||
state: {
|
||||
...snapshotArg.state,
|
||||
available: snapshotArg.state.available ?? Boolean(this.config.client),
|
||||
currentActivity,
|
||||
currentActivityId: this.stringValue(snapshotArg.state.currentActivityId) || this.activityIdForLabel(activities, currentActivity),
|
||||
activityStarting: snapshotArg.state.activityStarting ?? null,
|
||||
lastActivity: snapshotArg.state.lastActivity ?? null,
|
||||
},
|
||||
activities,
|
||||
devices: this.normalizeDevices(snapshotArg.devices),
|
||||
hubConfig: snapshotArg.hubConfig,
|
||||
updatedAt: snapshotArg.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeActivities(activitiesArg: IHarmonyConfig['activities'] | undefined): IHarmonyActivity[] {
|
||||
const activities = (activitiesArg || [])
|
||||
.map((activityArg) => this.normalizeActivity(activityArg))
|
||||
.filter((activityArg): activityArg is IHarmonyActivity => Boolean(activityArg));
|
||||
if (!activities.some((activityArg) => activityArg.isPowerOff || activityArg.label === harmonyPowerOffActivity || activityArg.id === '-1')) {
|
||||
activities.unshift({ id: '-1', label: harmonyPowerOffActivity, isPowerOff: true });
|
||||
}
|
||||
return activities;
|
||||
}
|
||||
|
||||
private normalizeActivity(activityArg: IHarmonyActivity | Record<string, unknown>): IHarmonyActivity | undefined {
|
||||
const record = activityArg as Record<string, unknown>;
|
||||
const id = this.stringValue(record.id ?? record.activityId ?? record.activity_id);
|
||||
const label = this.stringValue(record.label ?? record.name ?? record.activityName ?? record.activity_name);
|
||||
if (!id && !label) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedLabel = label || (id === '-1' ? harmonyPowerOffActivity : id) || 'Activity';
|
||||
return {
|
||||
id: id || normalizedLabel,
|
||||
label: normalizedLabel,
|
||||
isPowerOff: record.isPowerOff === true || id === '-1' || normalizedLabel === harmonyPowerOffActivity,
|
||||
raw: record,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeDevices(devicesArg: IHarmonyConfig['devices'] | undefined): IHarmonyDevice[] {
|
||||
return (devicesArg || [])
|
||||
.map((deviceArg) => this.normalizeDevice(deviceArg))
|
||||
.filter((deviceArg): deviceArg is IHarmonyDevice => Boolean(deviceArg));
|
||||
}
|
||||
|
||||
private normalizeDevice(deviceArg: IHarmonyDevice | Record<string, unknown>): IHarmonyDevice | undefined {
|
||||
const record = deviceArg as Record<string, unknown>;
|
||||
const id = this.stringValue(record.id ?? record.deviceId ?? record.device_id);
|
||||
const label = this.stringValue(record.label ?? record.name ?? record.deviceName ?? record.device_name);
|
||||
if (!id && !label) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedLabel = label || id || 'Device';
|
||||
const commandGroups = this.normalizeCommandGroups(record.commandGroups ?? record.controlGroup ?? record.control_group);
|
||||
return {
|
||||
id: id || normalizedLabel,
|
||||
label: normalizedLabel,
|
||||
manufacturer: this.stringValue(record.manufacturer),
|
||||
model: this.stringValue(record.model),
|
||||
commandGroups,
|
||||
commands: this.flattenCommands(commandGroups),
|
||||
raw: record,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeCommandGroups(valueArg: unknown): IHarmonyDeviceCommandGroup[] {
|
||||
if (!Array.isArray(valueArg)) {
|
||||
return [];
|
||||
}
|
||||
return valueArg.map((groupArg) => {
|
||||
const group = asRecord(groupArg) || {};
|
||||
return {
|
||||
name: this.stringValue(group.name) || 'Commands',
|
||||
commands: this.normalizeCommands(group.function || group.functions || group.commands),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeCommands(valueArg: unknown): IHarmonyDeviceCommand[] {
|
||||
if (!Array.isArray(valueArg)) {
|
||||
return [];
|
||||
}
|
||||
return valueArg.map((commandArg) => {
|
||||
if (typeof commandArg === 'string') {
|
||||
return { name: commandArg };
|
||||
}
|
||||
const command = asRecord(commandArg) || {};
|
||||
return {
|
||||
name: this.stringValue(command.name ?? command.command) || 'command',
|
||||
action: this.stringValue(command.action),
|
||||
label: this.stringValue(command.label),
|
||||
raw: command,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private flattenCommands(groupsArg: IHarmonyDeviceCommandGroup[]): IHarmonyDeviceCommand[] {
|
||||
return groupsArg.flatMap((groupArg) => groupArg.commands);
|
||||
}
|
||||
|
||||
private resolveActivity(snapshotArg: IHarmonySnapshot, activityArg: string): IHarmonyActivity | undefined {
|
||||
const normalized = activityArg === harmonySelectPowerOffOption ? harmonyPowerOffActivity : activityArg;
|
||||
if (!normalized || normalized === harmonyPreviousActiveActivity) {
|
||||
return undefined;
|
||||
}
|
||||
return snapshotArg.activities.find((activity) => activity.id === normalized || activity.label === normalized);
|
||||
}
|
||||
|
||||
private resolveDevice(snapshotArg: IHarmonySnapshot, deviceArg: string): IHarmonyDevice | undefined {
|
||||
const normalized = deviceArg.trim();
|
||||
return snapshotArg.devices.find((device) => device.id === normalized || device.label === normalized);
|
||||
}
|
||||
|
||||
private activityIdForLabel(activitiesArg: IHarmonyActivity[], labelArg: string): string | undefined {
|
||||
return activitiesArg.find((activityArg) => activityArg.label === labelArg)?.id;
|
||||
}
|
||||
|
||||
private activityLabelForId(activitiesArg: IHarmonyActivity[], idArg: unknown): string | undefined {
|
||||
const id = this.stringValue(idArg);
|
||||
return id ? activitiesArg.find((activityArg) => activityArg.id === id)?.label : undefined;
|
||||
}
|
||||
|
||||
private repeats(valueArg: unknown): number {
|
||||
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? Math.max(1, Math.floor(valueArg)) : harmonyDefaultRepeats;
|
||||
}
|
||||
|
||||
private positiveNumber(valueArg: unknown): number | undefined {
|
||||
return typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0 ? valueArg : undefined;
|
||||
}
|
||||
|
||||
private nonNegativeNumber(valueArg: unknown): number | undefined {
|
||||
return typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg >= 0 ? valueArg : undefined;
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string | undefined {
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
return valueArg.trim();
|
||||
}
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return String(valueArg);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private cloneSnapshot(snapshotArg: IHarmonySnapshot): IHarmonySnapshot {
|
||||
return {
|
||||
deviceInfo: { ...snapshotArg.deviceInfo },
|
||||
state: { ...snapshotArg.state },
|
||||
activities: snapshotArg.activities.map((activityArg) => ({ ...activityArg, raw: activityArg.raw ? { ...activityArg.raw } : undefined })),
|
||||
devices: snapshotArg.devices.map((deviceArg) => ({
|
||||
...deviceArg,
|
||||
commandGroups: deviceArg.commandGroups?.map((groupArg) => ({
|
||||
...groupArg,
|
||||
commands: groupArg.commands.map((commandArg) => ({ ...commandArg, raw: commandArg.raw ? { ...commandArg.raw } : undefined })),
|
||||
})),
|
||||
commands: deviceArg.commands?.map((commandArg) => ({ ...commandArg, raw: commandArg.raw ? { ...commandArg.raw } : undefined })),
|
||||
raw: deviceArg.raw ? { ...deviceArg.raw } : undefined,
|
||||
})),
|
||||
hubConfig: snapshotArg.hubConfig,
|
||||
updatedAt: snapshotArg.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const asRecord = (valueArg: unknown): Record<string, unknown> | undefined => {
|
||||
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg)
|
||||
? valueArg as Record<string, unknown>
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const asArray = (valueArg: unknown): Array<Record<string, unknown>> | undefined => {
|
||||
return Array.isArray(valueArg) ? valueArg.filter((itemArg): itemArg is Record<string, unknown> => Boolean(asRecord(itemArg))) : undefined;
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import { harmonyDefaultDelaySecs, harmonyDisplayName, harmonyDomain, harmonyHubPort, harmonyManufacturer } from './harmony.constants.js';
|
||||
import type { IHarmonyConfig } from './harmony.types.js';
|
||||
|
||||
export class HarmonyConfigFlow implements IConfigFlow<IHarmonyConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IHarmonyConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect Logitech Harmony Hub',
|
||||
description: 'Configure a local Harmony Hub. Activity and remote commands require an injected client/executor unless a live local transport is supplied by the host application.',
|
||||
fields: [
|
||||
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||
{ name: 'port', label: 'Local API port', type: 'number' },
|
||||
{ name: 'name', label: 'Name', type: 'text' },
|
||||
{ name: 'remoteId', label: 'Remote ID', type: 'text' },
|
||||
{ name: 'defaultActivity', label: 'Default activity', type: 'text' },
|
||||
{ name: 'delaySecs', label: 'Delay between repeated commands', type: 'number' },
|
||||
],
|
||||
submit: async (valuesArg) => {
|
||||
const host = this.stringValue(valuesArg.host) || candidateArg.host;
|
||||
if (!host) {
|
||||
return { kind: 'error', title: 'Harmony Hub configuration failed', error: 'Host is required.' };
|
||||
}
|
||||
const port = this.numberValue(valuesArg.port) || candidateArg.port || harmonyHubPort;
|
||||
const remoteId = this.stringValue(valuesArg.remoteId) || this.stringValue(candidateArg.metadata?.remoteId) || candidateArg.id;
|
||||
const name = this.stringValue(valuesArg.name) || candidateArg.name || harmonyDisplayName;
|
||||
const defaultActivity = this.stringValue(valuesArg.defaultActivity);
|
||||
const delaySecs = this.numberValue(valuesArg.delaySecs) ?? harmonyDefaultDelaySecs;
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'Harmony Hub configured',
|
||||
config: {
|
||||
host,
|
||||
port,
|
||||
name,
|
||||
remoteId,
|
||||
defaultActivity,
|
||||
delaySecs,
|
||||
manufacturer: candidateArg.manufacturer || harmonyManufacturer,
|
||||
model: candidateArg.model || 'Harmony Hub',
|
||||
deviceInfo: {
|
||||
id: remoteId || candidateArg.id,
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
remoteId,
|
||||
manufacturer: candidateArg.manufacturer || harmonyManufacturer,
|
||||
model: candidateArg.model || 'Harmony Hub',
|
||||
},
|
||||
discovery: {
|
||||
source: candidateArg.source,
|
||||
metadata: candidateArg.metadata,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown): number | undefined {
|
||||
return typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0 ? valueArg : undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,276 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { HarmonyClient } from './harmony.classes.client.js';
|
||||
import { HarmonyConfigFlow } from './harmony.classes.configflow.js';
|
||||
import {
|
||||
harmonyDefaultDelaySecs,
|
||||
harmonyDefaultHoldSecs,
|
||||
harmonyDefaultRepeats,
|
||||
harmonyDisplayName,
|
||||
harmonyDomain,
|
||||
harmonyHubPort,
|
||||
harmonyPowerOffActivity,
|
||||
harmonyPreviousActiveActivity,
|
||||
harmonySelectPowerOffOption,
|
||||
harmonySsdpDeviceType,
|
||||
} from './harmony.constants.js';
|
||||
import { createHarmonyDiscoveryDescriptor } from './harmony.discovery.js';
|
||||
import { HarmonyMapper } from './harmony.mapper.js';
|
||||
import type { IHarmonyConfig } from './harmony.types.js';
|
||||
|
||||
export class HomeAssistantHarmonyIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "harmony",
|
||||
displayName: "Logitech Harmony Hub",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/harmony",
|
||||
"upstreamDomain": "harmony",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_push",
|
||||
"requirements": [
|
||||
"aioharmony==0.5.3"
|
||||
],
|
||||
"dependencies": [
|
||||
"remote"
|
||||
],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@ehendrix23",
|
||||
"@bdraco",
|
||||
"@mkeesey",
|
||||
"@Aohzan"
|
||||
]
|
||||
},
|
||||
export class HarmonyIntegration extends BaseIntegration<IHarmonyConfig> {
|
||||
public readonly domain = harmonyDomain;
|
||||
public readonly displayName = harmonyDisplayName;
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createHarmonyDiscoveryDescriptor();
|
||||
public readonly configFlow = new HarmonyConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/harmony',
|
||||
upstreamDomain: harmonyDomain,
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_push',
|
||||
requirements: ['aioharmony==0.5.3'],
|
||||
dependencies: ['remote'],
|
||||
afterDependencies: [],
|
||||
codeowners: ['@ehendrix23', '@bdraco', '@mkeesey', '@Aohzan'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/harmony',
|
||||
ssdp: [{ deviceType: harmonySsdpDeviceType, manufacturer: 'Logitech' }],
|
||||
runtime: {
|
||||
type: 'control-runtime',
|
||||
polling: 'snapshot or injected local Harmony client/executor',
|
||||
services: ['snapshot', 'remote.turn_on', 'remote.turn_off', 'remote.send_command', 'harmony.change_channel', 'select.select_option'],
|
||||
controls: true,
|
||||
},
|
||||
localApi: {
|
||||
implemented: [
|
||||
'SSDP Harmony Hub discovery by deviceType/manufacturer',
|
||||
'manual host setup',
|
||||
'Harmony Hub snapshot normalization from native config or upstream hub config shape',
|
||||
'activity, select, media_player, device and remote-command modeling',
|
||||
'command dispatch through injected client/executor',
|
||||
],
|
||||
explicitUnsupported: [
|
||||
'Harmony cloud/account APIs',
|
||||
'cloud-backed sync service',
|
||||
'reporting command success without an injected client/executor or host-provided local transport',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
public async setup(configArg: IHarmonyConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new HarmonyRuntime(new HarmonyClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantHarmonyIntegration extends HarmonyIntegration {}
|
||||
|
||||
class HarmonyRuntime implements IIntegrationRuntime {
|
||||
public domain = harmonyDomain;
|
||||
|
||||
constructor(private readonly client: HarmonyClient) {}
|
||||
|
||||
public async devices(): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return HarmonyMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return HarmonyMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
try {
|
||||
if (requestArg.domain === 'remote') {
|
||||
return await this.callRemoteService(requestArg);
|
||||
}
|
||||
if (requestArg.domain === 'media_player') {
|
||||
return await this.callMediaPlayerService(requestArg);
|
||||
}
|
||||
if (requestArg.domain === 'select') {
|
||||
return await this.callSelectService(requestArg);
|
||||
}
|
||||
if (requestArg.domain === harmonyDomain) {
|
||||
return await this.callHarmonyService(requestArg);
|
||||
}
|
||||
return { success: false, error: `Unsupported Harmony Hub service domain: ${requestArg.domain}` };
|
||||
} catch (errorArg) {
|
||||
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
|
||||
private async callRemoteService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'turn_on') {
|
||||
const activity = await this.activityForTurnOn(requestArg);
|
||||
if (!activity) {
|
||||
return { success: false, error: 'Harmony Hub remote.turn_on requires data.activity, a default activity, a previous active activity, or at least one configured activity.' };
|
||||
}
|
||||
await this.client.startActivity(activity, 'remote_turn_on');
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'turn_off') {
|
||||
await this.client.powerOff('remote_turn_off');
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service !== 'send_command') {
|
||||
return { success: false, error: `Unsupported Harmony Hub remote service: ${requestArg.service}` };
|
||||
}
|
||||
const device = this.stringData(requestArg, 'device');
|
||||
if (!device) {
|
||||
return { success: false, error: 'Harmony Hub remote.send_command requires data.device.' };
|
||||
}
|
||||
const commands = this.stringArrayData(requestArg, 'command');
|
||||
if (!commands?.length) {
|
||||
return { success: false, error: 'Harmony Hub remote.send_command requires data.command.' };
|
||||
}
|
||||
await this.client.sendCommands(commands, device, {
|
||||
numRepeats: this.numberData(requestArg, 'num_repeats') ?? this.numberData(requestArg, 'numRepeats') ?? harmonyDefaultRepeats,
|
||||
delaySecs: this.numberData(requestArg, 'delay_secs') ?? this.numberData(requestArg, 'delaySecs') ?? this.client.delaySecs,
|
||||
holdSecs: this.numberData(requestArg, 'hold_secs') ?? this.numberData(requestArg, 'holdSecs') ?? this.client.holdSecs,
|
||||
reason: 'remote_send_command',
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'turn_on') {
|
||||
return this.callRemoteService({ ...requestArg, domain: 'remote', service: 'turn_on' });
|
||||
}
|
||||
if (requestArg.service === 'turn_off') {
|
||||
return this.callRemoteService({ ...requestArg, domain: 'remote', service: 'turn_off' });
|
||||
}
|
||||
if (requestArg.service === 'select_source') {
|
||||
const source = this.stringData(requestArg, 'source');
|
||||
if (!source) {
|
||||
return { success: false, error: 'Harmony Hub media_player.select_source requires data.source.' };
|
||||
}
|
||||
await this.client.startActivity(source, 'select_activity');
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'play_media') {
|
||||
const mediaType = this.stringData(requestArg, 'media_content_type') || this.stringData(requestArg, 'media_type');
|
||||
const mediaId = this.stringData(requestArg, 'media_content_id') || this.stringData(requestArg, 'media_id') || this.stringData(requestArg, 'channel');
|
||||
if (mediaType !== 'channel') {
|
||||
return { success: false, error: 'Harmony Hub media_player.play_media only supports media_content_type channel.' };
|
||||
}
|
||||
const channel = this.channelValue(mediaId);
|
||||
if (!channel) {
|
||||
return { success: false, error: 'Harmony Hub media_player.play_media requires a positive integer channel media_content_id.' };
|
||||
}
|
||||
await this.client.changeChannel(channel, 'change_channel');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: `Unsupported Harmony Hub media_player service: ${requestArg.service}` };
|
||||
}
|
||||
|
||||
private async callSelectService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service !== 'select_option') {
|
||||
return { success: false, error: `Unsupported Harmony Hub select service: ${requestArg.service}` };
|
||||
}
|
||||
const option = this.stringData(requestArg, 'option');
|
||||
if (!option) {
|
||||
return { success: false, error: 'Harmony Hub select.select_option requires data.option.' };
|
||||
}
|
||||
if (option === harmonySelectPowerOffOption || option === harmonyPowerOffActivity) {
|
||||
await this.client.powerOff('select_option');
|
||||
return { success: true };
|
||||
}
|
||||
await this.client.startActivity(option, 'select_option');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async callHarmonyService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'snapshot' || requestArg.service === 'status') {
|
||||
return { success: true, data: await this.client.getSnapshot() };
|
||||
}
|
||||
if (requestArg.service === 'connect') {
|
||||
await this.client.connect();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'start_activity' || requestArg.service === 'turn_on') {
|
||||
const activity = this.stringData(requestArg, 'activity') || this.stringData(requestArg, 'name') || this.stringData(requestArg, 'activity_id');
|
||||
if (!activity) {
|
||||
return { success: false, error: `Harmony Hub ${requestArg.service} requires data.activity.` };
|
||||
}
|
||||
await this.client.startActivity(activity, 'select_activity');
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'change_channel') {
|
||||
const channel = this.channelValue(requestArg.data?.channel);
|
||||
if (!channel) {
|
||||
return { success: false, error: 'Harmony Hub change_channel requires data.channel as a positive integer.' };
|
||||
}
|
||||
await this.client.changeChannel(channel, 'change_channel');
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'send_command') {
|
||||
return this.callRemoteService({ ...requestArg, domain: 'remote', service: 'send_command' });
|
||||
}
|
||||
if (requestArg.service === 'sync') {
|
||||
await this.client.sync();
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: `Unsupported Harmony Hub service: ${requestArg.service}` };
|
||||
}
|
||||
|
||||
private async activityForTurnOn(requestArg: IServiceCallRequest): Promise<string | undefined> {
|
||||
const requested = this.stringData(requestArg, 'activity');
|
||||
if (requested && requested !== harmonyPreviousActiveActivity) {
|
||||
return requested;
|
||||
}
|
||||
const explicitlyRequestedPrevious = requested === harmonyPreviousActiveActivity;
|
||||
if (!explicitlyRequestedPrevious && this.client.defaultActivity && this.client.defaultActivity !== harmonyPreviousActiveActivity) {
|
||||
return this.client.defaultActivity;
|
||||
}
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
if (snapshot.state.lastActivity) {
|
||||
return snapshot.state.lastActivity;
|
||||
}
|
||||
return snapshot.activities.find((activityArg) => !activityArg.isPowerOff && activityArg.label !== harmonyPowerOffActivity)?.label;
|
||||
}
|
||||
|
||||
private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private stringArrayData(requestArg: IServiceCallRequest, keyArg: string): string[] | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return [value.trim()];
|
||||
}
|
||||
return Array.isArray(value)
|
||||
? value.filter((itemArg): itemArg is string => typeof itemArg === 'string' && Boolean(itemArg.trim())).map((itemArg) => itemArg.trim())
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
private channelValue(valueArg: unknown): number | undefined {
|
||||
if (typeof valueArg === 'number' && Number.isInteger(valueArg) && valueArg > 0) {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string' && /^\d+$/.test(valueArg)) {
|
||||
const channel = Number(valueArg);
|
||||
return channel > 0 ? channel : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const harmonyDomain = 'harmony';
|
||||
export const harmonyDisplayName = 'Logitech Harmony Hub';
|
||||
export const harmonyManufacturer = 'Logitech';
|
||||
export const harmonyHubPort = 8088;
|
||||
export const harmonySsdpDeviceType = 'urn:myharmony-com:device:harmony:1';
|
||||
export const harmonyPowerOffActivity = 'PowerOff';
|
||||
export const harmonySelectPowerOffOption = 'power_off';
|
||||
export const harmonyPreviousActiveActivity = 'Previous Active Activity';
|
||||
export const harmonyDefaultDelaySecs = 0.4;
|
||||
export const harmonyDefaultHoldSecs = 0;
|
||||
export const harmonyDefaultRepeats = 1;
|
||||
@@ -0,0 +1,156 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import { harmonyDisplayName, harmonyDomain, harmonyHubPort, harmonyManufacturer, harmonySsdpDeviceType } from './harmony.constants.js';
|
||||
import type { IHarmonyManualEntry, IHarmonySsdpRecord } from './harmony.types.js';
|
||||
|
||||
export class HarmonySsdpMatcher implements IDiscoveryMatcher<IHarmonySsdpRecord> {
|
||||
public id = 'harmony-ssdp-match';
|
||||
public source = 'ssdp' as const;
|
||||
public description = 'Recognize Logitech Harmony Hub SSDP advertisements.';
|
||||
|
||||
public async matches(recordArg: IHarmonySsdpRecord): Promise<IDiscoveryMatch> {
|
||||
const upnp = recordArg.upnp || {};
|
||||
const metadata = recordArg.metadata || {};
|
||||
const deviceType = this.stringValue(recordArg.deviceType || recordArg.device_type || recordArg.st || recordArg.nt || upnp.deviceType || metadata.deviceType);
|
||||
const manufacturer = this.stringValue(recordArg.manufacturer || upnp.manufacturer || metadata.manufacturer);
|
||||
const name = this.stringValue(recordArg.friendlyName || recordArg.friendly_name || upnp.friendlyName || upnp.friendly_name || upnp['upnp:friendlyName'] || metadata.friendlyName || recordArg.name);
|
||||
const model = this.stringValue(recordArg.modelName || recordArg.model_name || upnp.modelName || upnp.model_name || metadata.modelName);
|
||||
const matchedByManifest = normalize(deviceType) === normalize(harmonySsdpDeviceType) && normalize(manufacturer) === normalize(harmonyManufacturer);
|
||||
const matchedByHarmonyHint = normalize(manufacturer) === normalize(harmonyManufacturer) && (normalize(name).includes('harmony') || normalize(model).includes('harmony') || normalize(deviceType).includes('harmony'));
|
||||
|
||||
if (!matchedByManifest && !matchedByHarmonyHint) {
|
||||
return { matched: false, confidence: 'low', reason: 'SSDP record is not a Logitech Harmony Hub advertisement.' };
|
||||
}
|
||||
|
||||
const location = this.stringValue(recordArg.ssdp_location || recordArg.location || metadata.ssdpLocation || metadata.location);
|
||||
const locationUrl = this.parseUrl(location);
|
||||
const host = this.stringValue(recordArg.host || metadata.host) || locationUrl?.hostname;
|
||||
const udn = this.stringValue(recordArg.udn || recordArg.usn || upnp.udn || metadata.udn || metadata.usn);
|
||||
const remoteId = this.stringValue(metadata.remoteId || metadata.hubId || metadata.uniqueId || recordArg.serialNumber || upnp.serialNumber);
|
||||
const id = remoteId || udn || host;
|
||||
|
||||
return {
|
||||
matched: true,
|
||||
confidence: matchedByManifest && host ? 'certain' : host ? 'high' : 'medium',
|
||||
reason: matchedByManifest ? 'SSDP record matches Home Assistant Harmony manifest metadata.' : 'SSDP record has Logitech Harmony Hub hints.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'ssdp',
|
||||
integrationDomain: harmonyDomain,
|
||||
id,
|
||||
host,
|
||||
port: harmonyHubPort,
|
||||
name: name || harmonyDisplayName,
|
||||
manufacturer: harmonyManufacturer,
|
||||
model: model || 'Harmony Hub',
|
||||
serialNumber: recordArg.serialNumber,
|
||||
metadata: {
|
||||
ssdpDeviceType: deviceType,
|
||||
ssdpLocation: location,
|
||||
ssdpLocationPort: locationUrl ? Number(locationUrl.port) || undefined : undefined,
|
||||
usn: recordArg.usn,
|
||||
udn,
|
||||
remoteId: remoteId || undefined,
|
||||
upnp,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private parseUrl(valueArg: string): URL | undefined {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return new URL(valueArg);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : '';
|
||||
}
|
||||
}
|
||||
|
||||
export class HarmonyManualMatcher implements IDiscoveryMatcher<IHarmonyManualEntry> {
|
||||
public id = 'harmony-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual Harmony Hub host entries.';
|
||||
|
||||
public async matches(inputArg: IHarmonyManualEntry): Promise<IDiscoveryMatch> {
|
||||
const manufacturer = normalize(inputArg.manufacturer);
|
||||
const model = normalize(inputArg.model);
|
||||
const name = normalize(inputArg.name || inputArg.deviceName);
|
||||
const matched = Boolean(inputArg.host || inputArg.metadata?.harmony || inputArg.metadata?.harmonyHub || manufacturer === normalize(harmonyManufacturer) && (model.includes('harmony') || name.includes('harmony')));
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Harmony Hub setup hints.' };
|
||||
}
|
||||
const remoteId = this.stringValue(inputArg.remoteId || inputArg.hubId || inputArg.metadata?.remoteId || inputArg.metadata?.hubId);
|
||||
const id = inputArg.id || remoteId || inputArg.host;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: inputArg.host ? 'high' : 'medium',
|
||||
reason: 'Manual entry can start local Harmony Hub setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: harmonyDomain,
|
||||
id,
|
||||
host: inputArg.host,
|
||||
port: inputArg.port || harmonyHubPort,
|
||||
name: inputArg.deviceName || inputArg.name || harmonyDisplayName,
|
||||
manufacturer: inputArg.manufacturer || harmonyManufacturer,
|
||||
model: inputArg.model || 'Harmony Hub',
|
||||
metadata: {
|
||||
...inputArg.metadata,
|
||||
remoteId: remoteId || undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : '';
|
||||
}
|
||||
}
|
||||
|
||||
export class HarmonyCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'harmony-candidate-validator';
|
||||
public description = 'Validate Harmony Hub candidates before local setup.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const manufacturer = normalize(candidateArg.manufacturer);
|
||||
const model = normalize(candidateArg.model);
|
||||
const name = normalize(candidateArg.name);
|
||||
const matched = candidateArg.integrationDomain === harmonyDomain
|
||||
|| Boolean(candidateArg.metadata?.harmony || candidateArg.metadata?.harmonyHub)
|
||||
|| manufacturer === normalize(harmonyManufacturer) && (model.includes('harmony') || name.includes('harmony'));
|
||||
const hasUsableAddress = Boolean(candidateArg.host && (!candidateArg.port || isValidPort(candidateArg.port)));
|
||||
return {
|
||||
matched: matched && hasUsableAddress,
|
||||
confidence: matched && candidateArg.id && hasUsableAddress ? 'certain' : matched && hasUsableAddress ? 'high' : matched ? 'medium' : 'low',
|
||||
reason: matched
|
||||
? hasUsableAddress ? 'Candidate has Harmony Hub metadata and a usable local address.' : 'Candidate has Harmony Hub metadata but no usable local address.'
|
||||
: 'Candidate is not a Harmony Hub.',
|
||||
candidate: matched && hasUsableAddress ? candidateArg : undefined,
|
||||
normalizedDeviceId: candidateArg.id || candidateArg.host,
|
||||
metadata: matched ? { port: candidateArg.port || harmonyHubPort } : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createHarmonyDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: harmonyDomain, displayName: harmonyDisplayName })
|
||||
.addMatcher(new HarmonySsdpMatcher())
|
||||
.addMatcher(new HarmonyManualMatcher())
|
||||
.addValidator(new HarmonyCandidateValidator());
|
||||
};
|
||||
|
||||
const normalize = (valueArg?: string): string => {
|
||||
return (valueArg || '').toLowerCase().trim().replace(/\.$/, '');
|
||||
};
|
||||
|
||||
const isValidPort = (valueArg: number): boolean => {
|
||||
return Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity } from '../../core/types.js';
|
||||
import { harmonyDomain, harmonyPowerOffActivity, harmonySelectPowerOffOption } from './harmony.constants.js';
|
||||
import type { IHarmonyActivity, IHarmonyDevice, IHarmonySnapshot } from './harmony.types.js';
|
||||
|
||||
export class HarmonyMapper {
|
||||
public static toDevices(snapshotArg: IHarmonySnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||
return [{
|
||||
id: this.deviceId(snapshotArg),
|
||||
integrationDomain: harmonyDomain,
|
||||
name: this.deviceName(snapshotArg),
|
||||
protocol: 'unknown',
|
||||
manufacturer: snapshotArg.deviceInfo.manufacturer || 'Logitech',
|
||||
model: snapshotArg.deviceInfo.model || 'Harmony Hub',
|
||||
online: this.available(snapshotArg),
|
||||
features: [
|
||||
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
|
||||
{ id: 'power', capability: 'media', name: 'Power', readable: true, writable: true },
|
||||
{ id: 'activity', capability: 'media', name: 'Activity', readable: true, writable: true },
|
||||
{ id: 'activity_starting', capability: 'sensor', name: 'Activity starting', readable: true, writable: false },
|
||||
{ id: 'remote_command', capability: 'media', name: 'Remote command', readable: false, writable: true },
|
||||
{ id: 'channel', capability: 'media', name: 'Channel', readable: false, writable: true },
|
||||
],
|
||||
state: [
|
||||
{ featureId: 'connectivity', value: this.available(snapshotArg) ? 'online' : 'offline', updatedAt },
|
||||
{ featureId: 'power', value: this.powerState(snapshotArg), updatedAt },
|
||||
{ featureId: 'activity', value: this.currentActivity(snapshotArg), updatedAt },
|
||||
{ featureId: 'activity_starting', value: snapshotArg.state.activityStarting || null, updatedAt },
|
||||
],
|
||||
metadata: this.cleanAttributes({
|
||||
host: snapshotArg.deviceInfo.host,
|
||||
port: snapshotArg.deviceInfo.port,
|
||||
remoteId: snapshotArg.deviceInfo.remoteId || snapshotArg.deviceInfo.hubId || snapshotArg.deviceInfo.id,
|
||||
softwareVersion: snapshotArg.deviceInfo.softwareVersion || snapshotArg.deviceInfo.firmwareVersion,
|
||||
currentActivityId: snapshotArg.state.currentActivityId,
|
||||
currentActivity: this.currentActivity(snapshotArg),
|
||||
lastActivity: snapshotArg.state.lastActivity,
|
||||
activities: this.activityList(snapshotArg),
|
||||
devices: snapshotArg.devices.map((deviceArg) => this.deviceMetadata(deviceArg)),
|
||||
}),
|
||||
}];
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IHarmonySnapshot): IIntegrationEntity[] {
|
||||
const base = this.slug(this.deviceName(snapshotArg));
|
||||
const deviceId = this.deviceId(snapshotArg);
|
||||
const currentActivity = this.currentActivity(snapshotArg);
|
||||
const activityList = this.activityList(snapshotArg);
|
||||
return [
|
||||
{
|
||||
id: `media_player.${base}`,
|
||||
uniqueId: `harmony_${this.slug(this.stableDeviceKey(snapshotArg))}_remote`,
|
||||
integrationDomain: harmonyDomain,
|
||||
deviceId,
|
||||
platform: 'media_player',
|
||||
name: this.deviceName(snapshotArg),
|
||||
state: this.mediaState(snapshotArg),
|
||||
attributes: this.cleanAttributes({
|
||||
currentActivity,
|
||||
activityList,
|
||||
source: currentActivity === harmonyPowerOffActivity ? undefined : currentActivity,
|
||||
sourceList: activityList,
|
||||
activityStarting: snapshotArg.state.activityStarting,
|
||||
lastActivity: snapshotArg.state.lastActivity,
|
||||
devicesList: snapshotArg.devices.map((deviceArg) => deviceArg.label),
|
||||
supportedFeatures: ['activity', 'remote_send_command', 'turn_on', 'turn_off', 'change_channel'],
|
||||
assumedState: true,
|
||||
}),
|
||||
available: this.available(snapshotArg),
|
||||
},
|
||||
{
|
||||
id: `select.${base}_activities`,
|
||||
uniqueId: `harmony_${this.slug(this.stableDeviceKey(snapshotArg))}_activities`,
|
||||
integrationDomain: harmonyDomain,
|
||||
deviceId,
|
||||
platform: 'select',
|
||||
name: `${this.deviceName(snapshotArg)} Activities`,
|
||||
state: currentActivity === harmonyPowerOffActivity ? harmonySelectPowerOffOption : currentActivity,
|
||||
attributes: {
|
||||
options: [harmonySelectPowerOffOption, ...activityList],
|
||||
},
|
||||
available: this.available(snapshotArg),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public static deviceId(snapshotArg: IHarmonySnapshot): string {
|
||||
return `harmony.device.${this.slug(this.stableDeviceKey(snapshotArg))}`;
|
||||
}
|
||||
|
||||
public static mediaState(snapshotArg: IHarmonySnapshot): string {
|
||||
if (!this.available(snapshotArg)) {
|
||||
return 'unavailable';
|
||||
}
|
||||
return this.currentActivity(snapshotArg) === harmonyPowerOffActivity ? 'off' : 'on';
|
||||
}
|
||||
|
||||
public static slug(valueArg: string): string {
|
||||
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || harmonyDomain;
|
||||
}
|
||||
|
||||
private static available(snapshotArg: IHarmonySnapshot): boolean {
|
||||
return snapshotArg.state.available !== false;
|
||||
}
|
||||
|
||||
private static powerState(snapshotArg: IHarmonySnapshot): string {
|
||||
if (!this.available(snapshotArg)) {
|
||||
return 'unavailable';
|
||||
}
|
||||
return this.currentActivity(snapshotArg) === harmonyPowerOffActivity ? 'off' : 'on';
|
||||
}
|
||||
|
||||
private static currentActivity(snapshotArg: IHarmonySnapshot): string {
|
||||
return snapshotArg.state.currentActivity || this.activityLabelForId(snapshotArg.activities, snapshotArg.state.currentActivityId) || harmonyPowerOffActivity;
|
||||
}
|
||||
|
||||
private static activityList(snapshotArg: IHarmonySnapshot): string[] {
|
||||
return snapshotArg.activities
|
||||
.filter((activityArg) => !this.isPowerOffActivity(activityArg))
|
||||
.map((activityArg) => activityArg.label)
|
||||
.sort((leftArg, rightArg) => leftArg.localeCompare(rightArg));
|
||||
}
|
||||
|
||||
private static isPowerOffActivity(activityArg: IHarmonyActivity): boolean {
|
||||
return activityArg.isPowerOff === true || activityArg.id === '-1' || activityArg.label === harmonyPowerOffActivity;
|
||||
}
|
||||
|
||||
private static activityLabelForId(activitiesArg: IHarmonyActivity[], idArg?: string): string | undefined {
|
||||
return idArg ? activitiesArg.find((activityArg) => activityArg.id === idArg)?.label : undefined;
|
||||
}
|
||||
|
||||
private static stableDeviceKey(snapshotArg: IHarmonySnapshot): string {
|
||||
return snapshotArg.deviceInfo.id || snapshotArg.deviceInfo.remoteId || snapshotArg.deviceInfo.hubId || snapshotArg.deviceInfo.host || this.deviceName(snapshotArg);
|
||||
}
|
||||
|
||||
private static deviceName(snapshotArg: IHarmonySnapshot): string {
|
||||
return snapshotArg.deviceInfo.name || snapshotArg.deviceInfo.host || 'Harmony Hub';
|
||||
}
|
||||
|
||||
private static deviceMetadata(deviceArg: IHarmonyDevice): Record<string, unknown> {
|
||||
return this.cleanAttributes({
|
||||
id: deviceArg.id,
|
||||
label: deviceArg.label,
|
||||
manufacturer: deviceArg.manufacturer,
|
||||
model: deviceArg.model,
|
||||
commands: deviceArg.commands?.map((commandArg) => commandArg.name),
|
||||
commandGroups: deviceArg.commandGroups?.map((groupArg) => ({
|
||||
name: groupArg.name,
|
||||
commands: groupArg.commands.map((commandArg) => commandArg.name),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
private static cleanAttributes(attributesArg: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,188 @@
|
||||
export interface IHomeAssistantHarmonyConfig {
|
||||
// TODO: replace with the TypeScript-native config for harmony.
|
||||
[key: string]: unknown;
|
||||
export type THarmonyJsonValue = string | number | boolean | null | THarmonyJsonValue[] | { [key: string]: THarmonyJsonValue | undefined };
|
||||
|
||||
export type THarmonyCommandAction =
|
||||
| 'connect'
|
||||
| 'start_activity'
|
||||
| 'power_off'
|
||||
| 'send_command'
|
||||
| 'change_channel';
|
||||
|
||||
export type THarmonyCommandReason =
|
||||
| 'connect'
|
||||
| 'remote_turn_on'
|
||||
| 'remote_turn_off'
|
||||
| 'remote_send_command'
|
||||
| 'select_activity'
|
||||
| 'select_option'
|
||||
| 'change_channel';
|
||||
|
||||
export interface IHarmonyDeviceInfo {
|
||||
id?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
remoteId?: string;
|
||||
hubId?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
softwareVersion?: string;
|
||||
firmwareVersion?: string;
|
||||
timeStampHash?: string;
|
||||
}
|
||||
|
||||
export interface IHarmonyActivity {
|
||||
id: string;
|
||||
label: string;
|
||||
isPowerOff?: boolean;
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IHarmonyDeviceCommand {
|
||||
name: string;
|
||||
action?: string;
|
||||
label?: string;
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IHarmonyDeviceCommandGroup {
|
||||
name: string;
|
||||
commands: IHarmonyDeviceCommand[];
|
||||
}
|
||||
|
||||
export interface IHarmonyDevice {
|
||||
id: string;
|
||||
label: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
commandGroups?: IHarmonyDeviceCommandGroup[];
|
||||
commands?: IHarmonyDeviceCommand[];
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IHarmonyState {
|
||||
available?: boolean;
|
||||
currentActivityId?: string;
|
||||
currentActivity?: string;
|
||||
activityStarting?: string | null;
|
||||
lastActivity?: string | null;
|
||||
rawState?: string;
|
||||
}
|
||||
|
||||
export interface IHarmonySnapshot {
|
||||
deviceInfo: IHarmonyDeviceInfo;
|
||||
state: IHarmonyState;
|
||||
activities: IHarmonyActivity[];
|
||||
devices: IHarmonyDevice[];
|
||||
hubConfig?: THarmonyJsonValue;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface IHarmonyRemoteCommandStep {
|
||||
deviceId: string;
|
||||
device: string;
|
||||
command: string;
|
||||
holdSecs?: number;
|
||||
}
|
||||
|
||||
export interface IHarmonyCommand {
|
||||
action: THarmonyCommandAction;
|
||||
reason?: THarmonyCommandReason;
|
||||
activityId?: string;
|
||||
activity?: string;
|
||||
deviceId?: string;
|
||||
device?: string;
|
||||
command?: string;
|
||||
commands?: string[];
|
||||
sequence?: IHarmonyRemoteCommandStep[];
|
||||
numRepeats?: number;
|
||||
delaySecs?: number;
|
||||
holdSecs?: number;
|
||||
channel?: number;
|
||||
}
|
||||
|
||||
export interface IHarmonyCommandContext {
|
||||
config: IHarmonyConfig;
|
||||
snapshot: IHarmonySnapshot;
|
||||
}
|
||||
|
||||
export type THarmonyCommandExecutor =
|
||||
| ((commandArg: IHarmonyCommand, contextArg: IHarmonyCommandContext) => Promise<void> | void)
|
||||
| {
|
||||
execute(commandArg: IHarmonyCommand, contextArg: IHarmonyCommandContext): Promise<void> | void;
|
||||
};
|
||||
|
||||
export interface IHarmonyClientAdapter {
|
||||
getSnapshot?(): Promise<IHarmonySnapshot> | IHarmonySnapshot;
|
||||
execute?(commandArg: IHarmonyCommand, contextArg: IHarmonyCommandContext): Promise<void> | void;
|
||||
destroy?(): Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface IHarmonyConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
deviceName?: string;
|
||||
remoteId?: string;
|
||||
hubId?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
defaultActivity?: string;
|
||||
activity?: string;
|
||||
delaySecs?: number;
|
||||
delay_secs?: number;
|
||||
holdSecs?: number;
|
||||
hold_secs?: number;
|
||||
numRepeats?: number;
|
||||
num_repeats?: number;
|
||||
currentActivity?: string;
|
||||
currentActivityId?: string | number;
|
||||
lastActivity?: string;
|
||||
activities?: IHarmonyActivity[] | Array<Record<string, unknown>>;
|
||||
devices?: IHarmonyDevice[] | Array<Record<string, unknown>>;
|
||||
deviceInfo?: IHarmonyDeviceInfo;
|
||||
state?: IHarmonyState;
|
||||
hubConfig?: THarmonyJsonValue | Record<string, unknown>;
|
||||
snapshot?: IHarmonySnapshot;
|
||||
executor?: THarmonyCommandExecutor;
|
||||
client?: IHarmonyClientAdapter;
|
||||
discovery?: {
|
||||
source?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IHarmonySsdpRecord {
|
||||
deviceType?: string;
|
||||
device_type?: string;
|
||||
st?: string;
|
||||
nt?: string;
|
||||
usn?: string;
|
||||
udn?: string;
|
||||
location?: string;
|
||||
ssdp_location?: string;
|
||||
host?: string;
|
||||
name?: string;
|
||||
friendlyName?: string;
|
||||
friendly_name?: string;
|
||||
manufacturer?: string;
|
||||
modelName?: string;
|
||||
model_name?: string;
|
||||
serialNumber?: string;
|
||||
upnp?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IHarmonyManualEntry {
|
||||
host?: string;
|
||||
port?: number;
|
||||
id?: string;
|
||||
name?: string;
|
||||
deviceName?: string;
|
||||
remoteId?: string;
|
||||
hubId?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type IHomeAssistantHarmonyConfig = IHarmonyConfig;
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
export * from './harmony.classes.client.js';
|
||||
export * from './harmony.classes.configflow.js';
|
||||
export * from './harmony.classes.integration.js';
|
||||
export * from './harmony.constants.js';
|
||||
export * from './harmony.discovery.js';
|
||||
export * from './harmony.mapper.js';
|
||||
export * from './harmony.types.js';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -0,0 +1,670 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { HuaweiLteMapper } from './huawei_lte.mapper.js';
|
||||
import type {
|
||||
IHuaweiLteClientLike,
|
||||
IHuaweiLteCommandRequest,
|
||||
IHuaweiLteConfig,
|
||||
IHuaweiLteRawData,
|
||||
IHuaweiLteRefreshResult,
|
||||
IHuaweiLteSnapshot,
|
||||
IHuaweiLteValueMap,
|
||||
THuaweiLteRawDataKey,
|
||||
} from './huawei_lte.types.js';
|
||||
import { huaweiLteDefaultHttpPort, huaweiLteDefaultHttpsPort, huaweiLteDefaultTimeoutMs } from './huawei_lte.types.js';
|
||||
|
||||
export class HuaweiLteApiError extends Error {}
|
||||
export class HuaweiLteApiConnectionError extends HuaweiLteApiError {}
|
||||
export class HuaweiLteApiAuthorizationError extends HuaweiLteApiError {}
|
||||
export class HuaweiLteApiResponseError extends HuaweiLteApiError {
|
||||
constructor(messageArg: string, public readonly code?: number) {
|
||||
super(messageArg);
|
||||
}
|
||||
}
|
||||
export class HuaweiLteUnsupportedCommandError extends HuaweiLteApiError {}
|
||||
|
||||
const rawResourceDefinitions: Array<{ key: THuaweiLteRawDataKey; endpoint: string }> = [
|
||||
{ key: 'deviceInformation', endpoint: 'device/information' },
|
||||
{ key: 'deviceBasicInformation', endpoint: 'device/basic_information' },
|
||||
{ key: 'deviceSignal', endpoint: 'device/signal' },
|
||||
{ key: 'dialupMobileDataswitch', endpoint: 'dialup/mobile-dataswitch' },
|
||||
{ key: 'monitoringMonthStatistics', endpoint: 'monitoring/month_statistics' },
|
||||
{ key: 'monitoringCheckNotifications', endpoint: 'monitoring/check-notifications' },
|
||||
{ key: 'monitoringStatus', endpoint: 'monitoring/status' },
|
||||
{ key: 'monitoringTrafficStatistics', endpoint: 'monitoring/traffic-statistics' },
|
||||
{ key: 'netCurrentPlmn', endpoint: 'net/current-plmn' },
|
||||
{ key: 'netNetMode', endpoint: 'net/net-mode' },
|
||||
{ key: 'smsSmsCount', endpoint: 'sms/sms-count' },
|
||||
{ key: 'lanHostInfo', endpoint: 'lan/HostInfo' },
|
||||
{ key: 'wlanHostList', endpoint: 'wlan/host-list' },
|
||||
{ key: 'wlanWifiFeatureSwitch', endpoint: 'wlan/wifi-feature-switch' },
|
||||
{ key: 'wlanMultiBasicSettings', endpoint: 'wlan/multi-basic-settings' },
|
||||
];
|
||||
|
||||
const resourceEndpointByKey = Object.fromEntries(rawResourceDefinitions.map((definitionArg) => [definitionArg.key, definitionArg.endpoint])) as Record<THuaweiLteRawDataKey, string>;
|
||||
|
||||
export class HuaweiLteClient {
|
||||
private currentSnapshot?: IHuaweiLteSnapshot;
|
||||
private suspended = false;
|
||||
|
||||
constructor(private readonly config: IHuaweiLteConfig) {}
|
||||
|
||||
public async getSnapshot(forceRefreshArg = false): Promise<IHuaweiLteSnapshot> {
|
||||
if (!forceRefreshArg && this.currentSnapshot) {
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
if (this.suspended && this.currentSnapshot) {
|
||||
return this.cloneSnapshot({ ...this.currentSnapshot, suspended: true });
|
||||
}
|
||||
|
||||
if (!forceRefreshArg && this.config.snapshot) {
|
||||
this.currentSnapshot = HuaweiLteMapper.toSnapshot({ config: this.config, source: 'snapshot', online: this.config.snapshot.online, suspended: this.suspended });
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
if (this.config.client) {
|
||||
try {
|
||||
this.currentSnapshot = await this.snapshotFromClient(this.config.client);
|
||||
} catch (errorArg) {
|
||||
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
|
||||
}
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
if (!forceRefreshArg && this.hasManualData()) {
|
||||
this.currentSnapshot = HuaweiLteMapper.toSnapshot({
|
||||
config: this.config,
|
||||
rawData: this.config.rawData,
|
||||
online: this.config.online ?? true,
|
||||
source: 'manual',
|
||||
suspended: this.suspended,
|
||||
});
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
if (this.config.url || this.config.host) {
|
||||
try {
|
||||
this.currentSnapshot = await this.fetchSnapshot();
|
||||
} catch (errorArg) {
|
||||
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
|
||||
}
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
this.currentSnapshot = this.offlineSnapshot('No Huawei LTE local HTTP endpoint, client, snapshot, or raw data is configured.');
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
public async refresh(): Promise<IHuaweiLteRefreshResult> {
|
||||
try {
|
||||
this.currentSnapshot = undefined;
|
||||
const snapshot = await this.getSnapshot(Boolean(this.config.url || this.config.host || this.config.client));
|
||||
const success = snapshot.online && !snapshot.error && snapshot.source !== 'runtime';
|
||||
return { success, snapshot, error: success ? undefined : snapshot.error, data: { source: snapshot.source } };
|
||||
} catch (errorArg) {
|
||||
const error = this.errorMessage(errorArg);
|
||||
const snapshot = this.offlineSnapshot(error);
|
||||
this.currentSnapshot = snapshot;
|
||||
return { success: false, snapshot: this.cloneSnapshot(snapshot), error };
|
||||
}
|
||||
}
|
||||
|
||||
public async fetchSnapshot(): Promise<IHuaweiLteSnapshot> {
|
||||
const session = await this.createHttpSession();
|
||||
const rawData = await this.fetchRawData(session);
|
||||
return HuaweiLteMapper.toSnapshot({ config: this.config, rawData, online: true, source: 'http', suspended: this.suspended });
|
||||
}
|
||||
|
||||
public async execute(commandArg: IHuaweiLteCommandRequest): Promise<unknown> {
|
||||
if (this.config.commandExecutor) {
|
||||
return this.config.commandExecutor.execute(commandArg);
|
||||
}
|
||||
if (this.config.client?.execute) {
|
||||
return this.config.client.execute(commandArg);
|
||||
}
|
||||
if (commandArg.action === 'refresh') {
|
||||
return (await this.refresh()).snapshot;
|
||||
}
|
||||
if (commandArg.action === 'suspend_integration') {
|
||||
this.suspended = true;
|
||||
return { suspended: true };
|
||||
}
|
||||
if (commandArg.action === 'resume_integration') {
|
||||
this.suspended = false;
|
||||
return { suspended: false };
|
||||
}
|
||||
if (!this.config.url && !this.config.host) {
|
||||
throw new HuaweiLteApiConnectionError('Huawei LTE commands require config.url/config.host, an injected client.execute, or commandExecutor. Static snapshots/manual data are read-only.');
|
||||
}
|
||||
|
||||
const session = await this.createHttpSession();
|
||||
switch (commandArg.action) {
|
||||
case 'set_mobile_dataswitch':
|
||||
if (commandArg.enabled === undefined) {
|
||||
throw new HuaweiLteUnsupportedCommandError('Huawei LTE mobile data switch commands require an enabled boolean.');
|
||||
}
|
||||
return session.postSet('dialup/mobile-dataswitch', { dataswitch: commandArg.enabled ? 1 : 0 });
|
||||
case 'set_wifi_guest_network':
|
||||
if (commandArg.enabled === undefined) {
|
||||
throw new HuaweiLteUnsupportedCommandError('Huawei LTE guest WiFi switch commands require an enabled boolean.');
|
||||
}
|
||||
return this.setWifiGuestNetwork(session, commandArg.enabled);
|
||||
case 'reboot':
|
||||
return session.postSet('device/control', { Control: 1 });
|
||||
case 'clear_traffic':
|
||||
return session.postSet('monitoring/clear-traffic', { ClearTraffic: 1 });
|
||||
case 'send_sms':
|
||||
return this.sendSms(session, commandArg);
|
||||
case 'set_net_mode':
|
||||
if (!commandArg.networkMode) {
|
||||
throw new HuaweiLteUnsupportedCommandError('Huawei LTE network mode commands require networkMode.');
|
||||
}
|
||||
return session.postSet('net/net-mode', {
|
||||
NetworkMode: commandArg.networkMode,
|
||||
NetworkBand: commandArg.networkBand || '3fffffff',
|
||||
LTEBand: commandArg.lteBand || '7fffffffffffffff',
|
||||
});
|
||||
default:
|
||||
throw new HuaweiLteUnsupportedCommandError(`Unsupported Huawei LTE command: ${commandArg.action}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.config.client?.destroy?.();
|
||||
}
|
||||
|
||||
private async createHttpSession(): Promise<HuaweiLteHttpSession> {
|
||||
const session = new HuaweiLteHttpSession(this.config);
|
||||
await session.initialize();
|
||||
return session;
|
||||
}
|
||||
|
||||
private async fetchRawData(sessionArg: HuaweiLteHttpSession): Promise<IHuaweiLteRawData> {
|
||||
const rawData: IHuaweiLteRawData = { resources: {}, errors: {}, fetchedAt: new Date().toISOString() };
|
||||
for (const definition of rawResourceDefinitions) {
|
||||
try {
|
||||
const data = await sessionArg.getResource(definition.endpoint);
|
||||
rawData.resources![definition.key] = data;
|
||||
(rawData as Record<string, unknown>)[definition.key] = data;
|
||||
} catch (errorArg) {
|
||||
rawData.errors![definition.key] = this.errorMessage(errorArg);
|
||||
}
|
||||
}
|
||||
|
||||
if (!rawData.deviceInformation && rawData.deviceBasicInformation) {
|
||||
rawData.deviceInformation = rawData.deviceBasicInformation;
|
||||
}
|
||||
if (rawData.wlanMultiBasicSettings) {
|
||||
const guest = this.guestNetworkSettings(rawData.wlanMultiBasicSettings);
|
||||
if (guest) {
|
||||
rawData.wlanWifiGuestNetworkSwitch = guest;
|
||||
}
|
||||
}
|
||||
const hasData = Object.keys(rawData.resources || {}).length > 0;
|
||||
if (!hasData) {
|
||||
throw new HuaweiLteApiConnectionError(`No Huawei LTE API endpoints returned usable data from ${sessionArg.baseUrl}.`);
|
||||
}
|
||||
return rawData;
|
||||
}
|
||||
|
||||
private async snapshotFromClient(clientArg: IHuaweiLteClientLike): Promise<IHuaweiLteSnapshot> {
|
||||
if (clientArg.getSnapshot) {
|
||||
const result = await clientArg.getSnapshot();
|
||||
if (this.isSnapshot(result)) {
|
||||
return HuaweiLteMapper.toSnapshot({ config: { ...this.config, snapshot: result }, source: 'client', online: result.online, suspended: this.suspended });
|
||||
}
|
||||
return HuaweiLteMapper.toSnapshot({ config: this.config, rawData: result, online: true, source: 'client', suspended: this.suspended });
|
||||
}
|
||||
if (clientArg.getRawData) {
|
||||
return HuaweiLteMapper.toSnapshot({ config: this.config, rawData: await clientArg.getRawData(), online: true, source: 'client', suspended: this.suspended });
|
||||
}
|
||||
if (clientArg.getResource) {
|
||||
const rawData: IHuaweiLteRawData = { resources: {}, fetchedAt: new Date().toISOString() };
|
||||
for (const definition of rawResourceDefinitions) {
|
||||
try {
|
||||
const data = await clientArg.getResource(definition.endpoint);
|
||||
rawData.resources![definition.key] = data;
|
||||
(rawData as Record<string, unknown>)[definition.key] = data;
|
||||
} catch {
|
||||
// Injected resource clients may expose only a subset of endpoints.
|
||||
}
|
||||
}
|
||||
return HuaweiLteMapper.toSnapshot({ config: this.config, rawData, online: true, source: 'client', suspended: this.suspended });
|
||||
}
|
||||
throw new HuaweiLteApiConnectionError('Huawei LTE client must expose getSnapshot(), getRawData(), getResource(), or execute().');
|
||||
}
|
||||
|
||||
private async setWifiGuestNetwork(sessionArg: HuaweiLteHttpSession, enabledArg: boolean): Promise<unknown> {
|
||||
const settings = await sessionArg.getResource('wlan/multi-basic-settings');
|
||||
const ssids = this.ssidList(settings).map((itemArg) => ({ ...itemArg }));
|
||||
const guest = ssids.find((itemArg) => String(itemArg.wifiisguestnetwork || '') === '1');
|
||||
if (!guest) {
|
||||
throw new HuaweiLteUnsupportedCommandError('Huawei LTE guest WiFi network settings are not available on this device.');
|
||||
}
|
||||
guest.WifiEnable = enabledArg ? '1' : '0';
|
||||
return sessionArg.postSet('wlan/multi-basic-settings', { Ssids: { Ssid: ssids }, WifiRestart: 1 });
|
||||
}
|
||||
|
||||
private async sendSms(sessionArg: HuaweiLteHttpSession, commandArg: IHuaweiLteCommandRequest): Promise<unknown> {
|
||||
const phoneNumbers = commandArg.phoneNumbers?.filter(Boolean) || [];
|
||||
const message = commandArg.message || '';
|
||||
if (!phoneNumbers.length || !message) {
|
||||
throw new HuaweiLteUnsupportedCommandError('Huawei LTE SMS commands require phoneNumbers and message.');
|
||||
}
|
||||
return sessionArg.postSet('sms/send-sms', {
|
||||
Index: commandArg.smsIndex ?? -1,
|
||||
Phones: { Phone: phoneNumbers },
|
||||
Sca: commandArg.sca || undefined,
|
||||
Content: message,
|
||||
Length: message.length,
|
||||
Reserved: 1,
|
||||
Date: this.huaweiDate(new Date()),
|
||||
});
|
||||
}
|
||||
|
||||
private guestNetworkSettings(settingsArg: IHuaweiLteValueMap): IHuaweiLteValueMap | undefined {
|
||||
return this.ssidList(settingsArg).find((itemArg) => String(itemArg.wifiisguestnetwork || '') === '1');
|
||||
}
|
||||
|
||||
private ssidList(settingsArg: IHuaweiLteValueMap): IHuaweiLteValueMap[] {
|
||||
const ssids = this.objectValue(settingsArg.Ssids)?.Ssid;
|
||||
return this.asArray(ssids).map((itemArg) => this.objectValue(itemArg)).filter(Boolean) as IHuaweiLteValueMap[];
|
||||
}
|
||||
|
||||
private hasManualData(): boolean {
|
||||
return Boolean(this.config.snapshot || this.config.rawData);
|
||||
}
|
||||
|
||||
private offlineSnapshot(errorArg: string): IHuaweiLteSnapshot {
|
||||
return HuaweiLteMapper.toSnapshot({ config: this.config, online: false, source: 'runtime', suspended: this.suspended, error: errorArg });
|
||||
}
|
||||
|
||||
private isSnapshot(valueArg: unknown): valueArg is IHuaweiLteSnapshot {
|
||||
return Boolean(valueArg && typeof valueArg === 'object' && 'device' in valueArg && 'connection' in valueArg && 'online' in valueArg);
|
||||
}
|
||||
|
||||
private objectValue(valueArg: unknown): IHuaweiLteValueMap | undefined {
|
||||
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as IHuaweiLteValueMap : undefined;
|
||||
}
|
||||
|
||||
private asArray(valueArg: unknown): unknown[] {
|
||||
if (Array.isArray(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (valueArg === undefined || valueArg === null || valueArg === '') {
|
||||
return [];
|
||||
}
|
||||
return [valueArg];
|
||||
}
|
||||
|
||||
private huaweiDate(valueArg: Date): string {
|
||||
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||
return `${valueArg.getUTCFullYear()}-${pad(valueArg.getUTCMonth() + 1)}-${pad(valueArg.getUTCDate())} ${pad(valueArg.getUTCHours())}:${pad(valueArg.getUTCMinutes())}:${pad(valueArg.getUTCSeconds())}`;
|
||||
}
|
||||
|
||||
private errorMessage(errorArg: unknown): string {
|
||||
return errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||
}
|
||||
|
||||
private cloneSnapshot(snapshotArg: IHuaweiLteSnapshot): IHuaweiLteSnapshot {
|
||||
return JSON.parse(JSON.stringify(snapshotArg)) as IHuaweiLteSnapshot;
|
||||
}
|
||||
}
|
||||
|
||||
class HuaweiLteHttpSession {
|
||||
public readonly baseUrl: string;
|
||||
private requestVerificationTokens: string[] = [];
|
||||
private cookies = new Map<string, string>();
|
||||
private loggedIn = false;
|
||||
|
||||
constructor(private readonly config: IHuaweiLteConfig) {
|
||||
this.baseUrl = this.resolveBaseUrl(config);
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
const homepage = await this.requestText('', { prefix: '', allowHtml: true }).catch(() => '');
|
||||
this.requestVerificationTokens = this.extractCsrfTokens(homepage);
|
||||
if (this.requestVerificationTokens.length) {
|
||||
return;
|
||||
}
|
||||
const token = await this.tryGetToken('webserver/token', 'token') || await this.tryGetToken('webserver/SesTokInfo', 'TokInfo');
|
||||
if (token) {
|
||||
this.requestVerificationTokens.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
public async getResource(endpointArg: string): Promise<IHuaweiLteValueMap> {
|
||||
const data = await this.requestParsed(endpointArg, { method: 'GET' });
|
||||
return this.objectResponse(data);
|
||||
}
|
||||
|
||||
public async postSet(endpointArg: string, dataArg: IHuaweiLteValueMap | number): Promise<unknown> {
|
||||
await this.loginIfConfigured();
|
||||
return this.requestParsed(endpointArg, { method: 'POST', body: this.requestXml(dataArg) });
|
||||
}
|
||||
|
||||
private async loginIfConfigured(): Promise<void> {
|
||||
if (this.loggedIn || this.config.unauthenticatedMode || (!this.config.username && !this.config.password)) {
|
||||
return;
|
||||
}
|
||||
const username = this.config.username || 'admin';
|
||||
const state = await this.getResource('user/state-login').catch(() => undefined);
|
||||
if (String(state?.State) === '0') {
|
||||
this.loggedIn = true;
|
||||
return;
|
||||
}
|
||||
const passwordType = Number(state?.password_type ?? 0);
|
||||
const password = this.encodedPassword(username, this.config.password || '', passwordType);
|
||||
const result = await this.requestParsed('user/login', {
|
||||
method: 'POST',
|
||||
body: this.requestXml({ Username: username, Password: password, password_type: passwordType }),
|
||||
refreshCsrf: true,
|
||||
});
|
||||
this.loggedIn = result === 'OK' || (typeof result === 'object' && result !== null);
|
||||
}
|
||||
|
||||
private async tryGetToken(endpointArg: string, keyArg: string): Promise<string | undefined> {
|
||||
try {
|
||||
const data = await this.getResource(endpointArg);
|
||||
const token = data[keyArg];
|
||||
return typeof token === 'string' && token ? token : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async requestParsed(endpointArg: string, optionsArg: { method: 'GET' | 'POST'; body?: string; refreshCsrf?: boolean }): Promise<unknown> {
|
||||
const text = await this.requestText(endpointArg, optionsArg);
|
||||
const data = this.parseResponse(text);
|
||||
return this.checkResponse(data);
|
||||
}
|
||||
|
||||
private async requestText(endpointArg: string, optionsArg: { method?: 'GET' | 'POST'; body?: string; prefix?: string; refreshCsrf?: boolean; allowHtml?: boolean } = {}): Promise<string> {
|
||||
const method = optionsArg.method || 'GET';
|
||||
const headers: Record<string, string> = {};
|
||||
if (method === 'POST') {
|
||||
headers['content-type'] = 'application/xml';
|
||||
}
|
||||
const token = this.tokenForRequest(method);
|
||||
if (token) {
|
||||
headers.__RequestVerificationToken = token;
|
||||
}
|
||||
const cookie = this.cookieHeader();
|
||||
if (cookie) {
|
||||
headers.cookie = cookie;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
const url = this.buildUrl(endpointArg, optionsArg.prefix ?? 'api');
|
||||
try {
|
||||
response = await globalThis.fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: optionsArg.body,
|
||||
signal: AbortSignal.timeout(this.config.timeoutMs || huaweiLteDefaultTimeoutMs),
|
||||
});
|
||||
} catch (errorArg) {
|
||||
throw new HuaweiLteApiConnectionError(`Connection to ${url} failed: ${this.errorMessage(errorArg)}`);
|
||||
}
|
||||
|
||||
this.storeCookies(response);
|
||||
if (optionsArg.refreshCsrf) {
|
||||
this.requestVerificationTokens = [];
|
||||
}
|
||||
this.storeTokens(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new HuaweiLteApiConnectionError(`Huawei LTE endpoint ${url} failed with HTTP ${response.status}${body ? `: ${body}` : ''}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!optionsArg.allowHtml && /^\s*<!doctype html|<html[\s>]/i.test(text)) {
|
||||
throw new HuaweiLteApiResponseError('Huawei LTE endpoint returned HTML instead of API data.', 100002);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private parseResponse(textArg: string): unknown {
|
||||
const text = textArg.trim();
|
||||
if (!text) {
|
||||
return {};
|
||||
}
|
||||
if (text.startsWith('{') || text.startsWith('[')) {
|
||||
return JSON.parse(text) as unknown;
|
||||
}
|
||||
return parseXml(text);
|
||||
}
|
||||
|
||||
private checkResponse(dataArg: unknown): unknown {
|
||||
const root = this.objectValue(dataArg);
|
||||
const payload = root?.response ?? root;
|
||||
const error = root?.error;
|
||||
if (error && typeof error === 'object') {
|
||||
const errorObject = error as Record<string, unknown>;
|
||||
const code = Number(errorObject.code);
|
||||
const message = typeof errorObject.message === 'string' && errorObject.message ? errorObject.message : this.errorMessageForCode(code);
|
||||
if (code === 100003) {
|
||||
throw new HuaweiLteApiAuthorizationError(`${code}: ${message}`);
|
||||
}
|
||||
throw new HuaweiLteApiResponseError(`${code}: ${message}`, code);
|
||||
}
|
||||
return payload === undefined || payload === null ? {} : payload;
|
||||
}
|
||||
|
||||
private objectResponse(valueArg: unknown): IHuaweiLteValueMap {
|
||||
if (valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)) {
|
||||
return valueArg as IHuaweiLteValueMap;
|
||||
}
|
||||
if (typeof valueArg === 'string') {
|
||||
return { value: valueArg };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private requestXml(valueArg: IHuaweiLteValueMap | number): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?><request>${this.xmlValue(valueArg)}</request>`;
|
||||
}
|
||||
|
||||
private xmlValue(valueArg: unknown, keyArg?: string): string {
|
||||
if (Array.isArray(valueArg)) {
|
||||
return valueArg.map((itemArg) => this.xmlValue(itemArg, keyArg)).join('');
|
||||
}
|
||||
if (keyArg) {
|
||||
return `<${keyArg}>${this.xmlValue(valueArg)}</${keyArg}>`;
|
||||
}
|
||||
if (valueArg && typeof valueArg === 'object') {
|
||||
return Object.entries(valueArg as Record<string, unknown>)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([key, value]) => this.xmlValue(value, key))
|
||||
.join('');
|
||||
}
|
||||
return escapeXml(valueArg === undefined || valueArg === null ? '' : String(valueArg));
|
||||
}
|
||||
|
||||
private encodedPassword(usernameArg: string, passwordArg: string, passwordTypeArg: number): string {
|
||||
if (!passwordArg) {
|
||||
return '';
|
||||
}
|
||||
if (passwordTypeArg === 4) {
|
||||
const passwordHash = this.base64(this.sha256Hex(passwordArg));
|
||||
const token = this.requestVerificationTokens[0] || '';
|
||||
return this.base64(this.sha256Hex(`${usernameArg}${passwordHash}${token}`));
|
||||
}
|
||||
return this.base64(passwordArg);
|
||||
}
|
||||
|
||||
private sha256Hex(valueArg: string): string {
|
||||
return plugins.crypto.createHash('sha256').update(valueArg, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
private base64(valueArg: string): string {
|
||||
return Buffer.from(valueArg, 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
private tokenForRequest(methodArg: 'GET' | 'POST'): string | undefined {
|
||||
if (!this.requestVerificationTokens.length) {
|
||||
return undefined;
|
||||
}
|
||||
if (methodArg === 'POST' && this.requestVerificationTokens.length > 1) {
|
||||
return this.requestVerificationTokens.shift();
|
||||
}
|
||||
return this.requestVerificationTokens[0];
|
||||
}
|
||||
|
||||
private storeTokens(responseArg: Response): void {
|
||||
const tokenOne = responseArg.headers.get('__RequestVerificationTokenone');
|
||||
const tokenTwo = responseArg.headers.get('__RequestVerificationTokentwo');
|
||||
const token = responseArg.headers.get('__RequestVerificationToken');
|
||||
if (tokenOne) {
|
||||
this.requestVerificationTokens.push(tokenOne);
|
||||
if (tokenTwo) {
|
||||
this.requestVerificationTokens.push(tokenTwo);
|
||||
}
|
||||
} else if (token) {
|
||||
this.requestVerificationTokens.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
private storeCookies(responseArg: Response): void {
|
||||
const headers = responseArg.headers as Headers & { getSetCookie?: () => string[] };
|
||||
const setCookies = headers.getSetCookie ? headers.getSetCookie() : splitSetCookieHeader(responseArg.headers.get('set-cookie'));
|
||||
for (const cookie of setCookies) {
|
||||
const [pair] = cookie.split(';', 1);
|
||||
const index = pair.indexOf('=');
|
||||
if (index > 0) {
|
||||
this.cookies.set(pair.slice(0, index), pair.slice(index + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private cookieHeader(): string | undefined {
|
||||
return this.cookies.size ? [...this.cookies.entries()].map(([key, value]) => `${key}=${value}`).join('; ') : undefined;
|
||||
}
|
||||
|
||||
private extractCsrfTokens(htmlArg: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
const pattern = /name=["']csrf_token["']\s+content=["']([^"']+)["']/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(htmlArg))) {
|
||||
tokens.push(match[1]);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private buildUrl(endpointArg: string, prefixArg: string): string {
|
||||
if (/^https?:\/\//i.test(endpointArg)) {
|
||||
return endpointArg;
|
||||
}
|
||||
const normalizedEndpoint = endpointArg.replace(/^\/+/, '');
|
||||
const prefix = prefixArg ? `${prefixArg.replace(/^\/+|\/+$/g, '')}/` : '';
|
||||
return new URL(`${prefix}${normalizedEndpoint}`, this.baseUrl).toString();
|
||||
}
|
||||
|
||||
private resolveBaseUrl(configArg: IHuaweiLteConfig): string {
|
||||
const configured = configArg.url || configArg.host || '192.168.8.1';
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(configured)) {
|
||||
const url = new URL(configured);
|
||||
return url.toString().endsWith('/') ? url.toString() : `${url.toString()}/`;
|
||||
}
|
||||
const ssl = configArg.ssl ?? false;
|
||||
const defaultPort = ssl ? huaweiLteDefaultHttpsPort : huaweiLteDefaultHttpPort;
|
||||
const port = configArg.port && configArg.port !== defaultPort ? `:${configArg.port}` : '';
|
||||
return `${ssl ? 'https' : 'http'}://${this.hostForUrl(configured)}${port}/`;
|
||||
}
|
||||
|
||||
private errorMessageForCode(codeArg: number): string {
|
||||
const messages: Record<number, string> = {
|
||||
100001: 'Unknown',
|
||||
100002: 'No support',
|
||||
100003: 'No rights (needs login)',
|
||||
100004: 'System busy',
|
||||
100005: 'Request format error',
|
||||
125002: 'Session error',
|
||||
125003: 'Wrong session token',
|
||||
};
|
||||
return messages[codeArg] || 'Unknown';
|
||||
}
|
||||
|
||||
private objectValue(valueArg: unknown): Record<string, unknown> | undefined {
|
||||
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
private hostForUrl(hostArg: string): string {
|
||||
return hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
|
||||
}
|
||||
|
||||
private errorMessage(errorArg: unknown): string {
|
||||
return errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||
}
|
||||
}
|
||||
|
||||
const parseXml = (xmlArg: string): unknown => {
|
||||
interface IXmlNode {
|
||||
name: string;
|
||||
value: Record<string, unknown>;
|
||||
text: string;
|
||||
}
|
||||
const root: IXmlNode = { name: '', value: {}, text: '' };
|
||||
const stack: IXmlNode[] = [root];
|
||||
const tokenPattern = /<\?[^>]*\?>|<!--[\s\S]*?-->|<!\[CDATA\[([\s\S]*?)\]\]>|<([A-Za-z0-9_:-]+)(?:\s[^>]*)?\/>|<([A-Za-z0-9_:-]+)(?:\s[^>]*)?>|<\/([A-Za-z0-9_:-]+)>|([^<]+)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = tokenPattern.exec(xmlArg))) {
|
||||
if (match[1] !== undefined) {
|
||||
stack[stack.length - 1].text += match[1];
|
||||
continue;
|
||||
}
|
||||
if (match[2]) {
|
||||
addXmlChild(stack[stack.length - 1], match[2], '');
|
||||
continue;
|
||||
}
|
||||
if (match[3]) {
|
||||
stack.push({ name: match[3], value: {}, text: '' });
|
||||
continue;
|
||||
}
|
||||
if (match[4]) {
|
||||
const node = stack.pop();
|
||||
if (node && stack.length) {
|
||||
addXmlChild(stack[stack.length - 1], node.name, Object.keys(node.value).length ? node.value : decodeXml(node.text.trim()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (match[5]) {
|
||||
stack[stack.length - 1].text += decodeXml(match[5]);
|
||||
}
|
||||
}
|
||||
return root.value;
|
||||
};
|
||||
|
||||
const addXmlChild = (nodeArg: { value: Record<string, unknown> }, keyArg: string, valueArg: unknown): void => {
|
||||
const current = nodeArg.value[keyArg];
|
||||
if (current === undefined) {
|
||||
nodeArg.value[keyArg] = valueArg;
|
||||
} else if (Array.isArray(current)) {
|
||||
current.push(valueArg);
|
||||
} else {
|
||||
nodeArg.value[keyArg] = [current, valueArg];
|
||||
}
|
||||
};
|
||||
|
||||
const escapeXml = (valueArg: string): string => valueArg
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const decodeXml = (valueArg: string): string => valueArg
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&');
|
||||
|
||||
const splitSetCookieHeader = (valueArg: string | null): string[] => {
|
||||
if (!valueArg) {
|
||||
return [];
|
||||
}
|
||||
return valueArg.split(/,(?=\s*[^;,]+=)/g).map((itemArg) => itemArg.trim()).filter(Boolean);
|
||||
};
|
||||
|
||||
export const huaweiLteResourceEndpointByKey = resourceEndpointByKey;
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import type { IHuaweiLteConfig, IHuaweiLteRawData, IHuaweiLteSnapshot } from './huawei_lte.types.js';
|
||||
import { huaweiLteDefaultHttpPort, huaweiLteDefaultHttpsPort, huaweiLteDefaultTimeoutMs } from './huawei_lte.types.js';
|
||||
|
||||
export class HuaweiLteConfigFlow implements IConfigFlow<IHuaweiLteConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IHuaweiLteConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Configure Huawei LTE',
|
||||
description: 'Configure a local Huawei HiLink router URL, or use snapshot/raw data from the discovery candidate. The runtime verifies live HTTP/API access on refresh.',
|
||||
fields: [
|
||||
{ name: 'url', label: 'Router URL', type: 'text' },
|
||||
{ name: 'verifySsl', label: 'Verify TLS certificate', type: 'boolean' },
|
||||
{ name: 'username', label: 'Username', type: 'text' },
|
||||
{ name: 'password', label: 'Password', type: 'password' },
|
||||
{ name: 'unauthenticatedMode', label: 'Unauthenticated mode', type: 'boolean' },
|
||||
{ name: 'trackWiredClients', label: 'Track wired clients', type: 'boolean' },
|
||||
{ name: 'recipients', label: 'Default SMS recipients, comma separated', type: 'text' },
|
||||
{ name: 'name', label: 'Name', type: 'text' },
|
||||
],
|
||||
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
|
||||
};
|
||||
}
|
||||
|
||||
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IHuaweiLteConfig>> {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const snapshot = snapshotValue(metadata.snapshot);
|
||||
const rawData = rawDataValue(metadata.rawData);
|
||||
const urlInput = this.stringValue(valuesArg.url) || this.stringMetadata(metadata, 'url');
|
||||
const parsedInput = parseEndpoint(urlInput);
|
||||
const host = parsedInput?.hostname || candidateArg.host || snapshot?.device.host;
|
||||
const ssl = parsedInput?.ssl ?? snapshot?.device.ssl ?? false;
|
||||
const port = parsedInput?.port || candidateArg.port || snapshot?.device.port || (ssl ? huaweiLteDefaultHttpsPort : huaweiLteDefaultHttpPort);
|
||||
const url = parsedInput ? normalizeEndpointUrl(urlInput) : host ? `${ssl ? 'https' : 'http'}://${hostForUrl(host)}${port !== (ssl ? huaweiLteDefaultHttpsPort : huaweiLteDefaultHttpPort) ? `:${port}` : ''}/` : undefined;
|
||||
const hasManualData = Boolean(snapshot || rawData || metadata.client);
|
||||
|
||||
if (!url && !hasManualData) {
|
||||
return { kind: 'error', title: 'Huawei LTE setup failed', error: 'Huawei LTE URL, host, injected client, snapshot, or raw data is required.' };
|
||||
}
|
||||
if (urlInput && !url) {
|
||||
return { kind: 'error', title: 'Huawei LTE setup failed', error: 'Huawei LTE URL is invalid.' };
|
||||
}
|
||||
if (!validPort(port)) {
|
||||
return { kind: 'error', title: 'Huawei LTE setup failed', error: 'Huawei LTE port must be between 1 and 65535.' };
|
||||
}
|
||||
|
||||
const config: IHuaweiLteConfig = {
|
||||
url,
|
||||
host,
|
||||
port,
|
||||
ssl,
|
||||
verifySsl: this.booleanValue(valuesArg.verifySsl) ?? this.booleanMetadata(metadata, 'verifySsl') ?? false,
|
||||
username: this.stringValue(valuesArg.username) || this.stringMetadata(metadata, 'username'),
|
||||
password: this.stringValue(valuesArg.password) || this.stringMetadata(metadata, 'password'),
|
||||
unauthenticatedMode: this.booleanValue(valuesArg.unauthenticatedMode) ?? this.booleanMetadata(metadata, 'unauthenticatedMode') ?? false,
|
||||
trackWiredClients: this.booleanValue(valuesArg.trackWiredClients) ?? this.booleanMetadata(metadata, 'trackWiredClients') ?? true,
|
||||
recipients: this.stringList(valuesArg.recipients) || this.stringList(metadata.recipients),
|
||||
timeoutMs: huaweiLteDefaultTimeoutMs,
|
||||
name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.device.name || this.stringMetadata(metadata, 'name'),
|
||||
manufacturer: candidateArg.manufacturer || snapshot?.device.manufacturer || this.stringMetadata(metadata, 'manufacturer'),
|
||||
model: candidateArg.model || snapshot?.device.model || this.stringMetadata(metadata, 'model'),
|
||||
uniqueId: candidateArg.id || snapshot?.device.id || snapshot?.device.serialNumber || this.stringMetadata(metadata, 'upnpUdn') || (host ? `${host}:${port}` : undefined),
|
||||
serialNumber: candidateArg.serialNumber || snapshot?.device.serialNumber,
|
||||
macAddress: candidateArg.macAddress || snapshot?.device.macAddresses[0],
|
||||
macAddresses: snapshot?.device.macAddresses,
|
||||
upnpUdn: this.stringMetadata(metadata, 'upnpUdn'),
|
||||
snapshot,
|
||||
rawData,
|
||||
client: metadata.client as IHuaweiLteConfig['client'],
|
||||
commandExecutor: metadata.commandExecutor as IHuaweiLteConfig['commandExecutor'],
|
||||
};
|
||||
|
||||
return { kind: 'done', title: 'Huawei LTE configured', config };
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
}
|
||||
|
||||
private stringMetadata(metadataArg: Record<string, unknown>, keyArg: string): string | undefined {
|
||||
return this.stringValue(metadataArg[keyArg]);
|
||||
}
|
||||
|
||||
private booleanValue(valueArg: unknown): boolean | undefined {
|
||||
if (typeof valueArg === 'boolean') {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string') {
|
||||
const normalized = valueArg.trim().toLowerCase();
|
||||
if (['true', 'yes', 'on', '1'].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (['false', 'no', 'off', '0'].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private booleanMetadata(metadataArg: Record<string, unknown>, keyArg: string): boolean | undefined {
|
||||
return typeof metadataArg[keyArg] === 'boolean' ? metadataArg[keyArg] as boolean : undefined;
|
||||
}
|
||||
|
||||
private stringList(valueArg: unknown): string[] | undefined {
|
||||
if (Array.isArray(valueArg)) {
|
||||
return valueArg.map((itemArg) => this.stringValue(itemArg)).filter(Boolean) as string[];
|
||||
}
|
||||
const value = this.stringValue(valueArg);
|
||||
return value ? value.split(',').map((itemArg) => itemArg.trim()).filter(Boolean) : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const parseEndpoint = (valueArg: string | undefined): { hostname: string; port?: number; ssl: boolean } | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg) ? valueArg : `http://${valueArg}`;
|
||||
try {
|
||||
const url = new URL(withScheme);
|
||||
return { hostname: url.hostname, port: url.port ? Number(url.port) : undefined, ssl: url.protocol === 'https:' };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeEndpointUrl = (valueArg: string | undefined): string | undefined => {
|
||||
const parsed = valueArg ? parseEndpoint(valueArg) : undefined;
|
||||
if (!parsed) {
|
||||
return undefined;
|
||||
}
|
||||
const protocol = parsed.ssl ? 'https' : 'http';
|
||||
const defaultPort = parsed.ssl ? huaweiLteDefaultHttpsPort : huaweiLteDefaultHttpPort;
|
||||
return `${protocol}://${hostForUrl(parsed.hostname)}${parsed.port && parsed.port !== defaultPort ? `:${parsed.port}` : ''}/`;
|
||||
};
|
||||
|
||||
const hostForUrl = (hostArg: string): string => hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
|
||||
const validPort = (valueArg: number): boolean => Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
|
||||
const snapshotValue = (valueArg: unknown): IHuaweiLteSnapshot | undefined => valueArg && typeof valueArg === 'object' && 'device' in valueArg && 'connection' in valueArg ? valueArg as IHuaweiLteSnapshot : undefined;
|
||||
const rawDataValue = (valueArg: unknown): Partial<IHuaweiLteRawData> | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Partial<IHuaweiLteRawData> : undefined;
|
||||
@@ -1,28 +1,108 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { HuaweiLteClient } from './huawei_lte.classes.client.js';
|
||||
import { HuaweiLteConfigFlow } from './huawei_lte.classes.configflow.js';
|
||||
import { createHuaweiLteDiscoveryDescriptor } from './huawei_lte.discovery.js';
|
||||
import { HuaweiLteMapper } from './huawei_lte.mapper.js';
|
||||
import type { IHuaweiLteConfig } from './huawei_lte.types.js';
|
||||
import { huaweiLteDefaultManufacturer, huaweiLteDisplayName, huaweiLteDomain, huaweiLteSsdpDeviceType, huaweiLteSsdpManufacturers } from './huawei_lte.types.js';
|
||||
|
||||
export class HomeAssistantHuaweiLteIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "huawei_lte",
|
||||
displayName: "Huawei LTE",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/huawei_lte",
|
||||
"upstreamDomain": "huawei_lte",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"huawei-lte-api==1.11.0",
|
||||
"url-normalize==3.0.0"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@scop",
|
||||
"@fphammerle"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class HuaweiLteIntegration extends BaseIntegration<IHuaweiLteConfig> {
|
||||
public readonly domain = huaweiLteDomain;
|
||||
public readonly displayName = huaweiLteDisplayName;
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createHuaweiLteDiscoveryDescriptor();
|
||||
public readonly configFlow = new HuaweiLteConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/huawei_lte',
|
||||
upstreamDomain: huaweiLteDomain,
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_polling',
|
||||
requirements: ['huawei-lte-api==1.11.0', 'url-normalize==3.0.0'],
|
||||
dependencies: [] as string[],
|
||||
afterDependencies: [] as string[],
|
||||
codeowners: ['@scop', '@fphammerle'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/huawei_lte',
|
||||
ssdp: huaweiLteSsdpManufacturers.map((manufacturerArg) => ({ deviceType: huaweiLteSsdpDeviceType, manufacturer: manufacturerArg })),
|
||||
defaults: {
|
||||
manufacturer: huaweiLteDefaultManufacturer,
|
||||
verifySsl: false,
|
||||
unauthenticatedMode: false,
|
||||
trackWiredClients: true,
|
||||
},
|
||||
runtime: {
|
||||
type: 'control-runtime',
|
||||
polling: 'local Huawei HiLink HTTP/XML API endpoints under /api/*',
|
||||
services: ['snapshot', 'status', 'refresh', 'mobile data switch', 'guest WiFi switch', 'reboot', 'clear traffic statistics', 'send SMS', 'network mode select', 'suspend/resume integration'],
|
||||
controls: true,
|
||||
},
|
||||
localApi: {
|
||||
implemented: [
|
||||
'SSDP InternetGatewayDevice matching for Huawei, Huawei Technologies, and SOYEA manufacturers from the Home Assistant manifest',
|
||||
'manual URL/host/snapshot/raw-data/client setup',
|
||||
'local HiLink XML GET snapshots for device, signal, monitoring, traffic, net, SMS, LAN host, WLAN host, and WLAN settings endpoints',
|
||||
'local HiLink token handling and optional username/password login for POST writes',
|
||||
'real POST writes for dialup/mobile-dataswitch, wlan/multi-basic-settings guest network switch, device/control reboot, monitoring/clear-traffic, sms/send-sms, and net/net-mode',
|
||||
'injected client and commandExecutor operation for tests and alternate transports',
|
||||
],
|
||||
explicitUnsupported: [
|
||||
'faking live command success for static snapshots or manual raw data',
|
||||
'encrypted profile/password-changing endpoints from huawei-lte-api',
|
||||
'full Home Assistant device_tracker platform parity because the shared runtime entity model has no device_tracker platform',
|
||||
'TLS certificate bypass through Node fetch; use HTTP, a valid certificate, or an injected client if the router uses a self-signed HTTPS certificate',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
public async setup(configArg: IHuaweiLteConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new HuaweiLteRuntime(new HuaweiLteClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantHuaweiLteIntegration extends HuaweiLteIntegration {}
|
||||
|
||||
class HuaweiLteRuntime implements IIntegrationRuntime {
|
||||
public domain = huaweiLteDomain;
|
||||
|
||||
constructor(private readonly client: HuaweiLteClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return HuaweiLteMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return HuaweiLteMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
try {
|
||||
if (requestArg.domain === huaweiLteDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
|
||||
return { success: true, data: await this.client.getSnapshot() };
|
||||
}
|
||||
if (requestArg.domain === huaweiLteDomain && requestArg.service === 'refresh') {
|
||||
const result = await this.client.refresh();
|
||||
return { success: result.success, error: result.error, data: result.snapshot || result.data };
|
||||
}
|
||||
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
const command = HuaweiLteMapper.commandForService(snapshot, requestArg);
|
||||
if (!command) {
|
||||
return { success: false, error: `Unsupported Huawei LTE service: ${requestArg.domain}.${requestArg.service}` };
|
||||
}
|
||||
const data = await this.client.execute(command);
|
||||
return { success: true, data: data ?? command };
|
||||
} catch (errorArg) {
|
||||
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type {
|
||||
IDiscoveryCandidate,
|
||||
IDiscoveryContext,
|
||||
IDiscoveryMatch,
|
||||
IDiscoveryMatcher,
|
||||
IDiscoveryProbe,
|
||||
IDiscoveryProbeResult,
|
||||
IDiscoveryValidator,
|
||||
} from '../../core/types.js';
|
||||
import { HuaweiLteMapper } from './huawei_lte.mapper.js';
|
||||
import type { IHuaweiLteManualEntry, IHuaweiLteRawData, IHuaweiLteSnapshot, IHuaweiLteSsdpRecord } from './huawei_lte.types.js';
|
||||
import {
|
||||
huaweiLteDefaultDeviceName,
|
||||
huaweiLteDefaultHttpPort,
|
||||
huaweiLteDefaultManufacturer,
|
||||
huaweiLteDisplayName,
|
||||
huaweiLteDomain,
|
||||
huaweiLteSsdpDeviceType,
|
||||
huaweiLteSsdpManufacturers,
|
||||
} from './huawei_lte.types.js';
|
||||
|
||||
export class HuaweiLteSsdpDiscoveryProbe implements IDiscoveryProbe {
|
||||
public id = 'huawei_lte-ssdp-discovery-probe';
|
||||
public source = 'ssdp' as const;
|
||||
public description = 'Discover Huawei LTE routers using local SSDP InternetGatewayDevice M-SEARCH responses.';
|
||||
|
||||
public async probe(contextArg: IDiscoveryContext): Promise<IDiscoveryProbeResult> {
|
||||
if (contextArg.abortSignal?.aborted) {
|
||||
return { candidates: [] };
|
||||
}
|
||||
return { candidates: await this.discover(1500) };
|
||||
}
|
||||
|
||||
private async discover(timeoutMsArg: number): Promise<IDiscoveryCandidate[]> {
|
||||
const { createSocket } = await import('node:dgram');
|
||||
const matcher = new HuaweiLteSsdpMatcher();
|
||||
const candidates: IDiscoveryCandidate[] = [];
|
||||
const message = Buffer.from([
|
||||
'M-SEARCH * HTTP/1.1',
|
||||
'HOST: 239.255.255.250:1900',
|
||||
'MAN: "ssdp:discover"',
|
||||
'MX: 1',
|
||||
`ST: ${huaweiLteSsdpDeviceType}`,
|
||||
'',
|
||||
'',
|
||||
].join('\r\n'), 'ascii');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const socket = createSocket({ type: 'udp4', reuseAddr: true });
|
||||
const timer = setTimeout(() => {
|
||||
closeSocket();
|
||||
resolve(candidates);
|
||||
}, timeoutMsArg);
|
||||
|
||||
const closeSocket = () => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// Discovery sockets may already be closed after timeout or OS errors.
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('message', async (dataArg, remoteArg) => {
|
||||
const record = parseSsdpResponse(dataArg.toString('utf8'), remoteArg.address);
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
const match = await matcher.matches(record);
|
||||
if (match.matched && match.candidate && !candidates.some((candidateArg) => candidateArg.id === match.candidate?.id || candidateArg.host === match.candidate?.host)) {
|
||||
candidates.push(match.candidate);
|
||||
}
|
||||
});
|
||||
socket.on('error', () => {
|
||||
closeSocket();
|
||||
resolve(candidates);
|
||||
});
|
||||
socket.bind(() => {
|
||||
socket.setMulticastTTL(2);
|
||||
socket.send(message, 1900, '239.255.255.250');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class HuaweiLteSsdpMatcher implements IDiscoveryMatcher<IHuaweiLteSsdpRecord> {
|
||||
public id = 'huawei_lte-ssdp-match';
|
||||
public source = 'ssdp' as const;
|
||||
public description = 'Recognize Huawei LTE SSDP records matching the Home Assistant manifest InternetGatewayDevice metadata.';
|
||||
|
||||
public async matches(recordArg: IHuaweiLteSsdpRecord): Promise<IDiscoveryMatch> {
|
||||
const headers = normalizeKeys({ ...recordArg.headers, ...recordArg.ssdpHeaders });
|
||||
const upnp = normalizeKeys(recordArg.upnp || {});
|
||||
const metadata = recordArg.metadata || {};
|
||||
const st = stringValue(recordArg.st || headers.st);
|
||||
const nt = stringValue(recordArg.nt || headers.nt);
|
||||
const usn = stringValue(recordArg.usn || headers.usn);
|
||||
const location = stringValue(recordArg.location || recordArg.ssdpLocation || headers.location || metadata.location || metadata.ssdpLocation);
|
||||
const presentationUrl = stringValue(recordArg.presentationUrl || upnp.presentationurl || metadata.url || metadata.presentationUrl) || location;
|
||||
const parsed = parseUrl(presentationUrl) || parseUrl(location);
|
||||
const deviceType = stringValue(recordArg.deviceType || upnp.devicetype || metadata.deviceType || st || nt);
|
||||
const manufacturer = stringValue(recordArg.manufacturer || upnp.manufacturer || metadata.manufacturer);
|
||||
const friendlyName = stringValue(recordArg.friendlyName || upnp.friendlyname || metadata.friendlyName || recordArg.name);
|
||||
const model = stringValue(recordArg.modelName || upnp.modelname || metadata.modelName);
|
||||
const serial = stringValue(recordArg.serialNumber || upnp.serialnumber || upnp.serial || metadata.serialNumber);
|
||||
const udn = stripUuid(stringValue(recordArg.udn || upnp.udn || upnp.UDN || usn?.split('::')[0] || metadata.udn || metadata.upnpUdn));
|
||||
const server = stringValue(headers.server);
|
||||
const matchedDeviceType = normalizeUrn(deviceType) === normalizeUrn(huaweiLteSsdpDeviceType) || normalizeUrn(usn).includes(normalizeUrn(huaweiLteSsdpDeviceType));
|
||||
const matchedManufacturer = isKnownManufacturer(manufacturer) || isKnownManufacturer(server);
|
||||
const explicitHint = metadata.huaweiLte === true || metadata.huawei_lte === true;
|
||||
|
||||
if ((!matchedDeviceType || !matchedManufacturer) && !explicitHint) {
|
||||
return { matched: false, confidence: 'low', reason: 'SSDP record is not a Huawei LTE InternetGatewayDevice advertisement.' };
|
||||
}
|
||||
|
||||
const host = stringValue(recordArg.host) || parsed?.hostname;
|
||||
const port = urlPort(parsed) || huaweiLteDefaultHttpPort;
|
||||
const id = serial || udn || host;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: matchedDeviceType && matchedManufacturer && host && id ? 'certain' : host ? 'high' : 'medium',
|
||||
reason: matchedDeviceType && matchedManufacturer ? 'SSDP record matches Home Assistant Huawei LTE manifest metadata.' : 'SSDP record has explicit Huawei LTE metadata.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'ssdp',
|
||||
integrationDomain: huaweiLteDomain,
|
||||
id,
|
||||
host,
|
||||
port,
|
||||
name: friendlyName || model || huaweiLteDisplayName,
|
||||
manufacturer: manufacturer || huaweiLteDefaultManufacturer,
|
||||
model,
|
||||
serialNumber: serial,
|
||||
metadata: {
|
||||
...metadata,
|
||||
huaweiLte: true,
|
||||
ssdpDeviceType: deviceType,
|
||||
ssdpLocation: location,
|
||||
ssdpManufacturer: manufacturer,
|
||||
ssdpUsn: usn,
|
||||
upnpUdn: udn,
|
||||
url: presentationUrl ? normalizeEndpointUrl(presentationUrl) : host ? `http://${hostForUrl(host)}/` : undefined,
|
||||
verifySsl: false,
|
||||
upnp: recordArg.upnp,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class HuaweiLteManualMatcher implements IDiscoveryMatcher<IHuaweiLteManualEntry> {
|
||||
public id = 'huawei_lte-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual Huawei LTE URL, host, snapshot, raw-data, or injected client setup entries.';
|
||||
|
||||
public async matches(inputArg: IHuaweiLteManualEntry): Promise<IDiscoveryMatch> {
|
||||
const metadata = inputArg.metadata || {};
|
||||
const parsed = parseUrl(stringValue(inputArg.url || inputArg.host || metadata.url));
|
||||
const snapshot = snapshotValue(inputArg.snapshot || metadata.snapshot);
|
||||
const rawData = rawDataValue(inputArg.rawData || metadata.rawData);
|
||||
const hasManualData = Boolean(snapshot || rawData || inputArg.client || metadata.client);
|
||||
const text = [inputArg.name, inputArg.manufacturer, inputArg.model, metadata.name, metadata.manufacturer, metadata.model].filter(Boolean).join(' ').toLowerCase();
|
||||
const matched = Boolean(inputArg.url || inputArg.host || inputArg.username || inputArg.password || metadata.huaweiLte || metadata.huawei_lte || hasManualData || text.includes('huawei'));
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Huawei LTE setup hints.' };
|
||||
}
|
||||
|
||||
const host = parsed?.hostname || (inputArg.url ? undefined : inputArg.host) || snapshot?.device.host;
|
||||
const port = inputArg.port || urlPort(parsed) || snapshot?.device.port || (parsed?.protocol === 'https:' ? 443 : huaweiLteDefaultHttpPort);
|
||||
const macAddress = inputArg.macAddress || inputArg.macAddresses?.[0] || snapshot?.device.macAddresses[0];
|
||||
const id = inputArg.id || inputArg.uniqueId || inputArg.serialNumber || snapshot?.device.id || snapshot?.device.serialNumber || macAddress || (host ? `${host}:${port}` : undefined);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: host || hasManualData ? 'high' : 'medium',
|
||||
reason: 'Manual entry can start Huawei LTE setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: huaweiLteDomain,
|
||||
id,
|
||||
host,
|
||||
port,
|
||||
name: inputArg.name || snapshot?.device.name || huaweiLteDefaultDeviceName,
|
||||
manufacturer: inputArg.manufacturer || snapshot?.device.manufacturer || huaweiLteDefaultManufacturer,
|
||||
model: inputArg.model || snapshot?.device.model,
|
||||
serialNumber: inputArg.serialNumber || snapshot?.device.serialNumber,
|
||||
macAddress: HuaweiLteMapper.normalizeMac(macAddress),
|
||||
metadata: {
|
||||
...metadata,
|
||||
huaweiLte: true,
|
||||
discoveryProtocol: 'manual',
|
||||
url: normalizeEndpointUrl(inputArg.url || stringValue(metadata.url)) || (host ? `${parsed?.protocol || 'http:'}//${hostForUrl(host)}${port && port !== huaweiLteDefaultHttpPort ? `:${port}` : ''}/` : undefined),
|
||||
verifySsl: inputArg.verifySsl ?? booleanMetadata(metadata, 'verifySsl') ?? false,
|
||||
username: inputArg.username || stringValue(metadata.username),
|
||||
password: inputArg.password || stringValue(metadata.password),
|
||||
unauthenticatedMode: inputArg.unauthenticatedMode ?? booleanMetadata(metadata, 'unauthenticatedMode'),
|
||||
trackWiredClients: inputArg.trackWiredClients ?? booleanMetadata(metadata, 'trackWiredClients'),
|
||||
recipients: inputArg.recipients || stringList(metadata.recipients),
|
||||
snapshot,
|
||||
rawData,
|
||||
client: inputArg.client || metadata.client,
|
||||
commandExecutor: inputArg.commandExecutor || metadata.commandExecutor,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class HuaweiLteCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'huawei_lte-candidate-validator';
|
||||
public description = 'Validate Huawei LTE candidates have a local URL, host, snapshot, raw data, or injected client.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const text = [candidateArg.integrationDomain, candidateArg.name, candidateArg.manufacturer, candidateArg.model, metadata.manufacturer, metadata.ssdpManufacturer, metadata.ssdpDeviceType].filter(Boolean).join(' ').toLowerCase();
|
||||
const snapshot = snapshotValue(metadata.snapshot);
|
||||
const rawData = rawDataValue(metadata.rawData);
|
||||
const matched = candidateArg.integrationDomain === huaweiLteDomain
|
||||
|| metadata.huaweiLte === true
|
||||
|| metadata.huawei_lte === true
|
||||
|| text.includes('huawei')
|
||||
|| text.includes('soyea')
|
||||
|| normalizeUrn(stringValue(metadata.ssdpDeviceType)) === normalizeUrn(huaweiLteSsdpDeviceType);
|
||||
const hasUsableSource = Boolean(candidateArg.host || metadata.url || snapshot || rawData || metadata.client);
|
||||
const normalizedDeviceId = candidateArg.serialNumber || candidateArg.id || stringValue(metadata.upnpUdn) || snapshot?.device.serialNumber || snapshot?.device.macAddresses[0] || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || huaweiLteDefaultHttpPort}` : undefined);
|
||||
if (!matched || !hasUsableSource) {
|
||||
return {
|
||||
matched: false,
|
||||
confidence: matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Huawei LTE candidate lacks a host, URL, injected client, snapshot, or raw data.' : 'Candidate is not Huawei LTE.',
|
||||
normalizedDeviceId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
matched: true,
|
||||
confidence: candidateArg.host && normalizedDeviceId ? 'certain' : candidateArg.host ? 'high' : 'medium',
|
||||
reason: 'Candidate has Huawei LTE metadata and a usable local endpoint, client, snapshot, or raw data.',
|
||||
normalizedDeviceId,
|
||||
candidate: {
|
||||
...candidateArg,
|
||||
integrationDomain: huaweiLteDomain,
|
||||
port: candidateArg.port || huaweiLteDefaultHttpPort,
|
||||
manufacturer: candidateArg.manufacturer || huaweiLteDefaultManufacturer,
|
||||
metadata: {
|
||||
...metadata,
|
||||
huaweiLte: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createHuaweiLteDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: huaweiLteDomain, displayName: huaweiLteDisplayName })
|
||||
.addProbe(new HuaweiLteSsdpDiscoveryProbe())
|
||||
.addMatcher(new HuaweiLteSsdpMatcher())
|
||||
.addMatcher(new HuaweiLteManualMatcher())
|
||||
.addValidator(new HuaweiLteCandidateValidator());
|
||||
};
|
||||
|
||||
const parseSsdpResponse = (valueArg: string, remoteAddressArg: string): IHuaweiLteSsdpRecord | undefined => {
|
||||
const lines = valueArg.split(/\r?\n/g).map((lineArg) => lineArg.trim()).filter(Boolean);
|
||||
if (!lines.length || !/^HTTP\//i.test(lines[0])) {
|
||||
return undefined;
|
||||
}
|
||||
const headers: Record<string, string> = {};
|
||||
for (const line of lines.slice(1)) {
|
||||
const index = line.indexOf(':');
|
||||
if (index === -1) {
|
||||
continue;
|
||||
}
|
||||
headers[line.slice(0, index).toLowerCase()] = line.slice(index + 1).trim();
|
||||
}
|
||||
return { host: remoteAddressArg, headers, st: headers.st, usn: headers.usn, location: headers.location };
|
||||
};
|
||||
|
||||
const normalizeKeys = (recordArg: Record<string, unknown>): Record<string, unknown> => {
|
||||
const normalized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(recordArg)) {
|
||||
normalized[key.toLowerCase()] = value;
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const parseUrl = (valueArg: string | undefined): URL | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg) ? valueArg : `http://${valueArg}`;
|
||||
try {
|
||||
return new URL(withScheme);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeEndpointUrl = (valueArg: string | undefined): string | undefined => {
|
||||
const parsed = parseUrl(valueArg);
|
||||
if (!parsed) {
|
||||
return undefined;
|
||||
}
|
||||
return `${parsed.protocol}//${parsed.host}/`;
|
||||
};
|
||||
|
||||
const urlPort = (urlArg: URL | undefined): number | undefined => urlArg?.port ? Number(urlArg.port) : undefined;
|
||||
const normalizeUrn = (valueArg: string | undefined): string => (valueArg || '').toLowerCase().replace(/\.$/, '');
|
||||
|
||||
const isKnownManufacturer = (valueArg: string | undefined): boolean => {
|
||||
const value = (valueArg || '').toLowerCase();
|
||||
return huaweiLteSsdpManufacturers.some((manufacturerArg) => value.includes(manufacturerArg.toLowerCase())) || value.includes('huawei') || value.includes('soyea');
|
||||
};
|
||||
|
||||
const stripUuid = (valueArg: string | undefined): string | undefined => valueArg?.replace(/^uuid:/i, '').trim() || undefined;
|
||||
const hostForUrl = (hostArg: string): string => hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
|
||||
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
const booleanMetadata = (metadataArg: Record<string, unknown>, keyArg: string): boolean | undefined => typeof metadataArg[keyArg] === 'boolean' ? metadataArg[keyArg] as boolean : undefined;
|
||||
const stringList = (valueArg: unknown): string[] | undefined => Array.isArray(valueArg)
|
||||
? valueArg.map((itemArg) => stringValue(itemArg)).filter(Boolean) as string[]
|
||||
: stringValue(valueArg)?.split(',').map((itemArg) => itemArg.trim()).filter(Boolean);
|
||||
const snapshotValue = (valueArg: unknown): IHuaweiLteSnapshot | undefined => valueArg && typeof valueArg === 'object' && 'device' in valueArg && 'connection' in valueArg ? valueArg as IHuaweiLteSnapshot : undefined;
|
||||
const rawDataValue = (valueArg: unknown): Partial<IHuaweiLteRawData> | undefined => valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Partial<IHuaweiLteRawData> : undefined;
|
||||
@@ -0,0 +1,801 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
|
||||
import type {
|
||||
IHuaweiLteCommandRequest,
|
||||
IHuaweiLteConfig,
|
||||
IHuaweiLteHostSnapshot,
|
||||
IHuaweiLteRawData,
|
||||
IHuaweiLteSnapshot,
|
||||
IHuaweiLteValueMap,
|
||||
THuaweiLteRawDataKey,
|
||||
THuaweiLteSnapshotSource,
|
||||
} from './huawei_lte.types.js';
|
||||
import {
|
||||
huaweiLteDefaultDeviceName,
|
||||
huaweiLteDefaultHttpPort,
|
||||
huaweiLteDefaultHttpsPort,
|
||||
huaweiLteDefaultManufacturer,
|
||||
huaweiLteDisplayName,
|
||||
huaweiLteDomain,
|
||||
} from './huawei_lte.types.js';
|
||||
|
||||
interface IHuaweiLteSnapshotOptions {
|
||||
config: IHuaweiLteConfig;
|
||||
rawData?: Partial<IHuaweiLteRawData>;
|
||||
online?: boolean;
|
||||
source?: THuaweiLteSnapshotSource;
|
||||
suspended?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface IHuaweiLteEntityDefinition {
|
||||
key: string;
|
||||
name: string;
|
||||
value: unknown;
|
||||
platform?: TEntityPlatform;
|
||||
unit?: string;
|
||||
deviceClass?: string;
|
||||
stateClass?: string;
|
||||
entityCategory?: string;
|
||||
available?: boolean;
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const rawSensorGroups: Array<{ key: THuaweiLteRawDataKey; name: string; category?: string; exclude?: Set<string> }> = [
|
||||
{ key: 'deviceInformation', name: 'Device', category: 'diagnostic', exclude: new Set(['DeviceName', 'SerialNumber', 'HardwareVersion', 'SoftwareVersion', 'MacAddress1', 'MacAddress2', 'WifiMacAddrWl0', 'WifiMacAddrWl1']) },
|
||||
{ key: 'deviceBasicInformation', name: 'Device Basic', category: 'diagnostic', exclude: new Set(['devicename', 'SerialNumber', 'SoftwareVersion']) },
|
||||
{ key: 'deviceSignal', name: 'Signal', category: 'diagnostic' },
|
||||
{ key: 'monitoringCheckNotifications', name: 'Notifications' },
|
||||
{ key: 'monitoringMonthStatistics', name: 'Month' },
|
||||
{ key: 'monitoringStatus', name: 'Status', category: 'diagnostic' },
|
||||
{ key: 'monitoringTrafficStatistics', name: 'Traffic' },
|
||||
{ key: 'netCurrentPlmn', name: 'Network', category: 'diagnostic' },
|
||||
{ key: 'netNetMode', name: 'Network Mode', category: 'diagnostic' },
|
||||
{ key: 'smsSmsCount', name: 'SMS' },
|
||||
];
|
||||
|
||||
const knownSensorUnits: Record<string, string> = {
|
||||
BatteryPercent: '%',
|
||||
CurrentConnectTime: 's',
|
||||
TotalConnectTime: 's',
|
||||
CurrentDownload: 'B',
|
||||
CurrentUpload: 'B',
|
||||
CurrentDownloadRate: 'B/s',
|
||||
CurrentUploadRate: 'B/s',
|
||||
MaxDownloadRate: 'B/s',
|
||||
MaxUploadRate: 'B/s',
|
||||
TotalDownload: 'B',
|
||||
TotalUpload: 'B',
|
||||
CurrentMonthDownload: 'B',
|
||||
CurrentMonthUpload: 'B',
|
||||
CurrentDayUsed: 'B',
|
||||
rssi: 'dBm',
|
||||
rsrp: 'dBm',
|
||||
nrrsrp: 'dBm',
|
||||
rsrq: 'dB',
|
||||
nrrsrq: 'dB',
|
||||
sinr: 'dB',
|
||||
nrsinr: 'dB',
|
||||
};
|
||||
|
||||
const knownDeviceClasses: Record<string, string> = {
|
||||
BatteryPercent: 'battery',
|
||||
CurrentDownload: 'data_size',
|
||||
CurrentUpload: 'data_size',
|
||||
TotalDownload: 'data_size',
|
||||
TotalUpload: 'data_size',
|
||||
CurrentMonthDownload: 'data_size',
|
||||
CurrentMonthUpload: 'data_size',
|
||||
CurrentDayUsed: 'data_size',
|
||||
CurrentDownloadRate: 'data_rate',
|
||||
CurrentUploadRate: 'data_rate',
|
||||
MaxDownloadRate: 'data_rate',
|
||||
MaxUploadRate: 'data_rate',
|
||||
CurrentConnectTime: 'duration',
|
||||
TotalConnectTime: 'duration',
|
||||
rssi: 'signal_strength',
|
||||
rsrp: 'signal_strength',
|
||||
nrrsrp: 'signal_strength',
|
||||
rsrq: 'signal_strength',
|
||||
nrrsrq: 'signal_strength',
|
||||
sinr: 'signal_strength',
|
||||
nrsinr: 'signal_strength',
|
||||
};
|
||||
|
||||
const networkModeOptions = ['00', '0302', '0301', '03', '0201', '02', '01'];
|
||||
|
||||
export class HuaweiLteMapper {
|
||||
public static toSnapshot(optionsArg: IHuaweiLteSnapshotOptions): IHuaweiLteSnapshot {
|
||||
if (optionsArg.config.snapshot && !optionsArg.rawData) {
|
||||
return this.normalizeSnapshot(this.clone(optionsArg.config.snapshot), optionsArg.config, optionsArg.source || 'snapshot', optionsArg.suspended);
|
||||
}
|
||||
|
||||
const rawData = this.rawData(optionsArg.config, optionsArg.rawData);
|
||||
const hasData = this.hasRawData(rawData);
|
||||
const device = this.deviceInfo(optionsArg.config, rawData);
|
||||
const connection = this.connection(rawData);
|
||||
const signal = this.signal(rawData.deviceSignal || {});
|
||||
const traffic = this.traffic(rawData);
|
||||
const sms = this.sms(rawData);
|
||||
const network = this.network(rawData);
|
||||
const hosts = this.hosts(rawData);
|
||||
const online = optionsArg.online ?? optionsArg.config.online ?? hasData;
|
||||
const source = optionsArg.source || (hasData ? 'manual' : 'runtime');
|
||||
const localControl = Boolean(optionsArg.config.url || optionsArg.config.host || optionsArg.config.commandExecutor || optionsArg.config.client?.execute);
|
||||
|
||||
return {
|
||||
device,
|
||||
connection,
|
||||
signal,
|
||||
traffic,
|
||||
sms,
|
||||
network,
|
||||
hosts,
|
||||
capabilities: {
|
||||
localControl,
|
||||
mobileDataSwitch: localControl && connection.mobileDataEnabled !== undefined,
|
||||
guestWifiSwitch: localControl && connection.guestWifiEnabled !== undefined,
|
||||
reboot: localControl,
|
||||
clearTraffic: localControl,
|
||||
sendSms: localControl,
|
||||
networkModeSelect: localControl && network.networkMode !== undefined,
|
||||
},
|
||||
rawData: hasData ? this.clone(rawData) : undefined,
|
||||
online,
|
||||
updatedAt: rawData.fetchedAt || new Date().toISOString(),
|
||||
source,
|
||||
suspended: optionsArg.suspended,
|
||||
error: optionsArg.error,
|
||||
};
|
||||
}
|
||||
|
||||
public static toDevices(snapshotArg: IHuaweiLteSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||
{ id: 'mobile_connection', capability: 'sensor', name: 'Mobile connection', readable: true, writable: false },
|
||||
{ id: 'mobile_data', capability: 'switch', name: 'Mobile data', readable: true, writable: snapshotArg.capabilities.mobileDataSwitch },
|
||||
{ id: 'wifi_status', capability: 'sensor', name: 'WiFi status', readable: true, writable: false },
|
||||
{ id: 'host_count', capability: 'sensor', name: 'Connected clients', readable: true, writable: false },
|
||||
];
|
||||
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||
{ featureId: 'mobile_connection', value: snapshotArg.connection.mobileConnected ?? null, updatedAt },
|
||||
{ featureId: 'mobile_data', value: snapshotArg.connection.mobileDataEnabled ?? null, updatedAt },
|
||||
{ featureId: 'wifi_status', value: snapshotArg.connection.wifiEnabled ?? null, updatedAt },
|
||||
{ featureId: 'host_count', value: snapshotArg.hosts.filter((hostArg) => hostArg.active).length, updatedAt },
|
||||
];
|
||||
|
||||
for (const key of ['rsrp', 'rsrq', 'sinr', 'nrrsrp', 'nrrsrq', 'nrsinr'] as const) {
|
||||
const value = snapshotArg.signal[key];
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
features.push({ id: key, capability: 'sensor', name: this.humanName(key), readable: true, writable: false, unit: knownSensorUnits[key] });
|
||||
state.push({ featureId: key, value, updatedAt });
|
||||
}
|
||||
if (snapshotArg.traffic.currentDownloadRate !== undefined) {
|
||||
features.push({ id: 'current_download_rate', capability: 'sensor', name: 'Current download rate', readable: true, writable: false, unit: 'B/s' });
|
||||
state.push({ featureId: 'current_download_rate', value: snapshotArg.traffic.currentDownloadRate, updatedAt });
|
||||
}
|
||||
if (snapshotArg.sms.unread !== undefined) {
|
||||
features.push({ id: 'sms_unread', capability: 'sensor', name: 'Unread SMS', readable: true, writable: false });
|
||||
state.push({ featureId: 'sms_unread', value: snapshotArg.sms.unread, updatedAt });
|
||||
}
|
||||
if (snapshotArg.capabilities.sendSms) {
|
||||
features.push({ id: 'send_sms', capability: 'notification', name: 'Send SMS', readable: false, writable: true });
|
||||
}
|
||||
|
||||
return [{
|
||||
id: this.deviceId(snapshotArg),
|
||||
integrationDomain: huaweiLteDomain,
|
||||
name: snapshotArg.device.name,
|
||||
protocol: snapshotArg.device.host ? 'http' : 'unknown',
|
||||
manufacturer: snapshotArg.device.manufacturer,
|
||||
model: snapshotArg.device.model || huaweiLteDisplayName,
|
||||
online: snapshotArg.online && !snapshotArg.suspended,
|
||||
features,
|
||||
state,
|
||||
metadata: this.cleanAttributes({
|
||||
host: snapshotArg.device.host,
|
||||
port: snapshotArg.device.port,
|
||||
url: snapshotArg.device.url,
|
||||
serialNumber: snapshotArg.device.serialNumber,
|
||||
macAddresses: snapshotArg.device.macAddresses,
|
||||
hardwareVersion: snapshotArg.device.hardwareVersion,
|
||||
softwareVersion: snapshotArg.device.softwareVersion,
|
||||
upnpUdn: snapshotArg.device.upnpUdn,
|
||||
source: snapshotArg.source,
|
||||
suspended: snapshotArg.suspended,
|
||||
error: snapshotArg.error,
|
||||
}),
|
||||
}];
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IHuaweiLteSnapshot): IIntegrationEntity[] {
|
||||
const deviceId = this.deviceId(snapshotArg);
|
||||
const uniqueBase = this.uniqueBase(snapshotArg);
|
||||
const baseName = snapshotArg.device.name;
|
||||
const entities: IIntegrationEntity[] = [];
|
||||
|
||||
this.pushEntity(entities, snapshotArg, deviceId, uniqueBase, {
|
||||
key: 'mobile_connection',
|
||||
name: `${baseName} Mobile Connection`,
|
||||
platform: 'binary_sensor',
|
||||
value: snapshotArg.connection.mobileConnected ? 'on' : 'off',
|
||||
deviceClass: 'connectivity',
|
||||
attributes: { key: 'mobile_connection', rawState: snapshotArg.connection.connectionStatus },
|
||||
available: snapshotArg.connection.mobileConnected !== undefined,
|
||||
});
|
||||
this.pushEntity(entities, snapshotArg, deviceId, uniqueBase, {
|
||||
key: 'wifi_status',
|
||||
name: `${baseName} WiFi Status`,
|
||||
platform: 'binary_sensor',
|
||||
value: snapshotArg.connection.wifiEnabled ? 'on' : 'off',
|
||||
deviceClass: 'connectivity',
|
||||
attributes: { key: 'wifi_status' },
|
||||
available: snapshotArg.connection.wifiEnabled !== undefined,
|
||||
});
|
||||
this.pushBooleanEntity(entities, snapshotArg, deviceId, uniqueBase, 'wifi_24ghz_status', `${baseName} 2.4GHz WiFi Status`, snapshotArg.connection.wifi24ghzEnabled);
|
||||
this.pushBooleanEntity(entities, snapshotArg, deviceId, uniqueBase, 'wifi_5ghz_status', `${baseName} 5GHz WiFi Status`, snapshotArg.connection.wifi5ghzEnabled);
|
||||
this.pushBooleanEntity(entities, snapshotArg, deviceId, uniqueBase, 'sms_storage_full', `${baseName} SMS Storage Full`, snapshotArg.connection.smsStorageFull);
|
||||
|
||||
if (snapshotArg.connection.mobileDataEnabled !== undefined) {
|
||||
this.pushEntity(entities, snapshotArg, deviceId, uniqueBase, {
|
||||
key: 'mobile_data',
|
||||
name: `${baseName} Mobile Data`,
|
||||
platform: 'switch',
|
||||
value: snapshotArg.connection.mobileDataEnabled ? 'on' : 'off',
|
||||
attributes: { key: 'mobile_data', writable: snapshotArg.capabilities.mobileDataSwitch },
|
||||
});
|
||||
}
|
||||
if (snapshotArg.connection.guestWifiEnabled !== undefined) {
|
||||
this.pushEntity(entities, snapshotArg, deviceId, uniqueBase, {
|
||||
key: 'wifi_guest_network',
|
||||
name: `${baseName} WiFi Guest Network`,
|
||||
platform: 'switch',
|
||||
value: snapshotArg.connection.guestWifiEnabled ? 'on' : 'off',
|
||||
attributes: { key: 'wifi_guest_network', writable: snapshotArg.capabilities.guestWifiSwitch },
|
||||
});
|
||||
}
|
||||
|
||||
for (const definition of this.sensorDefinitions(snapshotArg)) {
|
||||
this.pushEntity(entities, snapshotArg, deviceId, uniqueBase, definition);
|
||||
}
|
||||
|
||||
if (snapshotArg.network.networkMode !== undefined) {
|
||||
this.pushEntity(entities, snapshotArg, deviceId, uniqueBase, {
|
||||
key: 'preferred_network_mode',
|
||||
name: `${baseName} Preferred Network Mode`,
|
||||
platform: 'select',
|
||||
value: snapshotArg.network.networkMode,
|
||||
entityCategory: 'config',
|
||||
attributes: { key: 'preferred_network_mode', options: networkModeOptions, writable: snapshotArg.capabilities.networkModeSelect },
|
||||
});
|
||||
}
|
||||
|
||||
this.pushEntity(entities, snapshotArg, deviceId, uniqueBase, {
|
||||
key: 'clear_traffic_statistics',
|
||||
name: `${baseName} Clear Traffic Statistics`,
|
||||
platform: 'button',
|
||||
value: 'available',
|
||||
entityCategory: 'config',
|
||||
attributes: { key: 'clear_traffic_statistics', writable: snapshotArg.capabilities.clearTraffic },
|
||||
available: snapshotArg.online && snapshotArg.capabilities.clearTraffic,
|
||||
});
|
||||
this.pushEntity(entities, snapshotArg, deviceId, uniqueBase, {
|
||||
key: 'restart',
|
||||
name: `${baseName} Restart`,
|
||||
platform: 'button',
|
||||
value: 'available',
|
||||
entityCategory: 'config',
|
||||
attributes: { key: 'restart', writable: snapshotArg.capabilities.reboot },
|
||||
available: snapshotArg.online && snapshotArg.capabilities.reboot,
|
||||
});
|
||||
|
||||
return entities.filter((entityArg) => entityArg.available || entityArg.state !== undefined);
|
||||
}
|
||||
|
||||
public static commandForService(snapshotArg: IHuaweiLteSnapshot, requestArg: IServiceCallRequest): IHuaweiLteCommandRequest | undefined {
|
||||
if (requestArg.domain === huaweiLteDomain) {
|
||||
if (requestArg.service === 'refresh') {
|
||||
return { action: 'refresh', service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (requestArg.service === 'set_mobile_dataswitch') {
|
||||
const enabled = this.booleanValue(requestArg.data?.enabled ?? requestArg.data?.dataswitch ?? requestArg.data?.state);
|
||||
return enabled === undefined ? undefined : { action: 'set_mobile_dataswitch', enabled, service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (requestArg.service === 'set_wifi_guest_network') {
|
||||
const enabled = this.booleanValue(requestArg.data?.enabled ?? requestArg.data?.state);
|
||||
return enabled === undefined ? undefined : { action: 'set_wifi_guest_network', enabled, service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (requestArg.service === 'reboot' || requestArg.service === 'restart') {
|
||||
return { action: 'reboot', service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (requestArg.service === 'clear_traffic' || requestArg.service === 'clear_traffic_statistics') {
|
||||
return { action: 'clear_traffic', service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (requestArg.service === 'send_sms') {
|
||||
const message = this.stringValue(requestArg.data?.message);
|
||||
const phoneNumbers = this.stringList(requestArg.data?.phone_numbers ?? requestArg.data?.phoneNumbers ?? requestArg.data?.target ?? requestArg.data?.recipients);
|
||||
return { action: 'send_sms', phoneNumbers, message, service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (requestArg.service === 'set_net_mode') {
|
||||
const networkMode = this.stringValue(requestArg.data?.network_mode ?? requestArg.data?.networkMode);
|
||||
return networkMode ? { action: 'set_net_mode', networkMode, service: requestArg.service, target: requestArg.target, data: requestArg.data } : undefined;
|
||||
}
|
||||
if (requestArg.service === 'suspend_integration') {
|
||||
return { action: 'suspend_integration', service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (requestArg.service === 'resume_integration') {
|
||||
return { action: 'resume_integration', service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
}
|
||||
|
||||
if (requestArg.domain === 'switch') {
|
||||
const enabled = requestArg.service === 'turn_on' ? true : requestArg.service === 'turn_off' ? false : undefined;
|
||||
if (enabled === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const target = `${requestArg.target.entityId || ''} ${requestArg.target.deviceId || ''}`.toLowerCase();
|
||||
if (target.includes('guest')) {
|
||||
return { action: 'set_wifi_guest_network', enabled, service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (target.includes('mobile') || target.includes('dataswitch') || target.includes('data')) {
|
||||
return { action: 'set_mobile_dataswitch', enabled, service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
}
|
||||
|
||||
if (requestArg.domain === 'button' && requestArg.service === 'press') {
|
||||
const target = `${requestArg.target.entityId || ''} ${requestArg.target.deviceId || ''}`.toLowerCase();
|
||||
if (target.includes('clear') || target.includes('traffic')) {
|
||||
return { action: 'clear_traffic', service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (target.includes('restart') || target.includes('reboot')) {
|
||||
return { action: 'reboot', service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
}
|
||||
|
||||
if (requestArg.domain === 'select' && requestArg.service === 'select_option') {
|
||||
const target = `${requestArg.target.entityId || ''} ${requestArg.target.deviceId || ''}`.toLowerCase();
|
||||
const option = this.stringValue(requestArg.data?.option);
|
||||
if (option && target.includes('network')) {
|
||||
return { action: 'set_net_mode', networkMode: option, service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
}
|
||||
|
||||
void snapshotArg;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public static deviceId(snapshotArg: IHuaweiLteSnapshot): string {
|
||||
return `${huaweiLteDomain}.router.${this.uniqueBase(snapshotArg)}`;
|
||||
}
|
||||
|
||||
public static snapshotId(snapshotArg: IHuaweiLteSnapshot): string | undefined {
|
||||
return snapshotArg.device.serialNumber || snapshotArg.device.upnpUdn || snapshotArg.device.macAddresses[0] || snapshotArg.device.host;
|
||||
}
|
||||
|
||||
public static slug(valueArg: string): string {
|
||||
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || huaweiLteDomain;
|
||||
}
|
||||
|
||||
public static normalizeMac(valueArg: unknown): string | undefined {
|
||||
const value = this.stringValue(valueArg);
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const compact = value.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||
return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : value.toLowerCase();
|
||||
}
|
||||
|
||||
public static getDeviceMacs(deviceInfoArg: IHuaweiLteValueMap = {}, wlanSettingsArg: IHuaweiLteValueMap = {}): string[] {
|
||||
const values: unknown[] = [
|
||||
deviceInfoArg.MacAddress1,
|
||||
deviceInfoArg.MacAddress2,
|
||||
deviceInfoArg.WifiMacAddrWl0,
|
||||
deviceInfoArg.WifiMacAddrWl1,
|
||||
];
|
||||
for (const ssid of this.asArray(this.objectValue(wlanSettingsArg.Ssids)?.Ssid)) {
|
||||
values.push(this.objectValue(ssid)?.WifiMac);
|
||||
}
|
||||
return [...new Set(values.map((valueArg) => this.normalizeMac(valueArg)).filter(Boolean) as string[])].sort();
|
||||
}
|
||||
|
||||
private static normalizeSnapshot(snapshotArg: IHuaweiLteSnapshot, configArg: IHuaweiLteConfig, sourceArg: THuaweiLteSnapshotSource, suspendedArg?: boolean): IHuaweiLteSnapshot {
|
||||
const rawData = this.rawData(configArg, snapshotArg.rawData);
|
||||
const derived = this.toSnapshot({ config: { ...configArg, snapshot: undefined }, rawData, online: snapshotArg.online, source: sourceArg, suspended: suspendedArg ?? snapshotArg.suspended, error: snapshotArg.error });
|
||||
return {
|
||||
...derived,
|
||||
...snapshotArg,
|
||||
device: { ...derived.device, ...snapshotArg.device },
|
||||
connection: { ...derived.connection, ...snapshotArg.connection },
|
||||
signal: { ...derived.signal, ...snapshotArg.signal },
|
||||
traffic: { ...derived.traffic, ...snapshotArg.traffic },
|
||||
sms: { ...derived.sms, ...snapshotArg.sms },
|
||||
network: { ...derived.network, ...snapshotArg.network },
|
||||
capabilities: { ...derived.capabilities, ...snapshotArg.capabilities },
|
||||
hosts: snapshotArg.hosts?.length ? snapshotArg.hosts : derived.hosts,
|
||||
rawData: this.hasRawData(rawData) ? rawData : snapshotArg.rawData,
|
||||
source: snapshotArg.source || sourceArg,
|
||||
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
|
||||
suspended: suspendedArg ?? snapshotArg.suspended,
|
||||
};
|
||||
}
|
||||
|
||||
private static rawData(configArg: IHuaweiLteConfig, rawDataArg?: Partial<IHuaweiLteRawData>): IHuaweiLteRawData {
|
||||
const resources = this.cleanAttributes({ ...configArg.rawData?.resources, ...rawDataArg?.resources }) as Partial<Record<THuaweiLteRawDataKey, IHuaweiLteValueMap>>;
|
||||
const rawData = this.cleanAttributes({
|
||||
...configArg.rawData,
|
||||
...rawDataArg,
|
||||
resources,
|
||||
errors: this.cleanAttributes({ ...configArg.rawData?.errors, ...rawDataArg?.errors }),
|
||||
fetchedAt: rawDataArg?.fetchedAt || configArg.rawData?.fetchedAt,
|
||||
}) as IHuaweiLteRawData;
|
||||
for (const [key, value] of Object.entries(resources)) {
|
||||
if (value && !(key in rawData)) {
|
||||
(rawData as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
return rawData;
|
||||
}
|
||||
|
||||
private static deviceInfo(configArg: IHuaweiLteConfig, rawDataArg: IHuaweiLteRawData): IHuaweiLteSnapshot['device'] {
|
||||
const parsed = this.parseEndpoint(configArg.url || configArg.host);
|
||||
const host = parsed?.hostname || (configArg.url ? undefined : configArg.host);
|
||||
const ssl = configArg.ssl ?? parsed?.ssl;
|
||||
const port = configArg.port || parsed?.port || (host ? ssl ? huaweiLteDefaultHttpsPort : huaweiLteDefaultHttpPort : undefined);
|
||||
const url = configArg.url || (host ? `${ssl ? 'https' : 'http'}://${this.hostForUrl(host)}${port && port !== (ssl ? huaweiLteDefaultHttpsPort : huaweiLteDefaultHttpPort) ? `:${port}` : ''}/` : undefined);
|
||||
const info = rawDataArg.deviceInformation || {};
|
||||
const basic = rawDataArg.deviceBasicInformation || {};
|
||||
const wlan = rawDataArg.wlanMultiBasicSettings || {};
|
||||
const macAddresses = [...new Set([...(configArg.macAddresses || []), configArg.macAddress, ...this.getDeviceMacs(info, wlan)].map((valueArg) => this.normalizeMac(valueArg)).filter(Boolean) as string[])].sort();
|
||||
const serialNumber = configArg.serialNumber || this.stringValue(info.SerialNumber || basic.SerialNumber || basic.serialnumber);
|
||||
const name = configArg.name || this.stringValue(info.DeviceName || basic.devicename || basic.DeviceName) || host || huaweiLteDefaultDeviceName;
|
||||
const model = configArg.model || this.stringValue(info.DeviceName || basic.devicename || basic.ProductFamily || basic.Classify);
|
||||
return {
|
||||
id: configArg.uniqueId || serialNumber || configArg.upnpUdn || macAddresses[0] || (host ? `${host}:${port || huaweiLteDefaultHttpPort}` : undefined) || name,
|
||||
name,
|
||||
manufacturer: configArg.manufacturer || huaweiLteDefaultManufacturer,
|
||||
model,
|
||||
serialNumber,
|
||||
hardwareVersion: this.stringValue(info.HardwareVersion || basic.HardwareVersion),
|
||||
softwareVersion: this.stringValue(info.SoftwareVersion || basic.SoftwareVersion),
|
||||
host,
|
||||
port,
|
||||
url,
|
||||
ssl,
|
||||
verifySsl: configArg.verifySsl,
|
||||
macAddresses,
|
||||
upnpUdn: configArg.upnpUdn,
|
||||
};
|
||||
}
|
||||
|
||||
private static connection(rawDataArg: IHuaweiLteRawData): IHuaweiLteSnapshot['connection'] {
|
||||
const monitoring = rawDataArg.monitoringStatus || {};
|
||||
const notifications = rawDataArg.monitoringCheckNotifications || {};
|
||||
const wifi = rawDataArg.wlanWifiFeatureSwitch || {};
|
||||
const dialup = rawDataArg.dialupMobileDataswitch || {};
|
||||
const guest = rawDataArg.wlanWifiGuestNetworkSwitch || {};
|
||||
const connectionStatus = this.stringValue(monitoring.ConnectionStatus);
|
||||
const connectionNumber = this.numberValue(connectionStatus);
|
||||
return this.cleanAttributes({
|
||||
mobileConnected: connectionNumber === 901 || connectionNumber === 903 ? true : connectionNumber === undefined ? undefined : false,
|
||||
connectionStatus,
|
||||
mobileDataEnabled: this.booleanString(dialup.dataswitch),
|
||||
wifiEnabled: this.booleanString(monitoring.WifiStatus),
|
||||
wifi24ghzEnabled: this.booleanString(wifi.wifi24g_switch_enable),
|
||||
wifi5ghzEnabled: this.booleanString(wifi.wifi5g_enabled),
|
||||
guestWifiEnabled: this.booleanString(guest.WifiEnable),
|
||||
smsStorageFull: notifications.SmsStorageFull === undefined ? undefined : this.numberValue(notifications.SmsStorageFull) !== 0,
|
||||
}) as IHuaweiLteSnapshot['connection'];
|
||||
}
|
||||
|
||||
private static signal(valuesArg: IHuaweiLteValueMap): IHuaweiLteSnapshot['signal'] {
|
||||
const signal: IHuaweiLteSnapshot['signal'] = { values: this.cleanAttributes({ ...valuesArg }) };
|
||||
for (const key of ['rssi', 'rsrp', 'rsrq', 'sinr', 'nrrsrp', 'nrrsrq', 'nrsinr'] as const) {
|
||||
const value = this.numberValue(valuesArg[key]);
|
||||
if (value !== undefined) {
|
||||
signal[key] = value;
|
||||
}
|
||||
}
|
||||
return signal;
|
||||
}
|
||||
|
||||
private static traffic(rawDataArg: IHuaweiLteRawData): IHuaweiLteSnapshot['traffic'] {
|
||||
const traffic = rawDataArg.monitoringTrafficStatistics || {};
|
||||
const month = rawDataArg.monitoringMonthStatistics || {};
|
||||
return this.cleanAttributes({
|
||||
currentDownload: this.numberValue(traffic.CurrentDownload),
|
||||
currentUpload: this.numberValue(traffic.CurrentUpload),
|
||||
currentDownloadRate: this.numberValue(traffic.CurrentDownloadRate),
|
||||
currentUploadRate: this.numberValue(traffic.CurrentUploadRate),
|
||||
totalDownload: this.numberValue(traffic.TotalDownload),
|
||||
totalUpload: this.numberValue(traffic.TotalUpload),
|
||||
currentConnectTime: this.numberValue(traffic.CurrentConnectTime),
|
||||
totalConnectTime: this.numberValue(traffic.TotalConnectTime),
|
||||
currentMonthDownload: this.numberValue(month.CurrentMonthDownload),
|
||||
currentMonthUpload: this.numberValue(month.CurrentMonthUpload),
|
||||
currentDayUsed: this.numberValue(month.CurrentDayUsed),
|
||||
}) as IHuaweiLteSnapshot['traffic'];
|
||||
}
|
||||
|
||||
private static sms(rawDataArg: IHuaweiLteRawData): IHuaweiLteSnapshot['sms'] {
|
||||
const smsCount = rawDataArg.smsSmsCount || {};
|
||||
const notifications = rawDataArg.monitoringCheckNotifications || {};
|
||||
return this.cleanAttributes({
|
||||
unread: this.numberValue(notifications.UnreadMessage ?? smsCount.LocalUnread),
|
||||
localUnread: this.numberValue(smsCount.LocalUnread),
|
||||
localInbox: this.numberValue(smsCount.LocalInbox),
|
||||
localOutbox: this.numberValue(smsCount.LocalOutbox),
|
||||
localDraft: this.numberValue(smsCount.LocalDraft),
|
||||
localMax: this.numberValue(smsCount.LocalMax),
|
||||
simUnread: this.numberValue(smsCount.SimUnread),
|
||||
simInbox: this.numberValue(smsCount.SimInbox),
|
||||
simOutbox: this.numberValue(smsCount.SimOutbox),
|
||||
simDraft: this.numberValue(smsCount.SimDraft),
|
||||
simMax: this.numberValue(smsCount.SimMax),
|
||||
}) as IHuaweiLteSnapshot['sms'];
|
||||
}
|
||||
|
||||
private static network(rawDataArg: IHuaweiLteRawData): IHuaweiLteSnapshot['network'] {
|
||||
const plmn = rawDataArg.netCurrentPlmn || {};
|
||||
const netMode = rawDataArg.netNetMode || {};
|
||||
return this.cleanAttributes({
|
||||
operatorName: this.stringValue(plmn.FullName || plmn.ShortName || plmn.Spn),
|
||||
operatorCode: this.stringValue(plmn.Numeric),
|
||||
operatorSearchMode: this.stringValue(plmn.State),
|
||||
networkMode: this.stringValue(netMode.NetworkMode),
|
||||
}) as IHuaweiLteSnapshot['network'];
|
||||
}
|
||||
|
||||
private static hosts(rawDataArg: IHuaweiLteRawData): IHuaweiLteHostSnapshot[] {
|
||||
const source = rawDataArg.lanHostInfo || rawDataArg.wlanHostList || {};
|
||||
const hosts = this.asArray(this.objectValue(source.Hosts)?.Host);
|
||||
return hosts.map((hostArg) => this.objectValue(hostArg)).filter(Boolean).map((hostArg) => {
|
||||
const macAddress = this.normalizeMac(hostArg!.MacAddress) || this.stringValue(hostArg!.MacAddress) || '';
|
||||
const hostname = this.stringValue(hostArg!.HostName);
|
||||
const ipAddress = this.stringValue(hostArg!.IpAddress)?.split(';', 2)[0] || undefined;
|
||||
const interfaceType = this.stringValue(hostArg!.InterfaceType || 'Wireless');
|
||||
return this.cleanAttributes({
|
||||
id: macAddress || hostname || ipAddress || 'unknown',
|
||||
macAddress,
|
||||
hostname,
|
||||
ipAddress,
|
||||
active: this.stringValue(hostArg!.Active || '1') !== '0',
|
||||
wireless: interfaceType !== 'Ethernet',
|
||||
isLocalDevice: this.stringValue(hostArg!.isLocalDevice || '0') === '1',
|
||||
attributes: this.cleanAttributes({
|
||||
addressSource: hostArg!.AddressSource,
|
||||
associatedSsid: hostArg!.AssociatedSsid,
|
||||
interfaceType,
|
||||
}),
|
||||
}) as IHuaweiLteHostSnapshot;
|
||||
}).filter((hostArg) => Boolean(hostArg.macAddress || hostArg.hostname || hostArg.ipAddress));
|
||||
}
|
||||
|
||||
private static sensorDefinitions(snapshotArg: IHuaweiLteSnapshot): IHuaweiLteEntityDefinition[] {
|
||||
const definitions: IHuaweiLteEntityDefinition[] = [];
|
||||
definitions.push({
|
||||
key: 'host_count',
|
||||
name: `${snapshotArg.device.name} Connected Clients`,
|
||||
value: snapshotArg.hosts.filter((hostArg) => hostArg.active).length,
|
||||
unit: undefined,
|
||||
stateClass: 'measurement',
|
||||
attributes: { key: 'host_count', trackedHosts: snapshotArg.hosts.length },
|
||||
});
|
||||
for (const group of rawSensorGroups) {
|
||||
const items = snapshotArg.rawData?.[group.key];
|
||||
if (!items) {
|
||||
continue;
|
||||
}
|
||||
for (const [item, rawValue] of Object.entries(items)) {
|
||||
if (group.exclude?.has(item) || !this.isScalar(rawValue)) {
|
||||
continue;
|
||||
}
|
||||
const formatted = this.formatSensorValue(item, rawValue);
|
||||
if (formatted.value === undefined || formatted.value === null || formatted.value === '') {
|
||||
continue;
|
||||
}
|
||||
definitions.push({
|
||||
key: `${group.key}_${item}`,
|
||||
name: `${snapshotArg.device.name} ${group.name} ${this.humanName(item)}`,
|
||||
value: formatted.value,
|
||||
unit: formatted.unit,
|
||||
deviceClass: knownDeviceClasses[item],
|
||||
stateClass: typeof formatted.value === 'number' ? 'measurement' : undefined,
|
||||
entityCategory: group.category,
|
||||
attributes: { rawKey: group.key, item, rawValue },
|
||||
});
|
||||
}
|
||||
}
|
||||
return definitions;
|
||||
}
|
||||
|
||||
private static pushBooleanEntity(entitiesArg: IIntegrationEntity[], snapshotArg: IHuaweiLteSnapshot, deviceIdArg: string, uniqueBaseArg: string, keyArg: string, nameArg: string, valueArg: boolean | undefined): void {
|
||||
if (valueArg === undefined) {
|
||||
return;
|
||||
}
|
||||
this.pushEntity(entitiesArg, snapshotArg, deviceIdArg, uniqueBaseArg, {
|
||||
key: keyArg,
|
||||
name: nameArg,
|
||||
platform: 'binary_sensor',
|
||||
value: valueArg ? 'on' : 'off',
|
||||
deviceClass: 'connectivity',
|
||||
attributes: { key: keyArg },
|
||||
});
|
||||
}
|
||||
|
||||
private static pushEntity(entitiesArg: IIntegrationEntity[], snapshotArg: IHuaweiLteSnapshot, deviceIdArg: string, uniqueBaseArg: string, definitionArg: IHuaweiLteEntityDefinition): void {
|
||||
const platform = definitionArg.platform || 'sensor';
|
||||
entitiesArg.push({
|
||||
id: `${platform}.${this.slug(definitionArg.name)}`,
|
||||
uniqueId: `${huaweiLteDomain}_${uniqueBaseArg}_${this.slug(definitionArg.key)}`,
|
||||
integrationDomain: huaweiLteDomain,
|
||||
deviceId: deviceIdArg,
|
||||
platform,
|
||||
name: definitionArg.name,
|
||||
state: this.stateValue(definitionArg.value),
|
||||
attributes: this.cleanAttributes({
|
||||
key: definitionArg.key,
|
||||
unitOfMeasurement: definitionArg.unit,
|
||||
deviceClass: definitionArg.deviceClass,
|
||||
stateClass: definitionArg.stateClass,
|
||||
entityCategory: definitionArg.entityCategory,
|
||||
source: snapshotArg.source,
|
||||
error: snapshotArg.error,
|
||||
...definitionArg.attributes,
|
||||
}),
|
||||
available: definitionArg.available ?? (snapshotArg.online && !snapshotArg.suspended && definitionArg.value !== undefined && definitionArg.value !== null),
|
||||
});
|
||||
}
|
||||
|
||||
private static uniqueBase(snapshotArg: IHuaweiLteSnapshot): string {
|
||||
return this.slug(snapshotArg.device.serialNumber || snapshotArg.device.upnpUdn || snapshotArg.device.macAddresses[0] || snapshotArg.device.id || snapshotArg.device.host || snapshotArg.device.name);
|
||||
}
|
||||
|
||||
private static formatSensorValue(keyArg: string, valueArg: unknown): { value: unknown; unit?: string } {
|
||||
const parsed = this.numberWithUnit(valueArg);
|
||||
if (parsed) {
|
||||
return { value: parsed.value, unit: knownSensorUnits[keyArg] || parsed.unit };
|
||||
}
|
||||
const number = this.numberValue(valueArg);
|
||||
if (number !== undefined && String(valueArg).trim() === String(number)) {
|
||||
return { value: number, unit: knownSensorUnits[keyArg] };
|
||||
}
|
||||
return { value: valueArg, unit: knownSensorUnits[keyArg] };
|
||||
}
|
||||
|
||||
private static numberWithUnit(valueArg: unknown): { value: number; unit?: string } | undefined {
|
||||
if (typeof valueArg !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const match = valueArg.trim().match(/^(?:[<>]=?)?\s*(-?\d+(?:\.\d+)?)\s*([a-zA-Z/%]+)$/);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const value = Number(match[1]);
|
||||
return Number.isFinite(value) ? { value, unit: match[2] } : undefined;
|
||||
}
|
||||
|
||||
private static stateValue(valueArg: unknown): unknown {
|
||||
return valueArg instanceof Date ? valueArg.toISOString() : valueArg;
|
||||
}
|
||||
|
||||
private static parseEndpoint(valueArg: string | undefined): { hostname: string; port?: number; ssl: boolean } | undefined {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg) ? valueArg : `http://${valueArg}`;
|
||||
try {
|
||||
const url = new URL(withScheme);
|
||||
return { hostname: url.hostname, port: url.port ? Number(url.port) : undefined, ssl: url.protocol === 'https:' };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private static booleanString(valueArg: unknown): boolean | undefined {
|
||||
if (valueArg === undefined || valueArg === null || valueArg === '') {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof valueArg === 'boolean') {
|
||||
return valueArg;
|
||||
}
|
||||
const value = String(valueArg).trim().toLowerCase();
|
||||
if (['1', 'true', 'on', 'yes', 'connected'].includes(value)) {
|
||||
return true;
|
||||
}
|
||||
if (['0', 'false', 'off', 'no', 'disconnected'].includes(value)) {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static booleanValue(valueArg: unknown): boolean | undefined {
|
||||
if (typeof valueArg === 'boolean') {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return valueArg !== 0;
|
||||
}
|
||||
return this.booleanString(valueArg);
|
||||
}
|
||||
|
||||
private static numberValue(valueArg: unknown): number | undefined {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const match = valueArg.trim().match(/^-?\d+(?:\.\d+)?/);
|
||||
if (match) {
|
||||
const parsed = Number(match[0]);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static stringValue(valueArg: unknown): string | undefined {
|
||||
if (typeof valueArg === 'string') {
|
||||
return valueArg.trim() || undefined;
|
||||
}
|
||||
if (typeof valueArg === 'number' || typeof valueArg === 'boolean') {
|
||||
return String(valueArg);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static stringList(valueArg: unknown): string[] | undefined {
|
||||
if (Array.isArray(valueArg)) {
|
||||
return valueArg.map((itemArg) => this.stringValue(itemArg)).filter(Boolean) as string[];
|
||||
}
|
||||
const value = this.stringValue(valueArg);
|
||||
return value ? value.split(',').map((itemArg) => itemArg.trim()).filter(Boolean) : undefined;
|
||||
}
|
||||
|
||||
private static humanName(valueArg: string): string {
|
||||
return valueArg
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\b\w/g, (matchArg) => matchArg.toUpperCase())
|
||||
.replace(/\bSms\b/g, 'SMS')
|
||||
.replace(/\bWifi\b/g, 'WiFi')
|
||||
.trim();
|
||||
}
|
||||
|
||||
private static hostForUrl(hostArg: string): string {
|
||||
return hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
|
||||
}
|
||||
|
||||
private static objectValue(valueArg: unknown): IHuaweiLteValueMap | undefined {
|
||||
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as IHuaweiLteValueMap : undefined;
|
||||
}
|
||||
|
||||
private static asArray(valueArg: unknown): unknown[] {
|
||||
if (Array.isArray(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (valueArg === undefined || valueArg === null || valueArg === '') {
|
||||
return [];
|
||||
}
|
||||
return [valueArg];
|
||||
}
|
||||
|
||||
private static isScalar(valueArg: unknown): boolean {
|
||||
return valueArg === null || ['string', 'number', 'boolean'].includes(typeof valueArg);
|
||||
}
|
||||
|
||||
private static hasRawData(rawDataArg: IHuaweiLteRawData | undefined): boolean {
|
||||
if (!rawDataArg) {
|
||||
return false;
|
||||
}
|
||||
return Object.entries(rawDataArg).some(([key, value]) => key !== 'errors' && key !== 'fetchedAt' && key !== 'resources' && value && typeof value === 'object' && Object.keys(value).length > 0);
|
||||
}
|
||||
|
||||
private static cleanAttributes<TValue extends Record<string, unknown>>(attributesArg: TValue): Partial<TValue> {
|
||||
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)) as Partial<TValue>;
|
||||
}
|
||||
|
||||
private static clone<TValue>(valueArg: TValue): TValue {
|
||||
return JSON.parse(JSON.stringify(valueArg)) as TValue;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,283 @@
|
||||
export interface IHomeAssistantHuaweiLteConfig {
|
||||
// TODO: replace with the TypeScript-native config for huawei_lte.
|
||||
export const huaweiLteDomain = 'huawei_lte';
|
||||
export const huaweiLteDisplayName = 'Huawei LTE';
|
||||
export const huaweiLteDefaultDeviceName = 'LTE';
|
||||
export const huaweiLteDefaultManufacturer = 'Huawei Technologies Co., Ltd.';
|
||||
export const huaweiLteDefaultUrl = 'http://192.168.8.1/';
|
||||
export const huaweiLteDefaultHttpPort = 80;
|
||||
export const huaweiLteDefaultHttpsPort = 443;
|
||||
export const huaweiLteDefaultTimeoutMs = 10000;
|
||||
export const huaweiLteSsdpDeviceType = 'urn:schemas-upnp-org:device:InternetGatewayDevice:1';
|
||||
export const huaweiLteSsdpManufacturers = [
|
||||
'Huawei',
|
||||
'Huawei Technologies Co., Ltd.',
|
||||
'SOYEA TECHNOLOGY CO., LTD.',
|
||||
] as const;
|
||||
|
||||
export type THuaweiLteSnapshotSource = 'http' | 'client' | 'manual' | 'snapshot' | 'runtime' | 'executor';
|
||||
export type THuaweiLteCommandAction =
|
||||
| 'refresh'
|
||||
| 'set_mobile_dataswitch'
|
||||
| 'set_wifi_guest_network'
|
||||
| 'reboot'
|
||||
| 'clear_traffic'
|
||||
| 'send_sms'
|
||||
| 'set_net_mode'
|
||||
| 'suspend_integration'
|
||||
| 'resume_integration';
|
||||
|
||||
export type THuaweiLteRawDataKey =
|
||||
| 'deviceInformation'
|
||||
| 'deviceBasicInformation'
|
||||
| 'deviceSignal'
|
||||
| 'dialupMobileDataswitch'
|
||||
| 'lanHostInfo'
|
||||
| 'monitoringCheckNotifications'
|
||||
| 'monitoringMonthStatistics'
|
||||
| 'monitoringStatus'
|
||||
| 'monitoringTrafficStatistics'
|
||||
| 'netCurrentPlmn'
|
||||
| 'netNetMode'
|
||||
| 'smsSmsCount'
|
||||
| 'wlanHostList'
|
||||
| 'wlanWifiFeatureSwitch'
|
||||
| 'wlanWifiGuestNetworkSwitch'
|
||||
| 'wlanMultiBasicSettings';
|
||||
|
||||
export interface IHuaweiLteValueMap {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IHuaweiLteRawData {
|
||||
deviceInformation?: IHuaweiLteValueMap;
|
||||
deviceBasicInformation?: IHuaweiLteValueMap;
|
||||
deviceSignal?: IHuaweiLteValueMap;
|
||||
dialupMobileDataswitch?: IHuaweiLteValueMap;
|
||||
lanHostInfo?: IHuaweiLteValueMap;
|
||||
monitoringCheckNotifications?: IHuaweiLteValueMap;
|
||||
monitoringMonthStatistics?: IHuaweiLteValueMap;
|
||||
monitoringStatus?: IHuaweiLteValueMap;
|
||||
monitoringTrafficStatistics?: IHuaweiLteValueMap;
|
||||
netCurrentPlmn?: IHuaweiLteValueMap;
|
||||
netNetMode?: IHuaweiLteValueMap;
|
||||
smsSmsCount?: IHuaweiLteValueMap;
|
||||
wlanHostList?: IHuaweiLteValueMap;
|
||||
wlanWifiFeatureSwitch?: IHuaweiLteValueMap;
|
||||
wlanWifiGuestNetworkSwitch?: IHuaweiLteValueMap;
|
||||
wlanMultiBasicSettings?: IHuaweiLteValueMap;
|
||||
resources?: Partial<Record<THuaweiLteRawDataKey, IHuaweiLteValueMap>>;
|
||||
errors?: Partial<Record<THuaweiLteRawDataKey, string>>;
|
||||
fetchedAt?: string;
|
||||
}
|
||||
|
||||
export interface IHuaweiLteDeviceSnapshotInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
manufacturer: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
hardwareVersion?: string;
|
||||
softwareVersion?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
url?: string;
|
||||
ssl?: boolean;
|
||||
verifySsl?: boolean;
|
||||
macAddresses: string[];
|
||||
upnpUdn?: string;
|
||||
}
|
||||
|
||||
export interface IHuaweiLteConnectionSnapshot {
|
||||
mobileConnected?: boolean;
|
||||
connectionStatus?: string;
|
||||
mobileDataEnabled?: boolean;
|
||||
wifiEnabled?: boolean;
|
||||
wifi24ghzEnabled?: boolean;
|
||||
wifi5ghzEnabled?: boolean;
|
||||
guestWifiEnabled?: boolean;
|
||||
smsStorageFull?: boolean;
|
||||
}
|
||||
|
||||
export interface IHuaweiLteSignalSnapshot {
|
||||
values: IHuaweiLteValueMap;
|
||||
rssi?: number;
|
||||
rsrp?: number;
|
||||
rsrq?: number;
|
||||
sinr?: number;
|
||||
nrrsrp?: number;
|
||||
nrrsrq?: number;
|
||||
nrsinr?: number;
|
||||
}
|
||||
|
||||
export interface IHuaweiLteTrafficSnapshot {
|
||||
currentDownload?: number;
|
||||
currentUpload?: number;
|
||||
currentDownloadRate?: number;
|
||||
currentUploadRate?: number;
|
||||
totalDownload?: number;
|
||||
totalUpload?: number;
|
||||
currentConnectTime?: number;
|
||||
totalConnectTime?: number;
|
||||
currentMonthDownload?: number;
|
||||
currentMonthUpload?: number;
|
||||
currentDayUsed?: number;
|
||||
}
|
||||
|
||||
export interface IHuaweiLteSmsSnapshot {
|
||||
unread?: number;
|
||||
localUnread?: number;
|
||||
localInbox?: number;
|
||||
localOutbox?: number;
|
||||
localDraft?: number;
|
||||
localMax?: number;
|
||||
simUnread?: number;
|
||||
simInbox?: number;
|
||||
simOutbox?: number;
|
||||
simDraft?: number;
|
||||
simMax?: number;
|
||||
}
|
||||
|
||||
export interface IHuaweiLteNetworkSnapshot {
|
||||
operatorName?: string;
|
||||
operatorCode?: string;
|
||||
operatorSearchMode?: string;
|
||||
networkMode?: string;
|
||||
}
|
||||
|
||||
export interface IHuaweiLteHostSnapshot {
|
||||
id: string;
|
||||
macAddress: string;
|
||||
hostname?: string;
|
||||
ipAddress?: string;
|
||||
active: boolean;
|
||||
wireless: boolean;
|
||||
isLocalDevice?: boolean;
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IHuaweiLteCapabilities {
|
||||
localControl: boolean;
|
||||
mobileDataSwitch: boolean;
|
||||
guestWifiSwitch: boolean;
|
||||
reboot: boolean;
|
||||
clearTraffic: boolean;
|
||||
sendSms: boolean;
|
||||
networkModeSelect: boolean;
|
||||
}
|
||||
|
||||
export interface IHuaweiLteSnapshot {
|
||||
device: IHuaweiLteDeviceSnapshotInfo;
|
||||
connection: IHuaweiLteConnectionSnapshot;
|
||||
signal: IHuaweiLteSignalSnapshot;
|
||||
traffic: IHuaweiLteTrafficSnapshot;
|
||||
sms: IHuaweiLteSmsSnapshot;
|
||||
network: IHuaweiLteNetworkSnapshot;
|
||||
hosts: IHuaweiLteHostSnapshot[];
|
||||
capabilities: IHuaweiLteCapabilities;
|
||||
rawData?: IHuaweiLteRawData;
|
||||
online: boolean;
|
||||
updatedAt: string;
|
||||
source: THuaweiLteSnapshotSource;
|
||||
suspended?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface IHuaweiLteCommandRequest {
|
||||
action: THuaweiLteCommandAction;
|
||||
enabled?: boolean;
|
||||
phoneNumbers?: string[];
|
||||
message?: string;
|
||||
smsIndex?: number;
|
||||
sca?: string;
|
||||
networkMode?: string;
|
||||
networkBand?: string;
|
||||
lteBand?: string;
|
||||
service?: string;
|
||||
target?: Record<string, unknown>;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IHuaweiLteCommandExecutor {
|
||||
execute(requestArg: IHuaweiLteCommandRequest): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface IHuaweiLteClientLike {
|
||||
getSnapshot?: () => Promise<IHuaweiLteSnapshot | Partial<IHuaweiLteRawData>>;
|
||||
getRawData?: () => Promise<Partial<IHuaweiLteRawData>>;
|
||||
getResource?: (endpointArg: string) => Promise<IHuaweiLteValueMap>;
|
||||
execute?: (requestArg: IHuaweiLteCommandRequest) => Promise<unknown>;
|
||||
destroy?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface IHuaweiLteConfig {
|
||||
url?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
ssl?: boolean;
|
||||
verifySsl?: boolean;
|
||||
username?: string;
|
||||
password?: string;
|
||||
unauthenticatedMode?: boolean;
|
||||
trackWiredClients?: boolean;
|
||||
recipients?: string[];
|
||||
timeoutMs?: number;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
uniqueId?: string;
|
||||
serialNumber?: string;
|
||||
macAddress?: string;
|
||||
macAddresses?: string[];
|
||||
upnpUdn?: string;
|
||||
snapshot?: IHuaweiLteSnapshot;
|
||||
rawData?: Partial<IHuaweiLteRawData>;
|
||||
client?: IHuaweiLteClientLike;
|
||||
commandExecutor?: IHuaweiLteCommandExecutor;
|
||||
online?: boolean;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantHuaweiLteConfig extends IHuaweiLteConfig {}
|
||||
|
||||
export interface IHuaweiLteSsdpRecord {
|
||||
st?: string;
|
||||
nt?: string;
|
||||
usn?: string;
|
||||
location?: string;
|
||||
ssdpLocation?: string;
|
||||
deviceType?: string;
|
||||
manufacturer?: string;
|
||||
friendlyName?: string;
|
||||
modelName?: string;
|
||||
serialNumber?: string;
|
||||
presentationUrl?: string;
|
||||
udn?: string;
|
||||
host?: string;
|
||||
headers?: Record<string, string | undefined>;
|
||||
ssdpHeaders?: Record<string, string | undefined>;
|
||||
upnp?: {
|
||||
deviceType?: string;
|
||||
manufacturer?: string;
|
||||
friendlyName?: string;
|
||||
modelName?: string;
|
||||
serialNumber?: string;
|
||||
serial?: string;
|
||||
presentationURL?: string;
|
||||
presentationUrl?: string;
|
||||
udn?: string;
|
||||
UDN?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IHuaweiLteManualEntry extends Partial<IHuaweiLteConfig> {
|
||||
id?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IHuaweiLteRefreshResult {
|
||||
success: boolean;
|
||||
snapshot: IHuaweiLteSnapshot;
|
||||
error?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './huawei_lte.classes.client.js';
|
||||
export * from './huawei_lte.classes.configflow.js';
|
||||
export * from './huawei_lte.classes.integration.js';
|
||||
export * from './huawei_lte.discovery.js';
|
||||
export * from './huawei_lte.mapper.js';
|
||||
export * from './huawei_lte.types.js';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -0,0 +1,652 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { HyperionMapper } from './hyperion.mapper.js';
|
||||
import type {
|
||||
IHyperionCommandRequest,
|
||||
IHyperionConfig,
|
||||
IHyperionImageSnapshot,
|
||||
IHyperionJsonRpcRequest,
|
||||
IHyperionJsonRpcResponse,
|
||||
IHyperionRawData,
|
||||
IHyperionRefreshResult,
|
||||
IHyperionServerInfo,
|
||||
IHyperionSnapshot,
|
||||
IHyperionSystemInfo,
|
||||
THyperionImageFormat,
|
||||
THyperionSnapshotSource,
|
||||
THyperionTransport,
|
||||
} from './hyperion.types.js';
|
||||
import {
|
||||
hyperionDefaultJsonPort,
|
||||
hyperionDefaultOrigin,
|
||||
hyperionDefaultPriority,
|
||||
hyperionDefaultSslWebPort,
|
||||
hyperionDefaultTimeoutMs,
|
||||
hyperionDefaultWebPort,
|
||||
hyperionSolidEffect,
|
||||
} from './hyperion.types.js';
|
||||
|
||||
interface IRequestOptions {
|
||||
allowError?: boolean;
|
||||
skipAuth?: boolean;
|
||||
}
|
||||
|
||||
type TWebSocketEvent = { data?: unknown; message?: unknown; error?: unknown };
|
||||
type TWebSocketHandler = (eventArg: TWebSocketEvent) => void;
|
||||
type TWebSocketLike = {
|
||||
send(dataArg: string): void;
|
||||
close(): void;
|
||||
addEventListener?: (eventArg: 'open' | 'message' | 'error' | 'close', handlerArg: TWebSocketHandler) => void;
|
||||
removeEventListener?: (eventArg: 'open' | 'message' | 'error' | 'close', handlerArg: TWebSocketHandler) => void;
|
||||
onopen?: TWebSocketHandler | null;
|
||||
onmessage?: TWebSocketHandler | null;
|
||||
onerror?: TWebSocketHandler | null;
|
||||
onclose?: TWebSocketHandler | null;
|
||||
};
|
||||
type TWebSocketConstructor = new (urlArg: string) => TWebSocketLike;
|
||||
|
||||
export class HyperionApiError extends Error {}
|
||||
export class HyperionApiConnectionError extends HyperionApiError {}
|
||||
export class HyperionApiAuthorizationError extends HyperionApiError {}
|
||||
export class HyperionUnsupportedCommandError extends HyperionApiError {}
|
||||
|
||||
export class HyperionClient {
|
||||
private currentSnapshot?: IHyperionSnapshot;
|
||||
private restorePoint?: IHyperionSnapshot;
|
||||
private tan = 1;
|
||||
|
||||
constructor(private readonly config: IHyperionConfig) {
|
||||
this.currentSnapshot = config.snapshot ? HyperionMapper.toSnapshot({ config, source: 'snapshot', online: config.snapshot.online }) : undefined;
|
||||
}
|
||||
|
||||
public async getSnapshot(forceRefreshArg = false): Promise<IHyperionSnapshot> {
|
||||
if (!forceRefreshArg && this.currentSnapshot) {
|
||||
return HyperionMapper.clone(this.currentSnapshot);
|
||||
}
|
||||
if (!forceRefreshArg && this.config.snapshot) {
|
||||
this.currentSnapshot = HyperionMapper.toSnapshot({ config: this.config, source: 'snapshot', online: this.config.snapshot.online });
|
||||
return HyperionMapper.clone(this.currentSnapshot);
|
||||
}
|
||||
if (this.config.client) {
|
||||
try {
|
||||
this.currentSnapshot = await this.snapshotFromClient();
|
||||
} catch (errorArg) {
|
||||
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
|
||||
}
|
||||
return HyperionMapper.clone(this.currentSnapshot);
|
||||
}
|
||||
if (!forceRefreshArg && this.config.rawData) {
|
||||
this.currentSnapshot = HyperionMapper.toSnapshot({ config: this.config, rawData: this.config.rawData, online: this.config.online ?? true, source: 'manual' });
|
||||
return HyperionMapper.clone(this.currentSnapshot);
|
||||
}
|
||||
if (this.hasEndpoint()) {
|
||||
try {
|
||||
this.currentSnapshot = await this.fetchSnapshot();
|
||||
} catch (errorArg) {
|
||||
this.currentSnapshot = this.offlineSnapshot(this.errorMessage(errorArg));
|
||||
}
|
||||
return HyperionMapper.clone(this.currentSnapshot);
|
||||
}
|
||||
this.currentSnapshot = this.offlineSnapshot('Hyperion snapshots require config.host/config.url, an injected client, or static snapshot/rawData.');
|
||||
return HyperionMapper.clone(this.currentSnapshot);
|
||||
}
|
||||
|
||||
public async refresh(): Promise<IHyperionRefreshResult> {
|
||||
try {
|
||||
this.currentSnapshot = undefined;
|
||||
const snapshot = await this.getSnapshot(this.hasEndpoint() || Boolean(this.config.client));
|
||||
const success = snapshot.online && !snapshot.error;
|
||||
return { success, snapshot, error: success ? undefined : snapshot.error, data: { source: snapshot.source } };
|
||||
} catch (errorArg) {
|
||||
const error = this.errorMessage(errorArg);
|
||||
const snapshot = this.offlineSnapshot(error);
|
||||
this.currentSnapshot = snapshot;
|
||||
return { success: false, snapshot: HyperionMapper.clone(snapshot), error };
|
||||
}
|
||||
}
|
||||
|
||||
public async validateConnection(): Promise<IHyperionSnapshot> {
|
||||
const snapshot = await this.fetchSnapshot();
|
||||
if (!snapshot.device.id) {
|
||||
throw new HyperionApiConnectionError('Hyperion server did not provide a stable identifier.');
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public async request(payloadArg: IHyperionJsonRpcRequest, optionsArg: IRequestOptions = {}): Promise<IHyperionJsonRpcResponse> {
|
||||
if (this.config.client?.request && !this.hasEndpoint()) {
|
||||
const response = await this.config.client.request(this.withTan(payloadArg));
|
||||
return this.assertResponse(response, optionsArg);
|
||||
}
|
||||
const payload = this.withTan(payloadArg);
|
||||
const transport = this.transport();
|
||||
const response = transport === 'http'
|
||||
? await this.requestHttp(payload, optionsArg)
|
||||
: transport === 'websocket'
|
||||
? await this.requestWebSocket(payload, optionsArg)
|
||||
: await this.requestTcp(payload, optionsArg);
|
||||
return this.assertResponse(response, optionsArg);
|
||||
}
|
||||
|
||||
public async execute(commandArg: IHyperionCommandRequest): Promise<unknown> {
|
||||
if (this.config.commandExecutor) {
|
||||
return this.config.commandExecutor.execute(commandArg);
|
||||
}
|
||||
if (this.config.client?.execute) {
|
||||
return this.config.client.execute(commandArg);
|
||||
}
|
||||
if (commandArg.action === 'refresh') {
|
||||
return (await this.refresh()).snapshot;
|
||||
}
|
||||
if (commandArg.action === 'restore') {
|
||||
await this.restore(commandArg.snapshot);
|
||||
return { restored: true };
|
||||
}
|
||||
if (!this.hasEndpoint()) {
|
||||
throw new HyperionApiConnectionError('Hyperion commands require config.host/config.url, an injected client.execute, or commandExecutor. Static snapshots/manual data are read-only.');
|
||||
}
|
||||
const payload = this.payloadForCommand(commandArg);
|
||||
const response = await this.request(payload);
|
||||
if (commandArg.action === 'get_image_snapshot') {
|
||||
return response.info as IHyperionImageSnapshot | undefined;
|
||||
}
|
||||
return response.info ?? response;
|
||||
}
|
||||
|
||||
public async snapshot(): Promise<IHyperionSnapshot> {
|
||||
this.restorePoint = await this.getSnapshot();
|
||||
return HyperionMapper.clone(this.restorePoint);
|
||||
}
|
||||
|
||||
public async restore(snapshotArg = this.restorePoint): Promise<void> {
|
||||
if (!snapshotArg) {
|
||||
throw new HyperionUnsupportedCommandError('Hyperion restore requires a prior snapshot.');
|
||||
}
|
||||
for (const instance of snapshotArg.instances) {
|
||||
const priority = instance.priorities.find((priorityArg) => priorityArg.priority === snapshotArg.settings.priority) || instance.visiblePriority;
|
||||
if (!priority) {
|
||||
await this.execute({ action: 'clear', instance: instance.instance, priority: snapshotArg.settings.priority });
|
||||
continue;
|
||||
}
|
||||
if (priority.componentId === 'COLOR' && Array.isArray(priority.value?.RGB)) {
|
||||
await this.execute({ action: 'set_color', instance: instance.instance, color: priority.value.RGB, priority: priority.priority || snapshotArg.settings.priority, origin: priority.origin || snapshotArg.settings.origin });
|
||||
} else if (priority.componentId === 'EFFECT' && priority.owner) {
|
||||
await this.execute({ action: 'set_effect', instance: instance.instance, effect: priority.owner, priority: priority.priority || snapshotArg.settings.priority, origin: priority.origin || snapshotArg.settings.origin });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.config.client?.destroy?.();
|
||||
}
|
||||
|
||||
private async snapshotFromClient(): Promise<IHyperionSnapshot> {
|
||||
const client = this.config.client;
|
||||
if (!client) {
|
||||
throw new HyperionApiConnectionError('No Hyperion client configured.');
|
||||
}
|
||||
if (client.getSnapshot) {
|
||||
const snapshot = await client.getSnapshot();
|
||||
if (this.isSnapshot(snapshot)) {
|
||||
return HyperionMapper.toSnapshot({ config: { ...this.config, snapshot }, source: 'client', online: snapshot.online });
|
||||
}
|
||||
return HyperionMapper.toSnapshot({ config: this.config, rawData: snapshot, online: true, source: 'client' });
|
||||
}
|
||||
if (client.getRawData) {
|
||||
return HyperionMapper.toSnapshot({ config: this.config, rawData: await client.getRawData(), online: true, source: 'client' });
|
||||
}
|
||||
throw new HyperionApiConnectionError('Hyperion client must expose getSnapshot() or getRawData().');
|
||||
}
|
||||
|
||||
private async fetchSnapshot(): Promise<IHyperionSnapshot> {
|
||||
await this.assertAuthenticationConfigured();
|
||||
const source = this.transport() as THyperionSnapshotSource;
|
||||
const sysInfo = await this.request({ command: 'sysinfo' }).then((responseArg) => this.infoObject<IHyperionSystemInfo>(responseArg.info)).catch(() => undefined);
|
||||
const rootInfo = await this.request({ command: 'serverinfo', subcommand: 'getInfo', instance: this.config.instance }).then((responseArg) => this.infoObject<IHyperionServerInfo>(responseArg.info));
|
||||
const instanceServerInfo: Record<number, IHyperionServerInfo> = {};
|
||||
const instances = Array.isArray(rootInfo.instance) && rootInfo.instance.length ? rootInfo.instance : [{ instance: this.config.instance ?? 0, running: true }];
|
||||
for (const instance of instances) {
|
||||
if (typeof instance.instance !== 'number' || instance.running === false) {
|
||||
continue;
|
||||
}
|
||||
if (this.config.instance === instance.instance) {
|
||||
instanceServerInfo[instance.instance] = rootInfo;
|
||||
continue;
|
||||
}
|
||||
instanceServerInfo[instance.instance] = await this.request({ command: 'serverinfo', subcommand: 'getInfo', instance: instance.instance }).then((responseArg) => this.infoObject<IHyperionServerInfo>(responseArg.info)).catch(() => rootInfo);
|
||||
}
|
||||
const rawData: IHyperionRawData = {
|
||||
sysInfo,
|
||||
serverInfo: rootInfo,
|
||||
instanceServerInfo,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
return HyperionMapper.toSnapshot({ config: this.config, rawData, online: true, source });
|
||||
}
|
||||
|
||||
private async assertAuthenticationConfigured(): Promise<void> {
|
||||
const response = await this.request({ command: 'authorize', subcommand: 'tokenRequired' }, { allowError: true, skipAuth: true }).catch(() => undefined);
|
||||
const info = this.infoObject<{ required?: boolean }>(response?.info);
|
||||
if (info.required && !this.config.token) {
|
||||
throw new HyperionApiAuthorizationError('Hyperion requires an authorization token.');
|
||||
}
|
||||
}
|
||||
|
||||
private payloadForCommand(commandArg: IHyperionCommandRequest): IHyperionJsonRpcRequest {
|
||||
const instance = this.commandInstance(commandArg);
|
||||
const priority = this.priority(commandArg.priority);
|
||||
const origin = commandArg.origin || this.config.origin || hyperionDefaultOrigin;
|
||||
if (commandArg.action === 'set_color') {
|
||||
const color = commandArg.color?.slice(0, 3).map((valueArg) => this.colorByte(valueArg));
|
||||
if (!color || color.length < 3) {
|
||||
throw new HyperionUnsupportedCommandError('Hyperion set_color requires an RGB color array.');
|
||||
}
|
||||
return this.cleanPayload({ command: 'color', instance, color, priority, origin, duration: commandArg.duration });
|
||||
}
|
||||
if (commandArg.action === 'set_effect') {
|
||||
if (!commandArg.effect || commandArg.effect === hyperionSolidEffect) {
|
||||
return this.cleanPayload({ command: 'color', instance, color: [255, 255, 255], priority, origin, duration: commandArg.duration });
|
||||
}
|
||||
return this.cleanPayload({ command: 'effect', instance, effect: { name: commandArg.effect, args: commandArg.effectArgs }, priority, origin, duration: commandArg.duration });
|
||||
}
|
||||
if (commandArg.action === 'clear') {
|
||||
return this.cleanPayload({ command: 'clear', instance, priority });
|
||||
}
|
||||
if (commandArg.action === 'set_component') {
|
||||
if (!commandArg.component || typeof commandArg.state !== 'boolean') {
|
||||
throw new HyperionUnsupportedCommandError('Hyperion set_component requires component and boolean state.');
|
||||
}
|
||||
return this.cleanPayload({ command: 'componentstate', instance, componentstate: { component: commandArg.component, state: commandArg.state } });
|
||||
}
|
||||
if (commandArg.action === 'adjust_brightness') {
|
||||
if (typeof commandArg.brightness !== 'number') {
|
||||
throw new HyperionUnsupportedCommandError('Hyperion adjust_brightness requires brightness.');
|
||||
}
|
||||
return this.cleanPayload({ command: 'adjustment', instance, adjustment: { id: commandArg.adjustmentId || 'default', brightness: Math.max(0, Math.min(100, Math.round((commandArg.brightness * 100) / 255))) } });
|
||||
}
|
||||
if (commandArg.action === 'get_image_snapshot') {
|
||||
return this.cleanPayload({ command: 'instance-data', subcommand: 'getImageSnapshot', instance: this.singleInstance(commandArg), format: this.format(commandArg.format) });
|
||||
}
|
||||
if (commandArg.action === 'image_stream_start' || commandArg.action === 'image_stream_stop') {
|
||||
if (this.transport() === 'http') {
|
||||
throw new HyperionUnsupportedCommandError('Hyperion image streaming requires the TCP JSON server or WebSocket transport; HTTP JSON-RPC only supports still image snapshots.');
|
||||
}
|
||||
return this.cleanPayload({ command: 'ledcolors', subcommand: commandArg.action === 'image_stream_start' ? 'imagestream-start' : 'imagestream-stop', instance: this.singleInstance(commandArg) });
|
||||
}
|
||||
if (commandArg.action === 'raw_command') {
|
||||
if (!commandArg.payload) {
|
||||
throw new HyperionUnsupportedCommandError('Hyperion raw_command requires payload.');
|
||||
}
|
||||
return commandArg.payload;
|
||||
}
|
||||
throw new HyperionUnsupportedCommandError(`Unsupported Hyperion command: ${commandArg.action}`);
|
||||
}
|
||||
|
||||
private async requestHttp(payloadArg: IHyperionJsonRpcRequest, optionsArg: IRequestOptions): Promise<IHyperionJsonRpcResponse> {
|
||||
const url = `${this.baseUrl('http')}/json-rpc`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await globalThis.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: this.headers(optionsArg),
|
||||
body: JSON.stringify(payloadArg),
|
||||
signal: AbortSignal.timeout(this.timeoutMs()),
|
||||
});
|
||||
} catch (errorArg) {
|
||||
throw new HyperionApiConnectionError(`Connection to ${url} failed: ${this.errorMessage(errorArg)}`);
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new HyperionApiAuthorizationError(`Hyperion authorization failed for ${url}: HTTP ${response.status}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new HyperionApiConnectionError(`Hyperion request to ${url} failed: HTTP ${response.status}`);
|
||||
}
|
||||
try {
|
||||
return await response.json() as IHyperionJsonRpcResponse;
|
||||
} catch (errorArg) {
|
||||
throw new HyperionApiConnectionError(`Hyperion response from ${url} was not JSON: ${this.errorMessage(errorArg)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async requestTcp(payloadArg: IHyperionJsonRpcRequest, optionsArg: IRequestOptions): Promise<IHyperionJsonRpcResponse> {
|
||||
const host = this.host();
|
||||
if (!host) {
|
||||
throw new HyperionApiConnectionError('Hyperion TCP transport requires host.');
|
||||
}
|
||||
const port = this.port('tcp');
|
||||
const timeoutMs = this.timeoutMs();
|
||||
const loginTan = this.nextTan();
|
||||
const loginPayload = { command: 'authorize', subcommand: 'login', token: this.config.token, tan: loginTan };
|
||||
const needsLogin = Boolean(this.config.token && !optionsArg.skipAuth);
|
||||
|
||||
return new Promise<IHyperionJsonRpcResponse>((resolve, reject) => {
|
||||
let buffer = '';
|
||||
let loggedIn = !needsLogin;
|
||||
let settled = false;
|
||||
const socket = plugins.net.createConnection({ host, port });
|
||||
const timer = setTimeout(() => fail(new HyperionApiConnectionError(`Hyperion TCP request to ${host}:${port} timed out.`)), timeoutMs);
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
socket.removeAllListeners();
|
||||
socket.end();
|
||||
socket.destroy();
|
||||
};
|
||||
const fail = (errorArg: Error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(errorArg);
|
||||
};
|
||||
const finish = (responseArg: IHyperionJsonRpcResponse) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(responseArg);
|
||||
};
|
||||
const send = (payloadToSendArg: IHyperionJsonRpcRequest) => socket.write(`${JSON.stringify(payloadToSendArg)}\n`);
|
||||
const handleResponse = (responseArg: IHyperionJsonRpcResponse) => {
|
||||
if (!loggedIn && responseArg.tan === loginTan) {
|
||||
if (responseArg.success === false) {
|
||||
fail(new HyperionApiAuthorizationError(this.responseErrorMessage(responseArg)));
|
||||
return;
|
||||
}
|
||||
loggedIn = true;
|
||||
send(payloadArg);
|
||||
return;
|
||||
}
|
||||
if (responseArg.tan === payloadArg.tan || responseArg.command === payloadArg.command || `${responseArg.command}` === `${payloadArg.command}-${payloadArg.subcommand}`) {
|
||||
finish(responseArg);
|
||||
}
|
||||
};
|
||||
socket.on('connect', () => send(needsLogin ? loginPayload : payloadArg));
|
||||
socket.on('data', (chunkArg) => {
|
||||
buffer += chunkArg.toString('utf8');
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
handleResponse(JSON.parse(line) as IHyperionJsonRpcResponse);
|
||||
} catch (errorArg) {
|
||||
fail(new HyperionApiConnectionError(`Hyperion TCP response was not JSON: ${this.errorMessage(errorArg)}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
socket.on('error', (errorArg) => fail(new HyperionApiConnectionError(`Hyperion TCP connection to ${host}:${port} failed: ${this.errorMessage(errorArg)}`)));
|
||||
socket.on('close', () => {
|
||||
if (!settled) {
|
||||
fail(new HyperionApiConnectionError(`Hyperion TCP connection to ${host}:${port} closed before response.`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async requestWebSocket(payloadArg: IHyperionJsonRpcRequest, optionsArg: IRequestOptions): Promise<IHyperionJsonRpcResponse> {
|
||||
const WebSocketCtor = (globalThis as unknown as { WebSocket?: TWebSocketConstructor }).WebSocket;
|
||||
if (!WebSocketCtor) {
|
||||
throw new HyperionApiConnectionError('Global WebSocket is not available for Hyperion WebSocket transport.');
|
||||
}
|
||||
const url = `${this.baseUrl('websocket')}/json-rpc`;
|
||||
const loginTan = this.nextTan();
|
||||
const loginPayload = { command: 'authorize', subcommand: 'login', token: this.config.token, tan: loginTan };
|
||||
const needsLogin = Boolean(this.config.token && !optionsArg.skipAuth);
|
||||
return new Promise<IHyperionJsonRpcResponse>((resolve, reject) => {
|
||||
let loggedIn = !needsLogin;
|
||||
let settled = false;
|
||||
const socket: TWebSocketLike = new WebSocketCtor(url);
|
||||
const timer = setTimeout(() => fail(new HyperionApiConnectionError(`Hyperion WebSocket request to ${url} timed out.`)), this.timeoutMs());
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
this.removeWebSocketListener(socket, 'open');
|
||||
this.removeWebSocketListener(socket, 'message');
|
||||
this.removeWebSocketListener(socket, 'error');
|
||||
this.removeWebSocketListener(socket, 'close');
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// Socket may already be closed by the runtime.
|
||||
}
|
||||
};
|
||||
const fail = (errorArg: Error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(errorArg);
|
||||
};
|
||||
const finish = (responseArg: IHyperionJsonRpcResponse) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(responseArg);
|
||||
};
|
||||
const send = (payloadToSendArg: IHyperionJsonRpcRequest) => socket.send(JSON.stringify(payloadToSendArg));
|
||||
this.addWebSocketListener(socket, 'open', () => send(needsLogin ? loginPayload : payloadArg));
|
||||
this.addWebSocketListener(socket, 'message', (eventArg) => {
|
||||
const text = this.websocketData(eventArg.data ?? eventArg.message);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
let response: IHyperionJsonRpcResponse;
|
||||
try {
|
||||
response = JSON.parse(text) as IHyperionJsonRpcResponse;
|
||||
} catch (errorArg) {
|
||||
fail(new HyperionApiConnectionError(`Hyperion WebSocket response was not JSON: ${this.errorMessage(errorArg)}`));
|
||||
return;
|
||||
}
|
||||
if (!loggedIn && response.tan === loginTan) {
|
||||
if (response.success === false) {
|
||||
fail(new HyperionApiAuthorizationError(this.responseErrorMessage(response)));
|
||||
return;
|
||||
}
|
||||
loggedIn = true;
|
||||
send(payloadArg);
|
||||
return;
|
||||
}
|
||||
if (response.tan === payloadArg.tan || response.command === payloadArg.command || `${response.command}` === `${payloadArg.command}-${payloadArg.subcommand}`) {
|
||||
finish(response);
|
||||
}
|
||||
});
|
||||
this.addWebSocketListener(socket, 'error', (eventArg) => fail(new HyperionApiConnectionError(`Hyperion WebSocket error: ${this.errorMessage(eventArg.error || eventArg)}`)));
|
||||
this.addWebSocketListener(socket, 'close', () => {
|
||||
if (!settled) {
|
||||
fail(new HyperionApiConnectionError(`Hyperion WebSocket ${url} closed before response.`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private assertResponse(responseArg: IHyperionJsonRpcResponse, optionsArg: IRequestOptions): IHyperionJsonRpcResponse {
|
||||
if (!optionsArg.allowError && responseArg.success === false) {
|
||||
const message = this.responseErrorMessage(responseArg);
|
||||
if (/authorization|auth|token/i.test(message)) {
|
||||
throw new HyperionApiAuthorizationError(message);
|
||||
}
|
||||
throw new HyperionApiError(message);
|
||||
}
|
||||
return responseArg;
|
||||
}
|
||||
|
||||
private responseErrorMessage(responseArg: IHyperionJsonRpcResponse): string {
|
||||
const details = Array.isArray(responseArg.errorData) ? responseArg.errorData.map((errorArg) => errorArg.description).filter(Boolean).join('; ') : '';
|
||||
return [responseArg.error || 'Hyperion JSON-RPC request failed', details].filter(Boolean).join(': ');
|
||||
}
|
||||
|
||||
private withTan(payloadArg: IHyperionJsonRpcRequest): IHyperionJsonRpcRequest {
|
||||
return { ...payloadArg, tan: typeof payloadArg.tan === 'number' ? payloadArg.tan : this.nextTan() };
|
||||
}
|
||||
|
||||
private nextTan(): number {
|
||||
const tan = this.tan;
|
||||
this.tan = this.tan >= 999999 ? 1 : this.tan + 1;
|
||||
return tan;
|
||||
}
|
||||
|
||||
private headers(optionsArg: IRequestOptions): Record<string, string> {
|
||||
const headers: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json' };
|
||||
if (this.config.token && !optionsArg.skipAuth) {
|
||||
headers.authorization = `Bearer ${this.config.token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
private cleanPayload(payloadArg: IHyperionJsonRpcRequest): IHyperionJsonRpcRequest {
|
||||
const clean: IHyperionJsonRpcRequest = { command: payloadArg.command };
|
||||
for (const [key, value] of Object.entries(payloadArg)) {
|
||||
if (value !== undefined) {
|
||||
clean[key] = value;
|
||||
}
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
private commandInstance(commandArg: IHyperionCommandRequest): number[] | undefined {
|
||||
if (Array.isArray(commandArg.instance)) {
|
||||
return commandArg.instance;
|
||||
}
|
||||
if (typeof commandArg.instance === 'number') {
|
||||
return [commandArg.instance];
|
||||
}
|
||||
if (typeof this.config.instance === 'number') {
|
||||
return [this.config.instance];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private singleInstance(commandArg: IHyperionCommandRequest): number {
|
||||
if (typeof commandArg.instance === 'number') {
|
||||
return commandArg.instance;
|
||||
}
|
||||
if (Array.isArray(commandArg.instance) && typeof commandArg.instance[0] === 'number') {
|
||||
return commandArg.instance[0];
|
||||
}
|
||||
return this.config.instance ?? 0;
|
||||
}
|
||||
|
||||
private priority(valueArg: unknown): number {
|
||||
const value = typeof valueArg === 'number' && Number.isFinite(valueArg) ? Math.round(valueArg) : this.config.priority ?? hyperionDefaultPriority;
|
||||
return Math.max(1, Math.min(253, value));
|
||||
}
|
||||
|
||||
private colorByte(valueArg: unknown): number {
|
||||
const value = typeof valueArg === 'number' ? valueArg : Number(valueArg);
|
||||
return Math.max(0, Math.min(255, Math.round(Number.isFinite(value) ? value : 0)));
|
||||
}
|
||||
|
||||
private format(valueArg: THyperionImageFormat | undefined): THyperionImageFormat {
|
||||
return valueArg === 'BMP' || valueArg === 'JPG' || valueArg === 'PNG' ? valueArg : 'PNG';
|
||||
}
|
||||
|
||||
private infoObject<TInfo>(valueArg: unknown): TInfo {
|
||||
return valueArg && typeof valueArg === 'object' ? valueArg as TInfo : {} as TInfo;
|
||||
}
|
||||
|
||||
private isSnapshot(valueArg: unknown): valueArg is IHyperionSnapshot {
|
||||
return Boolean(valueArg && typeof valueArg === 'object' && 'device' in valueArg && 'instances' in valueArg);
|
||||
}
|
||||
|
||||
private offlineSnapshot(errorArg: string): IHyperionSnapshot {
|
||||
return HyperionMapper.toSnapshot({ config: this.config, online: false, source: 'runtime', error: errorArg });
|
||||
}
|
||||
|
||||
private hasEndpoint(): boolean {
|
||||
return Boolean(this.config.host || this.config.url);
|
||||
}
|
||||
|
||||
private transport(): THyperionTransport {
|
||||
if (this.config.transport) {
|
||||
return this.config.transport;
|
||||
}
|
||||
const endpoint = this.endpoint();
|
||||
if (endpoint?.transport) {
|
||||
return endpoint.transport;
|
||||
}
|
||||
return 'tcp';
|
||||
}
|
||||
|
||||
private host(): string | undefined {
|
||||
return this.config.host || this.endpoint()?.host;
|
||||
}
|
||||
|
||||
private port(transportArg = this.transport()): number {
|
||||
const endpoint = this.endpoint();
|
||||
if (this.config.port) {
|
||||
return this.config.port;
|
||||
}
|
||||
if (endpoint?.port) {
|
||||
return endpoint.port;
|
||||
}
|
||||
if (transportArg === 'tcp') {
|
||||
return this.config.jsonPort || hyperionDefaultJsonPort;
|
||||
}
|
||||
return this.config.webPort || (this.ssl() ? hyperionDefaultSslWebPort : hyperionDefaultWebPort);
|
||||
}
|
||||
|
||||
private ssl(): boolean {
|
||||
return this.config.ssl ?? this.config.tls ?? this.endpoint()?.ssl ?? false;
|
||||
}
|
||||
|
||||
private baseUrl(transportArg: 'http' | 'websocket'): string {
|
||||
const host = this.host();
|
||||
if (!host) {
|
||||
throw new HyperionApiConnectionError(`Hyperion ${transportArg} transport requires host.`);
|
||||
}
|
||||
const scheme = transportArg === 'websocket' ? this.ssl() ? 'wss' : 'ws' : this.ssl() ? 'https' : 'http';
|
||||
return `${scheme}://${host}:${this.port(transportArg === 'websocket' ? 'websocket' : 'http')}`;
|
||||
}
|
||||
|
||||
private endpoint(): { host: string; port?: number; ssl: boolean; transport: THyperionTransport } | undefined {
|
||||
if (!this.config.url || !/^[a-z][a-z0-9+.-]*:\/\//i.test(this.config.url)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const url = new URL(this.config.url);
|
||||
const protocol = url.protocol.replace(':', '').toLowerCase();
|
||||
const transport: THyperionTransport = protocol === 'ws' || protocol === 'wss' ? 'websocket' : protocol === 'http' || protocol === 'https' ? 'http' : 'tcp';
|
||||
return { host: url.hostname, port: url.port ? Number(url.port) : undefined, ssl: protocol === 'https' || protocol === 'wss', transport };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private timeoutMs(): number {
|
||||
return this.config.timeoutMs || hyperionDefaultTimeoutMs;
|
||||
}
|
||||
|
||||
private errorMessage(errorArg: unknown): string {
|
||||
return errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||
}
|
||||
|
||||
private addWebSocketListener(socketArg: TWebSocketLike, eventArg: 'open' | 'message' | 'error' | 'close', handlerArg: TWebSocketHandler): void {
|
||||
if (socketArg.addEventListener) {
|
||||
socketArg.addEventListener(eventArg, handlerArg);
|
||||
return;
|
||||
}
|
||||
socketArg[`on${eventArg}` as 'onopen'] = handlerArg;
|
||||
}
|
||||
|
||||
private removeWebSocketListener(socketArg: TWebSocketLike, eventArg: 'open' | 'message' | 'error' | 'close'): void {
|
||||
if (socketArg.removeEventListener) {
|
||||
return;
|
||||
}
|
||||
socketArg[`on${eventArg}` as 'onopen'] = null;
|
||||
}
|
||||
|
||||
private websocketData(valueArg: unknown): string | undefined {
|
||||
if (typeof valueArg === 'string') {
|
||||
return valueArg;
|
||||
}
|
||||
if (Buffer.isBuffer(valueArg)) {
|
||||
return valueArg.toString('utf8');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import type { IHyperionConfig, IHyperionRawData, IHyperionSnapshot, THyperionTransport } from './hyperion.types.js';
|
||||
import {
|
||||
hyperionDefaultJsonPort,
|
||||
hyperionDefaultOrigin,
|
||||
hyperionDefaultPriority,
|
||||
hyperionDefaultTimeoutMs,
|
||||
hyperionDefaultWebPort,
|
||||
} from './hyperion.types.js';
|
||||
|
||||
export class HyperionConfigFlow implements IConfigFlow<IHyperionConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IHyperionConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Configure Hyperion',
|
||||
description: 'Configure a local Hyperion JSON API endpoint. SSDP candidates use the JSON TCP server by default; manual HTTP/WebSocket endpoints can be selected explicitly.',
|
||||
fields: [
|
||||
{ name: 'host', label: 'Host', type: 'text' },
|
||||
{ name: 'port', label: 'Port for selected transport', type: 'number' },
|
||||
{ name: 'transport', label: 'Transport', type: 'select', options: [
|
||||
{ label: 'TCP JSON server', value: 'tcp' },
|
||||
{ label: 'HTTP JSON-RPC', value: 'http' },
|
||||
{ label: 'WebSocket JSON-RPC', value: 'websocket' },
|
||||
] },
|
||||
{ name: 'ssl', label: 'Use TLS for HTTP/WebSocket', type: 'boolean' },
|
||||
{ name: 'token', label: 'Authorization token', type: 'password' },
|
||||
{ name: 'priority', label: 'Input priority', type: 'number' },
|
||||
{ name: 'origin', label: 'Origin label', type: 'text' },
|
||||
{ name: 'name', label: 'Name', type: 'text' },
|
||||
],
|
||||
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
|
||||
};
|
||||
}
|
||||
|
||||
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IHyperionConfig>> {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const parsed = parseEndpoint(this.stringValue(valuesArg.host) || this.stringValue(metadata.url));
|
||||
const snapshot = snapshotValue(metadata.snapshot);
|
||||
const rawData = rawDataValue(metadata.rawData);
|
||||
const transport = this.transportValue(valuesArg.transport) || this.transportValue(metadata.transport) || parsed?.transport || 'tcp';
|
||||
const host = parsed?.host || this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.device.host;
|
||||
const ssl = this.booleanValue(valuesArg.ssl) ?? this.booleanMetadata(metadata, 'ssl') ?? parsed?.ssl ?? snapshot?.device.ssl ?? false;
|
||||
const jsonPort = this.numberValue(metadata.jsonPort) || snapshot?.device.jsonPort || (transport === 'tcp' ? candidateArg.port || parsed?.port : undefined) || hyperionDefaultJsonPort;
|
||||
const webPort = this.numberValue(metadata.webPort) || snapshot?.device.webPort || (transport === 'http' || transport === 'websocket' ? candidateArg.port || parsed?.port : undefined) || hyperionDefaultWebPort;
|
||||
const port = this.numberValue(valuesArg.port) || parsed?.port || candidateArg.port || (transport === 'tcp' ? jsonPort : webPort);
|
||||
const token = this.stringValue(valuesArg.token) || this.stringMetadata(metadata, 'token');
|
||||
const hasOfflineData = Boolean(snapshot || rawData || metadata.client);
|
||||
|
||||
if (!host && !hasOfflineData) {
|
||||
return { kind: 'error', title: 'Hyperion setup failed', error: 'Hyperion host, URL, injected client, snapshot, or raw data is required.' };
|
||||
}
|
||||
if (!this.validPort(port)) {
|
||||
return { kind: 'error', title: 'Hyperion setup failed', error: 'Hyperion port must be between 1 and 65535.' };
|
||||
}
|
||||
if (transport !== 'tcp' && transport !== 'http' && transport !== 'websocket') {
|
||||
return { kind: 'error', title: 'Hyperion setup failed', error: 'Hyperion transport must be tcp, http, or websocket.' };
|
||||
}
|
||||
|
||||
const config: IHyperionConfig = {
|
||||
host,
|
||||
port,
|
||||
jsonPort,
|
||||
webPort,
|
||||
ssl,
|
||||
transport,
|
||||
token,
|
||||
timeoutMs: hyperionDefaultTimeoutMs,
|
||||
priority: this.numberValue(valuesArg.priority) || this.numberMetadata(metadata, 'priority') || hyperionDefaultPriority,
|
||||
origin: this.stringValue(valuesArg.origin) || this.stringMetadata(metadata, 'origin') || hyperionDefaultOrigin,
|
||||
effectHideList: this.stringArray(metadata.effectHideList),
|
||||
name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.device.name || this.stringMetadata(metadata, 'name'),
|
||||
manufacturer: candidateArg.manufacturer || snapshot?.device.manufacturer || 'Hyperion',
|
||||
model: candidateArg.model || snapshot?.device.model || 'Hyperion-NG',
|
||||
uniqueId: candidateArg.id || snapshot?.device.id || (host ? `${host}:${port}` : undefined),
|
||||
snapshot,
|
||||
rawData,
|
||||
client: metadata.client as IHyperionConfig['client'],
|
||||
commandExecutor: metadata.commandExecutor as IHyperionConfig['commandExecutor'],
|
||||
};
|
||||
|
||||
return { kind: 'done', title: 'Hyperion configured', config };
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim().replace(/\/$/, '') : undefined;
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown): number | undefined {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return Math.round(valueArg);
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const parsed = Number(valueArg);
|
||||
return Number.isFinite(parsed) ? Math.round(parsed) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private booleanValue(valueArg: unknown): boolean | undefined {
|
||||
if (typeof valueArg === 'boolean') {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string') {
|
||||
const normalized = valueArg.trim().toLowerCase();
|
||||
if (['true', 'yes', 'on', '1'].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (['false', 'no', 'off', '0'].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private transportValue(valueArg: unknown): THyperionTransport | undefined {
|
||||
return valueArg === 'tcp' || valueArg === 'http' || valueArg === 'websocket' ? valueArg : undefined;
|
||||
}
|
||||
|
||||
private stringMetadata(metadataArg: Record<string, unknown>, keyArg: string): string | undefined {
|
||||
return this.stringValue(metadataArg[keyArg]);
|
||||
}
|
||||
|
||||
private numberMetadata(metadataArg: Record<string, unknown>, keyArg: string): number | undefined {
|
||||
return this.numberValue(metadataArg[keyArg]);
|
||||
}
|
||||
|
||||
private booleanMetadata(metadataArg: Record<string, unknown>, keyArg: string): boolean | undefined {
|
||||
return this.booleanValue(metadataArg[keyArg]);
|
||||
}
|
||||
|
||||
private stringArray(valueArg: unknown): string[] | undefined {
|
||||
return Array.isArray(valueArg) ? valueArg.filter((itemArg): itemArg is string => typeof itemArg === 'string') : undefined;
|
||||
}
|
||||
|
||||
private validPort(valueArg: number): boolean {
|
||||
return Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
|
||||
}
|
||||
}
|
||||
|
||||
const parseEndpoint = (valueArg: string | undefined): { host: string; port?: number; ssl: boolean; transport: THyperionTransport } | undefined => {
|
||||
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const url = new URL(valueArg);
|
||||
const protocol = url.protocol.replace(':', '').toLowerCase();
|
||||
const transport: THyperionTransport = protocol === 'ws' || protocol === 'wss' ? 'websocket' : protocol === 'http' || protocol === 'https' ? 'http' : 'tcp';
|
||||
return { host: url.hostname, port: url.port ? Number(url.port) : undefined, ssl: protocol === 'https' || protocol === 'wss', transport };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const snapshotValue = (valueArg: unknown): IHyperionSnapshot | undefined => {
|
||||
return valueArg && typeof valueArg === 'object' && 'device' in valueArg && 'instances' in valueArg ? valueArg as IHyperionSnapshot : undefined;
|
||||
};
|
||||
|
||||
const rawDataValue = (valueArg: unknown): IHyperionRawData | undefined => {
|
||||
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as IHyperionRawData : undefined;
|
||||
};
|
||||
@@ -1,26 +1,101 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { HyperionClient } from './hyperion.classes.client.js';
|
||||
import { HyperionConfigFlow } from './hyperion.classes.configflow.js';
|
||||
import { createHyperionDiscoveryDescriptor } from './hyperion.discovery.js';
|
||||
import { HyperionMapper } from './hyperion.mapper.js';
|
||||
import type { IHyperionConfig, IHyperionSnapshot } from './hyperion.types.js';
|
||||
import { hyperionDisplayName, hyperionDomain, hyperionSsdpManufacturer, hyperionSsdpServiceType } from './hyperion.types.js';
|
||||
|
||||
export class HomeAssistantHyperionIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "hyperion",
|
||||
displayName: "Hyperion",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/hyperion",
|
||||
"upstreamDomain": "hyperion",
|
||||
"integrationType": "hub",
|
||||
"iotClass": "local_push",
|
||||
"requirements": [
|
||||
"hyperion-py==0.7.6"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@dermotduffy"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class HyperionIntegration extends BaseIntegration<IHyperionConfig> {
|
||||
public readonly domain = hyperionDomain;
|
||||
public readonly displayName = hyperionDisplayName;
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createHyperionDiscoveryDescriptor();
|
||||
public readonly configFlow = new HyperionConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/hyperion',
|
||||
upstreamDomain: hyperionDomain,
|
||||
integrationType: 'hub',
|
||||
iotClass: 'local_push',
|
||||
requirements: ['hyperion-py==0.7.6'],
|
||||
dependencies: [] as string[],
|
||||
afterDependencies: [] as string[],
|
||||
codeowners: ['@dermotduffy'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/hyperion',
|
||||
ssdp: [{ manufacturer: hyperionSsdpManufacturer, st: hyperionSsdpServiceType }],
|
||||
localApi: {
|
||||
implemented: [
|
||||
'SSDP and manual discovery for Hyperion JSON API endpoints',
|
||||
'local HTTP JSON-RPC, TCP JSON server, and optional global WebSocket JSON-RPC request transports',
|
||||
'sysinfo plus serverinfo/getInfo modeled snapshots',
|
||||
'color, effect, clear, brightness adjustment, component state, still image snapshot, and raw JSON-RPC commands',
|
||||
'snapshot/manual-data and injected client/executor operation',
|
||||
],
|
||||
explicitUnsupported: [
|
||||
'claiming live command success for static snapshots without host/client/executor transport',
|
||||
'Hyperion UI token approval workflow beyond accepting an existing token in config',
|
||||
'long-lived WebSocket subscription streaming as a runtime event source',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
public async setup(configArg: IHyperionConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new HyperionRuntime(new HyperionClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantHyperionIntegration extends HyperionIntegration {}
|
||||
|
||||
class HyperionRuntime implements IIntegrationRuntime {
|
||||
public domain = hyperionDomain;
|
||||
|
||||
constructor(private readonly client: HyperionClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return HyperionMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return HyperionMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
try {
|
||||
if (requestArg.domain === hyperionDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
|
||||
return { success: true, data: await this.client.getSnapshot() };
|
||||
}
|
||||
if (requestArg.domain === hyperionDomain && requestArg.service === 'refresh') {
|
||||
const result = await this.client.refresh();
|
||||
return { success: result.success, error: result.error, data: result.snapshot || result.data };
|
||||
}
|
||||
if (requestArg.domain === hyperionDomain && requestArg.service === 'restore') {
|
||||
await this.client.restore(requestArg.data?.snapshot as IHyperionSnapshot | undefined);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
const commands = HyperionMapper.commandsForService(snapshot, requestArg);
|
||||
if (!commands.length) {
|
||||
return { success: false, error: `Unsupported Hyperion service: ${requestArg.domain}.${requestArg.service}` };
|
||||
}
|
||||
const results: unknown[] = [];
|
||||
for (const command of commands) {
|
||||
results.push(await this.client.execute(command));
|
||||
}
|
||||
return { success: true, data: results.length === 1 ? results[0] : results };
|
||||
} catch (errorArg) {
|
||||
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type {
|
||||
IDiscoveryCandidate,
|
||||
IDiscoveryContext,
|
||||
IDiscoveryMatch,
|
||||
IDiscoveryMatcher,
|
||||
IDiscoveryProbe,
|
||||
IDiscoveryProbeResult,
|
||||
IDiscoveryValidator,
|
||||
} from '../../core/types.js';
|
||||
import type { IHyperionManualEntry, IHyperionRawData, IHyperionSnapshot, IHyperionSsdpRecord, THyperionTransport } from './hyperion.types.js';
|
||||
import {
|
||||
hyperionDefaultJsonPort,
|
||||
hyperionDefaultSslWebPort,
|
||||
hyperionDefaultWebPort,
|
||||
hyperionDisplayName,
|
||||
hyperionDomain,
|
||||
hyperionSsdpManufacturer,
|
||||
hyperionSsdpServiceType,
|
||||
} from './hyperion.types.js';
|
||||
|
||||
export class HyperionSsdpDiscoveryProbe implements IDiscoveryProbe {
|
||||
public id = 'hyperion-ssdp-discovery-probe';
|
||||
public source = 'ssdp' as const;
|
||||
public description = 'Discover Hyperion JSON API servers using the upstream SSDP service type.';
|
||||
|
||||
public async probe(contextArg: IDiscoveryContext): Promise<IDiscoveryProbeResult> {
|
||||
if (contextArg.abortSignal?.aborted) {
|
||||
return { candidates: [] };
|
||||
}
|
||||
return { candidates: await this.discover(1200) };
|
||||
}
|
||||
|
||||
private async discover(timeoutMsArg: number): Promise<IDiscoveryCandidate[]> {
|
||||
const { createSocket } = await import('node:dgram');
|
||||
const matcher = new HyperionSsdpMatcher();
|
||||
const candidates: IDiscoveryCandidate[] = [];
|
||||
const request = [
|
||||
'M-SEARCH * HTTP/1.1',
|
||||
'HOST: 239.255.255.250:1900',
|
||||
'MAN: "ssdp:discover"',
|
||||
'MX: 1',
|
||||
`ST: ${hyperionSsdpServiceType}`,
|
||||
'',
|
||||
'',
|
||||
].join('\r\n');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const socket = createSocket({ type: 'udp4', reuseAddr: true });
|
||||
const timer = setTimeout(() => {
|
||||
closeSocket();
|
||||
resolve(candidates);
|
||||
}, timeoutMsArg);
|
||||
const closeSocket = () => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// Discovery sockets may already be closed after timeout or OS errors.
|
||||
}
|
||||
};
|
||||
socket.on('message', async (dataArg) => {
|
||||
const match = await matcher.matches({ headers: parseSsdpHeaders(dataArg.toString('utf8')) });
|
||||
if (match.matched && match.candidate && !candidates.some((candidateArg) => candidateArg.id === match.candidate?.id || candidateArg.host === match.candidate?.host)) {
|
||||
candidates.push(match.candidate);
|
||||
}
|
||||
});
|
||||
socket.on('error', () => {
|
||||
closeSocket();
|
||||
resolve(candidates);
|
||||
});
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
socket.send(Buffer.from(request, 'utf8'), 1900, '239.255.255.250');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class HyperionSsdpMatcher implements IDiscoveryMatcher<IHyperionSsdpRecord> {
|
||||
public id = 'hyperion-ssdp-match';
|
||||
public source = 'ssdp' as const;
|
||||
public description = 'Recognize Hyperion SSDP records by service type, manufacturer, and JSON server port metadata.';
|
||||
|
||||
public async matches(recordArg: IHyperionSsdpRecord, contextArg?: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
const headers = normalizeHeaders(recordArg.headers || {});
|
||||
const upnp = normalizeObject(recordArg.upnp || {});
|
||||
const ports = normalizeObject(recordArg.ports || upnp.ports || {});
|
||||
const st = stringValue(recordArg.ssdp_st || recordArg.st || headers.st);
|
||||
const manufacturer = stringValue(recordArg.manufacturer || upnp.manufacturer || headers.manufacturer);
|
||||
const server = stringValue(recordArg.ssdp_server || recordArg.server || headers.server);
|
||||
const model = stringValue(recordArg.modelName || upnp.modelname || upnp.modelName || recordArg.modelNumber || upnp.modelnumber || upnp.modelNumber);
|
||||
const location = stringValue(recordArg.ssdp_location || recordArg.location || headers.location);
|
||||
const locationUrl = parseEndpoint(location);
|
||||
const jsonPort = numberValue(ports.jsonserver || ports.jsonServer || headers['hyperion-jss-port']) || hyperionDefaultJsonPort;
|
||||
const webPort = locationUrl?.port || hyperionDefaultWebPort;
|
||||
const sslPort = numberValue(ports.sslserver || ports.sslServer || headers['hyperion-ssl-port']) || hyperionDefaultSslWebPort;
|
||||
const name = stringValue(recordArg.friendlyName || upnp.friendlyname || upnp.friendlyName || headers['hyperion-name']) || hyperionDisplayName;
|
||||
const id = normalizeId(stringValue(recordArg.serialNumber || upnp.serialnumber || upnp.serialNumber || recordArg.UDN || recordArg.ssdp_usn || recordArg.usn || headers.usn));
|
||||
const matched = st === hyperionSsdpServiceType
|
||||
|| manufacturer === hyperionSsdpManufacturer
|
||||
|| Boolean(server?.toLowerCase().includes('hyperion'))
|
||||
|| Boolean(model?.toLowerCase().includes('hyperion'));
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'SSDP record is not a Hyperion basic device advertisement.' };
|
||||
}
|
||||
const host = locationUrl?.host;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: host && id ? 'certain' : host ? 'high' : 'medium',
|
||||
reason: 'SSDP record matches Hyperion manufacturer/service metadata.',
|
||||
normalizedDeviceId: id || (host ? `${host}:${jsonPort}` : undefined),
|
||||
candidate: {
|
||||
source: 'ssdp',
|
||||
integrationDomain: hyperionDomain,
|
||||
id: id || (host ? `${host}:${jsonPort}` : undefined),
|
||||
host,
|
||||
port: jsonPort,
|
||||
name,
|
||||
manufacturer: 'Hyperion',
|
||||
model: model || 'Hyperion-NG',
|
||||
serialNumber: id,
|
||||
metadata: {
|
||||
...recordArg.metadata,
|
||||
hyperion: true,
|
||||
discoveryProtocol: 'ssdp',
|
||||
ssdpServiceType: st,
|
||||
ssdpLocation: location,
|
||||
ssdpManufacturer: manufacturer,
|
||||
ssdpServer: server,
|
||||
jsonPort,
|
||||
webPort,
|
||||
sslPort,
|
||||
transport: 'tcp' satisfies THyperionTransport,
|
||||
rawUpnp: upnp,
|
||||
headers,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class HyperionManualMatcher implements IDiscoveryMatcher<IHyperionManualEntry> {
|
||||
public id = 'hyperion-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual Hyperion host, URL, token, snapshot, client, and executor setup entries.';
|
||||
|
||||
public async matches(inputArg: IHyperionManualEntry, contextArg?: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
const metadata = inputArg.metadata || {};
|
||||
const parsed = parseEndpoint(inputArg.url || inputArg.host);
|
||||
const snapshot = snapshotValue(inputArg.snapshot || metadata.snapshot);
|
||||
const rawData = rawDataValue(inputArg.rawData || metadata.rawData);
|
||||
const hasData = Boolean(snapshot || rawData || inputArg.client || metadata.client);
|
||||
const text = [inputArg.name, inputArg.manufacturer, inputArg.model, metadata.name, metadata.manufacturer, metadata.model].filter(Boolean).join(' ').toLowerCase();
|
||||
const matched = Boolean(inputArg.host || inputArg.url || inputArg.token || metadata.hyperion || hasData || text.includes('hyperion'));
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Hyperion setup hints.' };
|
||||
}
|
||||
const transport = inputArg.transport || transportValue(metadata.transport) || parsed?.transport || 'tcp';
|
||||
const host = parsed?.host || (inputArg.url ? undefined : inputArg.host) || snapshot?.device.host;
|
||||
const webPort = inputArg.webPort || numberValue(metadata.webPort) || snapshot?.device.webPort || (transport === 'http' || transport === 'websocket' ? parsed?.port : undefined) || hyperionDefaultWebPort;
|
||||
const jsonPort = inputArg.jsonPort || numberValue(metadata.jsonPort) || snapshot?.device.jsonPort || (transport === 'tcp' ? parsed?.port : undefined) || hyperionDefaultJsonPort;
|
||||
const port = inputArg.port || parsed?.port || (transport === 'tcp' ? jsonPort : webPort);
|
||||
const id = inputArg.id || inputArg.uniqueId || snapshot?.device.id || (host ? `${host}:${port}` : undefined);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: host || hasData ? 'high' : 'medium',
|
||||
reason: 'Manual entry can start Hyperion setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: hyperionDomain,
|
||||
id,
|
||||
host,
|
||||
port,
|
||||
name: inputArg.name || snapshot?.device.name || hyperionDisplayName,
|
||||
manufacturer: inputArg.manufacturer || snapshot?.device.manufacturer || 'Hyperion',
|
||||
model: inputArg.model || snapshot?.device.model || 'Hyperion-NG',
|
||||
metadata: {
|
||||
...metadata,
|
||||
hyperion: true,
|
||||
discoveryProtocol: 'manual',
|
||||
transport,
|
||||
ssl: inputArg.ssl ?? inputArg.tls ?? parsed?.ssl ?? metadata.ssl,
|
||||
jsonPort,
|
||||
webPort,
|
||||
token: inputArg.token || metadata.token,
|
||||
priority: inputArg.priority || metadata.priority,
|
||||
origin: inputArg.origin || metadata.origin,
|
||||
effectHideList: inputArg.effectHideList || metadata.effectHideList,
|
||||
snapshot,
|
||||
rawData,
|
||||
client: inputArg.client || metadata.client,
|
||||
commandExecutor: inputArg.commandExecutor || metadata.commandExecutor,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class HyperionCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'hyperion-candidate-validator';
|
||||
public description = 'Validate Hyperion candidates from SSDP and manual setup.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate, contextArg?: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const snapshot = snapshotValue(metadata.snapshot);
|
||||
const rawData = rawDataValue(metadata.rawData);
|
||||
const text = [candidateArg.integrationDomain, candidateArg.name, candidateArg.manufacturer, candidateArg.model, metadata.ssdpServiceType].filter(Boolean).join(' ').toLowerCase();
|
||||
const matched = candidateArg.integrationDomain === hyperionDomain
|
||||
|| metadata.hyperion === true
|
||||
|| text.includes('hyperion')
|
||||
|| metadata.ssdpServiceType === hyperionSsdpServiceType;
|
||||
const hasUsableSource = Boolean(candidateArg.host || snapshot || rawData || metadata.client);
|
||||
const normalizedDeviceId = candidateArg.id || snapshot?.device.id || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || hyperionDefaultJsonPort}` : undefined);
|
||||
if (!matched || !hasUsableSource) {
|
||||
return {
|
||||
matched: false,
|
||||
confidence: matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Hyperion candidate lacks a host, injected client, snapshot, or raw data.' : 'Candidate is not Hyperion.',
|
||||
normalizedDeviceId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
matched: true,
|
||||
confidence: candidateArg.host && normalizedDeviceId ? 'certain' : candidateArg.host ? 'high' : 'medium',
|
||||
reason: 'Candidate has Hyperion metadata and a usable local endpoint, client, snapshot, or raw data.',
|
||||
normalizedDeviceId,
|
||||
candidate: {
|
||||
...candidateArg,
|
||||
integrationDomain: hyperionDomain,
|
||||
port: candidateArg.port || numberValue(metadata.jsonPort) || hyperionDefaultJsonPort,
|
||||
manufacturer: candidateArg.manufacturer || 'Hyperion',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createHyperionDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: hyperionDomain, displayName: hyperionDisplayName })
|
||||
.addProbe(new HyperionSsdpDiscoveryProbe())
|
||||
.addMatcher(new HyperionSsdpMatcher())
|
||||
.addMatcher(new HyperionManualMatcher())
|
||||
.addValidator(new HyperionCandidateValidator());
|
||||
};
|
||||
|
||||
const parseSsdpHeaders = (valueArg: string): Record<string, string> => {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const line of valueArg.split(/\r?\n/)) {
|
||||
const index = line.indexOf(':');
|
||||
if (index <= 0) {
|
||||
continue;
|
||||
}
|
||||
headers[line.slice(0, index).trim()] = line.slice(index + 1).trim();
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
const normalizeHeaders = (headersArg: Record<string, string | string[] | undefined>): Record<string, string | undefined> => {
|
||||
const normalized: Record<string, string | undefined> = {};
|
||||
for (const [key, value] of Object.entries(headersArg)) {
|
||||
normalized[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const normalizeObject = (valueArg: unknown): Record<string, unknown> => {
|
||||
if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) {
|
||||
return {};
|
||||
}
|
||||
const normalized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(valueArg)) {
|
||||
normalized[key] = value;
|
||||
normalized[key.toLowerCase()] = value;
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const parseEndpoint = (valueArg: string | undefined): { host: string; port?: number; ssl: boolean; transport: THyperionTransport } | undefined => {
|
||||
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const url = new URL(valueArg);
|
||||
const protocol = url.protocol.replace(':', '').toLowerCase();
|
||||
const transport: THyperionTransport = protocol === 'ws' || protocol === 'wss' ? 'websocket' : protocol === 'http' || protocol === 'https' ? 'http' : 'tcp';
|
||||
return { host: url.hostname, port: url.port ? Number(url.port) : undefined, ssl: protocol === 'https' || protocol === 'wss', transport };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeId = (valueArg: string | undefined): string | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
return valueArg.replace(/^uuid:/i, '').split('::')[0].trim() || undefined;
|
||||
};
|
||||
|
||||
const snapshotValue = (valueArg: unknown): IHyperionSnapshot | undefined => {
|
||||
return valueArg && typeof valueArg === 'object' && 'device' in valueArg && 'instances' in valueArg ? valueArg as IHyperionSnapshot : undefined;
|
||||
};
|
||||
|
||||
const rawDataValue = (valueArg: unknown): IHyperionRawData | undefined => {
|
||||
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as IHyperionRawData : undefined;
|
||||
};
|
||||
|
||||
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
const numberValue = (valueArg: unknown): number | undefined => typeof valueArg === 'number' && Number.isFinite(valueArg) ? Math.round(valueArg) : typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg)) ? Math.round(Number(valueArg)) : undefined;
|
||||
const transportValue = (valueArg: unknown): THyperionTransport | undefined => valueArg === 'tcp' || valueArg === 'http' || valueArg === 'websocket' ? valueArg : undefined;
|
||||
@@ -0,0 +1,598 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
|
||||
import type {
|
||||
IHyperionCommandRequest,
|
||||
IHyperionComponentState,
|
||||
IHyperionConfig,
|
||||
IHyperionEffectDefinition,
|
||||
IHyperionInstanceInfo,
|
||||
IHyperionInstanceSnapshot,
|
||||
IHyperionPriority,
|
||||
IHyperionRawData,
|
||||
IHyperionServerInfo,
|
||||
IHyperionSnapshot,
|
||||
THyperionComponentId,
|
||||
THyperionImageFormat,
|
||||
THyperionSnapshotSource,
|
||||
THyperionTransport,
|
||||
} from './hyperion.types.js';
|
||||
import {
|
||||
hyperionComponentIds,
|
||||
hyperionDefaultJsonPort,
|
||||
hyperionDefaultOrigin,
|
||||
hyperionDefaultPriority,
|
||||
hyperionDefaultWebPort,
|
||||
hyperionDisplayName,
|
||||
hyperionDomain,
|
||||
hyperionSolidEffect,
|
||||
} from './hyperion.types.js';
|
||||
|
||||
interface IHyperionSnapshotOptions {
|
||||
config: IHyperionConfig;
|
||||
rawData?: IHyperionRawData;
|
||||
online?: boolean;
|
||||
source?: THyperionSnapshotSource;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface IHyperionEntityDefinition {
|
||||
key: string;
|
||||
name: string;
|
||||
platform: TEntityPlatform;
|
||||
state: unknown;
|
||||
attributes?: Record<string, unknown>;
|
||||
available?: boolean;
|
||||
}
|
||||
|
||||
const componentLabels: Record<string, string> = {
|
||||
ALL: 'All',
|
||||
SMOOTHING: 'Smoothing',
|
||||
BLACKBORDER: 'Blackbar detection',
|
||||
FORWARDER: 'Forwarder',
|
||||
BOBLIGHTSERVER: 'Boblight server',
|
||||
GRABBER: 'Platform capture',
|
||||
LEDDEVICE: 'LED device',
|
||||
V4L: 'USB capture',
|
||||
AUDIO: 'Audio capture',
|
||||
};
|
||||
|
||||
export class HyperionMapper {
|
||||
public static toSnapshot(optionsArg: IHyperionSnapshotOptions): IHyperionSnapshot {
|
||||
if (optionsArg.config.snapshot && !optionsArg.rawData) {
|
||||
return this.normalizeSnapshot(this.clone(optionsArg.config.snapshot), optionsArg.config, optionsArg.source || 'snapshot');
|
||||
}
|
||||
|
||||
const rawData = optionsArg.rawData || optionsArg.config.rawData || {};
|
||||
const sysInfo = rawData.sysInfo || {};
|
||||
const serverInfo = rawData.serverInfo || {};
|
||||
const endpoint = parseEndpoint(optionsArg.config.url);
|
||||
const transport = optionsArg.config.transport || endpoint?.transport || 'tcp';
|
||||
const host = optionsArg.config.host || endpoint?.host;
|
||||
const ssl = optionsArg.config.ssl ?? optionsArg.config.tls ?? endpoint?.ssl ?? false;
|
||||
const port = optionsArg.config.port || endpoint?.port || this.defaultPort(transport, ssl, optionsArg.config);
|
||||
const webPort = optionsArg.config.webPort || (transport === 'http' || transport === 'websocket' ? port : undefined) || endpoint?.port || hyperionDefaultWebPort;
|
||||
const jsonPort = optionsArg.config.jsonPort || (transport === 'tcp' ? port : undefined) || hyperionDefaultJsonPort;
|
||||
const hyperionInfo = sysInfo.hyperion || {};
|
||||
const deviceId = optionsArg.config.uniqueId || hyperionInfo.id || (host ? `${host}:${port}` : undefined) || optionsArg.config.name || hyperionDomain;
|
||||
const deviceName = optionsArg.config.name || this.firstInstanceName(serverInfo) || hyperionDisplayName;
|
||||
const instances = this.instances(serverInfo, rawData, optionsArg.config, optionsArg.online ?? optionsArg.config.online ?? Boolean(rawData.serverInfo));
|
||||
const effectNames = new Set<string>();
|
||||
for (const instance of instances) {
|
||||
for (const effectName of instance.effectNames) {
|
||||
effectNames.add(effectName);
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot: IHyperionSnapshot = {
|
||||
device: {
|
||||
id: deviceId,
|
||||
name: deviceName,
|
||||
manufacturer: optionsArg.config.manufacturer || 'Hyperion',
|
||||
model: optionsArg.config.model || 'Hyperion-NG',
|
||||
version: hyperionInfo.version,
|
||||
host,
|
||||
port,
|
||||
jsonPort,
|
||||
webPort,
|
||||
ssl,
|
||||
transport,
|
||||
hostname: serverInfo.hostname || sysInfo.system?.hostName,
|
||||
},
|
||||
instances,
|
||||
capabilities: {
|
||||
localControl: Boolean(optionsArg.config.host || optionsArg.config.url || optionsArg.config.commandExecutor || optionsArg.config.client?.execute),
|
||||
effects: effectNames.size > 0,
|
||||
components: instances.some((instanceArg) => instanceArg.components.length > 0),
|
||||
imageSnapshot: Boolean(optionsArg.config.host || optionsArg.config.url || optionsArg.config.commandExecutor || optionsArg.config.client?.execute),
|
||||
liveStreaming: Boolean((transport === 'tcp' || transport === 'websocket') && (optionsArg.config.host || optionsArg.config.url || optionsArg.config.commandExecutor || optionsArg.config.client?.execute)),
|
||||
multiInstance: instances.length > 1,
|
||||
authRequired: rawData.authRequired,
|
||||
},
|
||||
settings: {
|
||||
priority: this.priority(optionsArg.config.priority),
|
||||
origin: optionsArg.config.origin || hyperionDefaultOrigin,
|
||||
effectHideList: optionsArg.config.effectHideList || [],
|
||||
},
|
||||
rawData: Object.keys(rawData).length ? rawData : undefined,
|
||||
online: optionsArg.online ?? optionsArg.config.online ?? Boolean(rawData.serverInfo),
|
||||
updatedAt: rawData.fetchedAt || new Date().toISOString(),
|
||||
source: optionsArg.source || (rawData.serverInfo ? transport : 'runtime'),
|
||||
error: optionsArg.error,
|
||||
};
|
||||
|
||||
return this.normalizeSnapshot(snapshot, optionsArg.config, snapshot.source);
|
||||
}
|
||||
|
||||
public static toDevices(snapshotArg: IHyperionSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||
return snapshotArg.instances.map((instanceArg) => {
|
||||
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||
{ id: 'power', capability: 'light', name: 'Power', readable: true, writable: snapshotArg.capabilities.localControl },
|
||||
{ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: snapshotArg.capabilities.localControl, unit: '%' },
|
||||
{ id: 'visible_priority', capability: 'sensor', name: 'Visible priority', readable: true, writable: false },
|
||||
{ id: 'image_snapshot', capability: 'sensor', name: 'Image snapshot', readable: true, writable: false },
|
||||
];
|
||||
for (const component of hyperionComponentIds) {
|
||||
features.push({ id: `component_${this.slug(component)}`, capability: 'switch', name: `Component ${componentLabels[component] || component}`, readable: true, writable: snapshotArg.capabilities.localControl });
|
||||
}
|
||||
return {
|
||||
id: this.deviceId(snapshotArg, instanceArg.instance),
|
||||
integrationDomain: hyperionDomain,
|
||||
name: instanceArg.name,
|
||||
protocol: (snapshotArg.device.transport || (snapshotArg.device.host ? 'hyperion-json' : 'unknown')) as plugins.shxInterfaces.data.TDeviceProtocol,
|
||||
manufacturer: snapshotArg.device.manufacturer,
|
||||
model: snapshotArg.device.model,
|
||||
online: snapshotArg.online && instanceArg.running,
|
||||
features,
|
||||
state: [
|
||||
{ featureId: 'power', value: this.lightIsOn(instanceArg, snapshotArg.settings.priority), updatedAt },
|
||||
{ featureId: 'brightness', value: this.brightnessPercent(instanceArg) ?? null, updatedAt },
|
||||
{ featureId: 'visible_priority', value: this.deviceStateValue(this.visiblePriorityValue(instanceArg)), updatedAt },
|
||||
{ featureId: 'image_snapshot', value: instanceArg.imageSnapshot ? `${instanceArg.imageSnapshot.format || 'PNG'}:${instanceArg.imageSnapshot.width || '?'}x${instanceArg.imageSnapshot.height || '?'}` : null, updatedAt },
|
||||
],
|
||||
metadata: this.cleanAttributes({
|
||||
serverId: snapshotArg.device.id,
|
||||
instance: instanceArg.instance,
|
||||
running: instanceArg.running,
|
||||
host: snapshotArg.device.host,
|
||||
port: snapshotArg.device.port,
|
||||
jsonPort: snapshotArg.device.jsonPort,
|
||||
webPort: snapshotArg.device.webPort,
|
||||
ssl: snapshotArg.device.ssl,
|
||||
version: snapshotArg.device.version,
|
||||
source: snapshotArg.source,
|
||||
error: snapshotArg.error,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IHyperionSnapshot): IIntegrationEntity[] {
|
||||
const entities: IIntegrationEntity[] = [];
|
||||
const uniqueBase = this.uniqueBase(snapshotArg);
|
||||
for (const instance of snapshotArg.instances) {
|
||||
const deviceId = this.deviceId(snapshotArg, instance.instance);
|
||||
const available = snapshotArg.online && instance.running;
|
||||
const lightPriority = this.priorityEntry(instance, snapshotArg.settings.priority);
|
||||
entities.push(this.entity(snapshotArg, instance, {
|
||||
key: 'light',
|
||||
name: instance.name,
|
||||
platform: 'light',
|
||||
state: lightPriority ? 'on' : 'off',
|
||||
available,
|
||||
attributes: {
|
||||
brightness: this.brightness255(instance),
|
||||
brightnessPercent: this.brightnessPercent(instance),
|
||||
rgbColor: this.rgbColor(lightPriority),
|
||||
effect: this.effectName(lightPriority),
|
||||
effectList: [hyperionSolidEffect, ...instance.effectNames.filter((effectArg) => !snapshotArg.settings.effectHideList.includes(effectArg))],
|
||||
priority: snapshotArg.settings.priority,
|
||||
instance: instance.instance,
|
||||
key: 'light',
|
||||
},
|
||||
}, deviceId, uniqueBase));
|
||||
|
||||
entities.push(this.entity(snapshotArg, instance, {
|
||||
key: 'visible_priority',
|
||||
name: `${instance.name} Visible priority`,
|
||||
platform: 'sensor',
|
||||
state: this.visiblePriorityValue(instance),
|
||||
available,
|
||||
attributes: this.cleanAttributes({
|
||||
component_id: instance.visiblePriority?.componentId,
|
||||
origin: instance.visiblePriority?.origin,
|
||||
priority: instance.visiblePriority?.priority,
|
||||
owner: instance.visiblePriority?.owner,
|
||||
color: instance.visiblePriority?.value,
|
||||
instance: instance.instance,
|
||||
key: 'visible_priority',
|
||||
}),
|
||||
}, deviceId, uniqueBase));
|
||||
|
||||
entities.push(this.entity(snapshotArg, instance, {
|
||||
key: 'camera',
|
||||
name: `${instance.name} Image`,
|
||||
platform: 'camera' as TEntityPlatform,
|
||||
state: available ? 'idle' : 'unavailable',
|
||||
available,
|
||||
attributes: this.cleanAttributes({
|
||||
format: instance.imageSnapshot?.format,
|
||||
width: instance.imageSnapshot?.width,
|
||||
height: instance.imageSnapshot?.height,
|
||||
hasImage: Boolean(instance.imageSnapshot?.data),
|
||||
instance: instance.instance,
|
||||
key: 'camera',
|
||||
}),
|
||||
}, deviceId, uniqueBase));
|
||||
|
||||
for (const component of hyperionComponentIds) {
|
||||
const state = this.componentState(instance, component);
|
||||
entities.push(this.entity(snapshotArg, instance, {
|
||||
key: `component_${this.slug(component)}`,
|
||||
name: `${instance.name} Component ${componentLabels[component] || component}`,
|
||||
platform: 'switch',
|
||||
state: state?.enabled ? 'on' : 'off',
|
||||
available,
|
||||
attributes: {
|
||||
component,
|
||||
instance: instance.instance,
|
||||
key: 'component',
|
||||
},
|
||||
}, deviceId, uniqueBase));
|
||||
}
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static commandsForService(snapshotArg: IHyperionSnapshot, requestArg: IServiceCallRequest): IHyperionCommandRequest[] {
|
||||
if (requestArg.domain === hyperionDomain) {
|
||||
const command = this.hyperionServiceCommand(snapshotArg, requestArg);
|
||||
return command ? [command] : [];
|
||||
}
|
||||
|
||||
if (requestArg.domain === 'light') {
|
||||
const instance = this.instanceForTarget(snapshotArg, requestArg);
|
||||
if (requestArg.service === 'turn_off') {
|
||||
return [{ action: 'clear', instance, priority: snapshotArg.settings.priority, service: requestArg.service, target: requestArg.target, data: requestArg.data }];
|
||||
}
|
||||
if (requestArg.service !== 'turn_on') {
|
||||
return [];
|
||||
}
|
||||
const commands: IHyperionCommandRequest[] = [];
|
||||
const brightness = this.numberData(requestArg, 'brightness');
|
||||
if (brightness !== undefined) {
|
||||
commands.push({ action: 'adjust_brightness', instance, brightness, service: requestArg.service, target: requestArg.target, data: requestArg.data });
|
||||
}
|
||||
const effect = this.stringData(requestArg, 'effect');
|
||||
if (effect && effect !== hyperionSolidEffect) {
|
||||
commands.push({ action: 'set_effect', instance, effect, priority: snapshotArg.settings.priority, origin: snapshotArg.settings.origin, duration: this.numberData(requestArg, 'duration'), service: requestArg.service, target: requestArg.target, data: requestArg.data });
|
||||
} else {
|
||||
const color = this.colorData(requestArg) || this.currentOrDefaultColor(snapshotArg, instance);
|
||||
commands.push({ action: 'set_color', instance, color, priority: snapshotArg.settings.priority, origin: snapshotArg.settings.origin, duration: this.numberData(requestArg, 'duration'), service: requestArg.service, target: requestArg.target, data: requestArg.data });
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
if (requestArg.domain === 'switch') {
|
||||
const enabled = requestArg.service === 'turn_on' ? true : requestArg.service === 'turn_off' ? false : undefined;
|
||||
if (enabled === undefined) {
|
||||
return [];
|
||||
}
|
||||
const component = this.componentForTarget(snapshotArg, requestArg);
|
||||
return component ? [{ action: 'set_component', instance: this.instanceForTarget(snapshotArg, requestArg), component, state: enabled, service: requestArg.service, target: requestArg.target, data: requestArg.data }] : [];
|
||||
}
|
||||
|
||||
if (requestArg.domain === 'camera' && (requestArg.service === 'snapshot' || requestArg.service === 'get_image_snapshot')) {
|
||||
return [{ action: 'get_image_snapshot', instance: this.instanceForTarget(snapshotArg, requestArg), format: this.imageFormat(requestArg.data?.format) }];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public static commandForService(snapshotArg: IHyperionSnapshot, requestArg: IServiceCallRequest): IHyperionCommandRequest | undefined {
|
||||
return this.commandsForService(snapshotArg, requestArg)[0];
|
||||
}
|
||||
|
||||
public static deviceId(snapshotArg: IHyperionSnapshot, instanceArg: number): string {
|
||||
return `${hyperionDomain}.instance.${this.uniqueBase(snapshotArg)}.${instanceArg}`;
|
||||
}
|
||||
|
||||
public static uniqueBase(snapshotArg: IHyperionSnapshot): string {
|
||||
return this.slug(snapshotArg.device.id || snapshotArg.device.host || snapshotArg.device.name || hyperionDomain);
|
||||
}
|
||||
|
||||
public static slug(valueArg: string): string {
|
||||
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || hyperionDomain;
|
||||
}
|
||||
|
||||
public static clone<TValue>(valueArg: TValue): TValue {
|
||||
return JSON.parse(JSON.stringify(valueArg)) as TValue;
|
||||
}
|
||||
|
||||
private static instances(serverInfoArg: IHyperionServerInfo, rawDataArg: IHyperionRawData, configArg: IHyperionConfig, onlineArg: boolean): IHyperionInstanceSnapshot[] {
|
||||
const rawInstances = Array.isArray(serverInfoArg.instance) && serverInfoArg.instance.length ? serverInfoArg.instance : [{ instance: configArg.instance ?? 0, friendly_name: configArg.name || hyperionDisplayName, running: onlineArg }];
|
||||
const seen = new Set<number>();
|
||||
return rawInstances.map((rawInstanceArg) => this.instanceSnapshot(rawInstanceArg, rawDataArg, serverInfoArg, configArg, onlineArg)).filter((instanceArg) => {
|
||||
if (seen.has(instanceArg.instance)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(instanceArg.instance);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private static instanceSnapshot(rawInstanceArg: IHyperionInstanceInfo, rawDataArg: IHyperionRawData, fallbackInfoArg: IHyperionServerInfo, configArg: IHyperionConfig, onlineArg: boolean): IHyperionInstanceSnapshot {
|
||||
const instance = Number.isInteger(rawInstanceArg.instance) ? rawInstanceArg.instance : configArg.instance ?? 0;
|
||||
const info = rawDataArg.instanceServerInfo?.[instance] || fallbackInfoArg;
|
||||
const effects = this.effects(info);
|
||||
const priorities = Array.isArray(info.priorities) ? info.priorities : [];
|
||||
return {
|
||||
instance,
|
||||
name: rawInstanceArg.friendly_name || configArg.name || `${hyperionDisplayName} ${instance}`,
|
||||
running: rawInstanceArg.running ?? onlineArg,
|
||||
serverInfo: info,
|
||||
components: Array.isArray(info.components) ? info.components : [],
|
||||
effects,
|
||||
effectNames: effects.map((effectArg) => effectArg.name).filter(Boolean),
|
||||
activeEffectNames: Array.isArray(info.activeEffects) ? info.activeEffects.map((effectArg) => effectArg.name).filter((effectArg): effectArg is string => Boolean(effectArg)) : [],
|
||||
adjustments: Array.isArray(info.adjustment) ? info.adjustment : [],
|
||||
priorities,
|
||||
visiblePriority: priorities.find((priorityArg) => priorityArg.visible === true),
|
||||
imageSnapshot: rawDataArg.imageSnapshots?.[instance],
|
||||
};
|
||||
}
|
||||
|
||||
private static effects(serverInfoArg: IHyperionServerInfo): IHyperionEffectDefinition[] {
|
||||
return Array.isArray(serverInfoArg.effects) ? serverInfoArg.effects.filter((effectArg) => typeof effectArg.name === 'string') : [];
|
||||
}
|
||||
|
||||
private static normalizeSnapshot(snapshotArg: IHyperionSnapshot, configArg: IHyperionConfig, sourceArg: THyperionSnapshotSource): IHyperionSnapshot {
|
||||
return {
|
||||
...snapshotArg,
|
||||
instances: snapshotArg.instances.length ? snapshotArg.instances : this.instances({}, {}, configArg, snapshotArg.online),
|
||||
settings: {
|
||||
priority: this.priority(snapshotArg.settings?.priority ?? configArg.priority),
|
||||
origin: snapshotArg.settings?.origin || configArg.origin || hyperionDefaultOrigin,
|
||||
effectHideList: snapshotArg.settings?.effectHideList || configArg.effectHideList || [],
|
||||
},
|
||||
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
|
||||
source: sourceArg,
|
||||
};
|
||||
}
|
||||
|
||||
private static entity(snapshotArg: IHyperionSnapshot, instanceArg: IHyperionInstanceSnapshot, definitionArg: IHyperionEntityDefinition, deviceIdArg: string, uniqueBaseArg: string): IIntegrationEntity {
|
||||
const entityId = `${definitionArg.platform}.${this.slug(`${instanceArg.name}_${definitionArg.key}`)}`;
|
||||
return {
|
||||
id: entityId,
|
||||
uniqueId: `${hyperionDomain}_${uniqueBaseArg}_${instanceArg.instance}_${definitionArg.key}`,
|
||||
integrationDomain: hyperionDomain,
|
||||
deviceId: deviceIdArg,
|
||||
platform: definitionArg.platform,
|
||||
name: definitionArg.name,
|
||||
state: definitionArg.state,
|
||||
available: definitionArg.available ?? (snapshotArg.online && instanceArg.running),
|
||||
attributes: this.cleanAttributes(definitionArg.attributes || {}),
|
||||
};
|
||||
}
|
||||
|
||||
private static hyperionServiceCommand(snapshotArg: IHyperionSnapshot, requestArg: IServiceCallRequest): IHyperionCommandRequest | undefined {
|
||||
if (requestArg.service === 'refresh') {
|
||||
return { action: 'refresh', service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (requestArg.service === 'set_color') {
|
||||
const color = this.colorData(requestArg);
|
||||
return color ? { action: 'set_color', instance: this.instanceForTarget(snapshotArg, requestArg), color, priority: this.priority(this.numberData(requestArg, 'priority') ?? snapshotArg.settings.priority), origin: this.stringData(requestArg, 'origin') || snapshotArg.settings.origin, duration: this.numberData(requestArg, 'duration'), service: requestArg.service, target: requestArg.target, data: requestArg.data } : undefined;
|
||||
}
|
||||
if (requestArg.service === 'set_effect') {
|
||||
const effect = this.stringData(requestArg, 'effect') || this.stringData(requestArg, 'name');
|
||||
return effect ? { action: 'set_effect', instance: this.instanceForTarget(snapshotArg, requestArg), effect, effectArgs: this.objectData(requestArg, 'args'), priority: this.priority(this.numberData(requestArg, 'priority') ?? snapshotArg.settings.priority), origin: this.stringData(requestArg, 'origin') || snapshotArg.settings.origin, duration: this.numberData(requestArg, 'duration'), service: requestArg.service, target: requestArg.target, data: requestArg.data } : undefined;
|
||||
}
|
||||
if (requestArg.service === 'clear') {
|
||||
return { action: 'clear', instance: this.instanceForTarget(snapshotArg, requestArg), priority: this.priority(this.numberData(requestArg, 'priority') ?? snapshotArg.settings.priority), service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (requestArg.service === 'set_component') {
|
||||
const component = this.stringData(requestArg, 'component') as THyperionComponentId | undefined;
|
||||
const state = this.booleanData(requestArg, 'state') ?? this.booleanData(requestArg, 'enabled');
|
||||
return component && state !== undefined ? { action: 'set_component', instance: this.instanceForTarget(snapshotArg, requestArg), component, state, service: requestArg.service, target: requestArg.target, data: requestArg.data } : undefined;
|
||||
}
|
||||
if (requestArg.service === 'get_image_snapshot' || requestArg.service === 'image_snapshot') {
|
||||
return { action: 'get_image_snapshot', instance: this.instanceForTarget(snapshotArg, requestArg), format: this.imageFormat(requestArg.data?.format), service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (requestArg.service === 'image_stream_start') {
|
||||
return { action: 'image_stream_start', instance: this.instanceForTarget(snapshotArg, requestArg), service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (requestArg.service === 'image_stream_stop') {
|
||||
return { action: 'image_stream_stop', instance: this.instanceForTarget(snapshotArg, requestArg), service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (requestArg.service === 'raw_command' || requestArg.service === 'command') {
|
||||
const payload = this.objectData(requestArg, 'payload') || this.objectData(requestArg, 'command');
|
||||
return payload && typeof payload.command === 'string' ? { action: 'raw_command', payload: payload as IHyperionCommandRequest['payload'], service: requestArg.service, target: requestArg.target, data: requestArg.data } : undefined;
|
||||
}
|
||||
if (requestArg.service === 'restore') {
|
||||
return { action: 'restore', snapshot: requestArg.data?.snapshot as IHyperionSnapshot | undefined, service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static firstInstanceName(serverInfoArg: IHyperionServerInfo): string | undefined {
|
||||
return Array.isArray(serverInfoArg.instance) ? serverInfoArg.instance.find((instanceArg) => instanceArg.friendly_name)?.friendly_name : undefined;
|
||||
}
|
||||
|
||||
private static defaultPort(transportArg: THyperionTransport, sslArg: boolean, configArg: IHyperionConfig): number {
|
||||
if (transportArg === 'tcp') {
|
||||
return configArg.jsonPort || hyperionDefaultJsonPort;
|
||||
}
|
||||
return configArg.webPort || (sslArg ? 8092 : hyperionDefaultWebPort);
|
||||
}
|
||||
|
||||
private static priority(valueArg: unknown): number {
|
||||
const value = typeof valueArg === 'number' && Number.isFinite(valueArg) ? Math.round(valueArg) : hyperionDefaultPriority;
|
||||
return Math.max(1, Math.min(253, value));
|
||||
}
|
||||
|
||||
private static priorityEntry(instanceArg: IHyperionInstanceSnapshot, priorityArg: number): IHyperionPriority | undefined {
|
||||
return instanceArg.priorities.find((priorityEntryArg) => priorityEntryArg.priority === priorityArg);
|
||||
}
|
||||
|
||||
private static lightIsOn(instanceArg: IHyperionInstanceSnapshot, priorityArg: number): boolean {
|
||||
return Boolean(this.priorityEntry(instanceArg, priorityArg));
|
||||
}
|
||||
|
||||
private static componentState(instanceArg: IHyperionInstanceSnapshot, componentArg: string): IHyperionComponentState | undefined {
|
||||
return instanceArg.components.find((componentStateArg) => componentStateArg.name === componentArg);
|
||||
}
|
||||
|
||||
private static brightnessPercent(instanceArg: IHyperionInstanceSnapshot): number | undefined {
|
||||
const brightness = instanceArg.adjustments[0]?.brightness;
|
||||
return typeof brightness === 'number' ? Math.max(0, Math.min(100, Math.round(brightness))) : undefined;
|
||||
}
|
||||
|
||||
private static brightness255(instanceArg: IHyperionInstanceSnapshot): number | undefined {
|
||||
const brightness = this.brightnessPercent(instanceArg);
|
||||
return brightness === undefined ? undefined : Math.round((brightness * 255) / 100);
|
||||
}
|
||||
|
||||
private static rgbColor(priorityArg: IHyperionPriority | undefined): number[] | undefined {
|
||||
const rgb = priorityArg?.value?.RGB;
|
||||
return Array.isArray(rgb) && rgb.length >= 3 ? rgb.slice(0, 3).map((valueArg) => this.colorByte(valueArg)) : undefined;
|
||||
}
|
||||
|
||||
private static effectName(priorityArg: IHyperionPriority | undefined): string {
|
||||
return priorityArg?.componentId === 'EFFECT' && priorityArg.owner ? priorityArg.owner : hyperionSolidEffect;
|
||||
}
|
||||
|
||||
private static visiblePriorityValue(instanceArg: IHyperionInstanceSnapshot): unknown {
|
||||
const priority = instanceArg.visiblePriority;
|
||||
if (!priority) {
|
||||
return null;
|
||||
}
|
||||
if (priority.componentId === 'COLOR' && priority.value?.RGB) {
|
||||
return priority.value.RGB;
|
||||
}
|
||||
return priority.owner || priority.componentId || (priority.priority ?? null);
|
||||
}
|
||||
|
||||
private static currentOrDefaultColor(snapshotArg: IHyperionSnapshot, instanceArg: number | undefined): number[] {
|
||||
const instance = snapshotArg.instances.find((instanceEntryArg) => instanceEntryArg.instance === instanceArg) || snapshotArg.instances[0];
|
||||
return this.rgbColor(this.priorityEntry(instance, snapshotArg.settings.priority)) || [255, 255, 255];
|
||||
}
|
||||
|
||||
private static instanceForTarget(snapshotArg: IHyperionSnapshot, requestArg: IServiceCallRequest): number | undefined {
|
||||
const dataInstance = this.numberData(requestArg, 'instance');
|
||||
if (dataInstance !== undefined) {
|
||||
return Math.max(0, Math.round(dataInstance));
|
||||
}
|
||||
const target = `${requestArg.target.entityId || ''} ${requestArg.target.deviceId || ''}`;
|
||||
for (const instance of snapshotArg.instances) {
|
||||
const slug = this.slug(instance.name);
|
||||
if (target.includes(`_${instance.instance}`) || target.includes(`.${slug}_`) || target.includes(`${slug}`)) {
|
||||
return instance.instance;
|
||||
}
|
||||
}
|
||||
return snapshotArg.instances.length === 1 ? snapshotArg.instances[0].instance : undefined;
|
||||
}
|
||||
|
||||
private static componentForTarget(snapshotArg: IHyperionSnapshot, requestArg: IServiceCallRequest): THyperionComponentId | undefined {
|
||||
const explicit = this.stringData(requestArg, 'component');
|
||||
if (explicit) {
|
||||
return explicit.toUpperCase();
|
||||
}
|
||||
const target = `${requestArg.target.entityId || ''} ${requestArg.target.deviceId || ''}`.toLowerCase();
|
||||
for (const component of hyperionComponentIds) {
|
||||
const slug = this.slug(component);
|
||||
const labelSlug = this.slug(componentLabels[component] || component);
|
||||
if (target.includes(`component_${slug}`) || target.includes(labelSlug)) {
|
||||
return component;
|
||||
}
|
||||
}
|
||||
void snapshotArg;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static colorData(requestArg: IServiceCallRequest): number[] | undefined {
|
||||
const color = requestArg.data?.color || requestArg.data?.rgb_color || requestArg.data?.rgbColor;
|
||||
if (Array.isArray(color) && color.length >= 3) {
|
||||
return color.slice(0, 3).map((valueArg) => this.colorByte(valueArg));
|
||||
}
|
||||
const hsColor = requestArg.data?.hs_color || requestArg.data?.hsColor;
|
||||
if (Array.isArray(hsColor) && hsColor.length >= 2) {
|
||||
return this.hsToRgb(Number(hsColor[0]), Number(hsColor[1]));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static hsToRgb(hueArg: number, saturationArg: number): number[] {
|
||||
const hue = ((hueArg % 360) + 360) % 360;
|
||||
const saturation = Math.max(0, Math.min(100, saturationArg)) / 100;
|
||||
const chroma = saturation;
|
||||
const x = chroma * (1 - Math.abs((hue / 60) % 2 - 1));
|
||||
const match = 1 - chroma;
|
||||
const [r, g, b] = hue < 60 ? [chroma, x, 0]
|
||||
: hue < 120 ? [x, chroma, 0]
|
||||
: hue < 180 ? [0, chroma, x]
|
||||
: hue < 240 ? [0, x, chroma]
|
||||
: hue < 300 ? [x, 0, chroma]
|
||||
: [chroma, 0, x];
|
||||
return [r, g, b].map((valueArg) => this.colorByte((valueArg + match) * 255));
|
||||
}
|
||||
|
||||
private static colorByte(valueArg: unknown): number {
|
||||
const value = typeof valueArg === 'number' ? valueArg : Number(valueArg);
|
||||
return Math.max(0, Math.min(255, Math.round(Number.isFinite(value) ? value : 0)));
|
||||
}
|
||||
|
||||
private static imageFormat(valueArg: unknown): THyperionImageFormat {
|
||||
return valueArg === 'BMP' || valueArg === 'JPG' || valueArg === 'PNG' ? valueArg : 'PNG';
|
||||
}
|
||||
|
||||
private static stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
private static numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
private static booleanData(requestArg: IServiceCallRequest, keyArg: string): boolean | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
}
|
||||
|
||||
private static objectData(requestArg: IServiceCallRequest, keyArg: string): Record<string, unknown> | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
private static cleanAttributes(attributesArg: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(attributesArg)) {
|
||||
if (value !== undefined) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
|
||||
if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null) {
|
||||
return valueArg;
|
||||
}
|
||||
return JSON.stringify(valueArg);
|
||||
}
|
||||
}
|
||||
|
||||
const parseEndpoint = (valueArg: string | undefined): { host: string; port?: number; ssl: boolean; transport: THyperionTransport } | undefined => {
|
||||
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const url = new URL(valueArg);
|
||||
const protocol = url.protocol.replace(':', '').toLowerCase();
|
||||
const transport: THyperionTransport = protocol === 'ws' || protocol === 'wss' ? 'websocket' : protocol === 'http' || protocol === 'https' ? 'http' : 'tcp';
|
||||
return { host: url.hostname, port: url.port ? Number(url.port) : undefined, ssl: protocol === 'https' || protocol === 'wss', transport };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,306 @@
|
||||
export interface IHomeAssistantHyperionConfig {
|
||||
// TODO: replace with the TypeScript-native config for hyperion.
|
||||
export const hyperionDomain = 'hyperion';
|
||||
export const hyperionDisplayName = 'Hyperion';
|
||||
export const hyperionDefaultJsonPort = 19444;
|
||||
export const hyperionDefaultWebPort = 8090;
|
||||
export const hyperionDefaultSslWebPort = 8092;
|
||||
export const hyperionDefaultTimeoutMs = 10000;
|
||||
export const hyperionDefaultPriority = 128;
|
||||
export const hyperionDefaultOrigin = 'smarthome.exchange';
|
||||
export const hyperionSolidEffect = 'Solid';
|
||||
export const hyperionSsdpManufacturer = 'Hyperion Open Source Ambient Lighting';
|
||||
export const hyperionSsdpServiceType = 'urn:hyperion-project.org:device:basic:1';
|
||||
|
||||
export const hyperionComponentIds = [
|
||||
'ALL',
|
||||
'SMOOTHING',
|
||||
'BLACKBORDER',
|
||||
'FORWARDER',
|
||||
'BOBLIGHTSERVER',
|
||||
'GRABBER',
|
||||
'LEDDEVICE',
|
||||
'V4L',
|
||||
'AUDIO',
|
||||
] as const;
|
||||
|
||||
export type THyperionTransport = 'tcp' | 'http' | 'websocket';
|
||||
export type THyperionSnapshotSource = 'tcp' | 'http' | 'websocket' | 'client' | 'manual' | 'snapshot' | 'runtime' | 'executor';
|
||||
export type THyperionComponentId = typeof hyperionComponentIds[number] | string;
|
||||
export type THyperionImageFormat = 'BMP' | 'JPG' | 'PNG';
|
||||
export type THyperionCommandAction =
|
||||
| 'refresh'
|
||||
| 'set_color'
|
||||
| 'set_effect'
|
||||
| 'clear'
|
||||
| 'set_component'
|
||||
| 'adjust_brightness'
|
||||
| 'get_image_snapshot'
|
||||
| 'image_stream_start'
|
||||
| 'image_stream_stop'
|
||||
| 'raw_command'
|
||||
| 'restore';
|
||||
|
||||
export interface IHyperionJsonRpcRequest {
|
||||
command: string;
|
||||
subcommand?: string;
|
||||
tan?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IHyperionJsonRpcResponse<TInfo = unknown> {
|
||||
command?: string;
|
||||
subcommand?: string;
|
||||
success?: boolean;
|
||||
info?: TInfo;
|
||||
error?: string;
|
||||
errorData?: Array<{ description?: string; [key: string]: unknown }>;
|
||||
tan?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IHyperionSystemInfo {
|
||||
hyperion?: {
|
||||
id?: string;
|
||||
version?: string;
|
||||
build?: string;
|
||||
buildType?: string;
|
||||
readOnlyMode?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
system?: {
|
||||
hostName?: string;
|
||||
prettyName?: string;
|
||||
kernelType?: string;
|
||||
productType?: string;
|
||||
productVersion?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IHyperionComponentState {
|
||||
name: THyperionComponentId;
|
||||
enabled: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IHyperionEffectDefinition {
|
||||
name: string;
|
||||
file?: string;
|
||||
script?: string;
|
||||
args?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IHyperionAdjustment {
|
||||
id?: string;
|
||||
brightness?: number;
|
||||
saturationGain?: number;
|
||||
brightnessGain?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IHyperionPriority {
|
||||
active?: boolean;
|
||||
componentId?: string;
|
||||
origin?: string;
|
||||
owner?: string;
|
||||
priority?: number;
|
||||
visible?: boolean;
|
||||
value?: {
|
||||
RGB?: number[];
|
||||
HSL?: number[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
duration_ms?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IHyperionInstanceInfo {
|
||||
instance: number;
|
||||
friendly_name?: string;
|
||||
running?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IHyperionServerInfo {
|
||||
hostname?: string;
|
||||
activeEffects?: Array<{ name?: string; priority?: number; [key: string]: unknown }>;
|
||||
adjustment?: IHyperionAdjustment[];
|
||||
components?: IHyperionComponentState[];
|
||||
effects?: IHyperionEffectDefinition[];
|
||||
instance?: IHyperionInstanceInfo[];
|
||||
priorities?: IHyperionPriority[];
|
||||
priorities_autoselect?: boolean;
|
||||
services?: string[];
|
||||
videomode?: string;
|
||||
imageToLedMappingType?: string;
|
||||
leds?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IHyperionImageSnapshot {
|
||||
data: string;
|
||||
format?: THyperionImageFormat;
|
||||
width?: number;
|
||||
height?: number;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
export interface IHyperionRawData {
|
||||
sysInfo?: IHyperionSystemInfo;
|
||||
serverInfo?: IHyperionServerInfo;
|
||||
instanceServerInfo?: Record<number, IHyperionServerInfo>;
|
||||
imageSnapshots?: Record<number, IHyperionImageSnapshot>;
|
||||
authRequired?: boolean;
|
||||
fetchedAt?: string;
|
||||
}
|
||||
|
||||
export interface IHyperionDeviceSnapshotInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
manufacturer: string;
|
||||
model?: string;
|
||||
version?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
jsonPort?: number;
|
||||
webPort?: number;
|
||||
ssl?: boolean;
|
||||
transport?: THyperionTransport;
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
export interface IHyperionInstanceSnapshot {
|
||||
instance: number;
|
||||
name: string;
|
||||
running: boolean;
|
||||
serverInfo?: IHyperionServerInfo;
|
||||
components: IHyperionComponentState[];
|
||||
effects: IHyperionEffectDefinition[];
|
||||
effectNames: string[];
|
||||
activeEffectNames: string[];
|
||||
adjustments: IHyperionAdjustment[];
|
||||
priorities: IHyperionPriority[];
|
||||
visiblePriority?: IHyperionPriority;
|
||||
imageSnapshot?: IHyperionImageSnapshot;
|
||||
}
|
||||
|
||||
export interface IHyperionCapabilities {
|
||||
localControl: boolean;
|
||||
effects: boolean;
|
||||
components: boolean;
|
||||
imageSnapshot: boolean;
|
||||
liveStreaming: boolean;
|
||||
multiInstance: boolean;
|
||||
authRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface IHyperionSnapshot {
|
||||
device: IHyperionDeviceSnapshotInfo;
|
||||
instances: IHyperionInstanceSnapshot[];
|
||||
capabilities: IHyperionCapabilities;
|
||||
settings: {
|
||||
priority: number;
|
||||
origin: string;
|
||||
effectHideList: string[];
|
||||
};
|
||||
rawData?: IHyperionRawData;
|
||||
online: boolean;
|
||||
updatedAt: string;
|
||||
source: THyperionSnapshotSource;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface IHyperionCommandRequest {
|
||||
action: THyperionCommandAction;
|
||||
instance?: number | number[];
|
||||
priority?: number;
|
||||
origin?: string;
|
||||
duration?: number;
|
||||
color?: number[];
|
||||
effect?: string;
|
||||
effectArgs?: Record<string, unknown>;
|
||||
component?: THyperionComponentId;
|
||||
state?: boolean;
|
||||
brightness?: number;
|
||||
adjustmentId?: string;
|
||||
format?: THyperionImageFormat;
|
||||
snapshot?: IHyperionSnapshot;
|
||||
payload?: IHyperionJsonRpcRequest;
|
||||
service?: string;
|
||||
target?: Record<string, unknown>;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IHyperionCommandExecutor {
|
||||
execute(requestArg: IHyperionCommandRequest): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface IHyperionClientLike {
|
||||
getSnapshot?: () => Promise<IHyperionSnapshot | IHyperionRawData>;
|
||||
getRawData?: () => Promise<IHyperionRawData>;
|
||||
request?: (payloadArg: IHyperionJsonRpcRequest) => Promise<IHyperionJsonRpcResponse>;
|
||||
execute?: (requestArg: IHyperionCommandRequest) => Promise<unknown>;
|
||||
destroy?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface IHyperionConfig {
|
||||
host?: string;
|
||||
url?: string;
|
||||
port?: number;
|
||||
jsonPort?: number;
|
||||
webPort?: number;
|
||||
ssl?: boolean;
|
||||
tls?: boolean;
|
||||
transport?: THyperionTransport;
|
||||
token?: string;
|
||||
timeoutMs?: number;
|
||||
priority?: number;
|
||||
origin?: string;
|
||||
effectHideList?: string[];
|
||||
instance?: number;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
uniqueId?: string;
|
||||
snapshot?: IHyperionSnapshot;
|
||||
rawData?: IHyperionRawData;
|
||||
client?: IHyperionClientLike;
|
||||
commandExecutor?: IHyperionCommandExecutor;
|
||||
online?: boolean;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantHyperionConfig extends IHyperionConfig {}
|
||||
|
||||
export interface IHyperionManualEntry extends IHyperionConfig {
|
||||
id?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IHyperionSsdpRecord {
|
||||
ssdp_location?: string;
|
||||
location?: string;
|
||||
ssdp_st?: string;
|
||||
st?: string;
|
||||
ssdp_usn?: string;
|
||||
usn?: string;
|
||||
UDN?: string;
|
||||
serialNumber?: string;
|
||||
friendlyName?: string;
|
||||
manufacturer?: string;
|
||||
modelName?: string;
|
||||
modelNumber?: string;
|
||||
server?: string;
|
||||
ssdp_server?: string;
|
||||
headers?: Record<string, string | string[] | undefined>;
|
||||
upnp?: Record<string, unknown>;
|
||||
ports?: Record<string, string | number | undefined>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IHyperionRefreshResult {
|
||||
success: boolean;
|
||||
snapshot?: IHyperionSnapshot;
|
||||
error?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './hyperion.classes.client.js';
|
||||
export * from './hyperion.classes.configflow.js';
|
||||
export * from './hyperion.classes.integration.js';
|
||||
export * from './hyperion.discovery.js';
|
||||
export * from './hyperion.mapper.js';
|
||||
export * from './hyperion.types.js';
|
||||
|
||||
@@ -13,6 +13,7 @@ export * from './axis/index.js';
|
||||
export * from './blebox/index.js';
|
||||
export * from './bluesound/index.js';
|
||||
export * from './bluetooth_le_tracker/index.js';
|
||||
export * from './bond/index.js';
|
||||
export * from './bosch_shc/index.js';
|
||||
export * from './braviatv/index.js';
|
||||
export * from './broadlink/index.js';
|
||||
@@ -20,6 +21,7 @@ export * from './brother/index.js';
|
||||
export * from './cast/index.js';
|
||||
export * from './daikin/index.js';
|
||||
export * from './deconz/index.js';
|
||||
export * from './denon/index.js';
|
||||
export * from './denonavr/index.js';
|
||||
export * from './devolo_home_network/index.js';
|
||||
export * from './directv/index.js';
|
||||
@@ -28,17 +30,21 @@ export * from './dlna_dms/index.js';
|
||||
export * from './doorbird/index.js';
|
||||
export * from './dsmr/index.js';
|
||||
export * from './dunehd/index.js';
|
||||
export * from './elgato/index.js';
|
||||
export * from './esphome/index.js';
|
||||
export * from './forked_daapd/index.js';
|
||||
export * from './fritz/index.js';
|
||||
export * from './frontier_silicon/index.js';
|
||||
export * from './glances/index.js';
|
||||
export * from './go2rtc/index.js';
|
||||
export * from './harmony/index.js';
|
||||
export * from './heos/index.js';
|
||||
export * from './hikvision/index.js';
|
||||
export * from './homekit_controller/index.js';
|
||||
export * from './homematic/index.js';
|
||||
export * from './huawei_lte/index.js';
|
||||
export * from './hue/index.js';
|
||||
export * from './hyperion/index.js';
|
||||
export * from './ipp/index.js';
|
||||
export * from './jellyfin/index.js';
|
||||
export * from './knx/index.js';
|
||||
|
||||
Reference in New Issue
Block a user