Add native local bridge and media integrations

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