Add native local media controller integrations

This commit is contained in:
2026-05-07 14:14:49 +00:00
parent 5d1b92fba5
commit 5d1bee83e2
75 changed files with 11972 additions and 173 deletions
@@ -0,0 +1,50 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { BluesoundClient, parseBluesoundInputsXml, parseBluesoundPresetsXml, parseBluesoundStatusXml, parseBluesoundSyncStatusXml, type IBluesoundRawCommandRequest } from '../../ts/integrations/bluesound/index.js';
tap.test('parses native Bluesound XML status and sync payloads', async () => {
const sync = parseBluesoundSyncStatusXml('<SyncStatus etag="s1" id="192.168.1.10:11000" mac="AA:BB:CC:DD:EE:01" name="Living Room" icon="/icon.png" initialized="true" brand="Bluesound" model="NODE2I" modelName="NODE 2i" db="-32.5" volume="35"><slave id="192.168.1.11" port="11000" /></SyncStatus>');
const status = parseBluesoundStatusXml('<status etag="st1"><state>play</state><name>Track</name><artist>Artist</artist><album>Album</album><image>/Artwork</image><volume>35</volume><db>-32.5</db><mute>0</mute><secs>12.8</secs><totlen>180</totlen><canSeek>1</canSeek><shuffle>1</shuffle><streamUrl>http://radio.example/jazz.mp3</streamUrl></status>');
expect(sync.followers?.[0].ip).toEqual('192.168.1.11');
expect(sync.modelName).toEqual('NODE 2i');
expect(status.state).toEqual('play');
expect(status.seconds).toEqual(12.8);
expect(status.shuffle).toBeTrue();
});
tap.test('parses native Bluesound presets and inputs XML payloads', async () => {
const presets = parseBluesoundPresetsXml('<presets><preset id="1" name="Jazz Radio" url="http://radio.example/jazz.mp3" image="/jazz.png" volume="40" /></presets>');
const inputs = parseBluesoundInputsXml('<radiotime><item id="linein" text="Analog Input" image="/analog.png" URL="Capture%3Alinein" /></radiotime>');
expect(presets[0].name).toEqual('Jazz Radio');
expect(presets[0].volume).toEqual(40);
expect(inputs[0].url).toEqual('Capture:linein');
});
tap.test('can build a live snapshot through a command executor', async () => {
const requests: IBluesoundRawCommandRequest[] = [];
const client = new BluesoundClient({
commandExecutor: {
execute: async (requestArg) => {
requests.push(requestArg);
if (requestArg.path === '/SyncStatus') {
return '<SyncStatus etag="s1" id="192.168.1.10:11000" mac="AA:BB:CC:DD:EE:01" name="Living Room" brand="Bluesound" model="NODE2I" modelName="NODE 2i" db="-32.5" volume="35" />';
}
if (requestArg.path === '/Status') {
return '<status etag="st1"><state>play</state><name>Track</name><volume>35</volume><db>-32.5</db><mute>0</mute></status>';
}
if (requestArg.path === '/Presets') {
return '<presets><preset id="1" name="Jazz Radio" url="http://radio.example/jazz.mp3" /></presets>';
}
return '<radiotime><item id="linein" text="Analog Input" image="/analog.png" URL="Capture%3Alinein" /></radiotime>';
},
},
});
const snapshot = await client.getSnapshot();
expect(snapshot.players[0].syncStatus.name).toEqual('Living Room');
expect(snapshot.players[0].presets?.[0].name).toEqual('Jazz Radio');
expect(requests.map((requestArg) => requestArg.path)).toEqual(['/SyncStatus', '/Status', '/Presets', '/RadioBrowse']);
});
export default tap.start();
@@ -0,0 +1,34 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { BluesoundConfigFlow } from '../../ts/integrations/bluesound/index.js';
tap.test('creates Bluesound config from discovered player host', async () => {
const flow = new BluesoundConfigFlow();
const step = await flow.start({
source: 'mdns',
integrationDomain: 'bluesound',
id: 'AA:BB:CC:DD:EE:01',
host: 'kitchen.local',
port: 11000,
name: 'Kitchen',
manufacturer: 'Bluesound',
model: 'NODE',
macAddress: 'AA:BB:CC:DD:EE:01',
}, {});
expect(step.kind).toEqual('form');
const done = await step.submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('kitchen.local');
expect(done.config?.port).toEqual(11000);
expect(done.config?.macAddress).toEqual('AA:BB:CC:DD:EE:01');
});
tap.test('requires a Bluesound host for manual setup', async () => {
const flow = new BluesoundConfigFlow();
const step = await flow.start({ source: 'manual', integrationDomain: 'bluesound' }, {});
const result = await step.submit!({});
expect(result.kind).toEqual('error');
});
export default tap.start();
@@ -0,0 +1,36 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { createBluesoundDiscoveryDescriptor } from '../../ts/integrations/bluesound/index.js';
tap.test('matches Bluesound mDNS _musc records', async () => {
const descriptor = createBluesoundDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[0];
const result = await matcher.matches({
name: 'Kitchen._musc._tcp.local.',
type: '_musc._tcp.local.',
host: 'kitchen.local',
port: 11000,
txt: {
mac: 'AA:BB:CC:DD:EE:01',
brand: 'Bluesound',
modelName: 'NODE',
},
}, {});
expect(result.matched).toBeTrue();
expect(result.confidence).toEqual('certain');
expect(result.candidate?.host).toEqual('kitchen.local');
expect(result.candidate?.port).toEqual(11000);
expect(result.normalizedDeviceId).toEqual('AA:BB:CC:DD:EE:01');
});
tap.test('matches manual Bluesound player setup entries', async () => {
const descriptor = createBluesoundDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[1];
const result = await matcher.matches({ host: '192.168.1.10', name: 'Living Room' }, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.integrationDomain).toEqual('bluesound');
expect(result.candidate?.port).toEqual(11000);
});
export default tap.start();
@@ -0,0 +1,88 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { BluesoundMapper, type IBluesoundSnapshot } from '../../ts/integrations/bluesound/index.js';
const snapshot: IBluesoundSnapshot = {
online: true,
source: 'snapshot',
players: [{
host: '192.168.1.10',
port: 11000,
syncStatus: {
id: '192.168.1.10:11000',
mac: 'AA:BB:CC:DD:EE:01',
name: 'Living Room',
brand: 'Bluesound',
model: 'NODE2I',
modelName: 'NODE 2i',
volume: 35,
followers: [{ ip: '192.168.1.11', port: 11000 }],
group: 'Downstairs',
},
status: {
state: 'play',
name: 'Example Track',
artist: 'Example Artist',
album: 'Example Album',
image: '/Artwork?sid=1',
volume: 35,
mute: false,
seconds: 31.6,
totalSeconds: 181.2,
streamUrl: 'http://radio.example/jazz.mp3',
shuffle: true,
canSeek: true,
service: 'Radio Paradise',
},
presets: [{ id: 1, name: 'Jazz Radio', url: 'http://radio.example/jazz.mp3', image: '/preset.jpg' }],
inputs: [{ id: 'linein', text: 'Analog Input', image: '/input.png', url: 'Capture:linein' }],
available: true,
}, {
host: '192.168.1.11',
port: 11000,
syncStatus: {
id: '192.168.1.11:11000',
mac: 'AA:BB:CC:DD:EE:02',
name: 'Kitchen',
brand: 'Bluesound',
model: 'PULSE',
modelName: 'PULSE Flex',
volume: 35,
leader: { ip: '192.168.1.10', port: 11000 },
group: 'Downstairs',
},
status: { state: 'play', volume: 25, mute: false, name: 'Follower Track' },
presets: [],
inputs: [],
available: true,
}],
};
tap.test('maps Bluesound players to devices with media source state', async () => {
const devices = BluesoundMapper.toDevices(snapshot);
const livingRoom = devices.find((deviceArg) => deviceArg.id === 'bluesound.player.aa_bb_cc_dd_ee_01');
expect(livingRoom?.manufacturer).toEqual('Bluesound');
expect(livingRoom?.state.some((stateArg) => stateArg.featureId === 'source' && stateArg.value === 'Jazz Radio')).toBeTrue();
expect(livingRoom?.state.some((stateArg) => stateArg.featureId === 'presets' && stateArg.value === 1)).toBeTrue();
});
tap.test('maps Bluesound media players presets sources and groups', async () => {
const entities = BluesoundMapper.toEntities(snapshot);
const livingRoom = entities.find((entityArg) => entityArg.id === 'media_player.living_room');
const kitchen = entities.find((entityArg) => entityArg.id === 'media_player.kitchen');
const presets = entities.find((entityArg) => entityArg.id === 'sensor.living_room_bluesound_presets');
const sources = entities.find((entityArg) => entityArg.id === 'sensor.living_room_bluesound_sources');
expect(livingRoom?.state).toEqual('playing');
expect(livingRoom?.attributes?.volumeLevel).toEqual(0.35);
expect(livingRoom?.attributes?.source).toEqual('Jazz Radio');
expect(livingRoom?.attributes?.mediaPosition).toEqual(31);
expect(livingRoom?.attributes?.mediaDuration).toEqual(181);
expect(livingRoom?.attributes?.groupMembers).toEqual(['media_player.living_room', 'media_player.kitchen']);
expect(kitchen?.state).toEqual('idle');
expect(kitchen?.attributes?.mediaTitle).toEqual(undefined);
expect(presets?.state).toEqual(1);
expect(sources?.attributes?.sourceList).toEqual(['Jazz Radio', 'Analog Input']);
});
export default tap.start();
@@ -0,0 +1,105 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { BluesoundIntegration, type IBluesoundRawCommandRequest, type IBluesoundSnapshot } from '../../ts/integrations/bluesound/index.js';
const snapshot: IBluesoundSnapshot = {
online: true,
source: 'snapshot',
players: [{
host: '192.168.1.10',
port: 11000,
syncStatus: {
id: '192.168.1.10:11000',
mac: 'AA:BB:CC:DD:EE:01',
name: 'Living Room',
brand: 'Bluesound',
model: 'NODE2I',
modelName: 'NODE 2i',
followers: [{ ip: '192.168.1.11', port: 11000 }],
volume: 35,
},
status: { state: 'pause', volume: 20, mute: false, streamUrl: 'http://radio.example/jazz.mp3' },
presets: [{ id: 1, name: 'Jazz Radio', url: 'http://radio.example/jazz.mp3' }],
inputs: [{ id: 'linein', text: 'Analog Input', url: 'Capture:linein' }],
available: true,
}, {
host: '192.168.1.11',
port: 11000,
syncStatus: {
id: '192.168.1.11:11000',
mac: 'AA:BB:CC:DD:EE:02',
name: 'Kitchen',
brand: 'Bluesound',
model: 'PULSE',
modelName: 'PULSE Flex',
leader: { ip: '192.168.1.10', port: 11000 },
volume: 35,
},
status: { state: 'pause', volume: 25, mute: false },
presets: [],
inputs: [],
available: true,
}],
};
tap.test('models Bluesound playback volume source preset and group commands through an executor', async () => {
const commands: IBluesoundRawCommandRequest[] = [];
const runtime = await new BluesoundIntegration().setup({
snapshot,
commandExecutor: {
execute: async (requestArg) => {
commands.push(requestArg);
return { status: 200, body: '<ok />' };
},
},
}, {});
const play = await runtime.callService!({ domain: 'media_player', service: 'media_play', target: { entityId: 'media_player.living_room' } });
const volume = await runtime.callService!({ domain: 'media_player', service: 'volume_set', target: { entityId: 'media_player.living_room' }, data: { volume_level: 0.35 } });
const source = await runtime.callService!({ domain: 'media_player', service: 'select_source', target: { entityId: 'media_player.living_room' }, data: { source: 'Analog Input' } });
const preset = await runtime.callService!({ domain: 'bluesound', service: 'play_preset', target: { entityId: 'media_player.living_room' }, data: { preset: 'Jazz Radio' } });
const join = await runtime.callService!({ domain: 'media_player', service: 'join', target: { entityId: 'media_player.living_room' }, data: { group_members: ['media_player.kitchen'] } });
const unjoin = await runtime.callService!({ domain: 'media_player', service: 'unjoin', target: { entityId: 'media_player.kitchen' } });
expect(play.success).toBeTrue();
expect(volume.success).toBeTrue();
expect(source.success).toBeTrue();
expect(preset.success).toBeTrue();
expect(join.success).toBeTrue();
expect(unjoin.success).toBeTrue();
expect(commands.map((commandArg) => commandArg.path)).toEqual(['/Play', '/Volume', '/Play', '/Preset', '/AddSlave', '/RemoveSlave']);
expect(commands[1].parameters).toEqual({ level: 35 });
expect(commands[2].parameters).toEqual({ url: 'Capture:linein' });
expect(commands[3].parameters).toEqual({ id: 1 });
expect(commands[4].parameters).toEqual({ slave: '192.168.1.11', port: 11000 });
expect(commands[5].host).toEqual('192.168.1.10');
expect(commands[5].parameters).toEqual({ slave: '192.168.1.11', port: 11000 });
});
tap.test('does not report live command success for static snapshots without transport', async () => {
const runtime = await new BluesoundIntegration().setup({ snapshot }, {});
const result = await runtime.callService!({ domain: 'media_player', service: 'media_play', target: { entityId: 'media_player.living_room' } });
expect(result.success).toBeFalse();
expect(result.error?.includes('config.host or commandExecutor')).toBeTrue();
});
tap.test('does not execute playback commands on grouped followers', async () => {
const commands: IBluesoundRawCommandRequest[] = [];
const runtime = await new BluesoundIntegration().setup({
snapshot,
commandExecutor: {
execute: async (requestArg) => {
commands.push(requestArg);
return { status: 200, body: '<ok />' };
},
},
}, {});
const result = await runtime.callService!({ domain: 'media_player', service: 'media_play', target: { entityId: 'media_player.kitchen' } });
expect(result.success).toBeTrue();
expect(commands.length).toEqual(0);
expect((result.data as { skipped?: boolean }).skipped).toBeTrue();
});
export default tap.start();
@@ -0,0 +1,36 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DunehdConfigFlow } from '../../ts/integrations/dunehd/index.js';
tap.test('creates Dune HD config from discovered host', async () => {
const flow = new DunehdConfigFlow();
const step = await flow.start({
source: 'ssdp',
integrationDomain: 'dunehd',
id: 'dune-udn-123',
host: '192.168.1.70',
port: 8080,
name: 'Living Room Dune',
manufacturer: 'Dune',
model: 'Dune HD Pro 4K',
}, {});
expect(step.kind).toEqual('form');
const done = await step.submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('192.168.1.70');
expect(done.config?.port).toEqual(8080);
expect(done.config?.uniqueId).toEqual('dune-udn-123');
expect(done.config?.deviceInfo?.name).toEqual('Living Room Dune');
});
tap.test('requires a valid Dune HD host for manual setup', async () => {
const flow = new DunehdConfigFlow();
const step = await flow.start({ source: 'manual', integrationDomain: 'dunehd' }, {});
const missing = await step.submit!({});
const invalid = await step.submit!({ host: 'bad host' });
expect(missing.kind).toEqual('error');
expect(invalid.kind).toEqual('error');
});
export default tap.start();
+51
View File
@@ -0,0 +1,51 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { createDunehdDiscoveryDescriptor } from '../../ts/integrations/dunehd/index.js';
tap.test('matches Dune HD SSDP records', async () => {
const descriptor = createDunehdDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[0];
const result = await matcher.matches({
st: 'urn:schemas-upnp-org:device:MediaRenderer:1',
usn: 'uuid:dune-udn-123::urn:schemas-upnp-org:device:MediaRenderer:1',
location: 'http://192.168.1.70:80/description.xml',
headers: {
manufacturer: 'Dune HD',
modelName: 'Dune HD Pro 4K',
},
}, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.host).toEqual('192.168.1.70');
expect(result.candidate?.port).toEqual(80);
expect(result.normalizedDeviceId).toEqual('dune-udn-123');
});
tap.test('matches manual Dune HD setup entries', async () => {
const descriptor = createDunehdDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[2];
const result = await matcher.matches({
host: 'dunehd.local',
name: 'Cinema Dune',
model: 'Dune HD Real Vision',
}, {});
expect(result.matched).toBeTrue();
expect(result.confidence).toEqual('high');
expect(result.candidate?.integrationDomain).toEqual('dunehd');
});
tap.test('validates Dune HD candidates', async () => {
const descriptor = createDunehdDiscoveryDescriptor();
const validator = descriptor.getValidators()[0];
const result = await validator.validate({
source: 'manual',
integrationDomain: 'dunehd',
host: '192.168.1.70',
manufacturer: 'Dune',
}, {});
expect(result.matched).toBeTrue();
expect(result.confidence).toEqual('high');
});
export default tap.start();
+45
View File
@@ -0,0 +1,45 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DunehdMapper, type IDunehdSnapshot } from '../../ts/integrations/dunehd/index.js';
const snapshot: IDunehdSnapshot = {
deviceInfo: {
id: 'dune-123',
host: '192.168.1.70',
name: 'Living Room Dune',
manufacturer: 'Dune',
model: 'Dune HD Pro 4K',
},
state: {
player_state: 'playing',
playback_speed: '256',
playback_volume: '45',
playback_mute: '0',
playback_url: 'smb://media.example/movies/Movie.mkv',
playback_position: '120',
},
online: true,
updatedAt: '2026-05-07T00:00:00.000Z',
};
tap.test('maps Dune HD snapshots to media devices and entities', async () => {
const devices = DunehdMapper.toDevices(snapshot);
const entities = DunehdMapper.toEntities(snapshot);
expect(devices[0].id).toEqual('dunehd.device.dune_123');
expect(devices[0].state.some((stateArg) => stateArg.featureId === 'playback' && stateArg.value === 'playing')).toBeTrue();
expect(devices[0].state.some((stateArg) => stateArg.featureId === 'volume' && stateArg.value === 45)).toBeTrue();
expect(entities[0].id).toEqual('media_player.living_room_dune');
expect(entities[0].platform).toEqual('media_player');
expect(entities[0].state).toEqual('playing');
expect(entities[0].attributes?.mediaTitle).toEqual('Movie.mkv');
expect(entities[0].attributes?.volumeLevel).toEqual(0.45);
});
tap.test('preserves Home Assistant Dune HD state precedence', async () => {
expect(DunehdMapper.mediaState({ player_state: 'navigator', playback_speed: '0' })).toEqual('on');
expect(DunehdMapper.mediaState({ player_state: 'photo_viewer', playback_speed: '0' })).toEqual('paused');
expect(DunehdMapper.mediaTitle({ player_state: 'bluray_playback' })).toEqual('Blu-Ray');
expect(DunehdMapper.mediaTitle({ player_state: 'photo_viewer' })).toEqual('Photo Viewer');
});
export default tap.start();
+61
View File
@@ -0,0 +1,61 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DunehdClient, DunehdIntegration, type IDunehdRawCommandRequest, type IDunehdSnapshot } from '../../ts/integrations/dunehd/index.js';
const snapshot: IDunehdSnapshot = {
deviceInfo: { id: 'dune-123', host: '192.168.1.70', name: 'Living Room Dune' },
state: { player_state: 'navigator', playback_volume: '40', playback_mute: '0' },
online: true,
};
tap.test('models Dune HD media and remote commands through an executor', async () => {
const commands: IDunehdRawCommandRequest[] = [];
const runtime = await new DunehdIntegration().setup({
snapshot,
commandExecutor: {
execute: async (requestArg) => {
commands.push(requestArg);
return { player_state: 'playing', playback_speed: '256' };
},
},
}, {});
const play = await runtime.callService!({ domain: 'media_player', service: 'media_play', target: { entityId: 'media_player.living_room_dune' } });
const mute = await runtime.callService!({ domain: 'media_player', service: 'volume_mute', target: { entityId: 'media_player.living_room_dune' }, data: { is_volume_muted: true } });
const media = await runtime.callService!({ domain: 'media_player', service: 'play_media', target: { entityId: 'media_player.living_room_dune' }, data: { media_content_id: 'http://media.example/movie.mkv' } });
const remote = await runtime.callService!({ domain: 'remote', service: 'send_command', target: { entityId: 'remote.living_room_dune' }, data: { command: 'previous' } });
expect(play.success).toBeTrue();
expect(mute.success).toBeTrue();
expect(media.success).toBeTrue();
expect(remote.success).toBeTrue();
expect(commands.map((commandArg) => commandArg.cmd)).toEqual([
'set_playback_state',
'set_playback_state',
'launch_media_url',
'ir_code',
]);
expect(commands[0].params.speed).toEqual(256);
expect(commands[1].params.mute).toEqual(1);
expect(commands[2].params.media_url).toEqual('http://media.example/movie.mkv');
expect(commands[3].params.ir_code).toEqual('B649BF00');
});
tap.test('parses Dune HD status name/value payloads', async () => {
const client = new DunehdClient({});
const state = client.parseStatus(`
<param name="player_state" value="navigator"/>
name="playback_volume" value="55"
`);
expect(state.player_state).toEqual('navigator');
expect(state.playback_volume).toEqual('55');
});
tap.test('does not report command success for static snapshots without transport', async () => {
const runtime = await new DunehdIntegration().setup({ snapshot }, {});
const result = await runtime.callService!({ domain: 'media_player', service: 'media_play', target: { entityId: 'media_player.living_room_dune' } });
expect(result.success).toBeFalse();
expect(result.error?.includes('config.host or commandExecutor')).toBeTrue();
});
export default tap.start();
@@ -0,0 +1,40 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { LinkplayConfigFlow } from '../../ts/integrations/linkplay/index.js';
tap.test('creates LinkPlay config from discovered endpoint', async () => {
const flow = new LinkplayConfigFlow();
const step = await flow.start({
source: 'mdns',
integrationDomain: 'linkplay',
id: 'leader-uuid',
host: 'living-room.local',
port: 80,
name: 'Living Room',
manufacturer: 'Arylic',
model: 'S50+',
metadata: { project: 'ARYLIC_S50', protocol: 'http' },
}, {});
expect(step.kind).toEqual('form');
const done = await step.submit!({ name: 'Living Room Speaker' });
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('living-room.local');
expect(done.config?.port).toEqual(80);
expect(done.config?.protocol).toEqual('http');
expect(done.config?.uniqueId).toEqual('leader-uuid');
expect(done.config?.name).toEqual('Living Room Speaker');
});
tap.test('requires host and blocks WiiM candidates like Home Assistant', async () => {
const flow = new LinkplayConfigFlow();
const missingHost = await flow.start({ source: 'manual', integrationDomain: 'linkplay' }, {});
const missingResult = await missingHost.submit!({});
expect(missingResult.kind).toEqual('error');
const wiim = await flow.start({ source: 'mdns', integrationDomain: 'linkplay', host: 'wiim.local', manufacturer: 'WiiM', model: 'WiiM Pro' }, {});
const wiimResult = await wiim.submit!({});
expect(wiimResult.kind).toEqual('error');
expect(wiimResult.error?.includes('WiiM')).toBeTrue();
});
export default tap.start();
@@ -0,0 +1,62 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { createLinkplayDiscoveryDescriptor } from '../../ts/integrations/linkplay/index.js';
tap.test('matches LinkPlay zeroconf records', async () => {
const descriptor = createLinkplayDiscoveryDescriptor();
const result = await descriptor.getMatchers()[0].matches({
name: 'Living Room._linkplay._tcp.local.',
type: '_linkplay._tcp.local.',
host: 'living-room.local',
port: 80,
txt: {
uuid: 'leader-uuid',
DeviceName: 'Living Room',
project: 'ARYLIC_S50',
},
}, {});
expect(result.matched).toBeTrue();
expect(result.confidence).toEqual('certain');
expect(result.normalizedDeviceId).toEqual('leader-uuid');
expect(result.candidate?.manufacturer).toEqual('Arylic');
expect(result.candidate?.model).toEqual('S50+');
});
tap.test('matches LinkPlay SSDP records only with LinkPlay hints', async () => {
const descriptor = createLinkplayDiscoveryDescriptor();
const result = await descriptor.getMatchers()[1].matches({
st: 'urn:schemas-upnp-org:device:MediaRenderer:1',
usn: 'uuid:linkplay-123::urn:schemas-upnp-org:device:MediaRenderer:1',
location: 'http://192.168.1.10:49152/description.xml',
upnp: {
manufacturer: 'LinkPlay',
modelName: 'Up2Stream Mini',
friendlyName: 'Kitchen Speaker',
serialNumber: 'LP123',
},
}, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.host).toEqual('192.168.1.10');
expect(result.candidate?.port).toEqual(80);
const genericRenderer = await descriptor.getMatchers()[1].matches({
st: 'urn:schemas-upnp-org:device:MediaRenderer:1',
location: 'http://192.168.1.20/device.xml',
upnp: { manufacturer: 'Generic Audio', modelName: 'Renderer' },
}, {});
expect(genericRenderer.matched).toBeFalse();
});
tap.test('matches and validates manual LinkPlay candidates', async () => {
const descriptor = createLinkplayDiscoveryDescriptor();
const manual = await descriptor.getMatchers()[2].matches({ host: '192.168.1.30', name: 'Manual LinkPlay', project: 'UP2STREAM_MINI_V3' }, {});
expect(manual.matched).toBeTrue();
expect(manual.candidate?.host).toEqual('192.168.1.30');
expect(manual.candidate?.metadata?.protocol).toEqual('http');
const validation = await descriptor.getValidators()[0].validate(manual.candidate!, {});
expect(validation.matched).toBeTrue();
expect(validation.normalizedDeviceId).toEqual('192.168.1.30:80');
});
export default tap.start();
+116
View File
@@ -0,0 +1,116 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { LinkplayMapper, type ILinkplaySnapshot } from '../../ts/integrations/linkplay/index.js';
const snapshot: ILinkplaySnapshot = {
online: true,
source: 'snapshot',
speakers: [{
uuid: 'leader-uuid',
name: 'Living Room',
available: true,
device: {
uuid: 'leader-uuid',
name: 'Living Room',
host: '192.168.1.10',
port: 80,
protocol: 'http',
manufacturer: 'Arylic',
model: 'S50+',
project: 'ARYLIC_S50',
macAddress: '00:11:22:33:44:55',
hardwareVersion: 'A97',
firmwareVersion: '4.6.415145',
ethernetAddress: '192.168.1.10',
playModeSupport: ['10', '41', '40'],
maxPresets: 3,
},
player: {
status: 'play',
playMode: '41',
loopMode: '2',
equalizerMode: 'Pop',
volume: 45,
muted: false,
title: 'Example Track',
artist: 'Example Artist',
album: 'Example Album',
albumArtUrl: 'http://example.invalid/cover.jpg',
currentPositionMs: 30000,
totalLengthMs: 180000,
},
sources: [
{ mode: '10', name: 'Wifi', commandValue: 'wifi' },
{ mode: '41', name: 'Bluetooth', commandValue: 'bluetooth' },
{ mode: '40', name: 'Line In', commandValue: 'line-in' },
],
presets: [{ number: 1, name: 'Jazz Radio' }, { number: 2, name: 'News' }, { number: 3, name: 'Kitchen' }],
audioOutputHardwareMode: 'line_out',
}, {
uuid: 'follower-uuid',
name: 'Kitchen',
available: true,
device: {
uuid: 'follower-uuid',
name: 'Kitchen',
host: '192.168.1.11',
port: 80,
protocol: 'http',
manufacturer: 'Arylic',
model: 'Up2Stream Mini',
ethernetAddress: '192.168.1.11',
playModeSupport: ['10', '41'],
maxPresets: 3,
},
player: {
status: 'pause',
playMode: '99',
loopMode: '4',
volume: 25,
muted: true,
},
}],
multirooms: [{
id: 'downstairs',
name: 'Downstairs',
leaderUuid: 'leader-uuid',
followerUuids: ['follower-uuid'],
volume: 40,
muted: false,
}],
};
tap.test('maps LinkPlay speakers and multiroom groups to devices', async () => {
const devices = LinkplayMapper.toDevices(snapshot);
const leader = devices.find((deviceArg) => deviceArg.id === 'linkplay.speaker.leader_uuid');
const group = devices.find((deviceArg) => deviceArg.id === 'linkplay.multiroom.downstairs');
expect(leader?.manufacturer).toEqual('Arylic');
expect(leader?.state.some((stateArg) => stateArg.featureId === 'source' && stateArg.value === 'Bluetooth')).toBeTrue();
expect(group?.state.some((stateArg) => stateArg.featureId === 'members' && stateArg.value === 2)).toBeTrue();
});
tap.test('maps LinkPlay media, sources, presets and multiroom entities', async () => {
const entities = LinkplayMapper.toEntities(snapshot);
const player = entities.find((entityArg) => entityArg.id === 'media_player.living_room');
const kitchen = entities.find((entityArg) => entityArg.id === 'media_player.kitchen');
const sources = entities.find((entityArg) => entityArg.id === 'sensor.living_room_linkplay_sources');
const presets = entities.find((entityArg) => entityArg.id === 'sensor.living_room_linkplay_presets');
const multiroom = entities.find((entityArg) => entityArg.id === 'sensor.living_room_linkplay_multiroom');
const audioOutput = entities.find((entityArg) => entityArg.id === 'select.living_room_audio_output_hardware_mode');
expect(player?.state).toEqual('playing');
expect(player?.attributes?.volumeLevel).toEqual(0.45);
expect(player?.attributes?.source).toEqual('Bluetooth');
expect(player?.attributes?.repeat).toEqual('all');
expect(player?.attributes?.shuffle).toBeTrue();
expect(player?.attributes?.groupMembers).toEqual(['media_player.living_room', 'media_player.kitchen']);
expect(player?.attributes?.mediaDuration).toEqual(180);
expect(player?.attributes?.mediaPosition).toEqual(30);
expect(kitchen?.state).toEqual('paused');
expect(sources?.state).toEqual(3);
expect(presets?.state).toEqual(3);
expect(multiroom?.state).toEqual('leader');
expect(audioOutput?.state).toEqual('line_out');
});
export default tap.start();
+124
View File
@@ -0,0 +1,124 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { LinkplayClient, LinkplayIntegration, type ILinkplayRawCommandRequest, type ILinkplaySnapshot } from '../../ts/integrations/linkplay/index.js';
const snapshot: ILinkplaySnapshot = {
online: true,
source: 'snapshot',
speakers: [{
uuid: 'leader-uuid',
name: 'Living Room',
available: true,
device: {
uuid: 'leader-uuid',
name: 'Living Room',
host: '192.168.1.10',
port: 80,
protocol: 'http',
manufacturer: 'Arylic',
model: 'S50+',
ethernetAddress: '192.168.1.10',
playModeSupport: ['10', '41', '40'],
maxPresets: 4,
},
player: { status: 'pause', playMode: '10', loopMode: '4', volume: 20, muted: false },
sources: [{ mode: '10', name: 'Wifi', commandValue: 'wifi' }, { mode: '41', name: 'Bluetooth', commandValue: 'bluetooth' }],
presets: [{ number: 1, name: 'Jazz' }, { number: 2, name: 'News' }, { number: 3, name: 'Rock' }, { number: 4, name: 'Kitchen' }],
}, {
uuid: 'follower-uuid',
name: 'Kitchen',
available: true,
device: {
uuid: 'follower-uuid',
name: 'Kitchen',
host: '192.168.1.11',
port: 80,
protocol: 'http',
manufacturer: 'Arylic',
model: 'Up2Stream Mini',
ethernetAddress: '192.168.1.11',
playModeSupport: ['10', '41'],
maxPresets: 4,
},
player: { status: 'pause', playMode: '99', loopMode: '4', volume: 30, muted: false },
}],
multirooms: [{ id: 'downstairs', leaderUuid: 'leader-uuid', followerUuids: ['follower-uuid'] }],
};
tap.test('models LinkPlay media, preset and multiroom commands through an executor', async () => {
const commands: ILinkplayRawCommandRequest[] = [];
const runtime = await new LinkplayIntegration().setup({
snapshot,
commandExecutor: {
execute: async (requestArg) => {
commands.push(requestArg);
return 'OK';
},
},
}, {});
const play = await runtime.callService!({ domain: 'media_player', service: 'media_play', target: { entityId: 'media_player.living_room' } });
const volume = await runtime.callService!({ domain: 'media_player', service: 'volume_set', target: { entityId: 'media_player.living_room' }, data: { volume_level: 0.35 } });
const source = await runtime.callService!({ domain: 'media_player', service: 'select_source', target: { entityId: 'media_player.living_room' }, data: { source: 'Bluetooth' } });
const preset = await runtime.callService!({ domain: 'linkplay', service: 'play_preset', target: { entityId: 'media_player.living_room' }, data: { preset_number: 2 } });
const join = await runtime.callService!({ domain: 'media_player', service: 'join', target: { entityId: 'media_player.living_room' }, data: { group_members: ['media_player.kitchen'] } });
const groupVolume = await runtime.callService!({ domain: 'linkplay', service: 'group_volume_set', target: { entityId: 'media_player.living_room' }, data: { volume_level: 0.5 } });
expect(play.success).toBeTrue();
expect(volume.success).toBeTrue();
expect(source.success).toBeTrue();
expect(preset.success).toBeTrue();
expect(join.success).toBeTrue();
expect(groupVolume.success).toBeTrue();
expect(commands.map((commandArg) => commandArg.command)).toEqual([
'setPlayerCmd:resume',
'setPlayerCmd:vol:35',
'setPlayerCmd:switchmode:bluetooth',
'MCUKeyShortClick:2',
'ConnectMasterAp:JoinGroupMaster:eth192.168.1.10:wifi0.0.0.0',
'setPlayerCmd:slave_vol:50',
]);
expect(commands[4].host).toEqual('192.168.1.11');
expect(commands[5].host).toEqual('192.168.1.10');
});
tap.test('does not report live command success for static snapshots without transport', async () => {
const staticSnapshot: ILinkplaySnapshot = {
...snapshot,
speakers: snapshot.speakers.map((speakerArg) => ({
...speakerArg,
device: { ...speakerArg.device, host: undefined },
})),
};
const runtime = await new LinkplayIntegration().setup({ snapshot: staticSnapshot }, {});
const result = await runtime.callService!({ domain: 'media_player', service: 'media_play', target: { entityId: 'media_player.living_room' } });
expect(result.success).toBeFalse();
expect(result.error?.includes('config.host or commandExecutor')).toBeTrue();
});
tap.test('uses native LinkPlay HTTP API for live commands', async () => {
const originalFetch = globalThis.fetch;
const calls: URL[] = [];
globalThis.fetch = (async (urlArg: URL | RequestInfo, initArg?: RequestInit) => {
void initArg;
calls.push(new URL(String(urlArg)));
return new Response('OK', { status: 200 });
}) as typeof globalThis.fetch;
try {
const client = new LinkplayClient({
host: 'speaker.local',
snapshot: {
...snapshot,
speakers: [{ ...snapshot.speakers[0], device: { ...snapshot.speakers[0].device, host: undefined } }],
multirooms: [],
},
});
await client.execute({ command: 'set_volume', speakerUuid: 'leader-uuid', volumeLevel: 0.4 });
expect(calls[0].toString()).toEqual('http://speaker.local/httpapi.asp?command=setPlayerCmd%3Avol%3A40');
expect(calls[0].searchParams.get('command')).toEqual('setPlayerCmd:vol:40');
} finally {
globalThis.fetch = originalFetch;
}
});
export default tap.start();
+59
View File
@@ -0,0 +1,59 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { MadvrClient, MadvrCommandModeler } from '../../ts/integrations/madvr/index.js';
tap.test('parses madVR notification snapshots from the IP-control stream', async () => {
const raw = MadvrClient.parseNotificationText([
'MacAddress AA:BB:CC:DD:EE:FF',
'Temperatures 55 40 45 35',
'IncomingSignalInfo 3840x2160 23.976 2D 444 10bit HDR HDR10 TV 16:9',
'OutgoingSignalInfo 4096x2160 23.976 2D RGB 12bit HDR 2020 PC',
'AspectRatio 3840x2160 1.78 178 Cinema',
'MaskingRatio 3840x1600 2.40 240',
].join('\r\n'));
expect(raw.mac_address).toEqual('AA:BB:CC:DD:EE:FF');
expect(raw.temp_gpu).toEqual('55');
expect(raw.incoming_res).toEqual('3840x2160');
expect(raw.hdr_flag).toBeTrue();
expect(raw.outgoing_colorimetry).toEqual('2020');
expect(raw.aspect_name).toEqual('Cinema');
});
tap.test('models safe madVR commands and rejects unsupported parameters', async () => {
const keyModel = MadvrCommandModeler.model('KeyPress, MENU');
expect(keyModel.transport).toEqual('tcp');
expect(keyModel.wireCommand).toEqual('KeyPress MENU\r\n');
const profileModel = MadvrCommandModeler.model({ command: 'ActivateProfile', args: ['DISPLAY', '2'] });
expect(profileModel.wireCommand).toEqual('ActivateProfile DISPLAY 2\r\n');
let errorMessage = '';
try {
MadvrCommandModeler.model('KeyPress, VOLUMEUP');
} catch (errorArg) {
errorMessage = errorArg instanceof Error ? errorArg.message : String(errorArg);
}
expect(errorMessage).toContain('Unsupported madVR remote key');
});
tap.test('does not fake TCP command success without host or executor', async () => {
const client = new MadvrClient({
snapshot: {
deviceInfo: { name: 'Cinema Envy' },
processor: { isOn: true },
display: {},
available: true,
},
});
let errorMessage = '';
try {
await client.execute({ command: 'PowerOff' });
} catch (errorArg) {
errorMessage = errorArg instanceof Error ? errorArg.message : String(errorArg);
}
expect(errorMessage).toContain('host is required');
});
export default tap.start();
+45
View File
@@ -0,0 +1,45 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { MadvrIntegration, createMadvrDiscoveryDescriptor } from '../../ts/integrations/madvr/index.js';
tap.test('matches manual madVR Envy candidates and builds config flow output', async () => {
const descriptor = createMadvrDiscoveryDescriptor();
const matcher = descriptor.getMatchers()[0];
const result = await matcher.matches({
host: '192.168.1.90',
name: 'Cinema Envy',
manufacturer: 'madVR',
model: 'Envy Extreme',
macAddress: 'AA:BB:CC:DD:EE:FF',
}, {});
expect(result.matched).toBeTrue();
expect(result.candidate?.port).toEqual(44077);
expect(result.normalizedDeviceId).toEqual('AA:BB:CC:DD:EE:FF');
const integration = new MadvrIntegration();
const step = await integration.configFlow.start(result.candidate!, {});
const submitted = await step.submit!({ host: '192.168.1.91', port: '44078', name: 'Rack Envy' });
expect(submitted.kind).toEqual('done');
expect(submitted.config?.host).toEqual('192.168.1.91');
expect(submitted.config?.port).toEqual(44078);
expect(submitted.config?.macAddress).toEqual('AA:BB:CC:DD:EE:FF');
});
tap.test('validates madVR candidates and rejects unrelated records', async () => {
const descriptor = createMadvrDiscoveryDescriptor();
const validator = descriptor.getValidators()[0];
const valid = await validator.validate({
source: 'manual',
integrationDomain: 'madvr',
host: '192.168.1.90',
model: 'Envy Pro',
}, {});
expect(valid.matched).toBeTrue();
expect(valid.confidence).toEqual('high');
const matcher = descriptor.getMatchers()[0];
const unrelated = await matcher.matches({ manufacturer: 'Other', model: 'Receiver' }, {});
expect(unrelated.matched).toBeFalse();
});
export default tap.start();
+105
View File
@@ -0,0 +1,105 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { MadvrMapper, type IMadvrSnapshot } from '../../ts/integrations/madvr/index.js';
const snapshot: IMadvrSnapshot = {
deviceInfo: {
host: '192.168.1.90',
port: 44077,
name: 'Cinema Envy',
manufacturer: 'madVR',
model: 'Envy Extreme',
macAddress: 'AA:BB:CC:DD:EE:FF',
},
processor: {
isOn: true,
isSignal: true,
incoming: {
resolution: '3840x2160',
frameRate: '23.976',
signalType: '2D',
colorSpace: '444',
bitDepth: '10bit',
hdr: true,
colorimetry: 'HDR10',
blackLevels: 'TV',
aspectRatio: '16:9',
},
outgoing: {
resolution: '4096x2160',
frameRate: '23.976',
signalType: '2D',
colorSpace: 'RGB',
bitDepth: '12bit',
hdr: true,
colorimetry: '2020',
blackLevels: 'PC',
},
temperatures: {
gpu: 55,
hdmi: 0,
cpu: 44,
mainboard: 41,
},
profileName: 'Cinema',
profileNumber: '1',
},
projector: {
name: 'Projector',
activeProfile: 'Cinema',
},
display: {
hdrFlag: true,
outgoingHdrFlag: true,
aspect: {
resolution: '3840x2160',
decimal: 1.78,
integer: '178',
name: '16:9',
},
masking: {
resolution: '3840x1600',
decimal: 2.4,
integer: '240',
},
},
buttons: [
{ key: 'hotplug', name: 'Hotplug', command: 'Hotplug' },
],
selects: [
{
key: 'display_profile',
name: 'Display profile',
current: 'Cinema',
options: ['Cinema', 'TV'],
commands: {
Cinema: { command: 'ActivateProfile', args: ['DISPLAY', '1'] },
TV: { command: 'ActivateProfile', args: ['DISPLAY', '2'] },
},
},
],
available: true,
};
tap.test('maps madVR Envy snapshots to processor/display devices and entities', async () => {
const devices = MadvrMapper.toDevices(snapshot);
const entities = MadvrMapper.toEntities(snapshot);
expect(devices[0].id).toEqual('madvr.device.aa_bb_cc_dd_ee_ff');
expect(devices[0].online).toBeTrue();
expect(devices[0].state.some((stateArg) => stateArg.featureId === 'incoming_res' && stateArg.value === '3840x2160')).toBeTrue();
expect(devices[0].features.some((featureArg) => featureArg.id === 'button_hotplug' && featureArg.writable)).toBeTrue();
const mediaEntity = entities.find((entityArg) => entityArg.platform === 'media_player');
expect(mediaEntity?.id).toEqual('media_player.cinema_envy');
expect(mediaEntity?.state).toEqual('on');
expect(mediaEntity?.attributes?.hdrFlag).toBeTrue();
expect((mediaEntity?.attributes?.incoming as Record<string, unknown>).resolution).toEqual('3840x2160');
expect(entities.find((entityArg) => entityArg.uniqueId.endsWith('_incoming_colorimetry'))?.state).toEqual('HDR10');
expect(entities.find((entityArg) => entityArg.uniqueId.endsWith('_temp_hdmi'))?.state).toEqual(null);
expect(entities.find((entityArg) => entityArg.uniqueId.endsWith('_power_state'))?.state).toBeTrue();
expect(entities.find((entityArg) => entityArg.platform === 'button')?.attributes?.writable).toBeTrue();
expect(entities.find((entityArg) => entityArg.platform === 'select')?.attributes?.options).toEqual(['Cinema', 'TV']);
});
export default tap.start();
+76
View File
@@ -0,0 +1,76 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { MadvrIntegration, type IMadvrCommandRequest, type IMadvrSnapshot } from '../../ts/integrations/madvr/index.js';
const snapshot: IMadvrSnapshot = {
deviceInfo: { name: 'Cinema Envy', macAddress: 'AA:BB:CC:DD:EE:FF' },
processor: { isOn: true, isSignal: true },
display: {},
buttons: [{ key: 'hotplug', name: 'Hotplug', command: 'Hotplug' }],
selects: [{
key: 'display_profile',
name: 'Display profile',
current: 'Cinema',
options: ['Cinema', 'TV'],
commands: {
Cinema: { command: 'ActivateProfile', args: ['DISPLAY', '1'] },
TV: { command: 'ActivateProfile', args: ['DISPLAY', '2'] },
},
}],
available: true,
};
tap.test('executes modeled madVR commands through an explicit executor', async () => {
const executed: IMadvrCommandRequest[] = [];
const integration = new MadvrIntegration();
const runtime = await integration.setup({
snapshot,
commandExecutor: {
execute: async (commandArg) => {
executed.push(commandArg);
},
},
}, {});
const remoteResult = await runtime.callService!({
domain: 'remote',
service: 'send_command',
target: { entityId: 'media_player.cinema_envy' },
data: { command: 'KeyPress, MENU' },
});
expect(remoteResult.success).toBeTrue();
expect(executed[0].command).toEqual('KeyPress');
expect(executed[0].args).toEqual(['MENU']);
const buttonResult = await runtime.callService!({
domain: 'button',
service: 'press',
target: { entityId: 'button.cinema_envy_hotplug' },
});
expect(buttonResult.success).toBeTrue();
expect(executed[1].command).toEqual('Hotplug');
const selectResult = await runtime.callService!({
domain: 'select',
service: 'select_option',
target: { entityId: 'select.cinema_envy_display_profile' },
data: { option: 'TV' },
});
expect(selectResult.success).toBeTrue();
expect(executed[2].command).toEqual('ActivateProfile');
expect(executed[2].args).toEqual(['DISPLAY', '2']);
});
tap.test('runtime reports live command errors instead of pretending success', async () => {
const integration = new MadvrIntegration();
const runtime = await integration.setup({ snapshot }, {});
const result = await runtime.callService!({
domain: 'remote',
service: 'turn_off',
target: { entityId: 'media_player.cinema_envy' },
});
expect(result.success).toBeFalse();
expect(result.error).toContain('host is required');
});
export default tap.start();
@@ -0,0 +1,31 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { OpenRGBIntegration, OpenRGBManualMatcher } from '../../ts/integrations/openrgb/index.js';
tap.test('config flow returns local OpenRGB SDK config and validates port', async () => {
const integration = new OpenRGBIntegration();
const step = await integration.configFlow.start({ source: 'manual', integrationDomain: 'openrgb', host: '192.168.1.20', name: 'Gaming PC' }, {});
const invalid = await step.submit?.({ name: 'Gaming PC', host: '192.168.1.20', port: 70000 });
const done = await step.submit?.({ name: 'Gaming PC', host: '192.168.1.20', port: 6742, clientName: 'Home Assistant' });
expect(invalid?.kind).toEqual('error');
expect(done?.kind).toEqual('done');
expect(done?.config?.host).toEqual('192.168.1.20');
expect(done?.config?.port).toEqual(6742);
expect(done?.config?.clientName).toEqual('Home Assistant');
});
tap.test('manual matcher recognizes OpenRGB SDK and snapshot candidates', async () => {
const match = await new OpenRGBManualMatcher().matches({
integrationDomain: 'openrgb',
host: '127.0.0.1',
port: 6742,
name: 'OpenRGB SDK Server',
metadata: { openrgb: true },
});
expect(match.matched).toBeTrue();
expect(match.candidate?.integrationDomain).toEqual('openrgb');
expect(match.candidate?.port).toEqual(6742);
});
export default tap.start();
+124
View File
@@ -0,0 +1,124 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { OpenRGBMapper, type IOpenRGBSnapshot } from '../../ts/integrations/openrgb/index.js';
const snapshot: IOpenRGBSnapshot = {
connected: true,
host: '192.168.1.20',
port: 6742,
name: 'Gaming PC',
protocolVersion: 4,
controller: {
id: 'gaming-pc',
name: 'Gaming PC',
host: '192.168.1.20',
port: 6742,
protocolVersion: 4,
},
devices: [
{
id: 0,
name: 'Mainboard',
typeName: 'motherboard',
metadata: {
vendor: 'ASRock',
description: 'X570 RGB',
version: '1.0',
serial: 'ABC123',
location: 'PCI',
},
activeMode: 1,
modes: [
{ id: 0, name: 'Off', value: 0, flags: 0, colorMode: 0 },
{ id: 1, name: 'Direct', value: 1, flags: 1 << 5, colorMode: 1 },
{ id: 2, name: 'Static', value: 2, flags: 1 << 6, colorMode: 2, colors: [{ red: 255, green: 255, blue: 255 }] },
{ id: 3, name: 'Rainbow', value: 3, flags: 0, colorMode: 0 },
],
colors: [
{ red: 128, green: 64, blue: 0 },
{ red: 128, green: 64, blue: 0 },
],
leds: [
{ id: 0, name: 'LED 1', value: 0 },
{ id: 1, name: 'LED 2', value: 0 },
],
zones: [
{
id: 0,
name: 'Header',
typeName: 'linear',
numLeds: 2,
colors: [
{ red: 0, green: 32, blue: 64 },
{ red: 0, green: 32, blue: 64 },
],
leds: [
{ id: 0, name: 'Header LED 1', value: 0 },
{ id: 1, name: 'Header LED 2', value: 0 },
],
},
],
available: true,
},
],
profiles: ['Gaming', 'Work'],
events: [],
};
tap.test('maps OpenRGB controller, devices, zones, lights, and effects', async () => {
const devices = OpenRGBMapper.toDevices(snapshot);
const entities = OpenRGBMapper.toEntities(snapshot);
const light = entities.find((entityArg) => entityArg.id === 'light.mainboard');
const zoneLight = entities.find((entityArg) => entityArg.id === 'light.mainboard_header');
expect(devices.some((deviceArg) => deviceArg.id === 'openrgb.controller.gaming_pc')).toBeTrue();
expect(devices.some((deviceArg) => deviceArg.id.startsWith('openrgb.device.'))).toBeTrue();
expect(devices.some((deviceArg) => deviceArg.id.startsWith('openrgb.zone.'))).toBeTrue();
expect(light?.state).toEqual('on');
expect(light?.attributes?.brightness).toEqual(128);
expect(light?.attributes?.rgbColor).toEqual([255, 128, 0]);
expect(light?.attributes?.effect).toEqual('off');
expect(light?.attributes?.effectList).toEqual(['off', 'rainbow']);
expect(zoneLight?.attributes?.brightness).toEqual(64);
expect(zoneLight?.attributes?.rgbColor).toEqual([0, 128, 255]);
expect(entities.find((entityArg) => entityArg.id === 'select.gaming_pc_profile')?.attributes?.options).toEqual(['Gaming', 'Work']);
});
tap.test('models safe OpenRGB light commands for mode, color, brightness, and off', async () => {
const effectCommand = OpenRGBMapper.commandForService(snapshot, {
domain: 'light',
service: 'turn_on',
target: { entityId: 'light.mainboard' },
data: { effect: 'Rainbow' },
});
const colorCommand = OpenRGBMapper.commandForService(snapshot, {
domain: 'light',
service: 'turn_on',
target: { entityId: 'light.mainboard' },
data: { rgb_color: [10, 20, 30], brightness: 128 },
});
const offCommand = OpenRGBMapper.commandForService(snapshot, {
domain: 'light',
service: 'turn_off',
target: { entityId: 'light.mainboard' },
});
const zoneOffCommand = OpenRGBMapper.commandForService(snapshot, {
domain: 'light',
service: 'turn_off',
target: { entityId: 'light.mainboard_header' },
});
const effectOperation = effectCommand?.operations[0];
const colorOperation = colorCommand?.operations[0];
const offOperation = offCommand?.operations[0];
const zoneOffOperation = zoneOffCommand?.operations[0];
if (effectOperation?.action !== 'setMode' || offOperation?.action !== 'setMode' || colorOperation?.action !== 'setColor' || zoneOffOperation?.action !== 'setColor') {
throw new Error('OpenRGB command operations were not modeled with the expected action types.');
}
expect(effectOperation.modeName).toEqual('Rainbow');
expect(colorOperation.rgb).toEqual({ red: 5, green: 10, blue: 15 });
expect(offOperation.modeName).toEqual('Off');
expect(zoneOffOperation.zoneIndex).toEqual(0);
expect(zoneOffOperation.rgb).toEqual({ red: 0, green: 0, blue: 0 });
});
export default tap.start();
+62
View File
@@ -0,0 +1,62 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { OpenRGBIntegration, type IOpenRGBClientCommand, type IOpenRGBSnapshot } from '../../ts/integrations/openrgb/index.js';
const snapshot: IOpenRGBSnapshot = {
connected: true,
name: 'Gaming PC',
controller: { id: 'gaming-pc', name: 'Gaming PC' },
devices: [
{
id: 0,
name: 'Mainboard',
typeName: 'motherboard',
metadata: { vendor: 'ASRock', description: 'X570 RGB', serial: 'ABC123', location: 'PCI' },
activeMode: 0,
modes: [{ id: 0, name: 'Direct', value: 0, flags: 1 << 5, colorMode: 1 }],
colors: [{ red: 255, green: 255, blue: 255 }],
leds: [{ id: 0, name: 'LED 1', value: 0 }],
available: true,
},
],
profiles: [],
events: [],
};
tap.test('does not fake OpenRGB SDK command success without host or executor', async () => {
const runtime = await new OpenRGBIntegration().setup({ snapshot }, {});
const result = await runtime.callService?.({
domain: 'light',
service: 'turn_on',
target: { entityId: 'light.mainboard' },
data: { brightness: 100 },
});
expect(result?.success).toBeFalse();
expect(result?.error).toContain('host');
await runtime.destroy();
});
tap.test('uses commandExecutor for snapshot/manual OpenRGB command execution', async () => {
let capturedCommand: IOpenRGBClientCommand | undefined;
const runtime = await new OpenRGBIntegration().setup({
snapshot,
commandExecutor: async (commandArg) => {
capturedCommand = commandArg;
return { success: true, data: { accepted: true } };
},
}, {});
const result = await runtime.callService?.({
domain: 'light',
service: 'turn_on',
target: { entityId: 'light.mainboard' },
data: { rgb_color: [255, 0, 0], brightness: 64 },
});
const operation = capturedCommand?.operations[0];
if (operation?.action !== 'setColor') {
throw new Error('Expected a setColor operation.');
}
expect(result?.success).toBeTrue();
expect(operation.rgb).toEqual({ red: 64, green: 0, blue: 0 });
await runtime.destroy();
});
export default tap.start();
@@ -0,0 +1,32 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SoundtouchConfigFlow } from '../../ts/integrations/soundtouch/index.js';
tap.test('creates SoundTouch config from discovered host', async () => {
const flow = new SoundtouchConfigFlow();
const step = await flow.start({
source: 'mdns',
integrationDomain: 'soundtouch',
id: 'DEVICE-123',
host: 'living-room.local',
name: 'Living Room',
model: 'SoundTouch 20',
macAddress: '00:11:22:33:44:55',
}, {});
expect(step.kind).toEqual('form');
const done = await step.submit!({});
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('living-room.local');
expect(done.config?.port).toEqual(8090);
expect(done.config?.dlnaPort).toEqual(8091);
expect(done.config?.deviceId).toEqual('DEVICE-123');
});
tap.test('requires a SoundTouch host for manual setup', async () => {
const flow = new SoundtouchConfigFlow();
const step = await flow.start({ source: 'manual', integrationDomain: 'soundtouch' }, {});
const result = await step.submit!({});
expect(result.kind).toEqual('error');
});
export default tap.start();
@@ -0,0 +1,42 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { createSoundtouchDiscoveryDescriptor } from '../../ts/integrations/soundtouch/index.js';
tap.test('matches Home Assistant SoundTouch zeroconf records', async () => {
const descriptor = createSoundtouchDiscoveryDescriptor();
const result = await descriptor.getMatchers()[0].matches({
name: 'Living Room SoundTouch',
type: '_soundtouch._tcp.local.',
host: 'living-room.local',
port: 8090,
txt: { deviceId: 'DEVICE-123', type: 'SoundTouch 20', mac: '00:11:22:33:44:55' },
}, {});
expect(result.matched).toBeTrue();
expect(result.confidence).toEqual('certain');
expect(result.normalizedDeviceId).toEqual('DEVICE-123');
expect(result.candidate?.host).toEqual('living-room.local');
expect(result.candidate?.port).toEqual(8090);
expect(result.candidate?.manufacturer).toEqual('Bose Corporation');
});
tap.test('matches SoundTouch manual candidates and validates them', async () => {
const descriptor = createSoundtouchDiscoveryDescriptor();
const manual = await descriptor.getMatchers()[1].matches({ host: '192.168.1.50', name: 'Living Room' }, {});
expect(manual.matched).toBeTrue();
expect(manual.candidate?.port).toEqual(8090);
const validation = await descriptor.getValidators()[0].validate(manual.candidate!, {});
expect(validation.matched).toBeTrue();
});
tap.test('rejects unrelated mDNS records', async () => {
const descriptor = createSoundtouchDiscoveryDescriptor();
const result = await descriptor.getMatchers()[0].matches({
name: 'Printer',
type: '_ipp._tcp.local.',
host: 'printer.local',
}, {});
expect(result.matched).toBeFalse();
});
export default tap.start();
@@ -0,0 +1,57 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SoundtouchMapper, type ISoundtouchSnapshot } from '../../ts/integrations/soundtouch/index.js';
const snapshot: ISoundtouchSnapshot = {
config: {
deviceId: 'DEVICE-123',
name: 'Living Room',
type: 'SoundTouch 20',
host: '192.168.1.50',
port: 8090,
networks: [{ type: 'SMSC', macAddress: '00:11:22:33:44:55', ipAddress: '192.168.1.50' }],
components: [{ category: 'PRODUCT', softwareVersion: '27.0.1', serialNumber: 'B123' }],
},
status: {
source: 'INTERNET_RADIO',
playStatus: 'PLAY_STATE',
stationName: 'Jazz Radio',
track: 'Example Track',
artist: 'Example Artist',
album: 'Example Album',
duration: 180,
position: 30,
contentItem: { source: 'INTERNET_RADIO', type: 'uri', location: 'station-1', name: 'Jazz Radio', isPresetable: true },
},
volume: { actual: 42, target: 42, muted: false },
presets: [{ presetId: '1', name: 'Jazz Radio', source: 'INTERNET_RADIO', type: 'uri', location: 'station-1', isPresetable: true }],
sourceList: ['AUX', 'BLUETOOTH'],
zone: { masterId: 'DEVICE-123', isMaster: true, slaves: [{ deviceId: 'DEVICE-456', ipAddress: '192.168.1.51', role: 'SLAVE' }] },
available: true,
};
tap.test('maps SoundTouch snapshots to canonical media devices', async () => {
const devices = SoundtouchMapper.toDevices(snapshot);
expect(devices[0].id).toEqual('soundtouch.player.device_123');
expect(devices[0].manufacturer).toEqual('Bose Corporation');
expect(devices[0].features.some((featureArg) => featureArg.id === 'presets')).toBeTrue();
expect(devices[0].state.some((stateArg) => stateArg.featureId === 'volume' && stateArg.value === 42)).toBeTrue();
});
tap.test('maps SoundTouch media player presets sources and media entities', async () => {
const entities = SoundtouchMapper.toEntities(snapshot);
const player = entities.find((entityArg) => entityArg.id === 'media_player.living_room');
const presets = entities.find((entityArg) => entityArg.id === 'sensor.living_room_soundtouch_presets');
const sources = entities.find((entityArg) => entityArg.id === 'sensor.living_room_soundtouch_sources');
const media = entities.find((entityArg) => entityArg.id === 'sensor.living_room_soundtouch_media');
expect(player?.state).toEqual('playing');
expect(player?.attributes?.volumeLevel).toEqual(0.42);
expect(player?.attributes?.source).toEqual('INTERNET_RADIO');
expect(player?.attributes?.mediaTitle).toEqual('Jazz Radio');
expect(player?.attributes?.soundtouchGroup).toEqual(['DEVICE-123', 'DEVICE-456']);
expect(presets?.state).toEqual(1);
expect(sources?.state).toEqual(2);
expect(media?.state).toEqual('Jazz Radio');
});
export default tap.start();
@@ -0,0 +1,101 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SoundtouchClient, SoundtouchIntegration, type ISoundtouchRawCommandRequest, type ISoundtouchSnapshot } from '../../ts/integrations/soundtouch/index.js';
const snapshot: ISoundtouchSnapshot = {
config: { deviceId: 'DEVICE-123', name: 'Living Room', type: 'SoundTouch 20', host: '192.168.1.50', port: 8090 },
status: { source: 'INTERNET_RADIO', playStatus: 'PAUSE_STATE' },
volume: { actual: 20, target: 20, muted: false },
presets: [{ presetId: '1', name: 'Jazz Radio', source: 'INTERNET_RADIO', type: 'uri', location: 'station-1', sourceXml: '<ContentItem source="INTERNET_RADIO" type="uri" location="station-1"><itemName>Jazz Radio</itemName></ContentItem>' }],
sourceList: ['AUX', 'BLUETOOTH'],
available: true,
};
tap.test('models SoundTouch playback volume source and preset commands through an executor', async () => {
const commands: ISoundtouchRawCommandRequest[] = [];
const runtime = await new SoundtouchIntegration().setup({
snapshot,
commandExecutor: {
execute: async (requestArg) => {
commands.push(requestArg);
return { body: '<ok />' };
},
},
}, {});
const play = await runtime.callService!({ domain: 'media_player', service: 'media_play', target: { entityId: 'media_player.living_room' } });
const volume = await runtime.callService!({ domain: 'media_player', service: 'volume_set', target: { entityId: 'media_player.living_room' }, data: { volume_level: 0.35 } });
const source = await runtime.callService!({ domain: 'media_player', service: 'select_source', target: { entityId: 'media_player.living_room' }, data: { source: 'AUX' } });
const preset = await runtime.callService!({ domain: 'media_player', service: 'play_media', target: { entityId: 'media_player.living_room' }, data: { media_content_id: '1' } });
expect(play.success).toBeTrue();
expect(volume.success).toBeTrue();
expect(source.success).toBeTrue();
expect(preset.success).toBeTrue();
expect(commands.map((commandArg) => `${commandArg.method} ${commandArg.path}`)).toEqual([
'POST /key',
'POST /key',
'POST /volume',
'POST /select',
'POST /select',
]);
expect(commands[0].body).toEqual('<key state="press" sender="Gabbo">PLAY</key>');
expect(commands[1].body).toEqual('<key state="release" sender="Gabbo">PLAY</key>');
expect(commands[2].body).toEqual('<volume>35</volume>');
expect(commands[3].body?.includes('source="AUX"')).toBeTrue();
expect(commands[4].body?.includes('location="station-1"')).toBeTrue();
});
tap.test('does not report live command success for static snapshots without transport', async () => {
const runtime = await new SoundtouchIntegration().setup({ snapshot }, {});
const result = await runtime.callService!({ domain: 'media_player', service: 'media_play', target: { entityId: 'media_player.living_room' } });
expect(result.success).toBeFalse();
expect(result.error?.includes('config.host or commandExecutor')).toBeTrue();
});
tap.test('parses native SoundTouch HTTP XML snapshots', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (urlArg: URL | RequestInfo) => {
const path = new URL(String(urlArg)).pathname;
const bodies: Record<string, string> = {
'/info': '<info deviceID="DEVICE-123"><name>Living Room</name><type>SoundTouch 20</type><networkInfo type="SMSC"><macAddress>00:11:22:33:44:55</macAddress><ipAddress>192.168.1.50</ipAddress></networkInfo><components><component><componentCategory>PRODUCT</componentCategory><softwareVersion>27.0.1</softwareVersion><serialNumber>B123</serialNumber></component></components></info>',
'/now_playing': '<nowPlaying source="INTERNET_RADIO"><ContentItem source="INTERNET_RADIO" type="uri" location="station-1" isPresetable="true"><itemName>Jazz Radio</itemName></ContentItem><stationName>Jazz Radio</stationName><artist>Example Artist</artist><track>Track</track><playStatus>PLAY_STATE</playStatus><time total="180">30</time></nowPlaying>',
'/volume': '<volume><actualvolume>42</actualvolume><targetvolume>42</targetvolume><muteenabled>false</muteenabled></volume>',
'/presets': '<presets><preset id="1"><ContentItem source="INTERNET_RADIO" type="uri" location="station-1" isPresetable="true"><itemName>Jazz Radio</itemName></ContentItem></preset></presets>',
'/getZone': '<zone master="DEVICE-123"><member ipaddress="192.168.1.51" role="SLAVE">DEVICE-456</member></zone>',
};
return new Response(bodies[path] || '', { status: bodies[path] ? 200 : 404, headers: { 'content-type': 'text/xml' } });
}) as typeof globalThis.fetch;
try {
const parsed = await new SoundtouchClient({ host: 'speaker.local' }).getSnapshot();
expect(parsed.config.deviceId).toEqual('DEVICE-123');
expect(parsed.config.name).toEqual('Living Room');
expect(parsed.status?.stationName).toEqual('Jazz Radio');
expect(parsed.volume?.actual).toEqual(42);
expect(parsed.presets?.[0].presetId).toEqual('1');
expect(parsed.zone?.isMaster).toBeTrue();
expect(parsed.zone?.slaves[0].deviceId).toEqual('DEVICE-456');
} finally {
globalThis.fetch = originalFetch;
}
});
tap.test('uses native SoundTouch HTTP endpoints for live commands', async () => {
const originalFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string; body?: string }> = [];
globalThis.fetch = (async (urlArg: URL | RequestInfo, initArg?: RequestInit) => {
calls.push({ url: String(urlArg), method: initArg?.method, body: String(initArg?.body || '') });
return new Response('<ok />', { status: 200, headers: { 'content-type': 'text/xml' } });
}) as typeof globalThis.fetch;
try {
await new SoundtouchClient({ host: 'speaker.local' }).execute({ command: 'set_volume', volumeLevel: 0.5 });
expect(calls[0].url).toEqual('http://speaker.local:8090/volume');
expect(calls[0].method).toEqual('POST');
expect(calls[0].body).toEqual('<volume>50</volume>');
} finally {
globalThis.fetch = originalFetch;
}
});
export default tap.start();
+12
View File
@@ -15,6 +15,7 @@ import { ArcamFmjIntegration } from './integrations/arcam_fmj/index.js';
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 { BoschShcIntegration } from './integrations/bosch_shc/index.js';
import { BraviatvIntegration } from './integrations/braviatv/index.js';
import { BroadlinkIntegration } from './integrations/broadlink/index.js';
@@ -24,6 +25,7 @@ import { DenonavrIntegration } from './integrations/denonavr/index.js';
import { DevoloHomeNetworkIntegration } from './integrations/devolo_home_network/index.js';
import { DlnaDmrIntegration } from './integrations/dlna_dmr/index.js';
import { DsmrIntegration } from './integrations/dsmr/index.js';
import { DunehdIntegration } from './integrations/dunehd/index.js';
import { EsphomeIntegration } from './integrations/esphome/index.js';
import { FritzIntegration } from './integrations/fritz/index.js';
import { GlancesIntegration } from './integrations/glances/index.js';
@@ -34,6 +36,8 @@ import { IppIntegration } from './integrations/ipp/index.js';
import { JellyfinIntegration } from './integrations/jellyfin/index.js';
import { KnxIntegration } from './integrations/knx/index.js';
import { KodiIntegration } from './integrations/kodi/index.js';
import { LinkplayIntegration } from './integrations/linkplay/index.js';
import { MadvrIntegration } from './integrations/madvr/index.js';
import { MatterIntegration } from './integrations/matter/index.js';
import { MikrotikIntegration } from './integrations/mikrotik/index.js';
import { ModbusIntegration } from './integrations/modbus/index.js';
@@ -44,6 +48,7 @@ import { NanoleafIntegration } from './integrations/nanoleaf/index.js';
import { OpenthermGwIntegration } from './integrations/opentherm_gw/index.js';
import { OpnsenseIntegration } from './integrations/opnsense/index.js';
import { OnvifIntegration } from './integrations/onvif/index.js';
import { OpenRGBIntegration } from './integrations/openrgb/index.js';
import { PiHoleIntegration } from './integrations/pi_hole/index.js';
import { PlexIntegration } from './integrations/plex/index.js';
import { RainbirdIntegration } from './integrations/rainbird/index.js';
@@ -53,6 +58,7 @@ import { SamsungtvIntegration } from './integrations/samsungtv/index.js';
import { ShellyIntegration } from './integrations/shelly/index.js';
import { SnapcastIntegration } from './integrations/snapcast/index.js';
import { SonosIntegration } from './integrations/sonos/index.js';
import { SoundtouchIntegration } from './integrations/soundtouch/index.js';
import { SqueezeboxIntegration } from './integrations/squeezebox/index.js';
import { SynologyDsmIntegration } from './integrations/synology_dsm/index.js';
import { TplinkIntegration } from './integrations/tplink/index.js';
@@ -83,6 +89,7 @@ export const integrations = [
new AxisIntegration(),
new BleboxIntegration(),
new BluetoothLeTrackerIntegration(),
new BluesoundIntegration(),
new BoschShcIntegration(),
new BraviatvIntegration(),
new BroadlinkIntegration(),
@@ -92,6 +99,7 @@ export const integrations = [
new DevoloHomeNetworkIntegration(),
new DlnaDmrIntegration(),
new DsmrIntegration(),
new DunehdIntegration(),
new EsphomeIntegration(),
new FritzIntegration(),
new GlancesIntegration(),
@@ -103,6 +111,8 @@ export const integrations = [
new JellyfinIntegration(),
new KnxIntegration(),
new KodiIntegration(),
new LinkplayIntegration(),
new MadvrIntegration(),
new MatterIntegration(),
new MikrotikIntegration(),
new ModbusIntegration(),
@@ -113,6 +123,7 @@ export const integrations = [
new OpenthermGwIntegration(),
new OpnsenseIntegration(),
new OnvifIntegration(),
new OpenRGBIntegration(),
new PiHoleIntegration(),
new PlexIntegration(),
new RainbirdIntegration(),
@@ -122,6 +133,7 @@ export const integrations = [
new ShellyIntegration(),
new SnapcastIntegration(),
new SonosIntegration(),
new SoundtouchIntegration(),
new SqueezeboxIntegration(),
new SynologyDsmIntegration(),
new TplinkIntegration(),
@@ -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,654 @@
import type {
IBluesoundCommandRequest,
IBluesoundConfig,
IBluesoundInput,
IBluesoundPairedPlayer,
IBluesoundPlayerSnapshot,
IBluesoundPreset,
IBluesoundRawCommandRequest,
IBluesoundRawCommandResponse,
IBluesoundSnapshot,
IBluesoundStatus,
IBluesoundSyncStatus,
} from './bluesound.types.js';
import { bluesoundDefaultPort, bluesoundDefaultTimeoutMs } from './bluesound.types.js';
export class BluesoundHttpError extends Error {
constructor(public readonly status: number, messageArg: string) {
super(messageArg);
this.name = 'BluesoundHttpError';
}
}
export class BluesoundClient {
private currentSnapshot?: IBluesoundSnapshot;
constructor(private readonly config: IBluesoundConfig) {
this.currentSnapshot = config.snapshot ? this.normalizeSnapshot(this.cloneSnapshot(config.snapshot), 'snapshot') : undefined;
}
public async getSnapshot(): Promise<IBluesoundSnapshot> {
if (this.config.snapshot) {
this.currentSnapshot = this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot), 'snapshot');
return this.cloneSnapshot(this.currentSnapshot);
}
if (this.currentSnapshot && !this.config.host && !this.config.commandExecutor) {
return this.cloneSnapshot(this.currentSnapshot);
}
if (!this.config.host && !this.config.commandExecutor) {
this.currentSnapshot = this.normalizeSnapshot(this.snapshotFromConfig(false, 'Bluesound refresh requires config.host, config.snapshot, or commandExecutor.'), 'runtime');
return this.cloneSnapshot(this.currentSnapshot);
}
try {
this.currentSnapshot = this.normalizeSnapshot(await this.fetchSnapshot(), 'http');
} catch (errorArg) {
this.currentSnapshot = this.normalizeSnapshot(this.snapshotFromConfig(false, errorArg instanceof Error ? errorArg.message : String(errorArg)), 'runtime');
}
return this.cloneSnapshot(this.currentSnapshot);
}
public async validateConnection(): Promise<IBluesoundSnapshot> {
const syncStatus = await this.getSyncStatus();
if (!syncStatus.mac) {
throw new Error('Bluesound player did not provide a MAC address.');
}
const snapshot = await this.fetchSnapshot(syncStatus);
return this.normalizeSnapshot(snapshot, 'http');
}
public async getStatus(): Promise<IBluesoundStatus> {
if (this.currentSnapshot && !this.config.host && !this.config.commandExecutor) {
return this.cloneValue(this.currentSnapshot.players[0]?.status || { state: 'unknown' });
}
const response = await this.requestText('/Status');
return parseStatus(response);
}
public async getSyncStatus(): Promise<IBluesoundSyncStatus> {
if (this.currentSnapshot && !this.config.host && !this.config.commandExecutor) {
const syncStatus = this.currentSnapshot.players[0]?.syncStatus;
if (!syncStatus) {
throw new Error('Bluesound snapshot does not contain a player.');
}
return this.cloneValue(syncStatus);
}
const response = await this.requestText('/SyncStatus');
return parseSyncStatus(response);
}
public async getPresets(): Promise<IBluesoundPreset[]> {
if (this.currentSnapshot && !this.config.host && !this.config.commandExecutor) {
return this.cloneValue(this.currentSnapshot.players[0]?.presets || []);
}
const response = await this.requestText('/Presets');
return parsePresets(response);
}
public async getInputs(): Promise<IBluesoundInput[]> {
if (this.currentSnapshot && !this.config.host && !this.config.commandExecutor) {
return this.cloneValue(this.currentSnapshot.players[0]?.inputs || []);
}
const response = await this.requestText('/RadioBrowse', { service: 'Capture' });
return parseInputs(response);
}
public async execute(requestArg: IBluesoundCommandRequest): Promise<unknown> {
const snapshot = await this.getSnapshot();
const player = this.resolvePlayer(snapshot, requestArg.playerId);
if (this.isLeaderOnlyCommand(requestArg.command) && player && isGroupedFollower(player)) {
return { skipped: true, reason: 'Grouped Bluesound followers are controlled by their leader.' };
}
if (requestArg.command === 'play') {
return this.requestRaw('/Play', {}, player);
}
if (requestArg.command === 'pause') {
return this.requestRaw('/Pause', {}, player);
}
if (requestArg.command === 'stop') {
return this.requestRaw('/Stop', {}, player);
}
if (requestArg.command === 'next_track') {
return this.requestRaw('/Skip', {}, player);
}
if (requestArg.command === 'previous_track') {
return this.requestRaw('/Back', {}, player);
}
if (requestArg.command === 'seek') {
if (typeof requestArg.position !== 'number' || !Number.isFinite(requestArg.position)) {
throw new Error('Bluesound seek requires position.');
}
return this.requestRaw('/Play', { seek: Math.max(0, Math.round(requestArg.position)) }, player);
}
if (requestArg.command === 'set_volume') {
return this.requestRaw('/Volume', { level: this.volumePercent(requestArg) }, player);
}
if (requestArg.command === 'mute') {
if (typeof requestArg.muted !== 'boolean') {
throw new Error('Bluesound mute requires muted.');
}
return this.requestRaw('/Volume', { mute: requestArg.muted ? 1 : 0 }, player);
}
if (requestArg.command === 'play_media') {
if (!requestArg.mediaId) {
throw new Error('Bluesound play_media requires mediaId.');
}
return this.requestRaw('/Play', { url: requestArg.mediaId }, player);
}
if (requestArg.command === 'select_source') {
if (!requestArg.source) {
throw new Error('Bluesound select_source requires source.');
}
return this.selectSource(player, requestArg.source);
}
if (requestArg.command === 'play_preset') {
return this.playPreset(player, requestArg);
}
if (requestArg.command === 'clear_playlist') {
return this.requestRaw('/Clear', {}, player);
}
if (requestArg.command === 'shuffle') {
if (typeof requestArg.shuffle !== 'boolean') {
throw new Error('Bluesound shuffle requires shuffle.');
}
return this.requestRaw('/Shuffle', { state: requestArg.shuffle ? 1 : 0 }, player);
}
if (requestArg.command === 'join') {
return this.joinPlayers(snapshot, player, requestArg);
}
if (requestArg.command === 'unjoin') {
return this.unjoinPlayer(snapshot, player, requestArg);
}
if (requestArg.command === 'add_follower') {
if (!requestArg.follower) {
throw new Error('Bluesound add_follower requires follower.');
}
return this.requestRaw('/AddSlave', { slave: requestArg.follower.ip, port: requestArg.follower.port }, player);
}
if (requestArg.command === 'remove_follower') {
if (!requestArg.follower) {
throw new Error('Bluesound remove_follower requires follower.');
}
return this.requestRaw('/RemoveSlave', { slave: requestArg.follower.ip, port: requestArg.follower.port }, player);
}
throw new Error(`Unsupported Bluesound command: ${requestArg.command}`);
}
public async destroy(): Promise<void> {}
private async fetchSnapshot(syncStatusArg?: IBluesoundSyncStatus): Promise<IBluesoundSnapshot> {
const syncStatus = syncStatusArg || await this.getSyncStatus();
const [status, presets, inputs] = await Promise.all([
this.getStatus(),
this.getPresets().catch(() => []),
this.getInputs().catch(() => []),
]);
const updatedAt = new Date().toISOString();
return {
players: [{
host: this.config.host,
port: this.config.port || bluesoundDefaultPort,
syncStatus,
status,
presets,
inputs,
available: true,
updatedAt,
}],
online: true,
updatedAt,
source: 'http',
};
}
private snapshotFromConfig(onlineArg: boolean, errorArg?: string): IBluesoundSnapshot {
const host = this.config.host;
const port = this.config.port || bluesoundDefaultPort;
const hasPlayerMetadata = Boolean(host || this.config.name || this.config.macAddress || this.config.model || this.config.modelName);
return {
players: hasPlayerMetadata ? [{
host,
port,
syncStatus: {
id: host ? `${host}:${port}` : this.config.name || 'bluesound',
mac: this.config.macAddress || host || 'bluesound',
name: this.config.name || host || 'Bluesound',
brand: this.config.manufacturer || 'Bluesound',
model: this.config.model,
modelName: this.config.modelName || this.config.model,
initialized: undefined,
},
status: { state: onlineArg ? 'stop' : 'unknown' },
presets: [],
inputs: [],
available: onlineArg,
updatedAt: new Date().toISOString(),
}] : [],
online: onlineArg,
updatedAt: new Date().toISOString(),
source: 'runtime',
error: errorArg,
};
}
private async selectSource(playerArg: IBluesoundPlayerSnapshot | undefined, sourceArg: string): Promise<unknown> {
const player = this.requirePlayer(playerArg);
const input = (player.inputs || []).find((inputArg) => sourceArg === inputArg.text || sourceArg === inputArg.id);
if (input) {
return this.requestRaw('/Play', { url: input.url }, player);
}
const preset = (player.presets || []).find((presetArg) => presetArg.name === sourceArg);
if (preset) {
return this.requestRaw('/Preset', { id: preset.id }, player);
}
throw new Error(`Unknown Bluesound source: ${sourceArg}`);
}
private async playPreset(playerArg: IBluesoundPlayerSnapshot | undefined, requestArg: IBluesoundCommandRequest): Promise<unknown> {
const player = this.requirePlayer(playerArg);
const presetId = requestArg.presetId ?? (requestArg.presetName ? (player.presets || []).find((presetArg) => presetArg.name === requestArg.presetName)?.id : undefined);
if (typeof presetId !== 'number' || !Number.isFinite(presetId)) {
throw new Error('Bluesound play_preset requires presetId or a matching presetName.');
}
return this.requestRaw('/Preset', { id: Math.round(presetId) }, player);
}
private async joinPlayers(snapshotArg: IBluesoundSnapshot, playerArg: IBluesoundPlayerSnapshot | undefined, requestArg: IBluesoundCommandRequest): Promise<unknown> {
const leader = this.requirePlayer(playerArg);
const followers = this.joinFollowers(snapshotArg, leader, requestArg);
if (!followers.length) {
throw new Error('Bluesound join requires followers or playerIds.');
}
if (followers.length === 1) {
return this.requestRaw('/AddSlave', { slave: followers[0].ip, port: followers[0].port }, leader);
}
return this.requestRaw('/AddSlave', {
slaves: followers.map((followerArg) => followerArg.ip).join(','),
ports: followers.map((followerArg) => followerArg.port).join(','),
}, leader);
}
private async unjoinPlayer(snapshotArg: IBluesoundSnapshot, playerArg: IBluesoundPlayerSnapshot | undefined, requestArg: IBluesoundCommandRequest): Promise<unknown> {
const player = this.requirePlayer(playerArg);
const explicitFollower = requestArg.follower || requestArg.followers?.[0];
if (explicitFollower) {
return this.requestRaw('/RemoveSlave', { slave: explicitFollower.ip, port: explicitFollower.port }, player);
}
const playerEndpoint = endpointFromPlayer(player);
if (player.syncStatus.leader && playerEndpoint) {
const leader = snapshotArg.players.find((candidateArg) => candidateArg.syncStatus.id === pairedPlayerId(player.syncStatus.leader as IBluesoundPairedPlayer));
return this.requestRaw('/RemoveSlave', { slave: playerEndpoint.ip, port: playerEndpoint.port }, leader || endpointPlayer(player.syncStatus.leader, snapshotArg.updatedAt));
}
if (player.syncStatus.followers?.length) {
const follower = player.syncStatus.followers[0];
return this.requestRaw('/RemoveSlave', { slave: follower.ip, port: follower.port }, player);
}
return { skipped: true, reason: 'Bluesound player is not grouped.' };
}
private joinFollowers(snapshotArg: IBluesoundSnapshot, leaderArg: IBluesoundPlayerSnapshot, requestArg: IBluesoundCommandRequest): IBluesoundPairedPlayer[] {
const fromDirect = requestArg.followers || [];
const ids = (requestArg.playerIds || []).filter((playerIdArg) => playerIdArg !== leaderArg.syncStatus.id);
const fromIds = ids.map((playerIdArg) => {
const player = snapshotArg.players.find((candidateArg) => candidateArg.syncStatus.id === playerIdArg);
return player ? endpointFromPlayer(player) : pairedPlayerFromId(playerIdArg);
}).filter((valueArg): valueArg is IBluesoundPairedPlayer => Boolean(valueArg));
const unique = new Map<string, IBluesoundPairedPlayer>();
[...fromDirect, ...fromIds].forEach((followerArg) => unique.set(pairedPlayerId(followerArg), followerArg));
return [...unique.values()];
}
private resolvePlayer(snapshotArg: IBluesoundSnapshot, playerIdArg: string | undefined): IBluesoundPlayerSnapshot | undefined {
if (playerIdArg) {
return snapshotArg.players.find((playerArg) => playerArg.syncStatus.id === playerIdArg);
}
return snapshotArg.players.length === 1 ? snapshotArg.players[0] : undefined;
}
private requirePlayer(playerArg: IBluesoundPlayerSnapshot | undefined): IBluesoundPlayerSnapshot {
if (playerArg) {
return playerArg;
}
throw new Error('Bluesound command requires a target player.');
}
private isLeaderOnlyCommand(commandArg: IBluesoundCommandRequest['command']): boolean {
return commandArg === 'play'
|| commandArg === 'pause'
|| commandArg === 'stop'
|| commandArg === 'next_track'
|| commandArg === 'previous_track'
|| commandArg === 'seek'
|| commandArg === 'select_source'
|| commandArg === 'play_media'
|| commandArg === 'play_preset'
|| commandArg === 'clear_playlist'
|| commandArg === 'shuffle';
}
private volumePercent(requestArg: IBluesoundCommandRequest): number {
const value = requestArg.volume ?? (typeof requestArg.volumeLevel === 'number' ? requestArg.volumeLevel * 100 : undefined);
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new Error('Bluesound set_volume requires volume or volumeLevel.');
}
return Math.max(0, Math.min(100, Math.round(value)));
}
private async requestText(pathArg: string, parametersArg: Record<string, string | number | boolean> = {}): Promise<string> {
const response = await this.requestRaw(pathArg, parametersArg);
if (typeof response === 'string') {
return response;
}
if (typeof (response as IBluesoundRawCommandResponse).body === 'string') {
return (response as IBluesoundRawCommandResponse).body as string;
}
return typeof (response as IBluesoundRawCommandResponse).data === 'string' ? (response as IBluesoundRawCommandResponse).data as string : '';
}
private async requestRaw(pathArg: string, parametersArg: Record<string, string | number | boolean>, playerArg?: IBluesoundPlayerSnapshot): Promise<unknown> {
const endpoint = this.endpoint(playerArg);
const url = endpoint.host ? this.url(pathArg, parametersArg, endpoint.host, endpoint.port) : undefined;
const request: IBluesoundRawCommandRequest = {
method: 'GET',
command: pathArg.replace(/^\//, ''),
path: pathArg,
parameters: parametersArg,
url,
host: endpoint.host,
port: endpoint.port,
};
if (this.config.commandExecutor) {
return this.executorResult(await this.config.commandExecutor.execute(request), pathArg);
}
if (!this.config.host || !endpoint.host || !url) {
throw new Error('Bluesound command transport requires config.host or commandExecutor. Static snapshots are read-only.');
}
const abortController = new AbortController();
const timeout = globalThis.setTimeout(() => abortController.abort(), this.config.timeoutMs || bluesoundDefaultTimeoutMs);
try {
const response = await globalThis.fetch(url, { method: 'GET', signal: abortController.signal });
const body = await response.text();
if (!response.ok) {
throw new BluesoundHttpError(response.status, `Bluesound request ${pathArg} failed with HTTP ${response.status}: ${body}`);
}
return { status: response.status, body } satisfies IBluesoundRawCommandResponse;
} finally {
globalThis.clearTimeout(timeout);
}
}
private executorResult(resultArg: unknown, pathArg: string): unknown {
if (isRawCommandResponse(resultArg) && typeof resultArg.status === 'number' && resultArg.status >= 400) {
throw new BluesoundHttpError(resultArg.status, `Bluesound request ${pathArg} failed with HTTP ${resultArg.status}: ${resultArg.body || ''}`);
}
return resultArg;
}
private endpoint(playerArg?: IBluesoundPlayerSnapshot): { host?: string; port: number } {
const paired = playerArg ? endpointFromPlayer(playerArg) : undefined;
return {
host: playerArg?.host || paired?.ip || this.config.host,
port: playerArg?.port || paired?.port || this.config.port || bluesoundDefaultPort,
};
}
private url(pathArg: string, parametersArg: Record<string, string | number | boolean>, hostArg: string, portArg: number): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(parametersArg)) {
params.set(key, String(value));
}
const query = params.toString();
return `http://${formatHost(hostArg)}:${portArg}${pathArg}${query ? `?${query}` : ''}`;
}
private normalizeSnapshot(snapshotArg: IBluesoundSnapshot, sourceArg: IBluesoundSnapshot['source']): IBluesoundSnapshot {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const players = (snapshotArg.players || []).map((playerArg) => ({
...playerArg,
host: playerArg.host || this.config.host,
port: playerArg.port || this.config.port || bluesoundDefaultPort,
status: { ...playerArg.status, state: playerArg.status.state || 'unknown' },
syncStatus: {
...playerArg.syncStatus,
name: playerArg.syncStatus.name || this.config.name || playerArg.host || 'Bluesound',
brand: playerArg.syncStatus.brand || this.config.manufacturer || 'Bluesound',
model: playerArg.syncStatus.model || this.config.model,
modelName: playerArg.syncStatus.modelName || this.config.modelName || this.config.model,
},
presets: playerArg.presets || [],
inputs: playerArg.inputs || [],
available: playerArg.available ?? snapshotArg.online,
updatedAt: playerArg.updatedAt || updatedAt,
}));
return {
...snapshotArg,
players,
online: snapshotArg.online,
updatedAt,
source: snapshotArg.source || sourceArg,
};
}
private cloneSnapshot(snapshotArg: IBluesoundSnapshot): IBluesoundSnapshot {
return this.cloneValue(snapshotArg);
}
private cloneValue<TValue>(valueArg: TValue): TValue {
return valueArg === undefined ? valueArg : JSON.parse(JSON.stringify(valueArg)) as TValue;
}
}
const parseStatus = (xmlArg: string): IBluesoundStatus => {
const status = firstElement(xmlArg, 'status');
if (!status) {
throw new Error('Bluesound status response missing status element.');
}
const child = (tagArg: string): string | undefined => childText(status.body, tagArg);
return {
etag: status.attributes.etag,
inputId: child('inputId'),
service: child('service'),
state: child('state') || 'unknown',
shuffle: booleanOne(child('shuffle')),
album: child('album') || child('title3'),
artist: child('artist') || child('title2'),
name: child('name') || child('title1'),
image: child('image'),
volume: numberValue(child('volume')),
volumeDb: numberValue(child('db')),
mute: booleanOne(child('mute')),
muteVolume: numberValue(child('muteVolume')),
muteVolumeDb: numberValue(child('muteDb')),
seconds: numberValue(child('secs')),
totalSeconds: numberValue(child('totlen')),
canSeek: booleanOne(child('canSeek')),
sleep: numberValue(child('sleep')) || 0,
groupName: child('groupName'),
groupVolume: numberValue(child('groupVolume')),
indexing: booleanOne(child('indexing')),
streamUrl: child('streamUrl'),
};
};
const parseSyncStatus = (xmlArg: string): IBluesoundSyncStatus => {
const syncStatus = firstElement(xmlArg, 'SyncStatus');
if (!syncStatus) {
throw new Error('Bluesound sync response missing SyncStatus element.');
}
const leaderElement = firstElement(syncStatus.body, 'master');
const leader = leaderElement?.body.trim() ? {
ip: unescapeXml(leaderElement.body.trim()),
port: numberValue(leaderElement.attributes.port) || bluesoundDefaultPort,
} : undefined;
const followers = allElements(syncStatus.body, 'slave')
.map((elementArg) => ({
ip: elementArg.attributes.id,
port: numberValue(elementArg.attributes.port) || bluesoundDefaultPort,
}))
.filter((playerArg): playerArg is IBluesoundPairedPlayer => Boolean(playerArg.ip));
const attrs = syncStatus.attributes;
return {
etag: attrs.etag,
id: attrs.id || attrs.name || 'bluesound',
mac: attrs.mac || attrs.id || attrs.name || 'bluesound',
name: attrs.name || attrs.id || 'Bluesound',
image: attrs.icon,
initialized: attrs.initialized === undefined ? undefined : attrs.initialized === 'true',
group: attrs.group,
leader,
followers: followers.length ? followers : undefined,
zone: attrs.zone,
zoneLeader: attrs.zoneMaster === undefined ? undefined : attrs.zoneMaster === 'true',
zoneFollower: attrs.zoneMaster === undefined ? undefined : attrs.zoneMaster !== 'true',
brand: attrs.brand || 'Bluesound',
model: attrs.model,
modelName: attrs.modelName || attrs.model,
muteVolumeDb: numberValue(attrs.muteDb),
muteVolume: numberValue(attrs.muteVolume),
volumeDb: numberValue(attrs.db),
volume: numberValue(attrs.volume),
};
};
const parsePresets = (xmlArg: string): IBluesoundPreset[] => {
return allElements(xmlArg, 'preset').map((elementArg) => ({
name: elementArg.attributes.name || `Preset ${elementArg.attributes.id || ''}`.trim(),
id: numberValue(elementArg.attributes.id) || 0,
url: elementArg.attributes.url || '',
image: elementArg.attributes.image,
volume: numberValue(elementArg.attributes.volume),
})).filter((presetArg) => Boolean(presetArg.name && presetArg.url));
};
const parseInputs = (xmlArg: string): IBluesoundInput[] => {
return allElements(xmlArg, 'item').map((elementArg) => {
const url = elementArg.attributes.URL || elementArg.attributes.url;
return {
id: elementArg.attributes.id,
text: elementArg.attributes.text,
image: elementArg.attributes.image,
url: decodeURIComponentSafe(url || ''),
};
}).filter((inputArg) => Boolean(inputArg.url));
};
export const parseBluesoundStatusXml = parseStatus;
export const parseBluesoundSyncStatusXml = parseSyncStatus;
export const parseBluesoundPresetsXml = parsePresets;
export const parseBluesoundInputsXml = parseInputs;
const allElements = (xmlArg: string, tagArg: string): Array<{ attributes: Record<string, string>; body: string }> => {
const escapedTag = tagArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`<${escapedTag}\\b([^>]*)>([\\s\\S]*?)<\\/${escapedTag}>|<${escapedTag}\\b([^>]*)\\/>`, 'gi');
const result: Array<{ attributes: Record<string, string>; body: string }> = [];
let match: RegExpExecArray | null;
while ((match = regex.exec(xmlArg))) {
result.push({ attributes: parseAttributes(match[1] || match[3] || ''), body: match[2] || '' });
}
return result;
};
const firstElement = (xmlArg: string, tagArg: string): { attributes: Record<string, string>; body: string } | undefined => allElements(xmlArg, tagArg)[0];
const childText = (xmlArg: string, tagArg: string): string | undefined => {
const child = firstElement(xmlArg, tagArg);
const value = child?.body.trim();
return value ? unescapeXml(value) : undefined;
};
const parseAttributes = (valueArg: string): Record<string, string> => {
const result: Record<string, string> = {};
const regex = /([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(valueArg))) {
result[match[1]] = unescapeXml(match[2] ?? match[3] ?? '');
}
return result;
};
const booleanOne = (valueArg: string | undefined): boolean | undefined => {
if (valueArg === undefined) {
return undefined;
}
return valueArg === '1' || valueArg.toLowerCase() === 'true';
};
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 endpointFromPlayer = (playerArg: IBluesoundPlayerSnapshot): IBluesoundPairedPlayer | undefined => {
if (playerArg.host) {
return { ip: playerArg.host, port: playerArg.port || bluesoundDefaultPort };
}
return pairedPlayerFromId(playerArg.syncStatus.id);
};
const endpointPlayer = (playerArg: IBluesoundPairedPlayer, updatedAtArg: string | undefined): IBluesoundPlayerSnapshot => ({
host: playerArg.ip,
port: playerArg.port,
syncStatus: {
id: pairedPlayerId(playerArg),
mac: pairedPlayerId(playerArg),
name: pairedPlayerId(playerArg),
brand: 'Bluesound',
},
status: { state: 'unknown' },
presets: [],
inputs: [],
available: true,
updatedAt: updatedAtArg,
});
const pairedPlayerFromId = (idArg: string | undefined): IBluesoundPairedPlayer | undefined => {
if (!idArg) {
return undefined;
}
const separator = idArg.lastIndexOf(':');
if (separator <= 0) {
return undefined;
}
const ip = idArg.slice(0, separator);
const port = Number(idArg.slice(separator + 1));
return Number.isFinite(port) ? { ip, port } : undefined;
};
const pairedPlayerId = (playerArg: IBluesoundPairedPlayer): string => `${playerArg.ip}:${playerArg.port}`;
const isGroupedFollower = (playerArg: IBluesoundPlayerSnapshot): boolean => Boolean(playerArg.syncStatus.leader);
const isRawCommandResponse = (valueArg: unknown): valueArg is IBluesoundRawCommandResponse => {
return Boolean(valueArg && typeof valueArg === 'object' && ('status' in valueArg || 'body' in valueArg || 'data' in valueArg));
};
const formatHost = (hostArg: string): string => hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
const unescapeXml = (valueArg: string): string => {
return valueArg
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/g, '&');
};
const decodeURIComponentSafe = (valueArg: string): string => {
try {
return decodeURIComponent(valueArg);
} catch {
return valueArg;
}
};
@@ -0,0 +1,56 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IBluesoundConfig } from './bluesound.types.js';
import { bluesoundDefaultPort, bluesoundDefaultTimeoutMs } from './bluesound.types.js';
export class BluesoundConfigFlow implements IConfigFlow<IBluesoundConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IBluesoundConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect Bluesound',
description: candidateArg.source === 'manual'
? 'Configure a local Bluesound/BluOS player HTTP endpoint.'
: 'Confirm or adjust the discovered local Bluesound/BluOS player.',
fields: [
{ name: 'host', label: 'Host', type: 'text', required: true },
{ name: 'port', label: 'Port', type: 'number' },
{ name: 'name', label: 'Name', type: 'text' },
],
submit: async (valuesArg) => {
const host = this.stringValue(valuesArg.host) || candidateArg.host || '';
if (!host) {
return { kind: 'error', title: 'Bluesound setup failed', error: 'Bluesound setup requires a host.' };
}
const port = this.numberValue(valuesArg.port) || candidateArg.port || bluesoundDefaultPort;
return {
kind: 'done',
title: 'Bluesound configured',
config: {
host,
port,
name: this.stringValue(valuesArg.name) || candidateArg.name,
macAddress: candidateArg.macAddress,
manufacturer: candidateArg.manufacturer,
model: candidateArg.model,
timeoutMs: bluesoundDefaultTimeoutMs,
},
};
},
};
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
private numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0) {
return Math.round(valueArg);
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : undefined;
}
return undefined;
}
}
@@ -1,29 +1,237 @@
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 { BluesoundClient } from './bluesound.classes.client.js';
import { BluesoundConfigFlow } from './bluesound.classes.configflow.js';
import { createBluesoundDiscoveryDescriptor } from './bluesound.discovery.js';
import { BluesoundMapper } from './bluesound.mapper.js';
import type { IBluesoundCommandRequest, IBluesoundConfig, IBluesoundPairedPlayer } from './bluesound.types.js';
export class HomeAssistantBluesoundIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "bluesound",
displayName: "Bluesound",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/bluesound",
"upstreamDomain": "bluesound",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"pyblu==2.0.6"
],
"dependencies": [],
"afterDependencies": [
"zeroconf"
],
"codeowners": [
"@thrawnarn",
"@LouisChrist"
]
},
export class BluesoundIntegration extends BaseIntegration<IBluesoundConfig> {
public readonly domain = 'bluesound';
public readonly displayName = 'Bluesound';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createBluesoundDiscoveryDescriptor();
public readonly configFlow = new BluesoundConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/bluesound',
upstreamDomain: 'bluesound',
integrationType: 'device',
iotClass: 'local_polling',
requirements: ['pyblu==2.0.6'],
dependencies: [],
afterDependencies: ['zeroconf'],
codeowners: ['@thrawnarn', '@LouisChrist'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/bluesound',
zeroconf: ['_musc._tcp.local.'],
};
public async setup(configArg: IBluesoundConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new BluesoundRuntime(new BluesoundClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantBluesoundIntegration extends BluesoundIntegration {}
class BluesoundRuntime implements IIntegrationRuntime {
public domain = 'bluesound';
constructor(private readonly client: BluesoundClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return BluesoundMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return BluesoundMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain === 'media_player') {
return await this.callMediaPlayerService(requestArg);
}
if (requestArg.domain === 'bluesound') {
return await this.callBluesoundService(requestArg);
}
return { success: false, error: `Unsupported Bluesound 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 callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service === 'media_play' || requestArg.service === 'play') {
return this.execute({ command: 'play', playerId: await this.playerIdFromRequest(requestArg) });
}
if (requestArg.service === 'media_pause' || requestArg.service === 'pause') {
return this.execute({ command: 'pause', playerId: await this.playerIdFromRequest(requestArg) });
}
if (requestArg.service === 'media_stop' || requestArg.service === 'stop') {
return this.execute({ command: 'stop', playerId: await this.playerIdFromRequest(requestArg) });
}
if (requestArg.service === 'media_next_track' || requestArg.service === 'next_track' || requestArg.service === 'next') {
return this.execute({ command: 'next_track', playerId: await this.playerIdFromRequest(requestArg) });
}
if (requestArg.service === 'media_previous_track' || requestArg.service === 'previous_track' || requestArg.service === 'previous') {
return this.execute({ command: 'previous_track', playerId: await this.playerIdFromRequest(requestArg) });
}
if (requestArg.service === 'media_seek' || requestArg.service === 'seek') {
return this.execute({ command: 'seek', playerId: await this.playerIdFromRequest(requestArg), position: this.numberData(requestArg, 'seek_position') ?? this.numberData(requestArg, 'position') });
}
if (requestArg.service === 'volume_set' || requestArg.service === 'set_volume') {
return this.execute({ command: 'set_volume', playerId: await this.playerIdFromRequest(requestArg), volumeLevel: this.numberData(requestArg, 'volume_level'), volume: this.numberData(requestArg, 'volume') });
}
if (requestArg.service === 'volume_mute' || requestArg.service === 'mute') {
return this.execute({ command: 'mute', playerId: await this.playerIdFromRequest(requestArg), muted: this.booleanData(requestArg, 'is_volume_muted') ?? this.booleanData(requestArg, 'muted') ?? this.booleanData(requestArg, 'mute') });
}
if (requestArg.service === 'select_source' || requestArg.service === 'source') {
return this.execute({ command: 'select_source', playerId: await this.playerIdFromRequest(requestArg), source: this.stringData(requestArg, 'source') });
}
if (requestArg.service === 'play_media') {
return this.execute({ command: 'play_media', playerId: await this.playerIdFromRequest(requestArg), mediaId: this.stringData(requestArg, 'media_content_id') || this.stringData(requestArg, 'mediaId') || this.stringData(requestArg, 'uri'), mediaType: this.stringData(requestArg, 'media_content_type') });
}
if (requestArg.service === 'clear_playlist') {
return this.execute({ command: 'clear_playlist', playerId: await this.playerIdFromRequest(requestArg) });
}
if (requestArg.service === 'shuffle_set') {
return this.execute({ command: 'shuffle', playerId: await this.playerIdFromRequest(requestArg), shuffle: this.booleanData(requestArg, 'shuffle') });
}
if (requestArg.service === 'join' || requestArg.service === 'join_players') {
return this.execute(await this.joinCommandRequest(requestArg));
}
if (requestArg.service === 'unjoin' || requestArg.service === 'unjoin_player') {
return this.execute({ command: 'unjoin', playerId: await this.playerIdFromRequest(requestArg) });
}
return { success: false, error: `Unsupported Bluesound media_player service: ${requestArg.service}` };
}
private async callBluesoundService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service === 'join' || requestArg.service === 'join_players' || requestArg.service === 'unjoin' || requestArg.service === 'unjoin_player') {
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
}
if (requestArg.service === 'play_preset' || requestArg.service === 'preset') {
return this.execute({
command: 'play_preset',
playerId: await this.playerIdFromRequest(requestArg),
presetId: this.numberData(requestArg, 'preset_id') ?? this.numberData(requestArg, 'presetId') ?? this.numberData(requestArg, 'id'),
presetName: this.stringData(requestArg, 'preset') || this.stringData(requestArg, 'preset_name') || this.stringData(requestArg, 'name'),
});
}
if (requestArg.service === 'play' || requestArg.service === 'pause' || requestArg.service === 'stop' || requestArg.service === 'media_play' || requestArg.service === 'media_pause' || requestArg.service === 'media_stop' || requestArg.service === 'volume_set' || requestArg.service === 'set_volume' || requestArg.service === 'volume_mute' || requestArg.service === 'mute' || requestArg.service === 'select_source' || requestArg.service === 'source' || requestArg.service === 'clear_playlist' || requestArg.service === 'shuffle_set') {
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
}
return { success: false, error: `Unsupported Bluesound service: ${requestArg.service}` };
}
private async execute(requestArg: IBluesoundCommandRequest): Promise<IServiceCallResult> {
return { success: true, data: await this.client.execute(requestArg) };
}
private async joinCommandRequest(requestArg: IServiceCallRequest): Promise<IBluesoundCommandRequest> {
const leaderId = await this.playerIdFromRequest(requestArg);
const playerIds = await this.joinPlayerIdsFromRequest(requestArg);
const followers = this.followersFromRequest(requestArg);
return {
command: 'join',
playerId: leaderId,
playerIds: [leaderId, ...playerIds.filter((playerIdArg) => playerIdArg !== leaderId)],
followers,
};
}
private async playerIdFromRequest(requestArg: IServiceCallRequest): Promise<string> {
const direct = this.stringData(requestArg, 'player_id') || this.stringData(requestArg, 'playerId') || this.stringData(requestArg, 'id');
if (direct) {
return direct;
}
const snapshot = await this.client.getSnapshot();
if (requestArg.target.entityId) {
const entityPlayerId = BluesoundMapper.entityPlayerId(snapshot, requestArg.target.entityId);
if (entityPlayerId) {
return entityPlayerId;
}
}
if (requestArg.target.deviceId) {
const player = snapshot.players.find((playerArg) => BluesoundMapper.playerDeviceId(playerArg) === requestArg.target.deviceId);
if (player) {
return player.syncStatus.id;
}
}
if (snapshot.players.length === 1) {
return snapshot.players[0].syncStatus.id;
}
throw new Error('Bluesound service call requires data.player_id or a target Bluesound media_player entity.');
}
private async joinPlayerIdsFromRequest(requestArg: IServiceCallRequest): Promise<string[]> {
const direct = this.stringArrayData(requestArg, 'player_ids') || this.stringArrayData(requestArg, 'playerIds');
if (direct?.length) {
return direct;
}
const members = this.stringArrayData(requestArg, 'group_members') || this.stringArrayData(requestArg, 'groupMembers');
if (!members?.length) {
return [];
}
const snapshot = await this.client.getSnapshot();
return members.map((memberArg) => BluesoundMapper.entityPlayerId(snapshot, memberArg) || memberArg);
}
private followersFromRequest(requestArg: IServiceCallRequest): IBluesoundPairedPlayer[] | undefined {
const host = this.stringData(requestArg, 'host') || this.stringData(requestArg, 'follower_host') || this.stringData(requestArg, 'followerHost');
if (!host) {
return undefined;
}
return [{ ip: host, port: this.numberData(requestArg, 'port') || this.numberData(requestArg, 'follower_port') || 11000 }];
}
private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
const value = requestArg.data?.[keyArg];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
private numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined {
const value = requestArg.data?.[keyArg];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
private booleanData(requestArg: IServiceCallRequest, keyArg: string): boolean | undefined {
const value = requestArg.data?.[keyArg];
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
if (value.toLowerCase() === 'true') {
return true;
}
if (value.toLowerCase() === 'false') {
return false;
}
}
return undefined;
}
private stringArrayData(requestArg: IServiceCallRequest, keyArg: string): string[] | undefined {
const value = requestArg.data?.[keyArg];
if (typeof value === 'string') {
return [value];
}
return Array.isArray(value) && value.every((itemArg) => typeof itemArg === 'string') ? value : undefined;
}
}
@@ -0,0 +1,150 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { IBluesoundManualEntry, IBluesoundMdnsRecord } from './bluesound.types.js';
import { bluesoundDefaultPort } from './bluesound.types.js';
const bluesoundDomain = 'bluesound';
const bluesoundMdnsType = '_musc._tcp';
export class BluesoundMdnsMatcher implements IDiscoveryMatcher<IBluesoundMdnsRecord> {
public id = 'bluesound-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize Bluesound/BluOS mDNS advertisements.';
public async matches(recordArg: IBluesoundMdnsRecord): Promise<IDiscoveryMatch> {
const type = normalizeMdnsType(recordArg.type || recordArg.serviceType || '');
const txt = { ...recordArg.txt, ...recordArg.properties };
const name = cleanName(recordArg.name || recordArg.hostname || valueForKey(txt, 'name'));
const manufacturer = valueForKey(txt, 'brand') || valueForKey(txt, 'manufacturer');
const model = valueForKey(txt, 'modelName') || valueForKey(txt, 'model');
const haystack = `${name || ''} ${type} ${manufacturer || ''} ${model || ''}`.toLowerCase();
const matched = type === bluesoundMdnsType || haystack.includes('bluesound') || haystack.includes('bluos');
if (!matched) {
return { matched: false, confidence: 'low', reason: 'mDNS record is not a Bluesound/BluOS advertisement.' };
}
const host = recordArg.host || recordArg.addresses?.[0];
const port = recordArg.port || numberString(valueForKey(txt, 'port')) || bluesoundDefaultPort;
const macAddress = valueForKey(txt, 'mac') || valueForKey(txt, 'macAddress');
const id = macAddress || valueForKey(txt, 'id') || valueForKey(txt, 'uuid') || (host ? `${host}:${port}` : name);
return {
matched: true,
confidence: type === bluesoundMdnsType && host ? 'certain' : host ? 'high' : 'medium',
reason: type === bluesoundMdnsType ? 'mDNS service type matches Home Assistant Bluesound zeroconf.' : 'mDNS metadata contains Bluesound/BluOS hints.',
normalizedDeviceId: id,
candidate: {
source: 'mdns',
integrationDomain: bluesoundDomain,
id,
host,
port,
name,
manufacturer: manufacturer || 'Bluesound',
model,
macAddress,
metadata: {
mdnsType: type,
txt,
},
},
metadata: { mdnsType: type },
};
}
}
export class BluesoundManualMatcher implements IDiscoveryMatcher<IBluesoundManualEntry> {
public id = 'bluesound-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Bluesound/BluOS setup entries.';
public async matches(inputArg: IBluesoundManualEntry): Promise<IDiscoveryMatch> {
const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''} ${inputArg.modelName || ''}`.toLowerCase();
const matched = Boolean(inputArg.host || inputArg.metadata?.bluesound || inputArg.metadata?.bluos || haystack.includes('bluesound') || haystack.includes('bluos'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Bluesound/BluOS setup hints.' };
}
const port = inputArg.port || bluesoundDefaultPort;
const id = inputArg.macAddress || inputArg.id || (inputArg.host ? `${inputArg.host}:${port}` : undefined);
return {
matched: true,
confidence: inputArg.host ? 'high' : 'medium',
reason: 'Manual entry can start Bluesound setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: bluesoundDomain,
id,
host: inputArg.host,
port,
name: inputArg.name,
manufacturer: inputArg.manufacturer || 'Bluesound',
model: inputArg.modelName || inputArg.model,
macAddress: inputArg.macAddress,
metadata: inputArg.metadata,
},
};
}
}
export class BluesoundCandidateValidator implements IDiscoveryValidator {
public id = 'bluesound-candidate-validator';
public description = 'Validate Bluesound/BluOS discovery candidates.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const mdnsType = typeof metadata.mdnsType === 'string' ? normalizeMdnsType(metadata.mdnsType) : '';
const haystack = `${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''}`.toLowerCase();
const matched = candidateArg.integrationDomain === bluesoundDomain
|| mdnsType === bluesoundMdnsType
|| Boolean(metadata.bluesound || metadata.bluos)
|| haystack.includes('bluesound')
|| haystack.includes('bluos');
return {
matched,
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
reason: matched ? 'Candidate has Bluesound/BluOS metadata.' : 'Candidate is not Bluesound/BluOS.',
normalizedDeviceId: candidateArg.macAddress || candidateArg.id || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || bluesoundDefaultPort}` : undefined),
candidate: matched ? { ...candidateArg, port: candidateArg.port || bluesoundDefaultPort } : undefined,
};
}
}
export const createBluesoundDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: bluesoundDomain, displayName: 'Bluesound' })
.addMatcher(new BluesoundMdnsMatcher())
.addMatcher(new BluesoundManualMatcher())
.addValidator(new BluesoundCandidateValidator());
};
const normalizeMdnsType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.local\.?$/, '').replace(/\.$/, '');
const cleanName = (valueArg: string | undefined): string | undefined => {
return valueArg
?.replace(/\._musc\._tcp\.local\.?$/i, '')
.replace(/\.local\.?$/i, '')
.trim() || undefined;
};
const valueForKey = (recordArg: Record<string, string | undefined> | undefined, keyArg: string): string | undefined => {
if (!recordArg) {
return undefined;
}
const lowerKey = keyArg.toLowerCase();
for (const [key, value] of Object.entries(recordArg)) {
if (key.toLowerCase() === lowerKey) {
return value;
}
}
return undefined;
};
const numberString = (valueArg: string | undefined): number | undefined => {
if (!valueArg) {
return undefined;
}
const value = Number(valueArg);
return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined;
};
@@ -0,0 +1,340 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity } from '../../core/types.js';
import type { IBluesoundPlayerSnapshot, IBluesoundSnapshot, IBluesoundStatus } from './bluesound.types.js';
import { bluesoundDefaultPort } from './bluesound.types.js';
export class BluesoundMapper {
public static toDevices(snapshotArg: IBluesoundSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
return snapshotArg.players.map((playerArg) => {
const status = playerArg.status;
return {
id: this.playerDeviceId(playerArg),
integrationDomain: 'bluesound',
name: playerArg.syncStatus.name,
protocol: 'http',
manufacturer: playerArg.syncStatus.brand || 'Bluesound',
model: playerArg.syncStatus.modelName || playerArg.syncStatus.model,
online: this.playerAvailable(playerArg),
features: [
{ id: 'playback', capability: 'media', name: 'Playback', 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: 'source', capability: 'media', name: 'Source', readable: true, writable: true },
{ id: 'presets', capability: 'media', name: 'Presets', readable: true, writable: true },
{ id: 'group', capability: 'media', name: 'Group', readable: true, writable: true },
{ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false },
{ id: 'media_position', capability: 'media', name: 'Media position', readable: true, writable: true, unit: 's' },
{ id: 'media_duration', capability: 'media', name: 'Media duration', readable: true, writable: false, unit: 's' },
{ id: 'shuffle', capability: 'media', name: 'Shuffle', readable: true, writable: true },
],
state: [
{ featureId: 'playback', value: this.mediaState(playerArg), updatedAt },
{ featureId: 'volume', value: this.volumePercent(playerArg) ?? null, updatedAt },
{ featureId: 'muted', value: this.muted(playerArg) ?? null, updatedAt },
{ featureId: 'source', value: this.currentSource(playerArg) || null, updatedAt },
{ featureId: 'presets', value: playerArg.presets?.length || 0, updatedAt },
{ featureId: 'group', value: playerArg.syncStatus.group || status.groupName || null, updatedAt },
{ featureId: 'current_title', value: this.mediaTitle(playerArg) || null, updatedAt },
{ featureId: 'media_position', value: this.mediaPosition(status) ?? null, updatedAt },
{ featureId: 'media_duration', value: this.seconds(status.totalSeconds) ?? null, updatedAt },
{ featureId: 'shuffle', value: status.shuffle ?? null, updatedAt },
],
metadata: {
playerId: playerArg.syncStatus.id,
macAddress: formatMac(playerArg.syncStatus.mac),
host: playerArg.host,
port: playerArg.port || bluesoundDefaultPort,
modelId: playerArg.syncStatus.model,
groupRole: this.groupRole(playerArg),
source: snapshotArg.source,
},
};
});
}
public static toEntities(snapshotArg: IBluesoundSnapshot): IIntegrationEntity[] {
const entities: IIntegrationEntity[] = [];
for (const player of snapshotArg.players) {
const base = this.playerEntityBase(player);
const sourceList = this.sourceList(player);
const presets = player.presets || [];
const inputs = player.inputs || [];
entities.push({
id: this.playerEntityId(player),
uniqueId: `bluesound_${this.slug(this.formatUniqueId(player))}`,
integrationDomain: 'bluesound',
deviceId: this.playerDeviceId(player),
platform: 'media_player',
name: player.syncStatus.name,
state: this.mediaState(player),
attributes: {
deviceClass: 'speaker',
playerId: player.syncStatus.id,
bluesoundDeviceName: player.syncStatus.name,
macAddress: formatMac(player.syncStatus.mac),
host: player.host,
port: player.port || bluesoundDefaultPort,
brand: player.syncStatus.brand,
model: player.syncStatus.model,
modelName: player.syncStatus.modelName,
initialized: player.syncStatus.initialized,
volumeLevel: this.volumeLevel(player),
volume: this.volumePercent(player),
volumeDb: this.isGrouped(player) ? player.syncStatus.volumeDb : player.status.volumeDb,
isVolumeMuted: this.muted(player),
source: this.currentSource(player),
sourceList,
presets,
inputs,
mediaContentType: 'music',
mediaContentId: player.status.streamUrl,
mediaTitle: this.mediaTitle(player),
mediaArtist: this.mediaArtist(player),
mediaAlbumName: this.isGroupedFollower(player) ? undefined : player.status.album,
mediaDuration: this.isGroupedFollower(player) ? undefined : this.seconds(player.status.totalSeconds),
mediaPosition: this.isGroupedFollower(player) ? undefined : this.mediaPosition(player.status),
mediaImageUrl: this.mediaImageUrl(player),
canSeek: player.status.canSeek,
shuffle: player.status.shuffle,
sleep: player.status.sleep,
indexing: player.status.indexing,
groupName: player.syncStatus.group || player.status.groupName,
groupRole: this.groupRole(player),
groupMembers: this.groupMembers(snapshotArg, player),
bluesoundGroup: this.bluesoundGroupNames(snapshotArg, player),
master: this.isLeader(player),
leader: player.syncStatus.leader,
followers: player.syncStatus.followers,
zone: player.syncStatus.zone,
zoneLeader: player.syncStatus.zoneLeader,
zoneFollower: player.syncStatus.zoneFollower,
},
available: this.playerAvailable(player),
});
entities.push({
id: `sensor.${base}_bluesound_presets`,
uniqueId: `bluesound_${this.slug(this.formatUniqueId(player))}_presets`,
integrationDomain: 'bluesound',
deviceId: this.playerDeviceId(player),
platform: 'sensor',
name: `${player.syncStatus.name} Bluesound Presets`,
state: presets.length,
attributes: { playerId: player.syncStatus.id, presets },
available: this.playerAvailable(player),
});
entities.push({
id: `sensor.${base}_bluesound_sources`,
uniqueId: `bluesound_${this.slug(this.formatUniqueId(player))}_sources`,
integrationDomain: 'bluesound',
deviceId: this.playerDeviceId(player),
platform: 'sensor',
name: `${player.syncStatus.name} Bluesound Sources`,
state: sourceList.length,
attributes: { playerId: player.syncStatus.id, sourceList, inputs, presets },
available: this.playerAvailable(player),
});
}
return entities;
}
public static entityPlayerId(snapshotArg: IBluesoundSnapshot, entityIdArg: string): string | undefined {
const entity = this.toEntities(snapshotArg).find((entityArg) => entityArg.id === entityIdArg);
const playerId = entity?.attributes?.playerId;
return typeof playerId === 'string' ? playerId : undefined;
}
public static playerEntityId(playerArg: IBluesoundPlayerSnapshot): string {
return `media_player.${this.playerEntityBase(playerArg)}`;
}
public static playerDeviceId(playerArg: IBluesoundPlayerSnapshot): string {
return `bluesound.player.${this.slug(this.deviceUniqueBase(playerArg))}`;
}
public static slug(valueArg: string | undefined): string {
return (valueArg || 'bluesound').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'bluesound';
}
public static sourceList(playerArg: IBluesoundPlayerSnapshot): string[] {
const sources = [
...(playerArg.presets || []).map((presetArg) => presetArg.name),
...(playerArg.inputs || []).map((inputArg) => inputArg.text || inputArg.id),
].filter((valueArg): valueArg is string => Boolean(valueArg));
return [...new Set(sources)];
}
private static mediaState(playerArg: IBluesoundPlayerSnapshot): string {
if (!this.playerAvailable(playerArg)) {
return 'off';
}
if (this.isGroupedFollower(playerArg)) {
return 'idle';
}
if (playerArg.status.state === 'pause') {
return 'paused';
}
if (playerArg.status.state === 'play' || playerArg.status.state === 'stream') {
return 'playing';
}
return 'idle';
}
private static currentSource(playerArg: IBluesoundPlayerSnapshot): string | undefined {
if (this.isGroupedFollower(playerArg)) {
return undefined;
}
const status = playerArg.status;
if (status.inputId) {
const input = (playerArg.inputs || []).find((inputArg) => inputArg.id === status.inputId || inputArg.url === status.streamUrl);
if (input) {
return input.text || input.id;
}
}
const preset = (playerArg.presets || []).find((presetArg) => presetArg.url === status.streamUrl);
if (preset) {
return preset.name;
}
return status.service;
}
private static groupMembers(snapshotArg: IBluesoundSnapshot, playerArg: IBluesoundPlayerSnapshot): string[] | undefined {
const leader = this.leaderPlayer(snapshotArg, playerArg);
if (!leader?.syncStatus.followers?.length) {
return undefined;
}
const ids = [leader.syncStatus.id, ...leader.syncStatus.followers.map((followerArg) => pairedPlayerId(followerArg))];
return ids
.map((idArg) => snapshotArg.players.find((candidateArg) => candidateArg.syncStatus.id === idArg))
.filter((candidateArg): candidateArg is IBluesoundPlayerSnapshot => Boolean(candidateArg))
.map((candidateArg) => this.playerEntityId(candidateArg));
}
private static bluesoundGroupNames(snapshotArg: IBluesoundSnapshot, playerArg: IBluesoundPlayerSnapshot): string[] | undefined {
const members = this.groupMembers(snapshotArg, playerArg);
if (!members?.length) {
return undefined;
}
return members
.map((entityIdArg) => snapshotArg.players.find((candidateArg) => this.playerEntityId(candidateArg) === entityIdArg)?.syncStatus.name)
.filter((valueArg): valueArg is string => Boolean(valueArg));
}
private static leaderPlayer(snapshotArg: IBluesoundSnapshot, playerArg: IBluesoundPlayerSnapshot): IBluesoundPlayerSnapshot | undefined {
if (this.isLeader(playerArg)) {
return playerArg;
}
const leaderId = playerArg.syncStatus.leader ? pairedPlayerId(playerArg.syncStatus.leader) : undefined;
return leaderId ? snapshotArg.players.find((candidateArg) => candidateArg.syncStatus.id === leaderId) : undefined;
}
private static mediaTitle(playerArg: IBluesoundPlayerSnapshot): string | undefined {
return this.isGroupedFollower(playerArg) ? undefined : playerArg.status.name;
}
private static mediaArtist(playerArg: IBluesoundPlayerSnapshot): string | undefined {
if (this.isGroupedFollower(playerArg)) {
return playerArg.syncStatus.group || playerArg.status.groupName;
}
return playerArg.status.artist;
}
private static mediaImageUrl(playerArg: IBluesoundPlayerSnapshot): string | undefined {
if (this.isGroupedFollower(playerArg)) {
return undefined;
}
const image = playerArg.status.image;
if (!image) {
return undefined;
}
if (!image.startsWith('/')) {
return image;
}
const host = playerArg.host;
if (!host) {
return image;
}
return `http://${formatHost(host)}:${playerArg.port || bluesoundDefaultPort}${image}`;
}
private static volumeLevel(playerArg: IBluesoundPlayerSnapshot): number | undefined {
const volume = this.volumePercent(playerArg);
return typeof volume === 'number' && volume >= 0 ? volume / 100 : undefined;
}
private static volumePercent(playerArg: IBluesoundPlayerSnapshot): number | undefined {
const volume = this.isGrouped(playerArg) ? playerArg.syncStatus.volume : playerArg.status.volume;
return typeof volume === 'number' && volume >= 0 ? Math.max(0, Math.min(100, Math.round(volume))) : undefined;
}
private static muted(playerArg: IBluesoundPlayerSnapshot): boolean | undefined {
if (this.isGrouped(playerArg)) {
return playerArg.syncStatus.muteVolume !== undefined;
}
return playerArg.status.mute;
}
private static mediaPosition(statusArg: IBluesoundStatus): number | undefined {
return this.seconds(statusArg.seconds);
}
private static seconds(valueArg: number | undefined): number | undefined {
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? Math.floor(valueArg) : undefined;
}
private static groupRole(playerArg: IBluesoundPlayerSnapshot): string | undefined {
if (this.isLeader(playerArg)) {
return 'leader';
}
if (this.isGroupedFollower(playerArg)) {
return 'member';
}
return undefined;
}
private static isGrouped(playerArg: IBluesoundPlayerSnapshot): boolean {
return this.isLeader(playerArg) || this.isGroupedFollower(playerArg);
}
private static isLeader(playerArg: IBluesoundPlayerSnapshot): boolean {
return Boolean(playerArg.syncStatus.followers?.length);
}
private static isGroupedFollower(playerArg: IBluesoundPlayerSnapshot): boolean {
return Boolean(playerArg.syncStatus.leader);
}
private static playerAvailable(playerArg: IBluesoundPlayerSnapshot): boolean {
return playerArg.available !== false;
}
private static playerEntityBase(playerArg: IBluesoundPlayerSnapshot): string {
return this.slug(playerArg.syncStatus.name || playerArg.syncStatus.id);
}
private static deviceUniqueBase(playerArg: IBluesoundPlayerSnapshot): string {
const mac = formatMac(playerArg.syncStatus.mac) || playerArg.syncStatus.id;
const port = playerArg.port || bluesoundDefaultPort;
return port === bluesoundDefaultPort ? mac : `${mac}-${port}`;
}
private static formatUniqueId(playerArg: IBluesoundPlayerSnapshot): string {
return `${formatMac(playerArg.syncStatus.mac) || playerArg.syncStatus.id}-${playerArg.port || bluesoundDefaultPort}`;
}
}
export const formatBluesoundUniqueId = (macArg: string, portArg: number): string => `${formatMac(macArg) || macArg}-${portArg}`;
const pairedPlayerId = (playerArg: { ip: string; port: number }): string => `${playerArg.ip}:${playerArg.port}`;
const formatMac = (valueArg: string | undefined): string => {
const clean = (valueArg || '').toLowerCase().replace(/[^a-f0-9]/g, '');
if (clean.length !== 12) {
return valueArg?.toLowerCase() || '';
}
return clean.match(/.{1,2}/g)?.join(':') || clean;
};
const formatHost = (hostArg: string): string => hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
+187 -3
View File
@@ -1,4 +1,188 @@
export interface IHomeAssistantBluesoundConfig {
// TODO: replace with the TypeScript-native config for bluesound.
[key: string]: unknown;
export const bluesoundDefaultPort = 11000;
export const bluesoundDefaultTimeoutMs = 5000;
export type TBluesoundSnapshotSource = 'snapshot' | 'http' | 'executor' | 'manual' | 'runtime';
export type TBluesoundPlaybackState = 'play' | 'stream' | 'pause' | 'stop' | 'unknown' | (string & {});
export type TBluesoundCommand =
| 'play'
| 'pause'
| 'stop'
| 'next_track'
| 'previous_track'
| 'seek'
| 'set_volume'
| 'mute'
| 'select_source'
| 'play_media'
| 'play_preset'
| 'clear_playlist'
| 'shuffle'
| 'join'
| 'unjoin'
| 'add_follower'
| 'remove_follower';
export interface IBluesoundConfig {
host?: string;
port?: number;
name?: string;
macAddress?: string;
manufacturer?: string;
model?: string;
modelName?: string;
timeoutMs?: number;
snapshot?: IBluesoundSnapshot;
commandExecutor?: IBluesoundCommandExecutor;
}
export interface IHomeAssistantBluesoundConfig extends IBluesoundConfig {}
export interface IBluesoundCommandExecutor {
execute(requestArg: IBluesoundRawCommandRequest): Promise<IBluesoundRawCommandResponse | Record<string, unknown> | unknown>;
}
export interface IBluesoundRawCommandRequest {
method: 'GET';
command: string;
path: string;
parameters: Record<string, string | number | boolean>;
url?: string;
host?: string;
port: number;
}
export interface IBluesoundRawCommandResponse {
status?: number;
body?: string;
data?: unknown;
}
export interface IBluesoundCommandRequest {
command: TBluesoundCommand;
playerId?: string;
playerIds?: string[];
followers?: IBluesoundPairedPlayer[];
follower?: IBluesoundPairedPlayer;
source?: string;
presetId?: number;
presetName?: string;
mediaId?: string;
mediaType?: string;
volumeLevel?: number;
volume?: number;
muted?: boolean;
shuffle?: boolean;
position?: number;
}
export interface IBluesoundPairedPlayer {
ip: string;
port: number;
}
export interface IBluesoundStatus {
etag?: string;
inputId?: string;
service?: string;
state: TBluesoundPlaybackState;
shuffle?: boolean;
album?: string;
artist?: string;
name?: string;
image?: string;
volume?: number;
volumeDb?: number;
mute?: boolean;
muteVolume?: number;
muteVolumeDb?: number;
seconds?: number;
totalSeconds?: number;
canSeek?: boolean;
sleep?: number;
groupName?: string;
groupVolume?: number;
indexing?: boolean;
streamUrl?: string;
raw?: Record<string, unknown>;
}
export interface IBluesoundSyncStatus {
etag?: string;
id: string;
mac: string;
name: string;
image?: string;
initialized?: boolean;
group?: string;
leader?: IBluesoundPairedPlayer;
followers?: IBluesoundPairedPlayer[];
zone?: string;
zoneLeader?: boolean;
zoneFollower?: boolean;
brand?: string;
model?: string;
modelName?: string;
muteVolumeDb?: number;
muteVolume?: number;
volumeDb?: number;
volume?: number;
raw?: Record<string, unknown>;
}
export interface IBluesoundPreset {
name: string;
id: number;
url: string;
image?: string;
volume?: number;
}
export interface IBluesoundInput {
id?: string;
text?: string;
image?: string;
url: string;
}
export interface IBluesoundPlayerSnapshot {
host?: string;
port?: number;
syncStatus: IBluesoundSyncStatus;
status: IBluesoundStatus;
presets?: IBluesoundPreset[];
inputs?: IBluesoundInput[];
available?: boolean;
updatedAt?: string;
}
export interface IBluesoundSnapshot {
players: IBluesoundPlayerSnapshot[];
online: boolean;
updatedAt?: string;
source?: TBluesoundSnapshotSource;
error?: string;
}
export interface IBluesoundMdnsRecord {
name?: string;
type?: string;
serviceType?: string;
host?: string;
hostname?: string;
port?: number;
addresses?: string[];
txt?: Record<string, string | undefined>;
properties?: Record<string, string | undefined>;
}
export interface IBluesoundManualEntry {
host?: string;
port?: number;
id?: string;
name?: string;
manufacturer?: string;
model?: string;
modelName?: string;
macAddress?: string;
metadata?: Record<string, unknown>;
}
+4
View File
@@ -1,2 +1,6 @@
export * from './bluesound.classes.client.js';
export * from './bluesound.classes.configflow.js';
export * from './bluesound.classes.integration.js';
export * from './bluesound.discovery.js';
export * from './bluesound.mapper.js';
export * from './bluesound.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,440 @@
import type {
IDunehdCommandRequest,
IDunehdConfig,
IDunehdDeviceInfo,
IDunehdRawCommandRequest,
IDunehdSnapshot,
IDunehdState,
} from './dunehd.types.js';
import { dunehdDefaultName, dunehdDefaultPort } from './dunehd.types.js';
const playbackSpeedPlay = 256;
const playbackSpeedPause = 0;
const playbackSpeedFastForward = 512;
const playbackSpeedRewind = -512;
const defaultTimeoutMs = 5000;
const irCodes = {
turnOn: 'A05FBF00',
turnOff: 'A15EBF00',
previousTrack: 'B649BF00',
nextTrack: 'E21DBF00',
} as const;
export class DunehdTransportError extends Error {
constructor(messageArg: string) {
super(messageArg);
this.name = 'DunehdTransportError';
}
}
export class DunehdClient {
private currentSnapshot?: IDunehdSnapshot;
constructor(private readonly config: IDunehdConfig) {
this.currentSnapshot = config.snapshot ? this.normalizeSnapshot(this.cloneValue(config.snapshot)) : undefined;
}
public async getSnapshot(): Promise<IDunehdSnapshot> {
if (this.currentSnapshot) {
return this.normalizeSnapshot(this.cloneValue(this.currentSnapshot));
}
if (!this.config.host && !this.config.commandExecutor) {
return this.normalizeSnapshot(this.snapshotFromConfig());
}
const state = await this.updateState().catch(() => ({}));
return this.normalizeSnapshot({
deviceInfo: this.deviceInfoFromConfig(),
state,
online: Object.keys(state).length > 0,
updatedAt: new Date().toISOString(),
});
}
public async validateConnection(): Promise<IDunehdSnapshot> {
const state = await this.updateState();
if (!Object.keys(state).length) {
throw new DunehdTransportError('Dune HD did not return status data.');
}
return this.normalizeSnapshot({
deviceInfo: this.deviceInfoFromConfig(),
state,
online: true,
updatedAt: new Date().toISOString(),
});
}
public async updateState(): Promise<IDunehdState> {
return this.sendCommand('status');
}
public async execute(requestArg: IDunehdCommandRequest): Promise<IDunehdState> {
if (requestArg.command === 'turn_on') {
return this.turnOn();
}
if (requestArg.command === 'turn_off') {
return this.turnOff();
}
if (requestArg.command === 'play') {
return this.play();
}
if (requestArg.command === 'pause') {
return this.pause();
}
if (requestArg.command === 'stop') {
return this.stop();
}
if (requestArg.command === 'previous_track') {
return this.previousTrack();
}
if (requestArg.command === 'next_track') {
return this.nextTrack();
}
if (requestArg.command === 'volume_up') {
return this.volumeUp();
}
if (requestArg.command === 'volume_down') {
return this.volumeDown();
}
if (requestArg.command === 'mute') {
if (typeof requestArg.muted !== 'boolean') {
throw new Error('Dune HD mute command requires muted.');
}
return this.mute(requestArg.muted);
}
if (requestArg.command === 'play_media') {
if (!requestArg.mediaUrl) {
throw new Error('Dune HD play_media requires mediaUrl.');
}
return this.launchMediaUrl(requestArg.mediaUrl);
}
if (requestArg.command === 'remote') {
if (!requestArg.remoteCommand) {
throw new Error('Dune HD remote command requires remoteCommand.');
}
return this.sendRemoteCommand(requestArg.remoteCommand);
}
throw new Error(`Unsupported Dune HD command: ${requestArg.command}`);
}
public async launchMediaUrl(mediaUrlArg: string): Promise<IDunehdState> {
return this.sendCommand('launch_media_url', { media_url: mediaUrlArg });
}
public async play(): Promise<IDunehdState> {
return this.changePlaybackSpeed(playbackSpeedPlay);
}
public async pause(): Promise<IDunehdState> {
return this.changePlaybackSpeed(playbackSpeedPause);
}
public async fastForward(): Promise<IDunehdState> {
return this.changePlaybackSpeed(playbackSpeedFastForward);
}
public async rewind(): Promise<IDunehdState> {
return this.changePlaybackSpeed(playbackSpeedRewind);
}
public async stop(): Promise<IDunehdState> {
return this.sendCommand('standby');
}
public async turnOn(): Promise<IDunehdState> {
return this.sendIrCode(irCodes.turnOn);
}
public async turnOff(): Promise<IDunehdState> {
return this.sendIrCode(irCodes.turnOff);
}
public async previousTrack(): Promise<IDunehdState> {
return this.sendIrCode(irCodes.previousTrack);
}
public async nextTrack(): Promise<IDunehdState> {
return this.sendIrCode(irCodes.nextTrack);
}
public async volumeUp(): Promise<IDunehdState> {
const state = await this.updateState();
return this.setVolume(Math.min(100, this.numberValue(state.playback_volume, 0) + 10));
}
public async volumeDown(): Promise<IDunehdState> {
const state = await this.updateState();
return this.setVolume(Math.max(0, this.numberValue(state.playback_volume, 0) - 10));
}
public async setVolume(volumePercentArg: number): Promise<IDunehdState> {
return this.sendCommand('set_playback_state', { volume: Math.max(0, Math.min(100, Math.round(volumePercentArg))) });
}
public async mute(mutedArg: boolean): Promise<IDunehdState> {
return this.sendCommand('set_playback_state', { mute: mutedArg ? 1 : 0 });
}
public async sendRemoteCommand(commandArg: string): Promise<IDunehdState> {
const command = commandArg.trim();
if (!command) {
throw new Error('Dune HD remote command cannot be empty.');
}
const normalized = command.replace(/[_\s-]+/g, '').toLowerCase();
const aliases: Record<string, () => Promise<IDunehdState>> = {
on: () => this.turnOn(),
poweron: () => this.turnOn(),
turnon: () => this.turnOn(),
off: () => this.turnOff(),
poweroff: () => this.turnOff(),
turnoff: () => this.turnOff(),
play: () => this.play(),
pause: () => this.pause(),
stop: () => this.stop(),
previous: () => this.previousTrack(),
previoustrack: () => this.previousTrack(),
prev: () => this.previousTrack(),
next: () => this.nextTrack(),
nexttrack: () => this.nextTrack(),
volumeup: () => this.volumeUp(),
volup: () => this.volumeUp(),
volumedown: () => this.volumeDown(),
voldown: () => this.volumeDown(),
mute: () => this.mute(true),
unmute: () => this.mute(false),
};
const alias = aliases[normalized];
if (alias) {
return alias();
}
if (/^[a-f0-9]{8}$/i.test(command)) {
return this.sendIrCode(command.toUpperCase());
}
throw new Error(`Unsupported Dune HD remote command: ${command}`);
}
public parseStatus(statusArg: string): IDunehdState {
const state: IDunehdState = {};
this.readStatusAttributes(statusArg).forEach((attrsArg) => {
const name = attrsArg.name;
if (name) {
state[name] = attrsArg.value || '';
}
});
return state;
}
public async destroy(): Promise<void> {}
private async sendIrCode(codeArg: string): Promise<IDunehdState> {
return this.sendCommand('ir_code', { ir_code: codeArg });
}
private async changePlaybackSpeed(speedArg: number): Promise<IDunehdState> {
return this.sendCommand('set_playback_state', { speed: speedArg });
}
private async sendCommand(cmdArg: string, paramsArg: Record<string, string | number | boolean> = {}): Promise<IDunehdState> {
const request = this.rawRequest(cmdArg, paramsArg);
if (this.config.commandExecutor) {
const result = await this.config.commandExecutor.execute(request);
const state = this.resultToState(result);
this.updateCurrentSnapshot(state);
return state;
}
const state = await this.requestHttp(request);
this.updateCurrentSnapshot(state);
return state;
}
private async requestHttp(requestArg: IDunehdRawCommandRequest): Promise<IDunehdState> {
if (!this.config.host) {
throw new DunehdTransportError('Dune HD command transport requires config.host or commandExecutor.');
}
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || defaultTimeoutMs);
try {
const response = await globalThis.fetch(requestArg.uri, { method: requestArg.method, signal: abortController.signal });
const text = await response.text();
if (!response.ok) {
throw new DunehdTransportError(`Dune HD request ${requestArg.cmd} failed with HTTP ${response.status}: ${text}`);
}
return this.parseStatus(text);
} catch (errorArg) {
if (errorArg instanceof DunehdTransportError) {
throw errorArg;
}
if (errorArg instanceof Error && errorArg.name === 'AbortError') {
throw new DunehdTransportError(`Dune HD request ${requestArg.cmd} timed out after ${this.config.timeoutMs || defaultTimeoutMs}ms.`);
}
throw new DunehdTransportError(`Dune HD request ${requestArg.cmd} failed: ${errorArg instanceof Error ? errorArg.message : String(errorArg)}`);
} finally {
clearTimeout(timeout);
}
}
private rawRequest(cmdArg: string, paramsArg: Record<string, string | number | boolean>): IDunehdRawCommandRequest {
return {
cmd: cmdArg,
params: { ...paramsArg },
uri: this.commandUri(cmdArg, paramsArg),
host: this.config.host,
port: this.config.port || dunehdDefaultPort,
method: 'GET',
};
}
private commandUri(cmdArg: string, paramsArg: Record<string, string | number | boolean>): string {
const searchParams = new URLSearchParams();
searchParams.set('cmd', cmdArg);
for (const [key, value] of Object.entries(paramsArg)) {
searchParams.set(key, String(value));
}
if (!this.config.host) {
return `/cgi-bin/do?${searchParams.toString()}`;
}
const port = this.config.port && this.config.port !== dunehdDefaultPort ? `:${this.config.port}` : '';
return `http://${this.config.host}${port}/cgi-bin/do?${searchParams.toString()}`;
}
private resultToState(resultArg: unknown): IDunehdState {
if (typeof resultArg === 'string') {
return this.parseStatus(resultArg);
}
if (this.isRecord(resultArg)) {
if (this.isRecord(resultArg.state)) {
return this.recordToState(resultArg.state);
}
return this.recordToState(resultArg);
}
return {};
}
private recordToState(recordArg: Record<string, unknown>): IDunehdState {
const state: IDunehdState = {};
for (const [key, value] of Object.entries(recordArg)) {
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value === undefined) {
state[key] = value;
}
}
return state;
}
private updateCurrentSnapshot(stateArg: IDunehdState): void {
if (!this.currentSnapshot) {
if (this.config.snapshot) {
this.currentSnapshot = this.normalizeSnapshot(this.config.snapshot);
} else if (this.config.state) {
this.currentSnapshot = this.snapshotFromConfig();
}
}
if (!this.currentSnapshot) {
return;
}
this.currentSnapshot.state = { ...this.currentSnapshot.state, ...stateArg };
this.currentSnapshot.online = Object.keys(this.currentSnapshot.state).length > 0;
this.currentSnapshot.updatedAt = new Date().toISOString();
}
private snapshotFromConfig(): IDunehdSnapshot {
const state = { ...(this.config.state || {}) };
return {
deviceInfo: this.deviceInfoFromConfig(),
state,
online: Object.keys(state).length > 0,
updatedAt: new Date().toISOString(),
};
}
private deviceInfoFromConfig(): IDunehdDeviceInfo {
return {
...this.config.deviceInfo,
id: this.config.deviceInfo?.id || this.config.uniqueId || this.config.serialNumber || this.config.macAddress || this.config.host,
host: this.config.deviceInfo?.host || this.config.host,
port: this.config.deviceInfo?.port || this.config.port || dunehdDefaultPort,
name: this.config.deviceInfo?.name || this.config.name || this.config.host || dunehdDefaultName,
manufacturer: this.config.deviceInfo?.manufacturer || this.config.manufacturer || 'Dune',
model: this.config.deviceInfo?.model || this.config.model,
serialNumber: this.config.deviceInfo?.serialNumber || this.config.serialNumber,
macAddress: this.config.deviceInfo?.macAddress || this.config.macAddress,
};
}
private normalizeSnapshot(snapshotArg: IDunehdSnapshot): IDunehdSnapshot {
const state = { ...(snapshotArg.state || {}) };
const deviceInfo = {
...this.deviceInfoFromConfig(),
...snapshotArg.deviceInfo,
};
return {
...snapshotArg,
deviceInfo,
state,
online: snapshotArg.online ?? Object.keys(state).length > 0,
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
};
}
private readStatusAttributes(statusArg: string): Array<Record<string, string>> {
const result: Array<Record<string, string>> = [];
const tagRegex = /<[^>]+>/g;
let tagMatch: RegExpExecArray | null;
while ((tagMatch = tagRegex.exec(statusArg))) {
const attrs = this.readAttributes(tagMatch[0]);
if (attrs.name !== undefined && attrs.value !== undefined) {
result.push(attrs);
}
}
const lineRegex = /name=(['"])(.*?)\1\s+value=(['"])(.*?)\3/g;
let lineMatch: RegExpExecArray | null;
while ((lineMatch = lineRegex.exec(statusArg))) {
result.push({ name: this.decodeHtml(lineMatch[2]), value: this.decodeHtml(lineMatch[4]) });
}
return result;
}
private readAttributes(valueArg: string): Record<string, string> {
const attrs: Record<string, string> = {};
const attrRegex = /([a-zA-Z0-9_-]+)=(['"])(.*?)\2/g;
let attrMatch: RegExpExecArray | null;
while ((attrMatch = attrRegex.exec(valueArg))) {
attrs[attrMatch[1]] = this.decodeHtml(attrMatch[3]);
}
return attrs;
}
private decodeHtml(valueArg: string): string {
return valueArg
.replace(/&#(\d+);/g, (_matchArg, codeArg: string) => String.fromCharCode(Number(codeArg)))
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/g, '&');
}
private numberValue(valueArg: unknown, fallbackArg: number): number {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) ? parsed : fallbackArg;
}
return fallbackArg;
}
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
}
private cloneValue<TValue>(valueArg: TValue): TValue {
return valueArg === undefined ? valueArg : JSON.parse(JSON.stringify(valueArg)) as TValue;
}
}
@@ -0,0 +1,79 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IDunehdConfig } from './dunehd.types.js';
import { dunehdDefaultName, dunehdDefaultPort } from './dunehd.types.js';
export class DunehdConfigFlow implements IConfigFlow<IDunehdConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IDunehdConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect Dune HD',
description: 'Configure the local Dune HD HTTP CGI endpoint. Ensure the player is powered on before validating live control.',
fields: [
{ name: 'host', label: 'Host', type: 'text', required: true },
{ name: 'port', label: 'HTTP port', type: 'number' },
{ name: 'name', label: 'Name', type: 'text' },
{ name: 'model', label: 'Model', type: 'text' },
],
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
};
}
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IDunehdConfig>> {
const host = this.stringValue(valuesArg.host) || candidateArg.host;
if (!host) {
return { kind: 'error', error: 'Dune HD host is required.' };
}
if (!this.isHostValid(host)) {
return { kind: 'error', error: 'Dune HD host is invalid.' };
}
const port = this.numberValue(valuesArg.port) || candidateArg.port || dunehdDefaultPort;
const name = this.stringValue(valuesArg.name) || candidateArg.name || dunehdDefaultName;
const model = this.stringValue(valuesArg.model) || candidateArg.model;
return {
kind: 'done',
title: 'Dune HD configured',
config: {
host,
port,
name,
model,
manufacturer: candidateArg.manufacturer || 'Dune',
uniqueId: candidateArg.id || candidateArg.serialNumber || candidateArg.macAddress || host,
serialNumber: candidateArg.serialNumber,
macAddress: candidateArg.macAddress,
deviceInfo: {
id: candidateArg.id || candidateArg.serialNumber || candidateArg.macAddress || host,
host,
port,
name,
manufacturer: candidateArg.manufacturer || 'Dune',
model,
serialNumber: candidateArg.serialNumber,
macAddress: candidateArg.macAddress,
},
timeoutMs: 5000,
},
};
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
private numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const value = Number(valueArg);
return Number.isFinite(value) ? value : undefined;
}
return undefined;
}
private isHostValid(hostArg: string): boolean {
return hostArg.length <= 253 && !/[\s/]/.test(hostArg) && !hostArg.includes('://');
}
}
@@ -1,24 +1,181 @@
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 { DunehdClient } from './dunehd.classes.client.js';
import { DunehdConfigFlow } from './dunehd.classes.configflow.js';
import { createDunehdDiscoveryDescriptor } from './dunehd.discovery.js';
import { DunehdMapper } from './dunehd.mapper.js';
import type { IDunehdCommandRequest, IDunehdConfig, TDunehdPlaybackCommand } from './dunehd.types.js';
export class HomeAssistantDunehdIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "dunehd",
displayName: "Dune HD",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/dunehd",
"upstreamDomain": "dunehd",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"pdunehd==1.3.2"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": []
},
});
export class DunehdIntegration extends BaseIntegration<IDunehdConfig> {
public readonly domain = 'dunehd';
public readonly displayName = 'Dune HD';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createDunehdDiscoveryDescriptor();
public readonly configFlow = new DunehdConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/dunehd',
upstreamDomain: 'dunehd',
integrationType: 'device',
iotClass: 'local_polling',
requirements: ['pdunehd==1.3.2'],
dependencies: [],
afterDependencies: [],
codeowners: [],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/dunehd',
};
public async setup(configArg: IDunehdConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new DunehdRuntime(new DunehdClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantDunehdIntegration extends DunehdIntegration {}
class DunehdRuntime implements IIntegrationRuntime {
public domain = 'dunehd';
constructor(private readonly client: DunehdClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return DunehdMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return DunehdMapper.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 === 'dunehd') {
return await this.callDunehdService(requestArg);
}
if (requestArg.domain !== 'media_player') {
return { success: false, error: `Unsupported Dune HD service domain: ${requestArg.domain}` };
}
return await this.callMediaPlayerService(requestArg);
} catch (errorArg) {
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
}
}
public async destroy(): Promise<void> {
await this.client.destroy();
}
private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
const command = this.commandFromMediaService(requestArg.service);
if (!command) {
return { success: false, error: `Unsupported Dune HD media_player service: ${requestArg.service}` };
}
const result = await this.client.execute(this.commandRequest(command, requestArg));
return { success: true, data: result };
}
private async callRemoteService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service !== 'send_command') {
return { success: false, error: `Unsupported Dune HD remote service: ${requestArg.service}` };
}
const commands = this.stringArrayData(requestArg, 'command');
if (!commands?.length) {
return { success: false, error: 'Dune HD remote.send_command requires data.command.' };
}
const repeatsValue = requestArg.data?.num_repeats ?? requestArg.data?.numRepeats ?? 1;
const repeats = typeof repeatsValue === 'number' && Number.isFinite(repeatsValue) ? Math.max(1, Math.floor(repeatsValue)) : 1;
let result: unknown;
for (let index = 0; index < repeats; index += 1) {
for (const command of commands) {
result = await this.client.execute({ command: 'remote', remoteCommand: command });
}
}
return { success: true, data: result };
}
private async callDunehdService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service === 'send_ir_code') {
const code = this.stringData(requestArg, 'ir_code') || this.stringData(requestArg, 'code');
if (!code) {
return { success: false, error: 'Dune HD send_ir_code requires data.ir_code.' };
}
return { success: true, data: await this.client.execute({ command: 'remote', remoteCommand: code }) };
}
if (requestArg.service === 'status') {
return { success: true, data: await this.client.updateState() };
}
return { success: false, error: `Unsupported Dune HD service: ${requestArg.service}` };
}
private commandFromMediaService(serviceArg: string): TDunehdPlaybackCommand | undefined {
if (serviceArg === 'turn_on') {
return 'turn_on';
}
if (serviceArg === 'turn_off') {
return 'turn_off';
}
if (serviceArg === 'play' || serviceArg === 'media_play') {
return 'play';
}
if (serviceArg === 'pause' || serviceArg === 'media_pause') {
return 'pause';
}
if (serviceArg === 'stop' || serviceArg === 'media_stop') {
return 'stop';
}
if (serviceArg === 'previous_track' || serviceArg === 'media_previous_track') {
return 'previous_track';
}
if (serviceArg === 'next_track' || serviceArg === 'media_next_track') {
return 'next_track';
}
if (serviceArg === 'volume_up') {
return 'volume_up';
}
if (serviceArg === 'volume_down') {
return 'volume_down';
}
if (serviceArg === 'volume_mute' || serviceArg === 'mute') {
return 'mute';
}
if (serviceArg === 'play_media') {
return 'play_media';
}
return undefined;
}
private commandRequest(commandArg: TDunehdPlaybackCommand, requestArg: IServiceCallRequest): IDunehdCommandRequest {
const result: IDunehdCommandRequest = { command: commandArg };
if (commandArg === 'mute') {
result.muted = this.boolData(requestArg, 'is_volume_muted') ?? this.boolData(requestArg, 'muted') ?? this.boolData(requestArg, 'mute');
}
if (commandArg === 'play_media') {
result.mediaUrl = this.stringData(requestArg, 'media_content_id') || this.stringData(requestArg, 'uri') || this.stringData(requestArg, 'mediaUrl');
}
return result;
}
private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
const value = requestArg.data?.[keyArg];
return typeof value === 'string' && value ? value : undefined;
}
private stringArrayData(requestArg: IServiceCallRequest, keyArg: string): string[] | undefined {
const value = requestArg.data?.[keyArg];
if (typeof value === 'string' && value) {
return [value];
}
return Array.isArray(value) && value.every((itemArg) => typeof itemArg === 'string' && Boolean(itemArg)) ? value : undefined;
}
private boolData(requestArg: IServiceCallRequest, keyArg: string): boolean | undefined {
const value = requestArg.data?.[keyArg];
return typeof value === 'boolean' ? value : undefined;
}
}
+203
View File
@@ -0,0 +1,203 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { IDunehdManualEntry, IDunehdMdnsRecord, IDunehdSsdpRecord } from './dunehd.types.js';
import { dunehdDefaultPort } from './dunehd.types.js';
export class DunehdSsdpMatcher implements IDiscoveryMatcher<IDunehdSsdpRecord> {
public id = 'dunehd-ssdp-match';
public source = 'ssdp' as const;
public description = 'Recognize local Dune HD UPnP/SSDP advertisements.';
public async matches(recordArg: IDunehdSsdpRecord): Promise<IDiscoveryMatch> {
const st = stringValue(recordArg, 'st', 'ST', 'ssdp_st') || recordArg.ssdp_st;
const usn = stringValue(recordArg, 'usn', 'USN', 'udn', 'UDN', 'ssdp_usn') || recordArg.ssdp_usn;
const location = stringValue(recordArg, 'location', 'LOCATION', 'ssdp_location') || recordArg.ssdp_location;
const manufacturer = stringValue(recordArg, 'manufacturer', 'MANUFACTURER', 'upnp:manufacturer');
const model = stringValue(recordArg, 'modelName', 'model_name', 'model', 'upnp:modelName');
const name = stringValue(recordArg, 'friendlyName', 'friendly_name', 'upnp:friendlyName', 'name');
const matched = isDuneHint(st, usn, manufacturer, model, name) || Boolean(recordArg.upnp?.dunehd);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'SSDP record does not contain Dune HD hints.' };
}
const url = safeUrl(location);
const id = stripUuid(usn || stringValue(recordArg, 'udn', 'UDN')) || stringValue(recordArg, 'serialNumber', 'serial_number', 'macAddress');
return {
matched: true,
confidence: id ? 'certain' : url?.hostname ? 'high' : 'medium',
reason: 'SSDP record matches Dune HD metadata.',
normalizedDeviceId: id,
candidate: {
source: 'ssdp',
integrationDomain: 'dunehd',
id,
host: url?.hostname,
port: portFromUrl(url) || dunehdDefaultPort,
name,
manufacturer: manufacturer || 'Dune',
model,
metadata: { st, usn, location },
},
};
}
}
export class DunehdMdnsMatcher implements IDiscoveryMatcher<IDunehdMdnsRecord> {
public id = 'dunehd-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize local Dune HD mDNS setup hints.';
public async matches(recordArg: IDunehdMdnsRecord): Promise<IDiscoveryMatch> {
const properties = { ...(recordArg.txt || {}), ...(recordArg.properties || {}) };
const type = normalizeType(recordArg.type);
const manufacturer = valueForKey(properties, 'manufacturer') || valueForKey(properties, 'mf');
const model = valueForKey(properties, 'model') || valueForKey(properties, 'md') || valueForKey(properties, 'modelName');
const name = cleanName(valueForKey(properties, 'name') || valueForKey(properties, 'fn') || recordArg.name);
const matched = type.includes('dune') || isDuneHint(manufacturer, model, name) || Boolean(valueForKey(properties, 'dunehd'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'mDNS record does not contain Dune HD hints.' };
}
const id = valueForKey(properties, 'id') || valueForKey(properties, 'serial') || valueForKey(properties, 'mac') || name;
return {
matched: true,
confidence: id ? 'certain' : recordArg.host ? 'high' : 'medium',
reason: 'mDNS record matches Dune HD metadata.',
normalizedDeviceId: id,
candidate: {
source: 'mdns',
integrationDomain: 'dunehd',
id,
host: recordArg.host,
port: recordArg.port || dunehdDefaultPort,
name,
manufacturer: manufacturer || 'Dune',
model,
macAddress: valueForKey(properties, 'mac') || valueForKey(properties, 'macaddress'),
metadata: { mdnsType: recordArg.type, mdnsName: recordArg.name, txt: properties },
},
};
}
}
export class DunehdManualMatcher implements IDiscoveryMatcher<IDunehdManualEntry> {
public id = 'dunehd-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Dune HD setup entries.';
public async matches(inputArg: IDunehdManualEntry): Promise<IDiscoveryMatch> {
const matched = Boolean(inputArg.host || inputArg.metadata?.dunehd || isDuneHint(inputArg.manufacturer, inputArg.model, inputArg.name));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Dune HD setup hints.' };
}
const id = inputArg.id || inputArg.serialNumber || inputArg.macAddress;
return {
matched: true,
confidence: inputArg.host ? 'high' : 'medium',
reason: 'Manual entry can start Dune HD setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: 'dunehd',
id,
host: inputArg.host,
port: inputArg.port || dunehdDefaultPort,
name: inputArg.name,
manufacturer: inputArg.manufacturer || 'Dune',
model: inputArg.model,
serialNumber: inputArg.serialNumber,
macAddress: inputArg.macAddress,
metadata: inputArg.metadata,
},
};
}
}
export class DunehdCandidateValidator implements IDiscoveryValidator {
public id = 'dunehd-candidate-validator';
public description = 'Validate Dune HD discovery candidates.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const matched = candidateArg.integrationDomain === 'dunehd'
|| Boolean(candidateArg.metadata?.dunehd)
|| isDuneHint(candidateArg.manufacturer, candidateArg.model, candidateArg.name);
return {
matched,
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
reason: matched ? 'Candidate has Dune HD metadata.' : 'Candidate is not Dune HD.',
candidate: matched ? candidateArg : undefined,
normalizedDeviceId: candidateArg.id || candidateArg.macAddress || candidateArg.serialNumber || candidateArg.host,
};
}
}
export const createDunehdDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: 'dunehd', displayName: 'Dune HD' })
.addMatcher(new DunehdSsdpMatcher())
.addMatcher(new DunehdMdnsMatcher())
.addMatcher(new DunehdManualMatcher())
.addValidator(new DunehdCandidateValidator());
};
const isDuneHint = (...valuesArg: Array<string | undefined>): boolean => {
const haystack = valuesArg.filter(Boolean).join(' ').toLowerCase();
return haystack.includes('dune hd') || haystack.includes('dunehd') || haystack.includes('dune');
};
const normalizeType = (valueArg?: string): string => (valueArg || '').toLowerCase().replace(/\.$/, '');
const cleanName = (valueArg?: string): string | undefined => valueArg?.replace(/\._[^.]+\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || undefined;
const stripUuid = (valueArg?: string): string | undefined => {
if (!valueArg) {
return undefined;
}
return valueArg.replace(/^uuid:/i, '').split('::')[0];
};
const safeUrl = (valueArg?: string): URL | undefined => {
if (!valueArg) {
return undefined;
}
try {
return new URL(valueArg);
} catch {
return undefined;
}
};
const portFromUrl = (urlArg: URL | undefined): number | undefined => {
if (!urlArg?.port) {
return undefined;
}
const port = Number(urlArg.port);
return Number.isFinite(port) ? port : undefined;
};
const valueForKey = (recordArg: Record<string, string | undefined> | undefined, keyArg: string): string | undefined => {
if (!recordArg) {
return undefined;
}
const lowerKey = keyArg.toLowerCase();
for (const [key, value] of Object.entries(recordArg)) {
if (key.toLowerCase() === lowerKey && value) {
return value;
}
}
return undefined;
};
const stringValue = (recordArg: IDunehdSsdpRecord, ...keysArg: string[]): string | undefined => {
const maps = [recordArg.headers, recordArg.upnp, recordArg as Record<string, unknown>].filter(Boolean) as Array<Record<string, unknown>>;
for (const key of keysArg) {
const lowerKey = key.toLowerCase();
for (const map of maps) {
for (const [candidateKey, value] of Object.entries(map)) {
if (candidateKey.toLowerCase() === lowerKey && typeof value === 'string' && value) {
return value;
}
}
}
}
return undefined;
};
+184
View File
@@ -0,0 +1,184 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity } from '../../core/types.js';
import type { IDunehdSnapshot, IDunehdState } from './dunehd.types.js';
import { dunehdDefaultName } from './dunehd.types.js';
export class DunehdMapper {
public static toDevices(snapshotArg: IDunehdSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const state = snapshotArg.state || {};
return [{
id: this.deviceId(snapshotArg),
integrationDomain: 'dunehd',
name: this.deviceName(snapshotArg),
protocol: 'http',
manufacturer: snapshotArg.deviceInfo.manufacturer || 'Dune',
model: snapshotArg.deviceInfo.model,
online: this.available(snapshotArg),
features: [
{ id: 'power', capability: 'media', name: 'Power', readable: true, writable: true },
{ id: 'playback', capability: 'media', name: 'Playback', readable: true, writable: true },
{ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false },
{ id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true, unit: '%' },
{ id: 'muted', capability: 'media', name: 'Muted', readable: true, writable: true },
{ id: 'media_url', capability: 'media', name: 'Media URL', readable: true, writable: true },
{ id: 'remote_command', capability: 'media', name: 'Remote command', readable: false, writable: true },
],
state: [
{ featureId: 'power', value: this.mediaState(state) === 'off' ? 'off' : 'on', updatedAt },
{ featureId: 'playback', value: this.mediaState(state), updatedAt },
{ featureId: 'current_title', value: this.mediaTitle(state) || null, updatedAt },
{ featureId: 'volume', value: this.volumePercent(state), updatedAt },
{ featureId: 'muted', value: this.isMuted(state), updatedAt },
{ featureId: 'media_url', value: state.playback_url || null, updatedAt },
],
metadata: {
host: snapshotArg.deviceInfo.host,
port: snapshotArg.deviceInfo.port,
serialNumber: snapshotArg.deviceInfo.serialNumber,
macAddress: snapshotArg.deviceInfo.macAddress,
firmwareVersion: snapshotArg.deviceInfo.firmwareVersion,
playerState: state.player_state,
playbackSpeed: state.playback_speed,
},
}];
}
public static toEntities(snapshotArg: IDunehdSnapshot): IIntegrationEntity[] {
const state = snapshotArg.state || {};
return [{
id: `media_player.${this.slug(this.deviceName(snapshotArg))}`,
uniqueId: `dunehd_${this.slug(this.identity(snapshotArg))}`,
integrationDomain: 'dunehd',
deviceId: this.deviceId(snapshotArg),
platform: 'media_player',
name: this.deviceName(snapshotArg),
state: this.mediaState(state),
attributes: {
volumeLevel: this.volumeLevel(state),
isVolumeMuted: this.isMuted(state),
mediaTitle: this.mediaTitle(state),
mediaContentId: state.playback_url,
mediaContentType: this.mediaContentType(state),
mediaDuration: this.numberValue(state.playback_duration),
mediaPosition: this.numberValue(state.playback_position),
playbackSpeed: this.numberValue(state.playback_speed),
playerState: state.player_state,
host: snapshotArg.deviceInfo.host,
supportedFeatures: [
'pause',
'turn_on',
'turn_off',
'previous_track',
'next_track',
'play',
'play_media',
'browse_media',
'volume_step',
'volume_mute',
],
},
available: this.available(snapshotArg),
}];
}
public static mediaState(stateArg: IDunehdState): string {
let state = 'off';
if ('playback_position' in stateArg) {
state = 'playing';
}
if (stateArg.player_state === 'playing' || stateArg.player_state === 'buffering' || stateArg.player_state === 'photo_viewer') {
state = 'playing';
}
if (this.numberValue(stateArg.playback_speed, 1234) === 0) {
state = 'paused';
}
if (stateArg.player_state === 'navigator') {
state = 'on';
}
return state;
}
public static mediaTitle(stateArg: IDunehdState): string | undefined {
if (stateArg.player_state === 'bluray_playback') {
return 'Blu-Ray';
}
if (stateArg.player_state === 'photo_viewer') {
return 'Photo Viewer';
}
if (typeof stateArg.playback_url === 'string' && stateArg.playback_url) {
return stateArg.playback_url.split('/').pop() || undefined;
}
return undefined;
}
public static deviceId(snapshotArg: IDunehdSnapshot): string {
return `dunehd.device.${this.slug(this.identity(snapshotArg))}`;
}
private static available(snapshotArg: IDunehdSnapshot): boolean {
return snapshotArg.online && Object.keys(snapshotArg.state || {}).length > 0;
}
private static mediaContentType(stateArg: IDunehdState): string | undefined {
if (stateArg.player_state === 'photo_viewer') {
return 'image';
}
if (stateArg.player_state === 'bluray_playback') {
return 'video';
}
const url = typeof stateArg.playback_url === 'string' ? stateArg.playback_url.toLowerCase() : '';
if (/\.(mp3|flac|aac|ogg|wav)(\?|$)/.test(url)) {
return 'music';
}
if (/\.(jpg|jpeg|png|gif|webp)(\?|$)/.test(url)) {
return 'image';
}
if (url) {
return 'video';
}
return undefined;
}
private static volumeLevel(stateArg: IDunehdState): number {
return this.volumePercent(stateArg) / 100;
}
private static volumePercent(stateArg: IDunehdState): number {
return Math.max(0, Math.min(100, Math.round(this.numberValue(stateArg.playback_volume, 0))));
}
private static isMuted(stateArg: IDunehdState): boolean {
if (typeof stateArg.playback_mute === 'boolean') {
return stateArg.playback_mute;
}
return this.numberValue(stateArg.playback_mute, 0) === 1;
}
private static deviceName(snapshotArg: IDunehdSnapshot): string {
return snapshotArg.deviceInfo.name || snapshotArg.deviceInfo.model || dunehdDefaultName;
}
private static identity(snapshotArg: IDunehdSnapshot): string {
return snapshotArg.deviceInfo.id
|| snapshotArg.deviceInfo.serialNumber
|| snapshotArg.deviceInfo.macAddress
|| snapshotArg.deviceInfo.host
|| this.deviceName(snapshotArg);
}
private static numberValue(valueArg: unknown, fallbackArg?: number): number {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) ? parsed : fallbackArg ?? 0;
}
return fallbackArg ?? 0;
}
private static slug(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'dunehd';
}
}
+117 -2
View File
@@ -1,4 +1,119 @@
export interface IHomeAssistantDunehdConfig {
// TODO: replace with the TypeScript-native config for dunehd.
export const dunehdDefaultPort = 80;
export const dunehdDefaultName = 'Dune HD';
export type TDunehdStateValue = string | number | boolean | undefined;
export type TDunehdPlaybackCommand =
| 'turn_on'
| 'turn_off'
| 'play'
| 'pause'
| 'stop'
| 'previous_track'
| 'next_track'
| 'volume_up'
| 'volume_down'
| 'mute'
| 'play_media'
| 'remote';
export interface IDunehdConfig {
host?: string;
port?: number;
name?: string;
model?: string;
manufacturer?: string;
uniqueId?: string;
serialNumber?: string;
macAddress?: string;
timeoutMs?: number;
deviceInfo?: IDunehdDeviceInfo;
state?: IDunehdState;
snapshot?: IDunehdSnapshot;
commandExecutor?: IDunehdCommandExecutor;
}
export interface IHomeAssistantDunehdConfig extends IDunehdConfig {}
export interface IDunehdDeviceInfo {
id?: string;
host?: string;
port?: number;
name?: string;
manufacturer?: string;
model?: string;
serialNumber?: string;
macAddress?: string;
firmwareVersion?: string;
}
export interface IDunehdState {
[key: string]: TDunehdStateValue;
player_state?: string;
playback_position?: string | number;
playback_speed?: string | number;
playback_volume?: string | number;
playback_mute?: string | number | boolean;
playback_url?: string;
playback_duration?: string | number;
}
export interface IDunehdSnapshot {
deviceInfo: IDunehdDeviceInfo;
state: IDunehdState;
online: boolean;
updatedAt?: string;
}
export interface IDunehdRawCommandRequest {
cmd: string;
params: Record<string, string | number | boolean>;
uri: string;
host?: string;
port: number;
method: 'GET';
}
export interface IDunehdCommandExecutor {
execute(requestArg: IDunehdRawCommandRequest): Promise<IDunehdState | string | unknown>;
}
export interface IDunehdCommandRequest {
command: TDunehdPlaybackCommand;
mediaUrl?: string;
muted?: boolean;
remoteCommand?: string;
}
export interface IDunehdSsdpRecord {
st?: string;
usn?: string;
location?: string;
headers?: Record<string, string | undefined>;
upnp?: Record<string, unknown>;
ssdp_st?: string;
ssdp_usn?: string;
ssdp_location?: string;
[key: string]: unknown;
}
export interface IDunehdMdnsRecord {
type?: string;
name?: string;
host?: string;
port?: number;
txt?: Record<string, string | undefined>;
properties?: Record<string, string | undefined>;
}
export interface IDunehdManualEntry {
host?: string;
port?: number;
id?: string;
name?: string;
model?: string;
manufacturer?: string;
serialNumber?: string;
macAddress?: string;
metadata?: Record<string, unknown>;
}
+4
View File
@@ -1,2 +1,6 @@
export * from './dunehd.classes.integration.js';
export * from './dunehd.classes.client.js';
export * from './dunehd.classes.configflow.js';
export * from './dunehd.discovery.js';
export * from './dunehd.mapper.js';
export * from './dunehd.types.js';
+7 -13
View File
@@ -128,7 +128,6 @@ import { HomeAssistantBlockchainIntegration } from '../blockchain/index.js';
import { HomeAssistantBlueCurrentIntegration } from '../blue_current/index.js';
import { HomeAssistantBluemaestroIntegration } from '../bluemaestro/index.js';
import { HomeAssistantBlueprintIntegration } from '../blueprint/index.js';
import { HomeAssistantBluesoundIntegration } from '../bluesound/index.js';
import { HomeAssistantBluetoothIntegration } from '../bluetooth/index.js';
import { HomeAssistantBluetoothAdaptersIntegration } from '../bluetooth_adapters/index.js';
import { HomeAssistantBmwConnectedDriveIntegration } from '../bmw_connected_drive/index.js';
@@ -261,7 +260,6 @@ import { HomeAssistantDsmrReaderIntegration } from '../dsmr_reader/index.js';
import { HomeAssistantDublinBusTransportIntegration } from '../dublin_bus_transport/index.js';
import { HomeAssistantDuckdnsIntegration } from '../duckdns/index.js';
import { HomeAssistantDucoIntegration } from '../duco/index.js';
import { HomeAssistantDunehdIntegration } from '../dunehd/index.js';
import { HomeAssistantDuotecnoIntegration } from '../duotecno/index.js';
import { HomeAssistantDuquesneLightIntegration } from '../duquesne_light/index.js';
import { HomeAssistantDwdWeatherWarningsIntegration } from '../dwd_weather_warnings/index.js';
@@ -651,7 +649,6 @@ import { HomeAssistantLightwaveIntegration } from '../lightwave/index.js';
import { HomeAssistantLimitlessledIntegration } from '../limitlessled/index.js';
import { HomeAssistantLinakIntegration } from '../linak/index.js';
import { HomeAssistantLinkedgoIntegration } from '../linkedgo/index.js';
import { HomeAssistantLinkplayIntegration } from '../linkplay/index.js';
import { HomeAssistantLinksysSmartIntegration } from '../linksys_smart/index.js';
import { HomeAssistantLinodeIntegration } from '../linode/index.js';
import { HomeAssistantLinuxBatteryIntegration } from '../linux_battery/index.js';
@@ -685,7 +682,6 @@ import { HomeAssistantLuxaflexIntegration } from '../luxaflex/index.js';
import { HomeAssistantLw12wifiIntegration } from '../lw12wifi/index.js';
import { HomeAssistantLyricIntegration } from '../lyric/index.js';
import { HomeAssistantMadecoIntegration } from '../madeco/index.js';
import { HomeAssistantMadvrIntegration } from '../madvr/index.js';
import { HomeAssistantMailgunIntegration } from '../mailgun/index.js';
import { HomeAssistantManualIntegration } from '../manual/index.js';
import { HomeAssistantManualMqttIntegration } from '../manual_mqtt/index.js';
@@ -855,7 +851,6 @@ import { HomeAssistantOpenexchangeratesIntegration } from '../openexchangerates/
import { HomeAssistantOpengarageIntegration } from '../opengarage/index.js';
import { HomeAssistantOpenhardwaremonitorIntegration } from '../openhardwaremonitor/index.js';
import { HomeAssistantOpenhomeIntegration } from '../openhome/index.js';
import { HomeAssistantOpenrgbIntegration } from '../openrgb/index.js';
import { HomeAssistantOpensensemapIntegration } from '../opensensemap/index.js';
import { HomeAssistantOpenskyIntegration } from '../opensky/index.js';
import { HomeAssistantOpenuvIntegration } from '../openuv/index.js';
@@ -1115,7 +1110,6 @@ import { HomeAssistantSomfyMylinkIntegration } from '../somfy_mylink/index.js';
import { HomeAssistantSonarrIntegration } from '../sonarr/index.js';
import { HomeAssistantSongpalIntegration } from '../songpal/index.js';
import { HomeAssistantSonyProjectorIntegration } from '../sony_projector/index.js';
import { HomeAssistantSoundtouchIntegration } from '../soundtouch/index.js';
import { HomeAssistantSpaceapiIntegration } from '../spaceapi/index.js';
import { HomeAssistantSpcIntegration } from '../spc/index.js';
import { HomeAssistantSpeedtestdotnetIntegration } from '../speedtestdotnet/index.js';
@@ -1524,7 +1518,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlockchainIntegrati
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlueCurrentIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluemaestroIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlueprintIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluesoundIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothAdaptersIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBmwConnectedDriveIntegration());
@@ -1657,7 +1650,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantDsmrReaderIntegrati
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDublinBusTransportIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDuckdnsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDucoIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDunehdIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDuotecnoIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDuquesneLightIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantDwdWeatherWarningsIntegration());
@@ -2047,7 +2039,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantLightwaveIntegratio
generatedHomeAssistantPortIntegrations.push(new HomeAssistantLimitlessledIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantLinakIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantLinkedgoIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantLinkplayIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantLinksysSmartIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantLinodeIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantLinuxBatteryIntegration());
@@ -2081,7 +2072,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantLuxaflexIntegration
generatedHomeAssistantPortIntegrations.push(new HomeAssistantLw12wifiIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantLyricIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMadecoIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMadvrIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMailgunIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantManualIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantManualMqttIntegration());
@@ -2251,7 +2241,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenexchangeratesIn
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpengarageIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenhardwaremonitorIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenhomeIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenrgbIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpensensemapIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenskyIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenuvIntegration());
@@ -2511,7 +2500,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantSomfyMylinkIntegrat
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSonarrIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSongpalIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSonyProjectorIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSoundtouchIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSpaceapiIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSpcIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSpeedtestdotnetIntegration());
@@ -2792,7 +2780,7 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
export const generatedHomeAssistantPortCount = 1394;
export const generatedHomeAssistantPortCount = 1388;
export const handwrittenHomeAssistantPortDomains = [
"adguard",
"airgradient",
@@ -2805,6 +2793,7 @@ export const handwrittenHomeAssistantPortDomains = [
"asuswrt",
"axis",
"blebox",
"bluesound",
"bluetooth_le_tracker",
"bosch_shc",
"braviatv",
@@ -2815,6 +2804,7 @@ export const handwrittenHomeAssistantPortDomains = [
"devolo_home_network",
"dlna_dmr",
"dsmr",
"dunehd",
"esphome",
"fritz",
"glances",
@@ -2826,6 +2816,8 @@ export const handwrittenHomeAssistantPortDomains = [
"jellyfin",
"knx",
"kodi",
"linkplay",
"madvr",
"matter",
"mikrotik",
"modbus",
@@ -2834,6 +2826,7 @@ export const handwrittenHomeAssistantPortDomains = [
"mqtt",
"nanoleaf",
"onvif",
"openrgb",
"opentherm_gw",
"opnsense",
"pi_hole",
@@ -2845,6 +2838,7 @@ export const handwrittenHomeAssistantPortDomains = [
"shelly",
"snapcast",
"sonos",
"soundtouch",
"squeezebox",
"synology_dsm",
"tplink",
+6
View File
@@ -11,6 +11,7 @@ export * from './arcam_fmj/index.js';
export * from './asuswrt/index.js';
export * from './axis/index.js';
export * from './blebox/index.js';
export * from './bluesound/index.js';
export * from './bluetooth_le_tracker/index.js';
export * from './bosch_shc/index.js';
export * from './braviatv/index.js';
@@ -21,6 +22,7 @@ export * from './denonavr/index.js';
export * from './devolo_home_network/index.js';
export * from './dlna_dmr/index.js';
export * from './dsmr/index.js';
export * from './dunehd/index.js';
export * from './esphome/index.js';
export * from './fritz/index.js';
export * from './glances/index.js';
@@ -32,6 +34,8 @@ export * from './ipp/index.js';
export * from './jellyfin/index.js';
export * from './knx/index.js';
export * from './kodi/index.js';
export * from './linkplay/index.js';
export * from './madvr/index.js';
export * from './matter/index.js';
export * from './mikrotik/index.js';
export * from './modbus/index.js';
@@ -40,6 +44,7 @@ export * from './mpd/index.js';
export * from './mqtt/index.js';
export * from './nanoleaf/index.js';
export * from './onvif/index.js';
export * from './openrgb/index.js';
export * from './opentherm_gw/index.js';
export * from './opnsense/index.js';
export * from './pi_hole/index.js';
@@ -51,6 +56,7 @@ export * from './samsungtv/index.js';
export * from './shelly/index.js';
export * from './snapcast/index.js';
export * from './sonos/index.js';
export * from './soundtouch/index.js';
export * from './squeezebox/index.js';
export * from './synology_dsm/index.js';
export * from './tplink/index.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -1,2 +1,6 @@
export * from './linkplay.classes.client.js';
export * from './linkplay.classes.configflow.js';
export * from './linkplay.classes.integration.js';
export * from './linkplay.discovery.js';
export * from './linkplay.mapper.js';
export * from './linkplay.types.js';
@@ -0,0 +1,803 @@
import type {
ILinkplayCommandExecutorResult,
ILinkplayCommandRequest,
ILinkplayConfig,
ILinkplayDeviceInfo,
ILinkplayDeviceProperties,
ILinkplayMetaInfo,
ILinkplayMultiroomGroup,
ILinkplayPlayerProperties,
ILinkplayPlayerStatus,
ILinkplayPreset,
ILinkplayRawCommandRequest,
ILinkplaySnapshot,
ILinkplaySource,
ILinkplaySpeaker,
TLinkplayAudioOutputHardwareMode,
TLinkplayLoopMode,
TLinkplayPlayingMode,
TLinkplayProtocol,
TLinkplayRepeatMode,
} from './linkplay.types.js';
import {
linkplayAudioOutputHardwareCodeToMode,
linkplayAudioOutputHardwareModeToCode,
linkplayDefaultPort,
linkplayDefaultTimeoutMs,
linkplayLoopModeToRepeat,
linkplayNormalEqualizerModeToNumber,
linkplayNormalEqualizerNumberToMode,
linkplayPlayModeSendMap,
linkplayProjectLookup,
linkplayRepeatToLoopMode,
linkplaySourceMap,
} from './linkplay.types.js';
const linkplayCommands = {
deviceStatus: 'getStatusEx',
playerStatus: 'getPlayerStatusEx',
metaInfo: 'getMetaInfo',
next: 'setPlayerCmd:next',
previous: 'setPlayerCmd:prev',
unmute: 'setPlayerCmd:mute:0',
mute: 'setPlayerCmd:mute:1',
resume: 'setPlayerCmd:resume',
pause: 'setPlayerCmd:pause',
stop: 'setPlayerCmd:stop',
play: (valueArg: string) => `setPlayerCmd:play:${valueArg}`,
seek: (valueArg: number) => `setPlayerCmd:seek:${valueArg}`,
volume: (valueArg: number) => `setPlayerCmd:vol:${valueArg}`,
equalizerMode: (valueArg: string) => `setPlayerCmd:equalizer:${valueArg}`,
loopMode: (valueArg: TLinkplayLoopMode) => `setPlayerCmd:loopmode:${valueArg}`,
switchMode: (valueArg: string) => `setPlayerCmd:switchmode:${valueArg}`,
playPreset: (valueArg: number) => `MCUKeyShortClick:${valueArg}`,
timesync: (valueArg: string) => `timeSync:${valueArg}`,
restart: 'reboot',
multiroomList: 'multiroom:getSlaveList',
multiroomUngroup: 'multiroom:ungroup',
multiroomKick: (ethArg: string) => `multiroom:SlaveKickout:${ethArg}`,
multiroomVolume: (valueArg: number) => `setPlayerCmd:slave_vol:${valueArg}`,
multiroomMute: 'setPlayerCmd:slave_mute:mute',
multiroomUnmute: 'setPlayerCmd:slave_mute:unmute',
multiroomJoin: (leaderEthArg: string) => `ConnectMasterAp:JoinGroupMaster:eth${leaderEthArg}:wifi0.0.0.0`,
audioOutputHardwareMode: 'getNewAudioOutputHardwareMode',
setAudioOutputHardwareMode: (valueArg: string) => `setAudioOutputHardwareMode:${valueArg}`,
wiimEqualizerOff: 'EQOff',
wiimEqualizerLoad: (valueArg: string) => `EQLoad:${valueArg}`,
} as const;
const inputModeMap: Array<{ flag: number; mode: TLinkplayPlayingMode }> = [
{ flag: 2, mode: '40' },
{ flag: 4, mode: '41' },
{ flag: 8, mode: '21' },
{ flag: 16, mode: '43' },
{ flag: 32, mode: '44' },
{ flag: 64, mode: '45' },
{ flag: 128, mode: '46' },
{ flag: 256, mode: '47' },
{ flag: 512, mode: '48' },
{ flag: 1024, mode: '49' },
{ flag: 2048, mode: '50' },
{ flag: 8192, mode: '16' },
{ flag: 16384, mode: '53' },
{ flag: 32768, mode: '51' },
{ flag: 65536, mode: '54' },
{ flag: 262144, mode: '56' },
{ flag: 524288, mode: '57' },
{ flag: 2097152, mode: '99' },
{ flag: 4194304, mode: '58' },
];
export class LinkplayHttpError extends Error {
constructor(public readonly status: number, messageArg: string) {
super(messageArg);
this.name = 'LinkplayHttpError';
}
}
export class LinkplayCommandError extends Error {
constructor(public readonly command: string, messageArg: string) {
super(`LinkPlay command ${command} failed: ${messageArg}`);
this.name = 'LinkplayCommandError';
}
}
export class LinkplayClient {
private currentSnapshot?: ILinkplaySnapshot;
constructor(private readonly config: ILinkplayConfig) {
this.currentSnapshot = config.snapshot ? this.normalizeSnapshot(this.cloneValue(config.snapshot), 'snapshot') : undefined;
}
public async getSnapshot(): Promise<ILinkplaySnapshot> {
if (this.currentSnapshot && (!this.config.host || this.config.snapshot)) {
return this.normalizeSnapshot(this.cloneValue(this.currentSnapshot), this.currentSnapshot.source || 'snapshot');
}
if (!this.config.host && !this.config.commandExecutor) {
return this.normalizeSnapshot(this.snapshotFromConfig(false, 'LinkPlay refresh requires config.host, config.snapshot, or commandExecutor.'), 'runtime');
}
if (!this.config.host && this.config.commandExecutor) {
return this.normalizeSnapshot(this.snapshotFromConfig(false, 'LinkPlay executor configs need a snapshot for state mapping.'), 'runtime');
}
try {
this.currentSnapshot = await this.fetchSnapshot();
return this.cloneValue(this.currentSnapshot);
} catch (errorArg) {
return this.normalizeSnapshot(this.snapshotFromConfig(false, errorArg instanceof Error ? errorArg.message : String(errorArg)), 'runtime');
}
}
public async validateConnection(): Promise<ILinkplaySnapshot> {
const snapshot = await this.fetchSnapshot();
if (!snapshot.speakers[0]?.uuid) {
throw new Error('LinkPlay device did not provide a unique identifier.');
}
return snapshot;
}
public async execute(requestArg: ILinkplayCommandRequest): Promise<unknown> {
if (requestArg.command === 'play') {
return this.sendCommand(linkplayCommands.resume, await this.speakerForRequest(requestArg));
}
if (requestArg.command === 'pause') {
return this.sendCommand(linkplayCommands.pause, await this.speakerForRequest(requestArg));
}
if (requestArg.command === 'stop') {
return this.sendCommand(linkplayCommands.stop, await this.speakerForRequest(requestArg));
}
if (requestArg.command === 'next_track') {
return this.sendCommand(linkplayCommands.next, await this.speakerForRequest(requestArg));
}
if (requestArg.command === 'previous_track') {
return this.sendCommand(linkplayCommands.previous, await this.speakerForRequest(requestArg));
}
if (requestArg.command === 'play_media') {
const mediaId = requestArg.url || requestArg.mediaId;
if (!mediaId) {
throw new Error('LinkPlay play_media requires mediaId or url.');
}
return this.sendCommand(linkplayCommands.play(mediaId), await this.speakerForRequest(requestArg));
}
if (requestArg.command === 'seek') {
return this.sendCommand(linkplayCommands.seek(Math.max(0, Math.round(this.requiredNumber(requestArg.position, 'LinkPlay seek requires position.')))), await this.speakerForRequest(requestArg));
}
if (requestArg.command === 'set_volume') {
return this.sendCommand(linkplayCommands.volume(this.volumePercent(requestArg)), await this.speakerForRequest(requestArg));
}
if (requestArg.command === 'mute') {
if (typeof requestArg.muted !== 'boolean') {
throw new Error('LinkPlay mute requires muted.');
}
return this.sendCommand(requestArg.muted ? linkplayCommands.mute : linkplayCommands.unmute, await this.speakerForRequest(requestArg));
}
if (requestArg.command === 'select_source') {
return this.selectSource(requestArg);
}
if (requestArg.command === 'set_repeat') {
return this.setRepeat(requestArg);
}
if (requestArg.command === 'set_sound_mode') {
return this.setSoundMode(requestArg);
}
if (requestArg.command === 'play_preset') {
return this.playPreset(requestArg);
}
if (requestArg.command === 'timesync') {
return this.sendCommand(linkplayCommands.timesync(this.timestamp()), await this.speakerForRequest(requestArg));
}
if (requestArg.command === 'restart') {
return this.sendCommand(linkplayCommands.restart, await this.speakerForRequest(requestArg));
}
if (requestArg.command === 'set_audio_output_hardware_mode') {
return this.setAudioOutputHardwareMode(requestArg);
}
if (requestArg.command === 'join') {
return this.joinPlayers(requestArg);
}
if (requestArg.command === 'unjoin') {
return this.unjoinPlayer(requestArg);
}
if (requestArg.command === 'group_volume_set') {
return this.setGroupVolume(requestArg);
}
if (requestArg.command === 'group_mute') {
return this.setGroupMute(requestArg);
}
if (requestArg.command === 'raw_command') {
if (!requestArg.rawCommand) {
throw new Error('LinkPlay raw_command requires rawCommand.');
}
return this.sendCommand(requestArg.rawCommand, await this.speakerForRequest(requestArg));
}
throw new Error(`Unsupported LinkPlay command: ${requestArg.command}`);
}
public async command(rawCommandArg: string, speakerUuidArg?: string): Promise<unknown> {
return this.execute({ command: 'raw_command', rawCommand: rawCommandArg, speakerUuid: speakerUuidArg });
}
public async destroy(): Promise<void> {}
private async fetchSnapshot(): Promise<ILinkplaySnapshot> {
const deviceProperties = await this.sendJsonCommand<ILinkplayDeviceProperties>(linkplayCommands.deviceStatus);
const playerProperties = await this.sendJsonCommand<ILinkplayPlayerProperties>(linkplayCommands.playerStatus);
const device = this.deviceInfoFromProperties(deviceProperties);
const metaInfo = await this.sendJsonCommand<ILinkplayMetaInfo>(linkplayCommands.metaInfo).catch(() => undefined);
const player = this.playerStatusFromProperties(playerProperties, metaInfo);
const speaker: ILinkplaySpeaker = {
uuid: device.uuid,
name: device.name,
device,
player,
available: true,
sources: this.sourcesFromDevice(device),
presets: this.presetsFromDevice(device),
};
const audioMode = await this.sendJsonCommand<Record<string, unknown>>(linkplayCommands.audioOutputHardwareMode).catch(() => undefined);
if (audioMode) {
speaker.audioOutputHardwareMode = linkplayAudioOutputHardwareCodeToMode[String(audioMode.hardware)] || String(audioMode.hardware || '');
}
const multirooms = await this.multiroomsFromDevice(speaker).catch(() => []);
return this.normalizeSnapshot({
speakers: [speaker],
multirooms,
online: true,
updatedAt: new Date().toISOString(),
source: 'http',
raw: { deviceProperties, playerProperties, metaInfo },
}, 'http');
}
private async selectSource(requestArg: ILinkplayCommandRequest): Promise<unknown> {
const source = this.requiredString(requestArg.source, 'LinkPlay select_source requires source.');
const mode = this.sourceMode(source);
const commandValue = linkplayPlayModeSendMap[mode];
if (!commandValue) {
throw new Error(`Unsupported LinkPlay source: ${source}`);
}
return this.sendCommand(linkplayCommands.switchMode(commandValue), await this.speakerForRequest(requestArg));
}
private async setRepeat(requestArg: ILinkplayCommandRequest): Promise<unknown> {
const repeat = this.requiredString(requestArg.repeat, 'LinkPlay set_repeat requires repeat.') as TLinkplayRepeatMode;
const loopMode = linkplayRepeatToLoopMode[repeat];
if (!loopMode) {
throw new Error(`Unsupported LinkPlay repeat mode: ${repeat}`);
}
return this.sendCommand(linkplayCommands.loopMode(loopMode), await this.speakerForRequest(requestArg));
}
private async setSoundMode(requestArg: ILinkplayCommandRequest): Promise<unknown> {
const soundMode = this.requiredString(requestArg.soundMode, 'LinkPlay set_sound_mode requires soundMode.');
const speaker = await this.speakerForRequest(requestArg);
if (speaker?.device.manufacturer === 'WiiM') {
return this.sendCommand(soundMode === 'None' ? linkplayCommands.wiimEqualizerOff : linkplayCommands.wiimEqualizerLoad(soundMode), speaker);
}
const modeNumber = linkplayNormalEqualizerModeToNumber[soundMode];
if (!modeNumber) {
throw new Error(`Unsupported LinkPlay sound mode: ${soundMode}`);
}
return this.sendCommand(linkplayCommands.equalizerMode(modeNumber), speaker);
}
private async playPreset(requestArg: ILinkplayCommandRequest): Promise<unknown> {
const preset = this.requiredNumber(requestArg.preset, 'LinkPlay play_preset requires preset.');
const speaker = await this.speakerForRequest(requestArg);
const maxPreset = speaker?.device.maxPresets || 10;
if (!Number.isInteger(preset) || preset < 1 || preset > maxPreset) {
throw new Error(`LinkPlay preset must be between 1 and ${maxPreset}.`);
}
return this.sendCommand(linkplayCommands.playPreset(preset), speaker);
}
private async setAudioOutputHardwareMode(requestArg: ILinkplayCommandRequest): Promise<unknown> {
const mode = this.requiredString(requestArg.hardwareMode, 'LinkPlay set_audio_output_hardware_mode requires hardwareMode.');
const code = linkplayAudioOutputHardwareModeToCode[mode] || mode;
if (!['1', '2', '3', '4'].includes(code)) {
throw new Error(`Unsupported LinkPlay audio output hardware mode: ${mode}`);
}
return this.sendCommand(linkplayCommands.setAudioOutputHardwareMode(code), await this.speakerForRequest(requestArg));
}
private async joinPlayers(requestArg: ILinkplayCommandRequest): Promise<unknown[]> {
const snapshot = await this.getSnapshot();
const leader = this.requiredSpeaker(snapshot, requestArg.speakerUuid);
if (!requestArg.groupMemberUuids?.length) {
throw new Error('LinkPlay join requires groupMemberUuids.');
}
const leaderEth = this.requiredString(leader.device.ethernetAddress, 'LinkPlay join requires leader ethernet address.');
const results: unknown[] = [];
for (const memberUuid of requestArg.groupMemberUuids.filter((uuidArg) => uuidArg !== leader.uuid)) {
const follower = this.requiredSpeaker(snapshot, memberUuid);
results.push(await this.sendCommand(linkplayCommands.multiroomJoin(leaderEth), follower));
}
return results;
}
private async unjoinPlayer(requestArg: ILinkplayCommandRequest): Promise<unknown> {
const snapshot = await this.getSnapshot();
const speaker = this.requiredSpeaker(snapshot, requestArg.speakerUuid);
const group = this.groupForSpeaker(snapshot, speaker.uuid);
if (!group) {
return undefined;
}
const leader = this.requiredSpeaker(snapshot, group.leaderUuid);
if (speaker.uuid === leader.uuid) {
return this.sendCommand(linkplayCommands.multiroomUngroup, leader);
}
const followerEth = this.requiredString(speaker.device.ethernetAddress, 'LinkPlay unjoin requires follower ethernet address.');
return this.sendCommand(linkplayCommands.multiroomKick(followerEth), leader);
}
private async setGroupVolume(requestArg: ILinkplayCommandRequest): Promise<unknown> {
const snapshot = await this.getSnapshot();
const speaker = this.requiredSpeaker(snapshot, requestArg.speakerUuid);
const group = this.groupForSpeaker(snapshot, speaker.uuid);
const leader = this.requiredSpeaker(snapshot, group?.leaderUuid || speaker.uuid);
return this.sendCommand(linkplayCommands.multiroomVolume(this.volumePercent(requestArg)), leader);
}
private async setGroupMute(requestArg: ILinkplayCommandRequest): Promise<unknown> {
if (typeof requestArg.muted !== 'boolean') {
throw new Error('LinkPlay group_mute requires muted.');
}
const snapshot = await this.getSnapshot();
const speaker = this.requiredSpeaker(snapshot, requestArg.speakerUuid);
const group = this.groupForSpeaker(snapshot, speaker.uuid);
const leader = this.requiredSpeaker(snapshot, group?.leaderUuid || speaker.uuid);
return this.sendCommand(requestArg.muted ? linkplayCommands.multiroomMute : linkplayCommands.multiroomUnmute, leader);
}
private async sendCommand(commandArg: string, speakerArg?: ILinkplaySpeaker): Promise<unknown> {
if (this.config.commandExecutor) {
return this.executorResultToOk(commandArg, await this.config.commandExecutor.execute(this.rawRequest(commandArg, false, speakerArg)));
}
const response = await this.requestText(commandArg, speakerArg);
if (response.trim() !== 'OK') {
throw new LinkplayCommandError(commandArg, `expected OK, received ${response}`);
}
return { response };
}
private async sendJsonCommand<TValue = Record<string, unknown>>(commandArg: string, speakerArg?: ILinkplaySpeaker): Promise<TValue> {
if (this.config.commandExecutor) {
const result = await this.config.commandExecutor.execute(this.rawRequest(commandArg, true, speakerArg));
return this.executorResultToJson<TValue>(commandArg, result);
}
const text = await this.requestText(commandArg, speakerArg);
try {
return JSON.parse(text) as TValue;
} catch (errorArg) {
throw new LinkplayCommandError(commandArg, `invalid JSON response: ${text}`);
}
}
private async requestText(commandArg: string, speakerArg?: ILinkplaySpeaker): Promise<string> {
const request = this.rawRequest(commandArg, false, speakerArg);
if (!request.host) {
throw new Error('LinkPlay HTTP command requires config.host or commandExecutor. Static snapshots are read-only.');
}
const abortController = new AbortController();
const timeout = globalThis.setTimeout(() => abortController.abort(), this.config.timeoutMs || linkplayDefaultTimeoutMs);
try {
const response = await globalThis.fetch(request.url || this.commandUrl(request.protocol, request.host, request.port, commandArg), {
method: 'GET',
signal: abortController.signal,
});
const text = await response.text();
if (!response.ok) {
throw new LinkplayHttpError(response.status, `LinkPlay request ${commandArg} failed with HTTP ${response.status}: ${text}`);
}
return text;
} finally {
globalThis.clearTimeout(timeout);
}
}
private rawRequest(commandArg: string, expectJsonArg: boolean, speakerArg?: ILinkplaySpeaker): ILinkplayRawCommandRequest {
const protocol = speakerArg?.device.protocol || this.config.protocol || 'http';
const host = speakerArg?.device.host || this.config.host;
const port = speakerArg?.device.port || this.config.port || defaultPort(protocol);
return {
command: commandArg,
host,
port,
protocol,
expectJson: expectJsonArg,
speakerUuid: speakerArg?.uuid,
speakerName: speakerArg?.name || speakerArg?.device.name,
url: host ? this.commandUrl(protocol, host, port, commandArg) : undefined,
};
}
private commandUrl(protocolArg: TLinkplayProtocol, hostArg: string, portArg: number, commandArg: string): string {
const includePort = !(protocolArg === 'http' && portArg === 80) && !(protocolArg === 'https' && portArg === 443);
const encodedHost = hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
const url = new URL(`${protocolArg}://${encodedHost}${includePort ? `:${portArg}` : ''}/httpapi.asp`);
url.searchParams.set('command', commandArg);
return url.toString();
}
private executorResultToOk(commandArg: string, resultArg: unknown): unknown {
if (resultArg === undefined || resultArg === true) {
return resultArg;
}
if (typeof resultArg === 'string') {
if (resultArg.trim() === 'OK') {
return { response: resultArg };
}
throw new LinkplayCommandError(commandArg, `executor returned ${resultArg}`);
}
if (this.isRecord(resultArg)) {
const result = resultArg as ILinkplayCommandExecutorResult;
if (result.ok === false || result.success === false || result.status && result.status >= 400) {
throw new LinkplayCommandError(commandArg, JSON.stringify(resultArg));
}
if (typeof result.response === 'string' && result.response.trim() !== 'OK') {
throw new LinkplayCommandError(commandArg, `executor returned ${result.response}`);
}
}
return resultArg;
}
private executorResultToJson<TValue>(commandArg: string, resultArg: unknown): TValue {
if (typeof resultArg === 'string') {
try {
return JSON.parse(resultArg) as TValue;
} catch {
throw new LinkplayCommandError(commandArg, `executor returned invalid JSON: ${resultArg}`);
}
}
if (this.isRecord(resultArg) && 'data' in resultArg) {
return resultArg.data as TValue;
}
if (this.isRecord(resultArg)) {
return resultArg as TValue;
}
throw new LinkplayCommandError(commandArg, 'executor returned no JSON data.');
}
private deviceInfoFromProperties(propertiesArg: ILinkplayDeviceProperties): ILinkplayDeviceInfo {
const project = stringValue(propertiesArg.project);
const projectInfo = project ? linkplayProjectLookup[project] : undefined;
const protocol = this.config.protocol || 'http';
const host = this.config.host;
const port = this.config.port || defaultPort(protocol);
const uuid = stringValue(propertiesArg.uuid) || this.config.uniqueId || (host ? `${host}:${port}` : 'linkplay');
const name = stringValue(propertiesArg.DeviceName) || this.config.name || host || 'LinkPlay';
return {
uuid,
name,
host,
port,
protocol,
endpoint: host ? `${protocol}://${host}${port === defaultPort(protocol) ? '' : `:${port}`}` : undefined,
manufacturer: this.config.manufacturer || projectInfo?.manufacturer || 'Generic',
model: this.config.model || projectInfo?.model || 'Generic',
modelId: this.config.modelId || (projectInfo?.model === 'Generic' ? undefined : project),
project,
macAddress: this.macAddress(propertiesArg),
hardwareVersion: stringValue(propertiesArg.hardware),
firmwareVersion: stringValue(propertiesArg.firmware),
ethernetAddress: this.ethernetAddress(propertiesArg),
playModeSupport: this.playModeSupport(propertiesArg),
maxPresets: numberValue(propertiesArg.preset_key) || 10,
properties: propertiesArg,
};
}
private playerStatusFromProperties(propertiesArg: ILinkplayPlayerProperties, metaInfoArg?: ILinkplayMetaInfo): ILinkplayPlayerStatus {
const playMode = stringValue(propertiesArg.mode) as TLinkplayPlayingMode | undefined;
const loopMode = stringValue(propertiesArg.loop) as TLinkplayLoopMode | undefined;
const eq = stringValue(propertiesArg.eq);
const status = validStatus(stringValue(propertiesArg.status)) || 'stop';
return {
status,
playMode,
loopMode,
equalizerMode: eq ? linkplayNormalEqualizerNumberToMode[eq] || eq : undefined,
volume: numberValue(propertiesArg.vol),
muted: stringValue(propertiesArg.mute) === '1',
title: decodeHexString(stringValue(propertiesArg.Title)),
artist: decodeHexString(stringValue(propertiesArg.Artist)),
album: decodeHexString(stringValue(propertiesArg.Album)),
albumArtUrl: metaInfoArg?.metaData?.albumArtURI,
currentPositionMs: numberValue(propertiesArg.curpos),
totalLengthMs: numberValue(propertiesArg.totlen),
source: playMode ? linkplaySourceMap[playMode] || 'other' : undefined,
speakerType: stringValue(propertiesArg.type),
channelType: stringValue(propertiesArg.ch),
properties: propertiesArg,
metaInfo: metaInfoArg,
};
}
private sourcesFromDevice(deviceArg: ILinkplayDeviceInfo): ILinkplaySource[] {
return (deviceArg.playModeSupport || ['10']).map((modeArg) => ({
mode: modeArg,
name: linkplaySourceMap[modeArg] || modeArg,
commandValue: linkplayPlayModeSendMap[modeArg],
available: true,
}));
}
private presetsFromDevice(deviceArg: ILinkplayDeviceInfo): ILinkplayPreset[] {
const maxPresets = deviceArg.maxPresets || 10;
return Array.from({ length: maxPresets }, (_itemArg, indexArg) => ({ number: indexArg + 1, name: `Preset ${indexArg + 1}` }));
}
private async multiroomsFromDevice(speakerArg: ILinkplaySpeaker): Promise<ILinkplayMultiroomGroup[]> {
const response = await this.sendJsonCommand<Record<string, unknown>>(linkplayCommands.multiroomList, speakerArg);
const followerRecords = Array.isArray(response.slave_list) ? response.slave_list.filter((itemArg): itemArg is Record<string, unknown> => this.isRecord(itemArg)) : [];
const followerUuids = followerRecords.map((itemArg) => stringValue(itemArg.uuid)).filter((valueArg): valueArg is string => Boolean(valueArg));
if (!followerUuids.length) {
return [];
}
return [{
id: `multiroom_${speakerArg.uuid}`,
name: `${speakerArg.name || speakerArg.device.name} Multiroom`,
leaderUuid: speakerArg.uuid,
followerUuids,
}];
}
private normalizeSnapshot(snapshotArg: ILinkplaySnapshot, sourceArg: ILinkplaySnapshot['source']): ILinkplaySnapshot {
const speakers = (snapshotArg.speakers || []).map((speakerArg) => this.normalizeSpeaker(speakerArg));
const sources = snapshotArg.sources || uniqueSources(speakers.flatMap((speakerArg) => speakerArg.sources || []));
const presets = snapshotArg.presets || uniquePresets(speakers.flatMap((speakerArg) => speakerArg.presets || []));
return {
...snapshotArg,
speakers,
multirooms: snapshotArg.multirooms || [],
sources,
presets,
online: Boolean(snapshotArg.online),
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
source: snapshotArg.source || sourceArg,
};
}
private normalizeSpeaker(speakerArg: ILinkplaySpeaker): ILinkplaySpeaker {
const uuid = speakerArg.uuid || speakerArg.device.uuid;
const device = {
...speakerArg.device,
uuid,
name: speakerArg.device.name || speakerArg.name || this.config.name || this.config.host || 'LinkPlay',
host: speakerArg.device.host || this.config.host,
port: speakerArg.device.port || this.config.port || (speakerArg.device.host || this.config.host ? defaultPort(speakerArg.device.protocol || this.config.protocol || 'http') : undefined),
protocol: speakerArg.device.protocol || this.config.protocol || 'http',
playModeSupport: speakerArg.device.playModeSupport || ['10'],
maxPresets: speakerArg.device.maxPresets || 10,
};
return {
...speakerArg,
uuid,
name: speakerArg.name || device.name,
device,
player: {
...speakerArg.player,
status: speakerArg.player.status || 'stop',
},
available: speakerArg.available ?? true,
sources: speakerArg.sources || this.sourcesFromDevice(device),
presets: speakerArg.presets || this.presetsFromDevice(device),
};
}
private snapshotFromConfig(onlineArg: boolean, errorArg?: string): ILinkplaySnapshot {
const protocol = this.config.protocol || 'http';
const host = this.config.host;
const port = this.config.port || (host ? defaultPort(protocol) : undefined);
const uuid = this.config.uniqueId || (host && port ? `${host}:${port}` : 'linkplay');
const device: ILinkplayDeviceInfo = {
uuid,
name: this.config.name || host || 'LinkPlay',
host,
port,
protocol,
manufacturer: this.config.manufacturer || 'Generic',
model: this.config.model || 'Generic',
modelId: this.config.modelId,
macAddress: this.config.macAddress,
playModeSupport: ['10'],
maxPresets: 10,
};
return {
speakers: [{ uuid, name: device.name, device, player: { status: onlineArg ? 'stop' : 'stop' }, available: onlineArg }],
multirooms: [],
online: onlineArg,
updatedAt: new Date().toISOString(),
source: 'runtime',
error: errorArg,
};
}
private async speakerForRequest(requestArg: ILinkplayCommandRequest): Promise<ILinkplaySpeaker | undefined> {
const snapshot = await this.getSnapshot();
if (requestArg.speakerUuid) {
return this.requiredSpeaker(snapshot, requestArg.speakerUuid);
}
return snapshot.speakers.length === 1 ? snapshot.speakers[0] : undefined;
}
private requiredSpeaker(snapshotArg: ILinkplaySnapshot, speakerUuidArg: string | undefined): ILinkplaySpeaker {
if (speakerUuidArg) {
const speaker = snapshotArg.speakers.find((speakerArg) => speakerArg.uuid === speakerUuidArg || speakerArg.device.uuid === speakerUuidArg);
if (speaker) {
return speaker;
}
throw new Error(`LinkPlay speaker not found: ${speakerUuidArg}`);
}
if (snapshotArg.speakers.length === 1) {
return snapshotArg.speakers[0];
}
throw new Error('LinkPlay service call requires data.speaker_uuid or a target LinkPlay media_player entity.');
}
private groupForSpeaker(snapshotArg: ILinkplaySnapshot, speakerUuidArg: string): ILinkplayMultiroomGroup | undefined {
return (snapshotArg.multirooms || []).find((groupArg) => groupArg.leaderUuid === speakerUuidArg || groupArg.followerUuids.includes(speakerUuidArg));
}
private sourceMode(sourceArg: string): TLinkplayPlayingMode {
const byName = Object.entries(linkplaySourceMap).find(([_modeArg, nameArg]) => nameArg.toLowerCase() === sourceArg.toLowerCase());
if (byName) {
return byName[0] as TLinkplayPlayingMode;
}
if (linkplayPlayModeSendMap[sourceArg]) {
return sourceArg as TLinkplayPlayingMode;
}
throw new Error(`Unknown LinkPlay source: ${sourceArg}`);
}
private playModeSupport(propertiesArg: ILinkplayDeviceProperties): TLinkplayPlayingMode[] {
const support = stringValue(propertiesArg.plm_support);
const modes: TLinkplayPlayingMode[] = ['10'];
if (!support) {
return modes;
}
const flags = Number.parseInt(support, 16);
if (!Number.isFinite(flags)) {
return modes;
}
for (const item of inputModeMap) {
if ((flags & item.flag) === item.flag) {
modes.push(item.mode);
}
}
return [...new Set(modes)];
}
private macAddress(propertiesArg: ILinkplayDeviceProperties): string | undefined {
return firstUsableMac(stringValue(propertiesArg.ETH_MAC), stringValue(propertiesArg.STA_MAC), stringValue(propertiesArg.MAC));
}
private ethernetAddress(propertiesArg: ILinkplayDeviceProperties): string | undefined {
return firstUsableIp(stringValue(propertiesArg.eth2), stringValue(propertiesArg.eth0), stringValue(propertiesArg.apcli0));
}
private volumePercent(requestArg: ILinkplayCommandRequest): number {
const value = requestArg.volume ?? (typeof requestArg.volumeLevel === 'number' ? requestArg.volumeLevel * 100 : undefined);
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new Error('LinkPlay volume command requires volumeLevel or volume.');
}
return Math.max(0, Math.min(100, Math.round(value)));
}
private timestamp(): string {
const now = new Date();
const parts = [
now.getFullYear(),
now.getMonth() + 1,
now.getDate(),
now.getHours(),
now.getMinutes(),
now.getSeconds(),
];
return `${parts[0]}${parts.slice(1).map((partArg) => String(partArg).padStart(2, '0')).join('')}`;
}
private requiredString(valueArg: unknown, errorArg: string): string {
if (typeof valueArg !== 'string' || !valueArg) {
throw new Error(errorArg);
}
return valueArg;
}
private requiredNumber(valueArg: unknown, errorArg: string): number {
if (typeof valueArg !== 'number' || !Number.isFinite(valueArg)) {
throw new Error(errorArg);
}
return valueArg;
}
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
return Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg));
}
private cloneValue<TValue>(valueArg: TValue): TValue {
return valueArg === undefined ? valueArg : JSON.parse(JSON.stringify(valueArg)) as TValue;
}
}
const defaultPort = (protocolArg: TLinkplayProtocol): number => protocolArg === 'https' ? 443 : linkplayDefaultPort;
const 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;
};
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 validStatus = (valueArg: string | undefined): 'play' | 'pause' | 'stop' | 'load' | undefined => {
return valueArg === 'play' || valueArg === 'pause' || valueArg === 'stop' || valueArg === 'load' ? valueArg : undefined;
};
const decodeHexString = (valueArg: string | undefined): string | undefined => {
if (!valueArg) {
return undefined;
}
if (!/^(?:[0-9a-f]{2})+$/i.test(valueArg)) {
return valueArg;
}
try {
return Buffer.from(valueArg, 'hex').toString('utf8');
} catch {
return valueArg;
}
};
const firstUsableMac = (...valuesArg: Array<string | undefined>): string | undefined => {
return valuesArg.find((valueArg) => valueArg && valueArg !== '00:00:00:00:00:00');
};
const firstUsableIp = (...valuesArg: Array<string | undefined>): string | undefined => {
return valuesArg.find((valueArg) => valueArg && valueArg !== '0.0.0.0');
};
const uniqueSources = (sourcesArg: ILinkplaySource[]): ILinkplaySource[] => {
const seen = new Set<string>();
return sourcesArg.filter((sourceArg) => {
const key = sourceArg.mode || sourceArg.name;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
};
const uniquePresets = (presetsArg: ILinkplayPreset[]): ILinkplayPreset[] => {
const seen = new Set<number>();
return presetsArg.filter((presetArg) => {
if (seen.has(presetArg.number)) {
return false;
}
seen.add(presetArg.number);
return true;
}).sort((leftArg, rightArg) => leftArg.number - rightArg.number);
};
export const linkplayRepeatFromLoopMode = (loopModeArg: TLinkplayLoopMode | undefined): TLinkplayRepeatMode | undefined => {
return loopModeArg ? linkplayLoopModeToRepeat[loopModeArg] : undefined;
};
export const linkplaySourceName = (modeArg: TLinkplayPlayingMode | undefined): string | undefined => {
return modeArg ? linkplaySourceMap[modeArg] || 'other' : undefined;
};
export const linkplayAudioOutputHardwareMode = (modeArg: TLinkplayAudioOutputHardwareMode | string | undefined): string | undefined => modeArg;
@@ -0,0 +1,80 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { ILinkplayConfig, TLinkplayProtocol } from './linkplay.types.js';
import { linkplayDefaultPort } from './linkplay.types.js';
export class LinkplayConfigFlow implements IConfigFlow<ILinkplayConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<ILinkplayConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect LinkPlay',
description: candidateArg.source === 'manual'
? 'Configure a local LinkPlay HTTP API endpoint.'
: 'Confirm or adjust the discovered local LinkPlay endpoint.',
fields: [
{ name: 'host', label: 'Host', type: 'text', required: true },
{ name: 'port', label: 'HTTP API port', type: 'number' },
{ name: 'protocol', label: 'Protocol', type: 'select', options: [{ label: 'HTTP', value: 'http' }, { label: 'HTTPS', value: 'https' }] },
{ name: 'name', label: 'Name', type: 'text' },
],
submit: async (valuesArg) => {
if (isWiimCandidate(candidateArg)) {
return { kind: 'error', title: 'LinkPlay setup failed', error: 'This device should be set up with the WiiM integration.' };
}
const host = stringValue(valuesArg.host) || candidateArg.host;
if (!host) {
return { kind: 'error', title: 'LinkPlay setup failed', error: 'LinkPlay host is required.' };
}
const protocol = protocolValue(valuesArg.protocol) || protocolMetadata(candidateArg) || protocolFromPort(candidateArg.port);
const port = numberValue(valuesArg.port) || candidateArg.port || defaultPort(protocol);
return {
kind: 'done',
title: 'LinkPlay configured',
config: {
host,
port,
protocol,
name: stringValue(valuesArg.name) || candidateArg.name,
uniqueId: candidateArg.id,
manufacturer: candidateArg.manufacturer,
model: candidateArg.model,
modelId: stringMetadata(candidateArg.metadata?.modelId),
macAddress: candidateArg.macAddress,
},
};
},
};
}
}
const stringValue = (valueArg: unknown): string | undefined => {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
};
const numberValue = (valueArg: unknown): number | undefined => {
if (typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0) {
return Math.round(valueArg);
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const parsed = Number(valueArg);
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : undefined;
}
return undefined;
};
const protocolValue = (valueArg: unknown): TLinkplayProtocol | undefined => {
return valueArg === 'http' || valueArg === 'https' ? valueArg : undefined;
};
const protocolMetadata = (candidateArg: IDiscoveryCandidate): TLinkplayProtocol | undefined => protocolValue(candidateArg.metadata?.protocol);
const protocolFromPort = (portArg: number | undefined): TLinkplayProtocol => portArg === 443 || portArg === 4443 ? 'https' : 'http';
const defaultPort = (protocolArg: TLinkplayProtocol): number => protocolArg === 'https' ? 443 : linkplayDefaultPort;
const stringMetadata = (valueArg: unknown): string | undefined => typeof valueArg === 'string' ? valueArg : undefined;
const isWiimCandidate = (candidateArg: IDiscoveryCandidate): boolean => {
const haystack = `${candidateArg.manufacturer || ''} ${candidateArg.model || ''} ${stringMetadata(candidateArg.metadata?.project) || ''}`.toLowerCase();
return haystack.includes('wiim') || haystack.includes('muzo_mini');
};
@@ -1,26 +1,283 @@
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 { LinkplayClient } from './linkplay.classes.client.js';
import { LinkplayConfigFlow } from './linkplay.classes.configflow.js';
import { createLinkplayDiscoveryDescriptor } from './linkplay.discovery.js';
import { LinkplayMapper } from './linkplay.mapper.js';
import type { ILinkplayCommandRequest, ILinkplayConfig } from './linkplay.types.js';
export class HomeAssistantLinkplayIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "linkplay",
displayName: "LinkPlay",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/linkplay",
"upstreamDomain": "linkplay",
"integrationType": "hub",
"iotClass": "local_polling",
"requirements": [
"python-linkplay==0.2.12"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@Velleman"
]
},
});
export class LinkplayIntegration extends BaseIntegration<ILinkplayConfig> {
public readonly domain = 'linkplay';
public readonly displayName = 'LinkPlay';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createLinkplayDiscoveryDescriptor();
public readonly configFlow = new LinkplayConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/linkplay',
upstreamDomain: 'linkplay',
integrationType: 'hub',
iotClass: 'local_polling',
requirements: ['python-linkplay==0.2.12'],
dependencies: [],
afterDependencies: [],
codeowners: ['@Velleman'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/linkplay',
zeroconf: ['_linkplay._tcp.local.'],
};
public async setup(configArg: ILinkplayConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new LinkplayRuntime(new LinkplayClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantLinkplayIntegration extends LinkplayIntegration {}
class LinkplayRuntime implements IIntegrationRuntime {
public domain = 'linkplay';
constructor(private readonly client: LinkplayClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return LinkplayMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return LinkplayMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain === 'media_player') {
return await this.callMediaPlayerService(requestArg);
}
if (requestArg.domain === 'linkplay') {
return await this.callLinkplayService(requestArg);
}
if (requestArg.domain === 'select') {
return await this.callSelectService(requestArg);
}
if (requestArg.domain === 'button') {
return await this.callButtonService(requestArg);
}
return { success: false, error: `Unsupported LinkPlay 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 callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service === 'join' || requestArg.service === 'join_players') {
return { success: true, data: await this.client.execute({ command: 'join', speakerUuid: await this.speakerUuidFromRequest(requestArg), groupMemberUuids: await this.memberUuidsFromRequest(requestArg) }) };
}
if (requestArg.service === 'unjoin' || requestArg.service === 'unjoin_player') {
return { success: true, data: await this.client.execute({ command: 'unjoin', speakerUuid: await this.speakerUuidFromRequest(requestArg) }) };
}
if (requestArg.service === 'group_volume_set') {
return { success: true, data: await this.client.execute({ command: 'group_volume_set', speakerUuid: await this.speakerUuidFromRequest(requestArg), volumeLevel: this.numberData(requestArg, 'volume_level'), volume: this.numberData(requestArg, 'volume') }) };
}
if (requestArg.service === 'group_mute') {
return { success: true, data: await this.client.execute({ command: 'group_mute', speakerUuid: await this.speakerUuidFromRequest(requestArg), muted: this.booleanData(requestArg, 'is_volume_muted') ?? this.booleanData(requestArg, 'muted') ?? this.booleanData(requestArg, 'mute') }) };
}
const command = this.commandFromMediaService(requestArg.service);
if (!command) {
return { success: false, error: `Unsupported LinkPlay media_player service: ${requestArg.service}` };
}
return { success: true, data: await this.client.execute(await this.commandRequest(command, requestArg)) };
}
private async callLinkplayService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service === 'play_preset') {
return { success: true, data: await this.client.execute({ command: 'play_preset', speakerUuid: await this.speakerUuidFromRequest(requestArg), preset: this.numberData(requestArg, 'preset_number') ?? this.numberData(requestArg, 'preset') }) };
}
if (requestArg.service === 'timesync' || requestArg.service === 'sync_time') {
return { success: true, data: await this.client.execute({ command: 'timesync', speakerUuid: await this.speakerUuidFromRequest(requestArg) }) };
}
if (requestArg.service === 'restart' || requestArg.service === 'reboot') {
return { success: true, data: await this.client.execute({ command: 'restart', speakerUuid: await this.speakerUuidFromRequest(requestArg) }) };
}
if (requestArg.service === 'set_audio_output_hardware_mode') {
return { success: true, data: await this.client.execute({ command: 'set_audio_output_hardware_mode', speakerUuid: await this.speakerUuidFromRequest(requestArg), hardwareMode: this.stringData(requestArg, 'hardware_mode') || this.stringData(requestArg, 'option') }) };
}
if (requestArg.service === 'command' || requestArg.service === 'raw_command') {
const rawCommand = this.stringData(requestArg, 'command') || this.stringData(requestArg, 'rawCommand');
if (!rawCommand) {
throw new Error('LinkPlay raw command service requires data.command.');
}
return { success: true, data: await this.client.execute({ command: 'raw_command', speakerUuid: await this.optionalSpeakerUuidFromRequest(requestArg), rawCommand }) };
}
if (requestArg.service === 'join' || requestArg.service === 'join_players' || requestArg.service === 'unjoin' || requestArg.service === 'unjoin_player' || requestArg.service === 'group_volume_set' || requestArg.service === 'group_mute') {
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
}
if (this.commandFromMediaService(requestArg.service)) {
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
}
return { success: false, error: `Unsupported LinkPlay service: ${requestArg.service}` };
}
private async callSelectService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service !== 'select_option') {
return { success: false, error: `Unsupported LinkPlay select service: ${requestArg.service}` };
}
return { success: true, data: await this.client.execute({ command: 'set_audio_output_hardware_mode', speakerUuid: await this.speakerUuidFromRequest(requestArg), hardwareMode: this.stringData(requestArg, 'option') }) };
}
private async callButtonService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service === 'press') {
const button = this.stringData(requestArg, 'button') || requestArg.target.entityId || '';
const command = button.includes('restart') ? 'restart' : 'timesync';
return { success: true, data: await this.client.execute({ command, speakerUuid: await this.speakerUuidFromRequest(requestArg) }) };
}
return { success: false, error: `Unsupported LinkPlay button service: ${requestArg.service}` };
}
private commandFromMediaService(serviceArg: string): ILinkplayCommandRequest['command'] | undefined {
if (serviceArg === 'media_play' || serviceArg === 'play') {
return 'play';
}
if (serviceArg === 'media_pause' || serviceArg === 'pause') {
return 'pause';
}
if (serviceArg === 'media_stop' || serviceArg === 'stop') {
return 'stop';
}
if (serviceArg === 'media_next_track' || serviceArg === 'next_track' || serviceArg === 'next') {
return 'next_track';
}
if (serviceArg === 'media_previous_track' || serviceArg === 'previous_track' || serviceArg === 'previous') {
return 'previous_track';
}
if (serviceArg === 'volume_set' || serviceArg === 'set_volume') {
return 'set_volume';
}
if (serviceArg === 'volume_mute' || serviceArg === 'mute') {
return 'mute';
}
if (serviceArg === 'select_source' || serviceArg === 'source') {
return 'select_source';
}
if (serviceArg === 'select_sound_mode' || serviceArg === 'set_sound_mode') {
return 'set_sound_mode';
}
if (serviceArg === 'repeat_set' || serviceArg === 'set_repeat') {
return 'set_repeat';
}
if (serviceArg === 'media_seek' || serviceArg === 'seek') {
return 'seek';
}
if (serviceArg === 'play_media') {
return 'play_media';
}
return undefined;
}
private async commandRequest(commandArg: ILinkplayCommandRequest['command'], requestArg: IServiceCallRequest): Promise<ILinkplayCommandRequest> {
const base: ILinkplayCommandRequest = {
command: commandArg,
speakerUuid: await this.speakerUuidFromRequest(requestArg),
};
if (commandArg === 'set_volume') {
base.volumeLevel = this.numberData(requestArg, 'volume_level');
base.volume = this.numberData(requestArg, 'volume');
}
if (commandArg === 'mute') {
base.muted = this.booleanData(requestArg, 'is_volume_muted') ?? this.booleanData(requestArg, 'muted') ?? this.booleanData(requestArg, 'mute');
}
if (commandArg === 'select_source') {
base.source = this.stringData(requestArg, 'source');
}
if (commandArg === 'set_sound_mode') {
base.soundMode = this.stringData(requestArg, 'sound_mode') || this.stringData(requestArg, 'soundMode');
}
if (commandArg === 'set_repeat') {
const repeat = this.stringData(requestArg, 'repeat') || this.stringData(requestArg, 'repeat_mode');
if (repeat === 'off' || repeat === 'one' || repeat === 'all') {
base.repeat = repeat;
}
}
if (commandArg === 'seek') {
base.position = this.numberData(requestArg, 'seek_position') ?? this.numberData(requestArg, 'position');
}
if (commandArg === 'play_media') {
base.mediaId = this.stringData(requestArg, 'media_content_id') || this.stringData(requestArg, 'mediaId') || this.stringData(requestArg, 'uri');
base.url = this.stringData(requestArg, 'url');
base.mediaType = this.stringData(requestArg, 'media_content_type') || this.stringData(requestArg, 'mediaType');
}
return base;
}
private async speakerUuidFromRequest(requestArg: IServiceCallRequest): Promise<string> {
const speakerUuid = await this.optionalSpeakerUuidFromRequest(requestArg);
if (speakerUuid) {
return speakerUuid;
}
throw new Error('LinkPlay service call requires data.speaker_uuid or a target LinkPlay media_player entity.');
}
private async optionalSpeakerUuidFromRequest(requestArg: IServiceCallRequest): Promise<string | undefined> {
const direct = this.stringData(requestArg, 'speaker_uuid') || this.stringData(requestArg, 'speakerUuid') || this.stringData(requestArg, 'uuid');
if (direct) {
return direct;
}
const snapshot = await this.client.getSnapshot();
if (requestArg.target.entityId) {
const entitySpeakerUuid = LinkplayMapper.entitySpeakerUuid(snapshot, requestArg.target.entityId);
if (entitySpeakerUuid) {
return entitySpeakerUuid;
}
}
if (requestArg.target.deviceId) {
const speaker = snapshot.speakers.find((speakerArg) => LinkplayMapper.speakerDeviceId(speakerArg) === requestArg.target.deviceId);
if (speaker) {
return speaker.uuid;
}
}
return snapshot.speakers.length === 1 ? snapshot.speakers[0].uuid : undefined;
}
private async memberUuidsFromRequest(requestArg: IServiceCallRequest): Promise<string[]> {
const direct = this.stringArrayData(requestArg, 'speaker_uuids') || this.stringArrayData(requestArg, 'speakerUuids') || this.stringArrayData(requestArg, 'member_uuids') || this.stringArrayData(requestArg, 'memberUuids');
if (direct?.length) {
return direct;
}
const members = this.stringArrayData(requestArg, 'group_members') || this.stringArrayData(requestArg, 'groupMembers');
if (!members?.length) {
throw new Error('LinkPlay join service requires data.group_members or data.speaker_uuids.');
}
const snapshot = await this.client.getSnapshot();
return members.map((memberArg) => LinkplayMapper.entitySpeakerUuid(snapshot, memberArg) || memberArg);
}
private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
const value = requestArg.data?.[keyArg];
return typeof value === 'string' && value ? value : undefined;
}
private numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined {
const value = requestArg.data?.[keyArg];
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
private booleanData(requestArg: IServiceCallRequest, keyArg: string): boolean | undefined {
const value = requestArg.data?.[keyArg];
return typeof value === 'boolean' ? value : undefined;
}
private stringArrayData(requestArg: IServiceCallRequest, keyArg: string): string[] | undefined {
const value = requestArg.data?.[keyArg];
if (typeof value === 'string') {
return [value];
}
return Array.isArray(value) && value.every((itemArg) => typeof itemArg === 'string') ? value : undefined;
}
}
@@ -0,0 +1,240 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { ILinkplayManualEntry, ILinkplayMdnsRecord, ILinkplaySsdpRecord, TLinkplayProtocol } from './linkplay.types.js';
import { linkplayDefaultPort, linkplayProjectLookup } from './linkplay.types.js';
const linkplayDomain = 'linkplay';
const linkplayMdnsType = '_linkplay._tcp';
const mediaRendererUrn = 'urn:schemas-upnp-org:device:MediaRenderer:1';
const linkplayNames = ['linkplay', 'arylic', 'up2stream', 'wiim', 'muzo', 'audiocast', 'ieast', 'ggmm', 'medion'];
export class LinkplayMdnsMatcher implements IDiscoveryMatcher<ILinkplayMdnsRecord> {
public id = 'linkplay-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize LinkPlay zeroconf advertisements.';
public async matches(recordArg: ILinkplayMdnsRecord): Promise<IDiscoveryMatch> {
const type = normalizeMdnsType(recordArg.type || recordArg.serviceType || '');
const properties = { ...recordArg.txt, ...recordArg.properties };
const project = valueForKey(properties, 'project') || valueForKey(properties, 'project_build_name');
const uuid = valueForKey(properties, 'uuid') || valueForKey(properties, 'id') || valueForKey(properties, 'UDN');
const name = cleanName(valueForKey(properties, 'name') || valueForKey(properties, 'DeviceName') || recordArg.name || recordArg.hostname);
const projectInfo = project ? linkplayProjectLookup[project] : undefined;
const haystack = `${name || ''} ${type} ${project || ''} ${projectInfo?.manufacturer || ''} ${projectInfo?.model || ''}`.toLowerCase();
const serviceMatch = type === linkplayMdnsType;
const hintMatch = linkplayNames.some((needleArg) => haystack.includes(needleArg));
if (!serviceMatch && !hintMatch) {
return { matched: false, confidence: 'low', reason: 'mDNS record is not a LinkPlay advertisement.' };
}
const host = recordArg.host || recordArg.addresses?.[0];
const port = recordArg.port || linkplayDefaultPort;
const id = uuid || (host ? `${host}:${port}` : name);
return {
matched: true,
confidence: serviceMatch && uuid ? 'certain' : serviceMatch && host ? 'high' : 'medium',
reason: serviceMatch ? 'mDNS service is a LinkPlay zeroconf service.' : 'mDNS metadata contains LinkPlay hints.',
normalizedDeviceId: id,
candidate: {
source: 'mdns',
integrationDomain: linkplayDomain,
id,
host,
port,
name,
manufacturer: projectInfo?.manufacturer || valueForKey(properties, 'manufacturer') || 'LinkPlay',
model: projectInfo?.model || valueForKey(properties, 'model'),
metadata: {
mdnsName: recordArg.name,
mdnsType: type,
txt: properties,
project,
},
},
metadata: { mdnsType: type, project },
};
}
}
export class LinkplaySsdpMatcher implements IDiscoveryMatcher<ILinkplaySsdpRecord> {
public id = 'linkplay-ssdp-match';
public source = 'ssdp' as const;
public description = 'Recognize LinkPlay UPnP MediaRenderer advertisements with LinkPlay metadata.';
public async matches(recordArg: ILinkplaySsdpRecord): Promise<IDiscoveryMatch> {
const st = header(recordArg, 'st') || upnp(recordArg, 'deviceType');
const usn = header(recordArg, 'usn');
const location = header(recordArg, 'location');
const manufacturer = upnp(recordArg, 'manufacturer');
const model = upnp(recordArg, 'modelName') || upnp(recordArg, 'model');
const friendlyName = upnp(recordArg, 'friendlyName');
const serialNumber = upnp(recordArg, 'serialNumber') || upnp(recordArg, 'serial');
const project = upnp(recordArg, 'project') || upnp(recordArg, 'modelNumber');
const projectInfo = project ? linkplayProjectLookup[project] : undefined;
const haystack = `${st || ''} ${usn || ''} ${manufacturer || ''} ${model || ''} ${friendlyName || ''} ${project || ''}`.toLowerCase();
const renderer = normalizeUrn(st) === normalizeUrn(mediaRendererUrn);
const hintMatch = linkplayNames.some((needleArg) => haystack.includes(needleArg)) || Boolean(projectInfo);
if (!renderer || !hintMatch) {
return { matched: false, confidence: renderer ? 'medium' : 'low', reason: renderer ? 'MediaRenderer record lacks LinkPlay metadata.' : 'SSDP record is not a LinkPlay MediaRenderer.' };
}
const url = parseUrl(location);
const id = serialNumber || stripUuid(usn) || (url?.hostname ? `${url.hostname}:${linkplayDefaultPort}` : undefined);
return {
matched: true,
confidence: url?.hostname && id ? 'high' : 'medium',
reason: 'SSDP MediaRenderer has LinkPlay-specific metadata.',
normalizedDeviceId: id,
candidate: {
source: 'ssdp',
integrationDomain: linkplayDomain,
id,
host: url?.hostname,
port: linkplayDefaultPort,
name: friendlyName,
manufacturer: projectInfo?.manufacturer || manufacturer || 'LinkPlay',
model: projectInfo?.model || model,
serialNumber,
metadata: { st, usn, location, project, deviceType: upnp(recordArg, 'deviceType') },
},
};
}
}
export class LinkplayManualMatcher implements IDiscoveryMatcher<ILinkplayManualEntry> {
public id = 'linkplay-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual LinkPlay setup entries.';
public async matches(inputArg: ILinkplayManualEntry): Promise<IDiscoveryMatch> {
const projectInfo = inputArg.project ? linkplayProjectLookup[inputArg.project] : undefined;
const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''} ${inputArg.project || ''}`.toLowerCase();
const matched = Boolean(inputArg.host || inputArg.metadata?.linkplay || projectInfo || linkplayNames.some((needleArg) => haystack.includes(needleArg)));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain LinkPlay setup hints.' };
}
const protocol = inputArg.protocol || protocolFromPort(inputArg.port);
const port = inputArg.port || linkplayDefaultPort;
const id = inputArg.uuid || inputArg.id || (inputArg.host ? `${inputArg.host}:${port}` : undefined);
return {
matched: true,
confidence: inputArg.host ? 'high' : 'medium',
reason: 'Manual entry can start LinkPlay setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: linkplayDomain,
id,
host: inputArg.host,
port,
name: inputArg.name,
manufacturer: inputArg.manufacturer || projectInfo?.manufacturer || 'LinkPlay',
model: inputArg.model || projectInfo?.model,
macAddress: inputArg.macAddress,
metadata: {
...inputArg.metadata,
protocol,
project: inputArg.project,
modelId: inputArg.modelId,
},
},
};
}
}
export class LinkplayCandidateValidator implements IDiscoveryValidator {
public id = 'linkplay-candidate-validator';
public description = 'Validate LinkPlay candidates have local endpoint metadata.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const project = stringMetadata(metadata.project);
const mdnsType = stringMetadata(metadata.mdnsType);
const haystack = `${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''} ${project || ''}`.toLowerCase();
const matched = candidateArg.integrationDomain === linkplayDomain
|| Boolean(metadata.linkplay)
|| normalizeMdnsType(mdnsType || '') === linkplayMdnsType
|| Boolean(project && linkplayProjectLookup[project])
|| linkplayNames.some((needleArg) => haystack.includes(needleArg));
if (!matched || !candidateArg.host) {
return {
matched: false,
confidence: matched ? 'medium' : 'low',
reason: matched ? 'LinkPlay candidate lacks host information.' : 'Candidate is not LinkPlay.',
};
}
const protocol = stringMetadata(metadata.protocol) === 'https' ? 'https' : protocolFromPort(candidateArg.port);
const port = candidateArg.port || linkplayDefaultPort;
return {
matched: true,
confidence: candidateArg.id ? 'certain' : 'high',
reason: 'Candidate has LinkPlay metadata and host information.',
normalizedDeviceId: candidateArg.id || `${candidateArg.host}:${port}`,
candidate: {
...candidateArg,
port,
metadata: {
...metadata,
protocol,
},
},
};
}
}
export const createLinkplayDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: linkplayDomain, displayName: 'LinkPlay' })
.addMatcher(new LinkplayMdnsMatcher())
.addMatcher(new LinkplaySsdpMatcher())
.addMatcher(new LinkplayManualMatcher())
.addValidator(new LinkplayCandidateValidator());
};
const normalizeMdnsType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.local\.?$/, '').replace(/\.$/, '');
const cleanName = (valueArg: string | undefined): string | undefined => {
return valueArg?.replace(/\._linkplay\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || undefined;
};
const valueForKey = (recordArg: Record<string, string | undefined> | undefined, keyArg: string): string | undefined => {
if (!recordArg) {
return undefined;
}
const lowerKey = keyArg.toLowerCase();
for (const [key, value] of Object.entries(recordArg)) {
if (key.toLowerCase() === lowerKey) {
return value;
}
}
return undefined;
};
const header = (recordArg: ILinkplaySsdpRecord, keyArg: string): string | undefined => {
return recordArg[keyArg as keyof ILinkplaySsdpRecord] as string | undefined || valueForKey(recordArg.headers, keyArg);
};
const upnp = (recordArg: ILinkplaySsdpRecord, keyArg: string): string | undefined => {
return valueForKey(recordArg.upnp, keyArg) || valueForKey(recordArg.headers, keyArg);
};
const parseUrl = (valueArg: string | undefined): URL | undefined => {
if (!valueArg) {
return undefined;
}
try {
return new URL(valueArg);
} catch {
return undefined;
}
};
const stripUuid = (valueArg: string | undefined): string | undefined => valueArg?.replace(/^uuid:/i, '').split('::')[0];
const normalizeUrn = (valueArg: string | undefined): string => (valueArg || '').toLowerCase();
const protocolFromPort = (portArg: number | undefined): TLinkplayProtocol => portArg === 443 || portArg === 4443 ? 'https' : 'http';
const stringMetadata = (valueArg: unknown): string | undefined => typeof valueArg === 'string' ? valueArg : undefined;
+323
View File
@@ -0,0 +1,323 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity } from '../../core/types.js';
import { linkplayRepeatFromLoopMode, linkplaySourceName } from './linkplay.classes.client.js';
import type { ILinkplayMultiroomGroup, ILinkplaySnapshot, ILinkplaySpeaker } from './linkplay.types.js';
export class LinkplayMapper {
public static toDevices(snapshotArg: ILinkplaySnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [];
for (const speaker of snapshotArg.speakers) {
devices.push({
id: this.speakerDeviceId(speaker),
integrationDomain: 'linkplay',
name: this.speakerName(speaker),
protocol: speaker.device.protocol === 'http' ? 'http' : 'unknown',
manufacturer: speaker.device.manufacturer || 'Generic',
model: speaker.device.model || speaker.device.modelId || 'LinkPlay Speaker',
online: snapshotArg.online && speaker.available !== false,
features: [
{ id: 'playback', capability: 'media', name: 'Playback', 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: 'source', capability: 'media', name: 'Source', readable: true, writable: true },
{ id: 'preset_count', capability: 'media', name: 'Presets', readable: true, writable: true },
{ id: 'multiroom_group', capability: 'media', name: 'Multiroom group', readable: true, writable: true },
{ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false },
],
state: [
{ featureId: 'playback', value: this.mediaState(snapshotArg, speaker), updatedAt },
{ featureId: 'volume', value: speaker.player.volume ?? null, updatedAt },
{ featureId: 'muted', value: speaker.player.muted ?? null, updatedAt },
{ featureId: 'source', value: this.currentSource(speaker) || null, updatedAt },
{ featureId: 'preset_count', value: (speaker.presets || snapshotArg.presets || []).length, updatedAt },
{ featureId: 'multiroom_group', value: this.groupForSpeaker(snapshotArg, speaker.uuid)?.id || null, updatedAt },
{ featureId: 'current_title', value: this.mediaTitle(speaker) || null, updatedAt },
],
metadata: {
uuid: speaker.uuid,
host: speaker.device.host,
port: speaker.device.port,
project: speaker.device.project,
modelId: speaker.device.modelId,
macAddress: speaker.device.macAddress,
hardwareVersion: speaker.device.hardwareVersion,
firmwareVersion: speaker.device.firmwareVersion,
viaIntegration: snapshotArg.source,
},
});
}
for (const group of snapshotArg.multirooms || []) {
devices.push({
id: this.multiroomDeviceId(group),
integrationDomain: 'linkplay',
name: group.name || 'LinkPlay Multiroom',
protocol: 'http',
manufacturer: 'LinkPlay',
model: 'Multiroom Group',
online: snapshotArg.online,
features: [
{ id: 'members', capability: 'media', name: 'Members', readable: true, writable: true },
{ id: 'volume', capability: 'media', name: 'Group volume', readable: true, writable: true, unit: '%' },
{ id: 'muted', capability: 'media', name: 'Group muted', readable: true, writable: true },
],
state: [
{ featureId: 'members', value: this.groupEntityIds(snapshotArg, group).length, updatedAt },
{ featureId: 'volume', value: group.volume ?? null, updatedAt },
{ featureId: 'muted', value: group.muted ?? null, updatedAt },
],
metadata: {
leaderUuid: group.leaderUuid,
followerUuids: group.followerUuids,
members: this.groupEntityIds(snapshotArg, group),
},
});
}
return devices;
}
public static toEntities(snapshotArg: ILinkplaySnapshot): IIntegrationEntity[] {
const entities: IIntegrationEntity[] = [];
for (const speaker of snapshotArg.speakers) {
const base = this.speakerEntityBase(speaker);
const available = snapshotArg.online && speaker.available !== false;
const group = this.groupForSpeaker(snapshotArg, speaker.uuid);
entities.push({
id: this.speakerEntityId(speaker),
uniqueId: `linkplay_${this.slug(speaker.uuid)}`,
integrationDomain: 'linkplay',
deviceId: this.speakerDeviceId(speaker),
platform: 'media_player',
name: this.speakerName(speaker),
state: this.mediaState(snapshotArg, speaker),
attributes: {
deviceClass: 'receiver',
speakerUuid: speaker.uuid,
model: speaker.device.model,
modelId: speaker.device.modelId,
project: speaker.device.project,
host: speaker.device.host,
volumeLevel: this.volumeLevel(speaker),
volume: speaker.player.volume,
isVolumeMuted: speaker.player.muted,
repeat: linkplayRepeatFromLoopMode(speaker.player.loopMode),
shuffle: speaker.player.loopMode === '2',
soundMode: speaker.player.equalizerMode,
soundModeList: this.soundModeList(speaker),
source: this.currentSource(speaker),
sourceList: this.sourceList(snapshotArg, speaker),
presets: speaker.presets || snapshotArg.presets || [],
audioOutputHardwareMode: speaker.audioOutputHardwareMode,
mediaContentType: 'music',
mediaDuration: this.seconds(speaker.player.totalLengthMs),
mediaPosition: this.seconds(speaker.player.currentPositionMs),
mediaTitle: speaker.player.title,
mediaArtist: speaker.player.artist,
mediaAlbumName: speaker.player.album,
mediaImageUrl: speaker.player.status === 'play' || speaker.player.status === 'pause' ? speaker.player.albumArtUrl : undefined,
playMode: speaker.player.playMode,
loopMode: speaker.player.loopMode,
groupId: group?.id,
groupRole: group ? group.leaderUuid === speaker.uuid ? 'leader' : 'follower' : undefined,
groupMembers: group ? this.groupEntityIds(snapshotArg, group) : undefined,
},
available,
});
entities.push({
id: `sensor.${base}_linkplay_media`,
uniqueId: `linkplay_${this.slug(speaker.uuid)}_media`,
integrationDomain: 'linkplay',
deviceId: this.speakerDeviceId(speaker),
platform: 'sensor',
name: `${this.speakerName(speaker)} LinkPlay Media`,
state: this.mediaTitle(speaker) || 'None',
attributes: {
speakerUuid: speaker.uuid,
player: speaker.player,
},
available,
});
entities.push({
id: `sensor.${base}_linkplay_sources`,
uniqueId: `linkplay_${this.slug(speaker.uuid)}_sources`,
integrationDomain: 'linkplay',
deviceId: this.speakerDeviceId(speaker),
platform: 'sensor',
name: `${this.speakerName(speaker)} LinkPlay Sources`,
state: this.sourceList(snapshotArg, speaker).length,
attributes: {
speakerUuid: speaker.uuid,
sources: speaker.sources || snapshotArg.sources || [],
sourceList: this.sourceList(snapshotArg, speaker),
},
available,
});
entities.push({
id: `sensor.${base}_linkplay_presets`,
uniqueId: `linkplay_${this.slug(speaker.uuid)}_presets`,
integrationDomain: 'linkplay',
deviceId: this.speakerDeviceId(speaker),
platform: 'sensor',
name: `${this.speakerName(speaker)} LinkPlay Presets`,
state: (speaker.presets || snapshotArg.presets || []).length,
attributes: {
speakerUuid: speaker.uuid,
presets: speaker.presets || snapshotArg.presets || [],
},
available,
});
if (group) {
entities.push({
id: `sensor.${base}_linkplay_multiroom`,
uniqueId: `linkplay_${this.slug(speaker.uuid)}_multiroom`,
integrationDomain: 'linkplay',
deviceId: this.speakerDeviceId(speaker),
platform: 'sensor',
name: `${this.speakerName(speaker)} LinkPlay Multiroom`,
state: group.leaderUuid === speaker.uuid ? 'leader' : 'follower',
attributes: {
speakerUuid: speaker.uuid,
group,
members: this.groupEntityIds(snapshotArg, group),
},
available,
});
}
if (speaker.audioOutputHardwareMode) {
entities.push({
id: `select.${base}_audio_output_hardware_mode`,
uniqueId: `linkplay_${this.slug(speaker.uuid)}_audio_output_hardware_mode`,
integrationDomain: 'linkplay',
deviceId: this.speakerDeviceId(speaker),
platform: 'select',
name: `${this.speakerName(speaker)} Audio output hardware mode`,
state: speaker.audioOutputHardwareMode,
attributes: {
speakerUuid: speaker.uuid,
options: ['optical', 'line_out', 'coaxial', 'headphones'],
},
available,
});
}
}
if ((snapshotArg.multirooms || []).length) {
entities.push({
id: `sensor.${this.slug(snapshotArg.speakers[0]?.name || 'linkplay')}_multiroom_groups`,
uniqueId: `linkplay_${this.snapshotUniqueBase(snapshotArg)}_multiroom_groups`,
integrationDomain: 'linkplay',
deviceId: this.speakerDeviceId(snapshotArg.speakers[0]),
platform: 'sensor',
name: 'LinkPlay Multiroom Groups',
state: (snapshotArg.multirooms || []).length,
attributes: {
groups: (snapshotArg.multirooms || []).map((groupArg) => ({
...groupArg,
members: this.groupEntityIds(snapshotArg, groupArg),
})),
},
available: snapshotArg.online,
});
}
return entities;
}
public static entitySpeakerUuid(snapshotArg: ILinkplaySnapshot, entityIdArg: string): string | undefined {
const entity = this.toEntities(snapshotArg).find((entityArg) => entityArg.id === entityIdArg);
const speakerUuid = entity?.attributes?.speakerUuid;
return typeof speakerUuid === 'string' ? speakerUuid : undefined;
}
public static speakerEntityId(speakerArg: ILinkplaySpeaker): string {
return `media_player.${this.speakerEntityBase(speakerArg)}`;
}
public static speakerDeviceId(speakerArg: ILinkplaySpeaker | undefined): string {
return `linkplay.speaker.${this.slug(speakerArg?.uuid || speakerArg?.device.uuid || speakerArg?.device.host || speakerArg?.name || 'linkplay')}`;
}
public static multiroomDeviceId(groupArg: ILinkplayMultiroomGroup): string {
return `linkplay.multiroom.${this.slug(groupArg.id || groupArg.leaderUuid)}`;
}
public static slug(valueArg: string | undefined): string {
return (valueArg || 'linkplay').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'linkplay';
}
private static mediaState(snapshotArg: ILinkplaySnapshot, speakerArg: ILinkplaySpeaker): string {
if (!snapshotArg.online || speakerArg.available === false) {
return 'off';
}
if (speakerArg.player.status === 'play') {
return 'playing';
}
if (speakerArg.player.status === 'pause') {
return 'paused';
}
if (speakerArg.player.status === 'load') {
return 'buffering';
}
if (speakerArg.player.status === 'stop') {
return 'idle';
}
return speakerArg.player.status || 'unknown';
}
private static currentSource(speakerArg: ILinkplaySpeaker): string | undefined {
return speakerArg.player.source || linkplaySourceName(speakerArg.player.playMode);
}
private static sourceList(snapshotArg: ILinkplaySnapshot, speakerArg: ILinkplaySpeaker): string[] {
const sources = speakerArg.sources || snapshotArg.sources || [];
return [...new Set(sources.map((sourceArg) => sourceArg.name).filter(Boolean))];
}
private static soundModeList(speakerArg: ILinkplaySpeaker): string[] {
if (speakerArg.device.manufacturer === 'WiiM') {
return ['None', 'Flat', 'Acoustic', 'Bass Booster', 'Bass Reducer', 'Classical', 'Dance', 'Deep', 'Electronic', 'Hip-Hop', 'Jazz', 'Latin', 'Loudness', 'Lounge', 'Piano', 'Pop', 'R&B', 'Rock', 'Small Speakers', 'Spoken Word', 'Treble Booster', 'Treble Reducer', 'Vocal Booster'];
}
return ['None', 'Classic', 'Pop', 'Jazz', 'Vocal'];
}
private static volumeLevel(speakerArg: ILinkplaySpeaker): number | undefined {
return typeof speakerArg.player.volume === 'number' ? Math.max(0, Math.min(1, speakerArg.player.volume / 100)) : undefined;
}
private static mediaTitle(speakerArg: ILinkplaySpeaker): string | undefined {
return speakerArg.player.title || speakerArg.player.metaInfo?.metaData?.title || speakerArg.player.metaInfo?.metaData?.subtitle;
}
private static seconds(valueArg: number | undefined): number | undefined {
return typeof valueArg === 'number' ? Math.floor(valueArg / 1000) : undefined;
}
private static groupForSpeaker(snapshotArg: ILinkplaySnapshot, speakerUuidArg: string): ILinkplayMultiroomGroup | undefined {
return (snapshotArg.multirooms || []).find((groupArg) => groupArg.leaderUuid === speakerUuidArg || groupArg.followerUuids.includes(speakerUuidArg));
}
private static groupEntityIds(snapshotArg: ILinkplaySnapshot, groupArg: ILinkplayMultiroomGroup): string[] {
return [groupArg.leaderUuid, ...groupArg.followerUuids]
.map((uuidArg) => snapshotArg.speakers.find((speakerArg) => speakerArg.uuid === uuidArg))
.filter((speakerArg): speakerArg is ILinkplaySpeaker => Boolean(speakerArg))
.map((speakerArg) => this.speakerEntityId(speakerArg));
}
private static speakerEntityBase(speakerArg: ILinkplaySpeaker): string {
return this.slug(this.speakerName(speakerArg));
}
private static speakerName(speakerArg: ILinkplaySpeaker): string {
return speakerArg.name || speakerArg.device.name || speakerArg.uuid || 'LinkPlay';
}
private static snapshotUniqueBase(snapshotArg: ILinkplaySnapshot): string {
return this.slug(snapshotArg.speakers[0]?.uuid || snapshotArg.speakers[0]?.device.host || 'linkplay');
}
}
+440 -2
View File
@@ -1,4 +1,442 @@
export interface IHomeAssistantLinkplayConfig {
// TODO: replace with the TypeScript-native config for linkplay.
export const linkplayDefaultPort = 80;
export const linkplayDefaultTimeoutMs = 5000;
export type TLinkplayProtocol = 'http' | 'https';
export type TLinkplaySnapshotSource = 'snapshot' | 'http' | 'executor' | 'manual' | 'runtime';
export type TLinkplayPlayingStatus = 'play' | 'pause' | 'stop' | 'load' | (string & {});
export type TLinkplayRepeatMode = 'off' | 'one' | 'all';
export type TLinkplayCommand =
| 'play'
| 'pause'
| 'stop'
| 'next_track'
| 'previous_track'
| 'play_media'
| 'seek'
| 'set_volume'
| 'mute'
| 'select_source'
| 'set_repeat'
| 'set_sound_mode'
| 'play_preset'
| 'timesync'
| 'restart'
| 'set_audio_output_hardware_mode'
| 'join'
| 'unjoin'
| 'group_volume_set'
| 'group_mute'
| 'raw_command';
export type TLinkplayPlayingMode =
| '-96'
| '-1'
| '0'
| '1'
| '2'
| '3'
| '10'
| '11'
| '12'
| '13'
| '14'
| '16'
| '19'
| '20'
| '21'
| '29'
| '30'
| '31'
| '32'
| '36'
| '40'
| '41'
| '43'
| '44'
| '45'
| '46'
| '47'
| '48'
| '49'
| '50'
| '51'
| '52'
| '53'
| '54'
| '56'
| '57'
| '58'
| '60'
| '99'
| (string & {});
export type TLinkplayLoopMode = '-1' | '0' | '1' | '2' | '3' | '4' | '5' | (string & {});
export type TLinkplayAudioOutputHardwareMode = 'optical' | 'line_out' | 'coaxial' | 'headphones' | (string & {});
export interface ILinkplayConfig {
host?: string;
port?: number;
protocol?: TLinkplayProtocol;
timeoutMs?: number;
name?: string;
uniqueId?: string;
manufacturer?: string;
model?: string;
modelId?: string;
macAddress?: string;
snapshot?: ILinkplaySnapshot;
commandExecutor?: ILinkplayCommandExecutor;
}
export interface IHomeAssistantLinkplayConfig extends ILinkplayConfig {}
export interface ILinkplayCommandExecutor {
execute(requestArg: ILinkplayRawCommandRequest): Promise<ILinkplayCommandExecutorResult | unknown>;
}
export interface ILinkplayRawCommandRequest {
command: string;
url?: string;
host?: string;
port: number;
protocol: TLinkplayProtocol;
expectJson?: boolean;
speakerUuid?: string;
speakerName?: string;
}
export interface ILinkplayCommandExecutorResult {
ok?: boolean;
success?: boolean;
status?: number;
response?: string;
data?: unknown;
}
export interface ILinkplayCommandRequest {
command: TLinkplayCommand;
speakerUuid?: string;
source?: string;
mediaId?: string;
mediaType?: string;
url?: string;
volumeLevel?: number;
volume?: number;
muted?: boolean;
repeat?: TLinkplayRepeatMode;
soundMode?: string;
preset?: number;
position?: number;
groupMemberUuids?: string[];
hardwareMode?: TLinkplayAudioOutputHardwareMode | string;
rawCommand?: string;
}
export interface ILinkplayDeviceProperties {
uuid?: string;
DeviceName?: string;
GroupName?: string;
firmware?: string;
hardware?: string;
project?: string;
MAC?: string;
STA_MAC?: string;
ETH_MAC?: string;
eth2?: string;
eth0?: string;
apcli0?: string;
plm_support?: string;
preset_key?: string;
[key: string]: unknown;
}
export interface ILinkplayPlayerProperties {
type?: string;
ch?: string;
mode?: TLinkplayPlayingMode;
loop?: TLinkplayLoopMode;
eq?: string;
status?: TLinkplayPlayingStatus;
curpos?: string | number;
offset_pts?: string | number;
totlen?: string | number;
Title?: string;
Artist?: string;
Album?: string;
plicount?: string | number;
plicurr?: string | number;
vol?: string | number;
mute?: string | number;
[key: string]: unknown;
}
export interface ILinkplayMetaInfo {
metaData?: {
album?: string;
title?: string;
subtitle?: string;
albumArtURI?: string;
sampleRate?: string | number;
bitDepth?: string | number;
bitRate?: string | number;
trackId?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
export interface ILinkplayDeviceInfo {
uuid: string;
name: string;
host?: string;
port?: number;
protocol?: TLinkplayProtocol;
endpoint?: string;
manufacturer?: string;
model?: string;
modelId?: string;
project?: string;
macAddress?: string;
hardwareVersion?: string;
firmwareVersion?: string;
ethernetAddress?: string;
playModeSupport?: TLinkplayPlayingMode[];
maxPresets?: number;
properties?: ILinkplayDeviceProperties;
}
export interface ILinkplayPlayerStatus {
status: TLinkplayPlayingStatus;
playMode?: TLinkplayPlayingMode;
loopMode?: TLinkplayLoopMode;
equalizerMode?: string;
volume?: number;
muted?: boolean;
title?: string;
artist?: string;
album?: string;
albumArtUrl?: string;
currentPositionMs?: number;
totalLengthMs?: number;
source?: string;
speakerType?: string;
channelType?: string;
properties?: ILinkplayPlayerProperties;
metaInfo?: ILinkplayMetaInfo;
}
export interface ILinkplaySource {
mode: TLinkplayPlayingMode;
name: string;
commandValue?: string;
available?: boolean;
}
export interface ILinkplayPreset {
number: number;
name?: string;
source?: string;
}
export interface ILinkplaySpeaker {
uuid: string;
name?: string;
device: ILinkplayDeviceInfo;
player: ILinkplayPlayerStatus;
available?: boolean;
audioOutputHardwareMode?: TLinkplayAudioOutputHardwareMode | string;
sources?: ILinkplaySource[];
presets?: ILinkplayPreset[];
}
export interface ILinkplayMultiroomGroup {
id: string;
name?: string;
leaderUuid: string;
followerUuids: string[];
volume?: number;
muted?: boolean;
}
export interface ILinkplaySnapshot {
speakers: ILinkplaySpeaker[];
multirooms?: ILinkplayMultiroomGroup[];
sources?: ILinkplaySource[];
presets?: ILinkplayPreset[];
online: boolean;
updatedAt?: string;
source?: TLinkplaySnapshotSource;
error?: string;
raw?: Record<string, unknown>;
}
export interface ILinkplayMdnsRecord {
type?: string;
serviceType?: string;
name?: string;
host?: string;
hostname?: string;
port?: number;
addresses?: string[];
txt?: Record<string, string | undefined>;
properties?: Record<string, string | undefined>;
}
export interface ILinkplaySsdpRecord {
st?: string;
usn?: string;
location?: string;
headers?: Record<string, string | undefined>;
upnp?: Record<string, string | undefined>;
}
export interface ILinkplayManualEntry {
host?: string;
port?: number;
protocol?: TLinkplayProtocol;
id?: string;
uuid?: string;
name?: string;
manufacturer?: string;
model?: string;
modelId?: string;
project?: string;
macAddress?: string;
metadata?: Record<string, unknown>;
}
export const linkplaySourceMap: Record<string, string> = {
'10': 'Wifi',
'40': 'Line In',
'41': 'Bluetooth',
'43': 'Optical',
'47': 'Line In 2',
'51': 'USB DAC',
'45': 'Coaxial',
'48': 'XLR',
'49': 'HDMI',
'56': 'Optical 2',
'53': 'External Bluetooth',
'54': 'Phono',
'58': 'ARC',
'57': 'Coaxial 2',
'16': 'SD Card 1',
'52': 'SD Card 2',
'50': 'CD',
'-96': 'DAB Radio',
'46': 'FM Radio',
'44': 'RCA',
'21': 'USB',
'31': 'Spotify',
'32': 'Tidal',
'99': 'Follower',
};
export const linkplayPlayModeSendMap: Record<string, string> = {
'0': 'Idle',
'1': 'Airplay',
'2': 'DLNA',
'3': 'QPlay',
'10': 'wifi',
'11': 'udisk',
'16': 'TFcard',
'20': 'API',
'21': 'udisk',
'30': 'Alarm',
'31': 'Spotify',
'32': 'Tidal',
'36': 'Qobuz',
'40': 'line-in',
'41': 'bluetooth',
'43': 'optical',
'44': 'RCA',
'45': 'co-axial',
'46': 'FM',
'47': 'line-in2',
'48': 'XLR',
'49': 'HDMI',
'50': 'cd',
'51': 'PCUSB',
'52': 'TFcard',
'60': 'Talk',
'99': 'Idle',
'56': 'optical2',
'54': 'phono',
'57': 'co-axial2',
'58': 'ARC',
'-96': 'DAB',
'53': 'extern_bluetooth',
};
export const linkplayRepeatToLoopMode: Record<TLinkplayRepeatMode, TLinkplayLoopMode> = {
one: '-1',
off: '4',
all: '5',
};
export const linkplayLoopModeToRepeat: Record<string, TLinkplayRepeatMode> = {
'-1': 'one',
'0': 'off',
'1': 'all',
'2': 'all',
'3': 'all',
'4': 'off',
'5': 'all',
};
export const linkplayNormalEqualizerModeToNumber: Record<string, string> = {
None: '0',
Classic: '1',
Pop: '2',
Jazz: '3',
Vocal: '4',
};
export const linkplayNormalEqualizerNumberToMode: Record<string, string> = {
'0': 'None',
'1': 'Classic',
'2': 'Pop',
'3': 'Jazz',
'4': 'Vocal',
};
export const linkplayAudioOutputHardwareModeToCode: Record<string, string> = {
optical: '1',
line_out: '2',
coaxial: '3',
headphones: '4',
};
export const linkplayAudioOutputHardwareCodeToMode: Record<string, TLinkplayAudioOutputHardwareMode> = {
'1': 'optical',
'2': 'line_out',
'3': 'coaxial',
'4': 'headphones',
};
export const linkplayProjectLookup: Record<string, { manufacturer: string; model: string }> = {
SMART_ZONE4_AMP: { manufacturer: 'ArtSound', model: 'Smart Zone 4 AMP' },
SMART_HYDE: { manufacturer: 'ArtSound', model: 'Smart Hyde' },
ARYLIC_S50: { manufacturer: 'Arylic', model: 'S50+' },
RP0016_S50PRO_S: { manufacturer: 'Arylic', model: 'S50 Pro' },
RP0011_WB60_S: { manufacturer: 'Arylic', model: 'A30' },
'X-50': { manufacturer: 'Arylic', model: 'A50' },
ARYLIC_A50S: { manufacturer: 'Arylic', model: 'A50+' },
RP0011_WB60: { manufacturer: 'Arylic', model: 'Up2Stream Amp 2.0' },
UP2STREAM_AMP_V3: { manufacturer: 'Arylic', model: 'Up2Stream Amp v3' },
UP2STREAM_AMP_V4: { manufacturer: 'Arylic', model: 'Up2Stream Amp v4' },
UP2STREAM_PRO_V3: { manufacturer: 'Arylic', model: 'Up2Stream Pro v3' },
UP2STREAM_PRO_V4: { manufacturer: 'Arylic', model: 'Up2Stream Pro v4' },
S10P_WIFI: { manufacturer: 'Arylic', model: 'Arylic S10+' },
ARYLIC_V20: { manufacturer: 'Arylic', model: 'Up2Stream Plate Amp' },
UP2STREAM_MINI_V3: { manufacturer: 'Arylic', model: 'Up2Stream Mini' },
UP2STREAM_AMP_2P1: { manufacturer: 'Arylic', model: 'Up2Stream Amp 2.1' },
'iEAST-02': { manufacturer: 'iEAST', model: 'AudioCast M5' },
WiiM_Amp_4layer: { manufacturer: 'WiiM', model: 'WiiM Amp' },
WiiM_Pro_with_gc4a: { manufacturer: 'WiiM', model: 'WiiM Pro' },
WiiM_Pro_Plus: { manufacturer: 'WiiM', model: 'WiiM Pro Plus' },
WiiM_AMP: { manufacturer: 'WiiM', model: 'WiiM Amp' },
Muzo_Mini: { manufacturer: 'WiiM', model: 'WiiM Mini' },
GGMM_E2A: { manufacturer: 'GGMM', model: 'GGMM E2' },
A16: { manufacturer: 'Medion', model: 'Life P66970 (MD 43970)' },
};
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -1,2 +1,6 @@
export * from './madvr.classes.client.js';
export * from './madvr.classes.configflow.js';
export * from './madvr.classes.integration.js';
export * from './madvr.discovery.js';
export * from './madvr.mapper.js';
export * from './madvr.types.js';
@@ -0,0 +1,747 @@
import * as plugins from '../../plugins.js';
import type {
IMadvrCommandModel,
IMadvrCommandRequest,
IMadvrCommandResult,
IMadvrConfig,
IMadvrDisplayState,
IMadvrProcessorState,
IMadvrRawState,
IMadvrSnapshot,
TMadvrCommandInput,
TMadvrCommandName,
TMadvrMenu,
TMadvrProfileGroup,
TMadvrRemoteKey,
TMadvrToggle,
} from './madvr.types.js';
import { madvrDefaultPort } from './madvr.types.js';
type TMadvrCommandKind = 'single' | 'info' | 'key' | 'menu' | 'profile' | 'toggle';
interface IMadvrCommandDefinition {
wire: string;
kind: TMadvrCommandKind;
informational: boolean;
}
const commandTimeoutMs = 3000;
const connectTimeoutMs = 5000;
const snapshotCollectMs = 1000;
const commandResponseCollectMs = 150;
const heartbeatCommand = 'Heartbeat\r\n';
const commandDefinitions: Record<Exclude<TMadvrCommandName, 'PowerOn'>, IMadvrCommandDefinition> = {
PowerOff: { wire: 'PowerOff', kind: 'single', informational: false },
Standby: { wire: 'Standby', kind: 'single', informational: false },
Restart: { wire: 'Restart', kind: 'single', informational: false },
ReloadSoftware: { wire: 'ReloadSoftware', kind: 'single', informational: false },
Bye: { wire: 'Bye', kind: 'single', informational: false },
ResetTemporary: { wire: 'ResetTemporary', kind: 'single', informational: false },
ActivateProfile: { wire: 'ActivateProfile', kind: 'profile', informational: false },
OpenMenu: { wire: 'OpenMenu', kind: 'menu', informational: false },
CloseMenu: { wire: 'CloseMenu', kind: 'single', informational: false },
KeyPress: { wire: 'KeyPress', kind: 'key', informational: false },
KeyHold: { wire: 'KeyHold', kind: 'key', informational: false },
GetIncomingSignalInfo: { wire: 'GetIncomingSignalInfo', kind: 'info', informational: true },
GetOutgoingSignalInfo: { wire: 'GetOutgoingSignalInfo', kind: 'info', informational: true },
GetAspectRatio: { wire: 'GetAspectRatio', kind: 'info', informational: true },
GetMaskingRatio: { wire: 'GetMaskingRatio', kind: 'info', informational: true },
GetTemperatures: { wire: 'GetTemperatures', kind: 'info', informational: true },
GetMacAddress: { wire: 'GetMacAddress', kind: 'info', informational: true },
Toggle: { wire: 'Toggle', kind: 'toggle', informational: false },
ToneMapOn: { wire: 'ToneMapOn', kind: 'single', informational: false },
ToneMapOff: { wire: 'ToneMapOff', kind: 'single', informational: false },
Hotplug: { wire: 'Hotplug', kind: 'single', informational: false },
RefreshLicenseInfo: { wire: 'RefreshLicenseInfo', kind: 'single', informational: false },
Force1080p60Output: { wire: 'Force1080p60Output', kind: 'single', informational: false },
};
const remoteKeys: TMadvrRemoteKey[] = ['MENU', 'UP', 'DOWN', 'LEFT', 'RIGHT', 'OK', 'INPUT', 'SETTINGS', 'RED', 'GREEN', 'BLUE', 'YELLOW', 'POWER'];
const menus: TMadvrMenu[] = ['Info', 'Settings', 'Configuration', 'Profiles', 'TestPatterns'];
const profileGroups: TMadvrProfileGroup[] = ['SOURCE', 'DISPLAY', 'CUSTOM'];
const toggles: TMadvrToggle[] = ['ToneMap', 'HighlightRecovery', 'ContrastRecovery', 'ShadowRecovery', '3DLUT', 'ScreenBoundaries', 'Histogram', 'DebugOSD'];
export class MadvrCommandModeler {
public static requestsFromInput(inputArg: unknown): IMadvrCommandRequest[] {
if (typeof inputArg === 'string') {
return [this.requestFromString(inputArg)];
}
if (Array.isArray(inputArg)) {
const strings = inputArg.filter((itemArg): itemArg is string => typeof itemArg === 'string' && Boolean(itemArg.trim()));
if (!strings.length) {
return [];
}
const firstCommand = this.normalizeCommandName(strings[0]);
const firstDefinition = firstCommand === 'PowerOn' ? undefined : commandDefinitions[firstCommand as Exclude<TMadvrCommandName, 'PowerOn'>];
if (firstDefinition && firstDefinition.kind !== 'single' && firstDefinition.kind !== 'info' && strings.length <= 3) {
return [{ command: firstCommand, args: strings.slice(1).map((itemArg) => itemArg.trim()).filter(Boolean) }];
}
return strings.flatMap((commandArg) => this.requestsFromInput(commandArg));
}
if (this.isCommandRequest(inputArg)) {
return [{ ...inputArg, command: this.normalizeCommandName(inputArg.command), args: inputArg.args?.map((itemArg) => String(itemArg).trim()).filter(Boolean) }];
}
return [];
}
public static model(inputArg: TMadvrCommandInput): IMadvrCommandModel {
const requests = this.requestsFromInput(inputArg);
if (requests.length !== 1) {
throw new Error('madVR command modeling requires exactly one command request.');
}
return this.modelRequest(requests[0]);
}
public static modelRequest(requestArg: IMadvrCommandRequest): IMadvrCommandModel {
const command = this.normalizeCommandName(requestArg.command);
const request: IMadvrCommandRequest = { ...requestArg, command };
if (command === 'PowerOn') {
return {
request,
transport: 'wake_on_lan',
informational: false,
requiresConnection: false,
description: 'Wake madVR Envy with Wake-on-LAN. Requires a MAC address or custom executor.',
};
}
const definition = commandDefinitions[command as Exclude<TMadvrCommandName, 'PowerOn'>];
if (!definition) {
throw new Error(`Unsupported madVR command: ${requestArg.command}`);
}
const args = this.protocolArgs(definition, request.args || []);
const wireCommand = `${definition.wire}${args.length ? ` ${args.join(' ')}` : ''}\r\n`;
return {
request: { ...request, args },
transport: 'tcp',
wireCommand,
informational: definition.informational,
requiresConnection: true,
};
}
private static requestFromString(valueArg: string): IMadvrCommandRequest {
const parts = valueArg.split(',').map((partArg) => partArg.trim()).filter(Boolean);
if (!parts.length) {
throw new Error('madVR command cannot be empty.');
}
return { command: this.normalizeCommandName(parts[0]), args: parts.slice(1) };
}
private static protocolArgs(definitionArg: IMadvrCommandDefinition, argsArg: string[]): string[] {
if (definitionArg.kind === 'single' || definitionArg.kind === 'info') {
if (argsArg.length) {
throw new Error(`${definitionArg.wire} does not accept parameters.`);
}
return [];
}
if (definitionArg.kind === 'key') {
return [this.normalizeKey(this.requiredArg(definitionArg.wire, argsArg, 0))];
}
if (definitionArg.kind === 'menu') {
return [this.normalizeMenu(this.requiredArg(definitionArg.wire, argsArg, 0))];
}
if (definitionArg.kind === 'toggle') {
return [this.normalizeToggle(this.requiredArg(definitionArg.wire, argsArg, 0))];
}
const group = this.normalizeProfileGroup(this.requiredArg(definitionArg.wire, argsArg, 0));
const profileNumber = argsArg[1] ? this.normalizeInteger(argsArg[1], 'profile number') : undefined;
if (argsArg.length > 2) {
throw new Error('ActivateProfile accepts a profile group and optional numeric profile number.');
}
return profileNumber ? [group, profileNumber] : [group];
}
private static requiredArg(commandArg: string, argsArg: string[], indexArg: number): string {
const value = argsArg[indexArg];
if (!value) {
throw new Error(`${commandArg} requires parameter ${indexArg + 1}.`);
}
if (/\r|\n/.test(value)) {
throw new Error('madVR command parameters cannot contain line breaks.');
}
return value;
}
private static normalizeCommandName(valueArg: string): TMadvrCommandName {
const normalized = valueArg.replace(/[_\s-]+/g, '').toLowerCase();
const aliases: Record<string, TMadvrCommandName> = {
on: 'PowerOn',
turnon: 'PowerOn',
poweron: 'PowerOn',
off: 'PowerOff',
turnoff: 'PowerOff',
poweroff: 'PowerOff',
standby: 'Standby',
key: 'KeyPress',
keypress: 'KeyPress',
keyhold: 'KeyHold',
openmenu: 'OpenMenu',
closemenu: 'CloseMenu',
activateprofile: 'ActivateProfile',
profile: 'ActivateProfile',
toggle: 'Toggle',
getincomingsignalinfo: 'GetIncomingSignalInfo',
getoutgoingsignalinfo: 'GetOutgoingSignalInfo',
getaspectratio: 'GetAspectRatio',
getmaskingratio: 'GetMaskingRatio',
gettemperatures: 'GetTemperatures',
getmacaddress: 'GetMacAddress',
};
if (aliases[normalized]) {
return aliases[normalized];
}
const direct = (['PowerOn', ...Object.keys(commandDefinitions)] as TMadvrCommandName[]).find((commandArg) => commandArg.toLowerCase() === valueArg.toLowerCase());
if (direct) {
return direct;
}
return valueArg as TMadvrCommandName;
}
private static normalizeKey(valueArg: string): TMadvrRemoteKey {
const key = valueArg.toUpperCase();
if (remoteKeys.includes(key as TMadvrRemoteKey)) {
return key as TMadvrRemoteKey;
}
throw new Error(`Unsupported madVR remote key: ${valueArg}`);
}
private static normalizeMenu(valueArg: string): TMadvrMenu {
const menu = menus.find((itemArg) => itemArg.toLowerCase() === valueArg.toLowerCase());
if (menu) {
return menu;
}
throw new Error(`Unsupported madVR menu: ${valueArg}`);
}
private static normalizeProfileGroup(valueArg: string): TMadvrProfileGroup {
const group = valueArg.toUpperCase();
if (profileGroups.includes(group as TMadvrProfileGroup)) {
return group as TMadvrProfileGroup;
}
throw new Error(`Unsupported madVR profile group: ${valueArg}`);
}
private static normalizeToggle(valueArg: string): TMadvrToggle {
const normalized = valueArg.replace(/^_/, '').toLowerCase();
const toggle = toggles.find((itemArg) => itemArg.toLowerCase() === normalized);
if (toggle) {
return toggle;
}
throw new Error(`Unsupported madVR toggle: ${valueArg}`);
}
private static normalizeInteger(valueArg: string, labelArg: string): string {
if (!/^\d+$/.test(valueArg)) {
throw new Error(`madVR ${labelArg} must be a non-negative integer.`);
}
return String(Number(valueArg));
}
private static isCommandRequest(valueArg: unknown): valueArg is IMadvrCommandRequest {
return typeof valueArg === 'object' && valueArg !== null && typeof (valueArg as IMadvrCommandRequest).command === 'string';
}
}
export class MadvrClient {
constructor(private readonly config: IMadvrConfig) {}
public async getSnapshot(): Promise<IMadvrSnapshot> {
if (this.config.commandExecutor?.getSnapshot) {
return this.normalizeSnapshot(await this.config.commandExecutor.getSnapshot());
}
if (this.config.snapshot) {
return this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot));
}
if (this.config.host) {
try {
return await this.getLiveSnapshot();
} catch (errorArg) {
return this.snapshotFromConfig(this.errorMessage(errorArg));
}
}
return this.snapshotFromConfig();
}
public async execute(commandArg: IMadvrCommandRequest): Promise<IMadvrCommandResult> {
const model = MadvrCommandModeler.modelRequest(commandArg);
if (this.config.commandExecutor) {
const result = await this.config.commandExecutor.execute(model.request, model);
if (typeof result === 'string') {
return { executed: true, command: model, transport: 'executor', response: result };
}
if (result) {
return { ...result, command: result.command || model, transport: result.transport || 'executor' };
}
return { executed: true, command: model, transport: 'executor' };
}
if (model.transport === 'wake_on_lan') {
const macAddress = this.config.macAddress || this.config.snapshot?.deviceInfo.macAddress || this.config.state?.mac_address || this.config.state?.macAddress;
if (!macAddress) {
throw new Error('madVR Envy turn_on requires a MAC address or commandExecutor for Wake-on-LAN.');
}
await this.sendWakeOnLan(String(macAddress));
return { executed: true, command: model, transport: 'wake_on_lan' };
}
if (!model.wireCommand) {
throw new Error(`madVR command ${model.request.command} does not have a TCP wire command.`);
}
const response = await this.sendTcpCommands([model.wireCommand], commandResponseCollectMs);
this.applyLocalState(model.request);
return { executed: true, command: model, transport: 'tcp', response };
}
public async executeInput(inputArg: unknown): Promise<IMadvrCommandResult[]> {
const requests = MadvrCommandModeler.requestsFromInput(inputArg);
if (!requests.length) {
throw new Error('madVR command input did not contain any supported commands.');
}
const results: IMadvrCommandResult[] = [];
for (const request of requests) {
results.push(await this.execute(request));
}
return results;
}
public async destroy(): Promise<void> {
await this.config.commandExecutor?.destroy?.();
}
public static parseNotificationText(textArg: string): IMadvrRawState {
const state: IMadvrRawState = {};
const notifications = textArg.split(/\r?\n/).map((lineArg) => lineArg.trim()).filter(Boolean);
for (const notification of notifications) {
if (notification === 'OK' || notification === 'WELCOME') {
continue;
}
const [title, ...info] = notification.split(/\s+/);
if (title === 'PowerOff' || title === 'Standby') {
state.is_on = false;
continue;
}
if (title === 'NoSignal') {
state.is_signal = false;
continue;
}
if (title === 'MacAddress' && info[0]) {
state.mac_address = info[0];
continue;
}
if (title === 'Temperatures') {
state.temp_gpu = info[0];
state.temp_hdmi = info[1];
state.temp_cpu = info[2];
state.temp_mainboard = info[3];
continue;
}
if (title === 'IncomingSignalInfo') {
state.is_signal = true;
state.incoming_res = info[0];
state.incoming_frame_rate = info[1];
state.incoming_signal_type = info[2];
state.incoming_color_space = info[3];
state.incoming_bit_depth = info[4];
state.hdr_flag = info[5]?.includes('HDR') || false;
state.incoming_colorimetry = info[6];
state.incoming_black_levels = info[7];
state.incoming_aspect_ratio = info[8];
continue;
}
if (title === 'OutgoingSignalInfo' || title === 'OngoingSignalInfo') {
state.outgoing_res = info[0];
state.outgoing_frame_rate = info[1];
state.outgoing_signal_type = info[2];
state.outgoing_color_space = info[3];
state.outgoing_bit_depth = info[4];
state.outgoing_hdr_flag = info[5]?.includes('HDR') || false;
state.outgoing_colorimetry = info[6];
state.outgoing_black_levels = info[7];
continue;
}
if (title === 'AspectRatio') {
state.aspect_res = info[0];
state.aspect_dec = info[1];
state.aspect_int = info[2];
state.aspect_name = info.slice(3).join(' ') || undefined;
continue;
}
if (title === 'MaskingRatio') {
state.masking_res = info[0];
state.masking_dec = info[1];
state.masking_int = info[2];
continue;
}
if ((title === 'ActivateProfile' || title === 'ActiveProfile') && info[0]) {
state.profile_name = info[0];
state.profile_num = info[1];
}
}
return state;
}
private async getLiveSnapshot(): Promise<IMadvrSnapshot> {
const commands = [
'GetMacAddress\r\n',
'GetTemperatures\r\n',
'GetIncomingSignalInfo\r\n',
'GetOutgoingSignalInfo\r\n',
'GetAspectRatio\r\n',
'GetMaskingRatio\r\n',
];
const response = await this.sendTcpCommands(commands, this.config.snapshotCollectMs ?? snapshotCollectMs);
const raw = { is_on: true, ...MadvrClient.parseNotificationText(response) };
return this.snapshotFromRawState(raw, true);
}
private async sendTcpCommands(commandsArg: string[], collectMsArg: number): Promise<string> {
if (!this.config.host) {
throw new Error('madVR Envy host is required for live TCP commands.');
}
const host = this.config.host;
const port = this.port();
const timeoutMs = this.config.commandTimeoutMs ?? commandTimeoutMs;
return new Promise<string>((resolve, reject) => {
const socket = plugins.net.createConnection({ host, port });
const chunks: Buffer[] = [];
let settled = false;
let wroteCommands = false;
let finishTimer: ReturnType<typeof setTimeout> | undefined;
const timeoutTimer = setTimeout(() => finish(new Error(`madVR Envy TCP command timed out after ${timeoutMs}ms.`)), timeoutMs);
const finish = (errorArg?: Error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeoutTimer);
if (finishTimer) {
clearTimeout(finishTimer);
}
socket.destroy();
if (errorArg) {
reject(errorArg);
return;
}
resolve(Buffer.concat(chunks).toString('utf8'));
};
const writeNext = (payloadsArg: string[], indexArg = 0) => {
if (indexArg >= payloadsArg.length) {
wroteCommands = true;
finishTimer = setTimeout(() => finish(), collectMsArg);
return;
}
socket.write(payloadsArg[indexArg], (errorArg?: Error | null) => {
if (errorArg) {
finish(errorArg);
return;
}
writeNext(payloadsArg, indexArg + 1);
});
};
socket.setTimeout(this.config.connectTimeoutMs ?? connectTimeoutMs, () => finish(new Error(`madVR Envy TCP connection to ${host}:${port} timed out.`)));
socket.on('connect', () => writeNext([heartbeatCommand, ...commandsArg]));
socket.on('data', (chunkArg) => chunks.push(Buffer.from(chunkArg)));
socket.on('error', (errorArg) => finish(errorArg));
socket.on('close', () => {
if (!settled && wroteCommands) {
finish();
}
});
});
}
private async sendWakeOnLan(macAddressArg: string): Promise<void> {
const packet = this.createMagicPacket(macAddressArg);
const dgram = await import('node:dgram');
const socket = dgram.createSocket('udp4');
try {
await new Promise<void>((resolve, reject) => {
socket.once('error', reject);
socket.bind(() => {
socket.setBroadcast(true);
socket.send(packet, this.config.wolPort ?? 9, this.config.wolBroadcastAddress || '255.255.255.255', (errorArg) => {
if (errorArg) {
reject(errorArg);
return;
}
resolve();
});
});
});
} finally {
socket.close();
}
}
private createMagicPacket(macAddressArg: string): Buffer {
let mac = macAddressArg.trim();
if (mac.length === 17) {
mac = mac.replaceAll(mac[2], '');
} else if (mac.length === 14) {
mac = mac.replaceAll(mac[4], '');
}
if (!/^[0-9a-fA-F]{12}$/.test(mac)) {
throw new Error('madVR Envy Wake-on-LAN requires a valid MAC address.');
}
return Buffer.from(`${'F'.repeat(12)}${mac.repeat(16)}`, 'hex');
}
private snapshotFromConfig(errorMessageArg?: string): IMadvrSnapshot {
return this.snapshotFromRawState(this.config.state || {}, errorMessageArg ? false : undefined, errorMessageArg);
}
private snapshotFromRawState(rawArg: IMadvrRawState, availableArg?: boolean, errorMessageArg?: string): IMadvrSnapshot {
const parts = this.statePartsFromRaw(rawArg);
const processor = this.mergeProcessor(parts.processor, this.config.processor);
const display = this.mergeDisplay(parts.display, this.config.display);
return this.normalizeSnapshot({
deviceInfo: this.deviceInfoFromConfig(rawArg),
processor: {
...processor,
lastError: errorMessageArg || processor.lastError,
},
projector: {
profileName: processor.profileName,
profileNumber: processor.profileNumber,
...this.config.projector,
},
display,
buttons: this.config.buttons,
selects: this.config.selects,
raw: rawArg,
available: availableArg ?? this.config.processor?.isOn ?? this.config.state?.is_on ?? this.config.state?.isOn ?? Boolean(this.config.host || this.config.snapshot),
updatedAt: new Date().toISOString(),
});
}
private normalizeSnapshot(snapshotArg: IMadvrSnapshot): IMadvrSnapshot {
const rawParts = snapshotArg.raw ? this.statePartsFromRaw(snapshotArg.raw) : undefined;
const processor = this.mergeProcessor(rawParts?.processor, snapshotArg.processor);
const display = this.mergeDisplay(rawParts?.display, snapshotArg.display);
return {
...snapshotArg,
deviceInfo: {
...this.deviceInfoFromConfig(snapshotArg.raw),
...snapshotArg.deviceInfo,
port: snapshotArg.deviceInfo.port || this.port(),
manufacturer: snapshotArg.deviceInfo.manufacturer || this.config.manufacturer || 'madVR',
model: snapshotArg.deviceInfo.model || this.config.model || 'Envy',
name: snapshotArg.deviceInfo.name || this.config.name || 'madVR Envy',
},
processor,
projector: snapshotArg.projector || (processor.profileName || processor.profileNumber ? { profileName: processor.profileName, profileNumber: processor.profileNumber } : undefined),
display,
buttons: snapshotArg.buttons || [],
selects: snapshotArg.selects || [],
available: snapshotArg.available ?? true,
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
};
}
private statePartsFromRaw(rawArg: IMadvrRawState): { processor: IMadvrProcessorState; display: IMadvrDisplayState } {
const isOn = this.boolRaw(rawArg, 'is_on', 'isOn');
const isSignal = this.boolRaw(rawArg, 'is_signal', 'isSignal');
const processor: IMadvrProcessorState = {
isOn,
isSignal,
power: isOn === true ? 'on' : isOn === false ? 'off' : undefined,
incoming: {
resolution: this.stringRaw(rawArg, 'incoming_res', 'incomingRes'),
frameRate: this.stringRaw(rawArg, 'incoming_frame_rate', 'incomingFrameRate'),
signalType: this.stringRaw(rawArg, 'incoming_signal_type', 'incomingSignalType'),
colorSpace: this.stringRaw(rawArg, 'incoming_color_space', 'incomingColorSpace'),
bitDepth: this.stringRaw(rawArg, 'incoming_bit_depth', 'incomingBitDepth'),
hdr: this.boolRaw(rawArg, 'hdr_flag', 'hdrFlag'),
colorimetry: this.stringRaw(rawArg, 'incoming_colorimetry', 'incomingColorimetry'),
blackLevels: this.stringRaw(rawArg, 'incoming_black_levels', 'incomingBlackLevels'),
aspectRatio: this.stringRaw(rawArg, 'incoming_aspect_ratio', 'incomingAspectRatio'),
},
outgoing: {
resolution: this.stringRaw(rawArg, 'outgoing_res', 'outgoingRes'),
frameRate: this.stringRaw(rawArg, 'outgoing_frame_rate', 'outgoingFrameRate'),
signalType: this.stringRaw(rawArg, 'outgoing_signal_type', 'outgoingSignalType'),
colorSpace: this.stringRaw(rawArg, 'outgoing_color_space', 'outgoingColorSpace'),
bitDepth: this.stringRaw(rawArg, 'outgoing_bit_depth', 'outgoingBitDepth'),
hdr: this.boolRaw(rawArg, 'outgoing_hdr_flag', 'outgoingHdrFlag'),
colorimetry: this.stringRaw(rawArg, 'outgoing_colorimetry', 'outgoingColorimetry'),
blackLevels: this.stringRaw(rawArg, 'outgoing_black_levels', 'outgoingBlackLevels'),
},
temperatures: {
gpu: this.numberRaw(rawArg, 'temp_gpu', 'tempGpu'),
hdmi: this.numberRaw(rawArg, 'temp_hdmi', 'tempHdmi'),
cpu: this.numberRaw(rawArg, 'temp_cpu', 'tempCpu'),
mainboard: this.numberRaw(rawArg, 'temp_mainboard', 'tempMainboard'),
},
profileName: this.stringRaw(rawArg, 'profile_name', 'profileName'),
profileNumber: this.stringRaw(rawArg, 'profile_num', 'profileNumber'),
raw: rawArg,
};
const display: IMadvrDisplayState = {
hdrFlag: this.boolRaw(rawArg, 'hdr_flag', 'hdrFlag'),
outgoingHdrFlag: this.boolRaw(rawArg, 'outgoing_hdr_flag', 'outgoingHdrFlag'),
aspect: {
resolution: this.stringRaw(rawArg, 'aspect_res', 'aspectRes'),
decimal: this.numberRaw(rawArg, 'aspect_dec', 'aspectDec'),
integer: this.stringRaw(rawArg, 'aspect_int', 'aspectInt'),
name: this.stringRaw(rawArg, 'aspect_name', 'aspectName'),
},
masking: {
resolution: this.stringRaw(rawArg, 'masking_res', 'maskingRes'),
decimal: this.numberRaw(rawArg, 'masking_dec', 'maskingDec'),
integer: this.stringRaw(rawArg, 'masking_int', 'maskingInt'),
},
};
return { processor: this.cleanProcessor(processor), display: this.cleanDisplay(display) };
}
private mergeProcessor(baseArg: IMadvrProcessorState | undefined, overrideArg: IMadvrProcessorState | undefined): IMadvrProcessorState {
const base = baseArg || {};
const override = overrideArg || {};
return this.cleanProcessor({
...base,
...override,
incoming: { ...base.incoming, ...override.incoming },
outgoing: { ...base.outgoing, ...override.outgoing },
temperatures: { ...base.temperatures, ...override.temperatures },
raw: { ...base.raw, ...override.raw },
});
}
private mergeDisplay(baseArg: IMadvrDisplayState | undefined, overrideArg: IMadvrDisplayState | undefined): IMadvrDisplayState {
const base = baseArg || {};
const override = overrideArg || {};
return this.cleanDisplay({
...base,
...override,
aspect: { ...base.aspect, ...override.aspect },
masking: { ...base.masking, ...override.masking },
});
}
private cleanProcessor(processorArg: IMadvrProcessorState): IMadvrProcessorState {
return this.cleanObject({
...processorArg,
incoming: this.cleanObject(processorArg.incoming || {}),
outgoing: this.cleanObject(processorArg.outgoing || {}),
temperatures: this.cleanObject(processorArg.temperatures || {}),
raw: processorArg.raw,
}) as IMadvrProcessorState;
}
private cleanDisplay(displayArg: IMadvrDisplayState): IMadvrDisplayState {
return this.cleanObject({
...displayArg,
aspect: this.cleanObject(displayArg.aspect || {}),
masking: this.cleanObject(displayArg.masking || {}),
}) as IMadvrDisplayState;
}
private cleanObject<TValue extends object>(valueArg: TValue): Partial<TValue> {
return Object.fromEntries(Object.entries(valueArg as Record<string, unknown>).filter(([, valueArg]) => {
if (valueArg === undefined) {
return false;
}
if (typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg) && !Object.keys(valueArg).length) {
return false;
}
return true;
})) as Partial<TValue>;
}
private deviceInfoFromConfig(rawArg?: IMadvrRawState): IMadvrSnapshot['deviceInfo'] {
return {
host: this.config.host,
port: this.port(),
name: this.config.name || 'madVR Envy',
manufacturer: this.config.manufacturer || 'madVR',
model: this.config.model || 'Envy',
macAddress: this.config.macAddress || this.stringRaw(rawArg, 'mac_address', 'macAddress'),
serialNumber: this.config.serialNumber,
};
}
private applyLocalState(commandArg: IMadvrCommandRequest): void {
const state = this.config.snapshot?.raw || this.config.state || {};
if (commandArg.command === 'PowerOff' || commandArg.command === 'Standby') {
state.is_on = false;
}
if (this.config.snapshot) {
this.config.snapshot.raw = state;
this.config.snapshot.processor.isOn = state.is_on;
this.config.snapshot.processor.power = state.is_on === false ? 'off' : this.config.snapshot.processor.power;
return;
}
this.config.state = state;
}
private stringRaw(rawArg: IMadvrRawState | undefined, ...keysArg: string[]): string | undefined {
const value = this.rawValue(rawArg, ...keysArg);
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value);
}
return undefined;
}
private numberRaw(rawArg: IMadvrRawState | undefined, ...keysArg: string[]): number | undefined {
const value = this.rawValue(rawArg, ...keysArg);
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim()) {
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : undefined;
}
return undefined;
}
private boolRaw(rawArg: IMadvrRawState | undefined, ...keysArg: string[]): boolean | undefined {
const value = this.rawValue(rawArg, ...keysArg);
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
const normalized = value.toLowerCase();
if (normalized === 'true' || normalized === 'on' || normalized === '1') {
return true;
}
if (normalized === 'false' || normalized === 'off' || normalized === '0') {
return false;
}
}
return undefined;
}
private rawValue(rawArg: IMadvrRawState | undefined, ...keysArg: string[]): unknown {
if (!rawArg) {
return undefined;
}
for (const key of keysArg) {
if (rawArg[key] !== undefined && rawArg[key] !== null) {
return rawArg[key];
}
}
return undefined;
}
private port(): number {
return this.config.port || madvrDefaultPort;
}
private cloneSnapshot(snapshotArg: IMadvrSnapshot): IMadvrSnapshot {
return JSON.parse(JSON.stringify(snapshotArg)) as IMadvrSnapshot;
}
private errorMessage(errorArg: unknown): string {
return errorArg instanceof Error ? errorArg.message : String(errorArg);
}
}
@@ -0,0 +1,60 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IMadvrConfig } from './madvr.types.js';
import { madvrDefaultPort } from './madvr.types.js';
export class MadvrConfigFlow implements IConfigFlow<IMadvrConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IMadvrConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect madVR Envy',
description: 'Configure the local madVR Envy TCP endpoint. Live control uses validated madVR IP-control commands; Wake-on-LAN requires a MAC address.',
fields: [
{ name: 'host', label: 'Host', type: 'text', required: true },
{ name: 'port', label: 'TCP port', type: 'number' },
{ name: 'macAddress', label: 'MAC address', type: 'text' },
{ name: 'name', label: 'Name', type: 'text' },
{ name: 'model', label: 'Model', type: 'text' },
],
submit: async (valuesArg) => {
const host = this.stringValue(valuesArg.host) || candidateArg.host;
if (!host) {
return { kind: 'error', error: 'madVR Envy host is required.' };
}
return {
kind: 'done',
title: 'madVR Envy configured',
config: {
host,
port: this.numberValue(valuesArg.port) || candidateArg.port || madvrDefaultPort,
name: this.stringValue(valuesArg.name) || candidateArg.name || 'madVR Envy',
model: this.stringValue(valuesArg.model) || candidateArg.model || 'Envy',
manufacturer: candidateArg.manufacturer || 'madVR',
macAddress: this.stringValue(valuesArg.macAddress) || candidateArg.macAddress || this.stringMetadata(candidateArg, 'macAddress'),
serialNumber: candidateArg.serialNumber,
},
};
},
};
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
private numberValue(valueArg: unknown): number | undefined {
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
return valueArg;
}
if (typeof valueArg === 'string' && valueArg.trim()) {
const value = Number(valueArg);
return Number.isFinite(value) ? value : undefined;
}
return undefined;
}
private stringMetadata(candidateArg: IDiscoveryCandidate, keyArg: string): string | undefined {
const value = candidateArg.metadata?.[keyArg];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
}
@@ -1,26 +1,187 @@
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 { MadvrClient } from './madvr.classes.client.js';
import { MadvrConfigFlow } from './madvr.classes.configflow.js';
import { createMadvrDiscoveryDescriptor } from './madvr.discovery.js';
import { MadvrMapper } from './madvr.mapper.js';
import type { IMadvrCommandRequest, IMadvrConfig } from './madvr.types.js';
export class HomeAssistantMadvrIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "madvr",
displayName: "madVR Envy",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/madvr",
"upstreamDomain": "madvr",
"integrationType": "device",
"iotClass": "local_push",
"requirements": [
"py-madvr2==1.6.40"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@iloveicedgreentea"
]
},
});
export class MadvrIntegration extends BaseIntegration<IMadvrConfig> {
public readonly domain = 'madvr';
public readonly displayName = 'madVR Envy';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createMadvrDiscoveryDescriptor();
public readonly configFlow = new MadvrConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/madvr',
upstreamDomain: 'madvr',
integrationType: 'device',
iotClass: 'local_push',
requirements: ['py-madvr2==1.6.40'],
dependencies: [],
afterDependencies: [],
codeowners: ['@iloveicedgreentea'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/madvr',
};
public async setup(configArg: IMadvrConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new MadvrRuntime(new MadvrClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantMadvrIntegration extends MadvrIntegration {}
class MadvrRuntime implements IIntegrationRuntime {
public domain = 'madvr';
constructor(private readonly client: MadvrClient) {}
public async devices(): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
return MadvrMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return MadvrMapper.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 === 'button' || requestArg.domain === 'select') {
return await this.callWritableEntityService(requestArg);
}
if (requestArg.domain === 'madvr') {
return await this.callMadvrService(requestArg);
}
return { success: false, error: `Unsupported madVR Envy 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') {
return { success: true, data: await this.client.execute({ command: 'PowerOn', source: 'remote.turn_on', target: requestArg.target }) };
}
if (requestArg.service === 'turn_off') {
return { success: true, data: await this.client.execute({ command: 'PowerOff', source: 'remote.turn_off', target: requestArg.target }) };
}
if (requestArg.service !== 'send_command') {
return { success: false, error: `Unsupported madVR Envy remote service: ${requestArg.service}` };
}
const command = requestArg.data?.command;
if (!command) {
return { success: false, error: 'madVR Envy remote.send_command requires data.command.' };
}
return { success: true, data: await this.client.executeInput(command) };
}
private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service === 'turn_on') {
return { success: true, data: await this.client.execute({ command: 'PowerOn', source: 'media_player.turn_on', target: requestArg.target }) };
}
if (requestArg.service === 'turn_off') {
return { success: true, data: await this.client.execute({ command: 'PowerOff', source: 'media_player.turn_off', target: requestArg.target }) };
}
return { success: false, error: `Unsupported madVR Envy media_player service: ${requestArg.service}` };
}
private async callWritableEntityService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
const snapshot = await this.client.getSnapshot();
const entity = MadvrMapper.findTargetEntity(snapshot, requestArg);
if (!entity) {
return { success: false, error: `madVR Envy ${requestArg.domain}.${requestArg.service} target was not found.` };
}
const command = MadvrMapper.commandFromWritableEntity(entity, requestArg);
if (!command) {
return { success: false, error: `madVR Envy ${entity.platform} entity is not backed by a command for ${requestArg.service}.` };
}
return { success: true, data: await this.client.execute(command) };
}
private async callMadvrService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service === 'send_command' || requestArg.service === 'execute_command') {
const command = requestArg.data?.command || requestArg.data?.commands;
if (!command) {
return { success: false, error: `madVR Envy ${requestArg.service} requires data.command.` };
}
return { success: true, data: await this.client.executeInput(command) };
}
const command = this.commandForMadvrService(requestArg);
if (!command) {
return { success: false, error: `Unsupported madVR Envy service: ${requestArg.service}` };
}
return { success: true, data: await this.client.execute(command) };
}
private commandForMadvrService(requestArg: IServiceCallRequest): IMadvrCommandRequest | undefined {
if (requestArg.service === 'standby') {
return { command: 'Standby', source: 'madvr.standby', target: requestArg.target };
}
if (requestArg.service === 'restart') {
return { command: 'Restart', source: 'madvr.restart', target: requestArg.target };
}
if (requestArg.service === 'reload_software') {
return { command: 'ReloadSoftware', source: 'madvr.reload_software', target: requestArg.target };
}
if (requestArg.service === 'hotplug') {
return { command: 'Hotplug', source: 'madvr.hotplug', target: requestArg.target };
}
if (requestArg.service === 'refresh_license_info') {
return { command: 'RefreshLicenseInfo', source: 'madvr.refresh_license_info', target: requestArg.target };
}
if (requestArg.service === 'force_1080p60_output') {
return { command: 'Force1080p60Output', source: 'madvr.force_1080p60_output', target: requestArg.target };
}
if (requestArg.service === 'open_menu') {
return { command: 'OpenMenu', args: [this.requiredString(requestArg, 'menu', 'madVR Envy open_menu requires data.menu.')], source: 'madvr.open_menu', target: requestArg.target };
}
if (requestArg.service === 'key_press' || requestArg.service === 'key_hold') {
return { command: requestArg.service === 'key_hold' ? 'KeyHold' : 'KeyPress', args: [this.requiredString(requestArg, 'key', `madVR Envy ${requestArg.service} requires data.key.`)], source: `madvr.${requestArg.service}`, target: requestArg.target };
}
if (requestArg.service === 'activate_profile') {
const group = this.requiredString(requestArg, 'profile_group', 'madVR Envy activate_profile requires data.profile_group.');
const profileNumber = this.optionalString(requestArg, 'profile_number');
return { command: 'ActivateProfile', args: profileNumber ? [group, profileNumber] : [group], source: 'madvr.activate_profile', target: requestArg.target };
}
if (requestArg.service === 'toggle') {
return { command: 'Toggle', args: [this.requiredString(requestArg, 'option', 'madVR Envy toggle requires data.option.')], source: 'madvr.toggle', target: requestArg.target };
}
return undefined;
}
private requiredString(requestArg: IServiceCallRequest, keyArg: string, errorArg: string): string {
const value = this.optionalString(requestArg, keyArg);
if (!value) {
throw new Error(errorArg);
}
return value;
}
private optionalString(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;
}
}
+126
View File
@@ -0,0 +1,126 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { IMadvrManualEntry, IMadvrMdnsRecord } from './madvr.types.js';
import { madvrDefaultPort } from './madvr.types.js';
export class MadvrManualMatcher implements IDiscoveryMatcher<IMadvrManualEntry> {
public id = 'madvr-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual madVR Envy setup entries.';
public async matches(inputArg: IMadvrManualEntry): Promise<IDiscoveryMatch> {
const matched = Boolean(inputArg.host || isMadvrHint(inputArg.manufacturer, inputArg.model, inputArg.name) || inputArg.metadata?.madvr);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain madVR Envy setup hints.' };
}
const id = inputArg.id || inputArg.macAddress || inputArg.serialNumber || inputArg.host;
return {
matched: true,
confidence: inputArg.host ? 'high' : 'medium',
reason: 'Manual entry can start madVR Envy setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: 'madvr',
id,
host: inputArg.host,
port: inputArg.port || madvrDefaultPort,
name: inputArg.name || 'madVR Envy',
manufacturer: inputArg.manufacturer || 'madVR',
model: inputArg.model || 'Envy',
macAddress: inputArg.macAddress,
serialNumber: inputArg.serialNumber,
metadata: inputArg.metadata,
},
};
}
}
export class MadvrMdnsMatcher implements IDiscoveryMatcher<IMadvrMdnsRecord> {
public id = 'madvr-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize mDNS records that explicitly advertise madVR Envy metadata.';
public async matches(recordArg: IMadvrMdnsRecord): Promise<IDiscoveryMatch> {
const properties = { ...(recordArg.txt || {}), ...(recordArg.properties || {}) };
const type = normalize(recordArg.type);
const name = cleanName(valueForKey(properties, 'name') || recordArg.name);
const manufacturer = valueForKey(properties, 'manufacturer') || valueForKey(properties, 'mf');
const model = valueForKey(properties, 'model') || valueForKey(properties, 'modelName') || valueForKey(properties, 'md');
const matched = type.includes('madvr') || isMadvrHint(manufacturer, model, name);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'mDNS record does not contain madVR Envy hints.' };
}
const macAddress = valueForKey(properties, 'mac') || valueForKey(properties, 'macAddress') || valueForKey(properties, 'mac_address');
const id = valueForKey(properties, 'id') || macAddress || name;
return {
matched: true,
confidence: id ? 'high' : 'medium',
reason: 'mDNS record matches madVR Envy metadata.',
normalizedDeviceId: id,
candidate: {
source: 'mdns',
integrationDomain: 'madvr',
id,
host: recordArg.host,
port: recordArg.port || madvrDefaultPort,
name: name || 'madVR Envy',
manufacturer: manufacturer || 'madVR',
model: model || 'Envy',
macAddress,
metadata: { mdnsType: recordArg.type, mdnsName: recordArg.name, txt: properties },
},
};
}
}
export class MadvrCandidateValidator implements IDiscoveryValidator {
public id = 'madvr-candidate-validator';
public description = 'Validate madVR Envy discovery candidates.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const matched = candidateArg.integrationDomain === 'madvr'
|| Boolean(candidateArg.metadata?.madvr)
|| isMadvrHint(candidateArg.manufacturer, candidateArg.model, candidateArg.name);
return {
matched,
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
reason: matched ? 'Candidate has madVR Envy metadata.' : 'Candidate is not madVR Envy.',
candidate: matched ? {
...candidateArg,
integrationDomain: 'madvr',
port: candidateArg.port || madvrDefaultPort,
} : undefined,
normalizedDeviceId: candidateArg.id || candidateArg.macAddress || candidateArg.serialNumber || candidateArg.host,
};
}
}
export const createMadvrDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: 'madvr', displayName: 'madVR Envy' })
.addMatcher(new MadvrManualMatcher())
.addMatcher(new MadvrMdnsMatcher())
.addValidator(new MadvrCandidateValidator());
};
const isMadvrHint = (...valuesArg: Array<string | undefined>): boolean => {
const value = valuesArg.filter(Boolean).join(' ').toLowerCase();
return value.includes('madvr') || value.includes('mad vr') || value.includes('envy');
};
const normalize = (valueArg?: string): string => (valueArg || '').toLowerCase().replace(/\.$/, '');
const cleanName = (valueArg?: string): string | undefined => valueArg?.replace(/\._[^.]+\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || undefined;
const valueForKey = (recordArg: Record<string, string | undefined> | undefined, keyArg: string): string | undefined => {
if (!recordArg) {
return undefined;
}
const lowerKey = keyArg.toLowerCase();
for (const [key, value] of Object.entries(recordArg)) {
if (key.toLowerCase() === lowerKey && value) {
return value;
}
}
return undefined;
};
+381
View File
@@ -0,0 +1,381 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
import type {
IMadvrButtonRecord,
IMadvrCommandRequest,
IMadvrRatioState,
IMadvrSelectRecord,
IMadvrSignalState,
IMadvrSnapshot,
TMadvrPowerState,
} from './madvr.types.js';
const madvrDomain = 'madvr';
const madvrManufacturer = 'madVR';
interface ISensorDescription {
key: string;
name: string;
value: (snapshotArg: IMadvrSnapshot) => unknown;
deviceClass?: string;
stateClass?: string;
unit?: string;
options?: string[];
enabledDefault?: boolean;
}
interface IBinarySensorDescription {
key: string;
name: string;
value: (snapshotArg: IMadvrSnapshot) => boolean;
icon?: string;
}
const enumValue = (valueArg: unknown, optionsArg: string[]): string | null => {
return typeof valueArg === 'string' && optionsArg.includes(valueArg) ? valueArg : null;
};
const signalOptions = ['2D', '3D'];
const colorSpaceOptions = ['RGB', '444', '422', '420'];
const bitDepthOptions = ['8bit', '10bit', '12bit'];
const colorimetryOptions = ['SDR', 'HDR10', 'HLG 601', 'PAL', '709', 'DCI', '2020'];
const blackLevelOptions = ['TV', 'PC'];
const aspectOptions = ['16:9', '4:3'];
const sensors: ISensorDescription[] = [
{ key: 'temp_gpu', name: 'GPU temperature', value: (snapshotArg) => validTemperature(snapshotArg.processor.temperatures?.gpu), deviceClass: 'temperature', stateClass: 'measurement', unit: 'C', enabledDefault: false },
{ key: 'temp_hdmi', name: 'HDMI temperature', value: (snapshotArg) => validTemperature(snapshotArg.processor.temperatures?.hdmi), deviceClass: 'temperature', stateClass: 'measurement', unit: 'C', enabledDefault: false },
{ key: 'temp_cpu', name: 'CPU temperature', value: (snapshotArg) => validTemperature(snapshotArg.processor.temperatures?.cpu), deviceClass: 'temperature', stateClass: 'measurement', unit: 'C', enabledDefault: false },
{ key: 'temp_mainboard', name: 'Mainboard temperature', value: (snapshotArg) => validTemperature(snapshotArg.processor.temperatures?.mainboard), deviceClass: 'temperature', stateClass: 'measurement', unit: 'C', enabledDefault: false },
{ key: 'incoming_res', name: 'Incoming resolution', value: (snapshotArg) => snapshotArg.processor.incoming?.resolution ?? null },
{ key: 'incoming_signal_type', name: 'Incoming signal type', value: (snapshotArg) => enumValue(snapshotArg.processor.incoming?.signalType, signalOptions), deviceClass: 'enum', options: signalOptions, enabledDefault: false },
{ key: 'incoming_frame_rate', name: 'Incoming frame rate', value: (snapshotArg) => snapshotArg.processor.incoming?.frameRate ?? null },
{ key: 'incoming_color_space', name: 'Incoming color space', value: (snapshotArg) => enumValue(snapshotArg.processor.incoming?.colorSpace, colorSpaceOptions), deviceClass: 'enum', options: colorSpaceOptions },
{ key: 'incoming_bit_depth', name: 'Incoming bit depth', value: (snapshotArg) => enumValue(snapshotArg.processor.incoming?.bitDepth, bitDepthOptions), deviceClass: 'enum', options: bitDepthOptions },
{ key: 'incoming_colorimetry', name: 'Incoming colorimetry', value: (snapshotArg) => enumValue(snapshotArg.processor.incoming?.colorimetry, colorimetryOptions), deviceClass: 'enum', options: colorimetryOptions },
{ key: 'incoming_black_levels', name: 'Incoming black levels', value: (snapshotArg) => enumValue(snapshotArg.processor.incoming?.blackLevels, blackLevelOptions), deviceClass: 'enum', options: blackLevelOptions },
{ key: 'incoming_aspect_ratio', name: 'Incoming aspect ratio', value: (snapshotArg) => enumValue(snapshotArg.processor.incoming?.aspectRatio, aspectOptions), deviceClass: 'enum', options: aspectOptions, enabledDefault: false },
{ key: 'outgoing_res', name: 'Outgoing resolution', value: (snapshotArg) => snapshotArg.processor.outgoing?.resolution ?? null },
{ key: 'outgoing_signal_type', name: 'Outgoing signal type', value: (snapshotArg) => enumValue(snapshotArg.processor.outgoing?.signalType, signalOptions), deviceClass: 'enum', options: signalOptions, enabledDefault: false },
{ key: 'outgoing_frame_rate', name: 'Outgoing frame rate', value: (snapshotArg) => snapshotArg.processor.outgoing?.frameRate ?? null },
{ key: 'outgoing_color_space', name: 'Outgoing color space', value: (snapshotArg) => enumValue(snapshotArg.processor.outgoing?.colorSpace, colorSpaceOptions), deviceClass: 'enum', options: colorSpaceOptions },
{ key: 'outgoing_bit_depth', name: 'Outgoing bit depth', value: (snapshotArg) => enumValue(snapshotArg.processor.outgoing?.bitDepth, bitDepthOptions), deviceClass: 'enum', options: bitDepthOptions },
{ key: 'outgoing_colorimetry', name: 'Outgoing colorimetry', value: (snapshotArg) => enumValue(snapshotArg.processor.outgoing?.colorimetry, colorimetryOptions), deviceClass: 'enum', options: colorimetryOptions },
{ key: 'outgoing_black_levels', name: 'Outgoing black levels', value: (snapshotArg) => enumValue(snapshotArg.processor.outgoing?.blackLevels, blackLevelOptions), deviceClass: 'enum', options: blackLevelOptions },
{ key: 'aspect_res', name: 'Aspect resolution', value: (snapshotArg) => snapshotArg.display.aspect?.resolution ?? null, enabledDefault: false },
{ key: 'aspect_dec', name: 'Aspect decimal', value: (snapshotArg) => snapshotArg.display.aspect?.decimal ?? null },
{ key: 'aspect_int', name: 'Aspect integer', value: (snapshotArg) => snapshotArg.display.aspect?.integer ?? null, enabledDefault: false },
{ key: 'aspect_name', name: 'Aspect name', value: (snapshotArg) => snapshotArg.display.aspect?.name ?? null, enabledDefault: false },
{ key: 'masking_res', name: 'Masking resolution', value: (snapshotArg) => snapshotArg.display.masking?.resolution ?? null, enabledDefault: false },
{ key: 'masking_dec', name: 'Masking decimal', value: (snapshotArg) => snapshotArg.display.masking?.decimal ?? null },
{ key: 'masking_int', name: 'Masking integer', value: (snapshotArg) => snapshotArg.display.masking?.integer ?? null, enabledDefault: false },
];
const binarySensors: IBinarySensorDescription[] = [
{ key: 'power_state', name: 'Power state', value: (snapshotArg) => snapshotArg.processor.isOn === true, icon: 'mdi:power' },
{ key: 'signal_state', name: 'Signal state', value: (snapshotArg) => snapshotArg.processor.isSignal === true, icon: 'mdi:signal' },
{ key: 'hdr_flag', name: 'HDR flag', value: (snapshotArg) => snapshotArg.display.hdrFlag === true, icon: 'mdi:hdr' },
{ key: 'outgoing_hdr_flag', name: 'Outgoing HDR flag', value: (snapshotArg) => snapshotArg.display.outgoingHdrFlag === true, icon: 'mdi:hdr' },
];
export class MadvrMapper {
public static toDevices(snapshotArg: IMadvrSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'power', capability: 'media', name: 'Power', readable: true, writable: true },
{ id: 'signal', capability: 'sensor', name: 'Signal', readable: true, writable: false },
{ id: 'incoming_res', capability: 'media', name: 'Incoming resolution', readable: true, writable: false },
{ id: 'outgoing_res', capability: 'media', name: 'Outgoing resolution', readable: true, writable: false },
{ id: 'aspect_ratio', capability: 'sensor', name: 'Aspect ratio', readable: true, writable: false },
{ id: 'masking_ratio', capability: 'sensor', name: 'Masking ratio', readable: true, writable: false },
{ id: 'remote_command', capability: 'media', name: 'Remote command', readable: false, writable: true },
];
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'power', value: this.powerState(snapshotArg), updatedAt },
{ featureId: 'signal', value: snapshotArg.processor.isSignal ?? null, updatedAt },
{ featureId: 'incoming_res', value: snapshotArg.processor.incoming?.resolution ?? null, updatedAt },
{ featureId: 'outgoing_res', value: snapshotArg.processor.outgoing?.resolution ?? null, updatedAt },
{ featureId: 'aspect_ratio', value: this.ratioLabel(snapshotArg.display.aspect), updatedAt },
{ featureId: 'masking_ratio', value: this.ratioLabel(snapshotArg.display.masking), updatedAt },
];
for (const sensor of sensors) {
if (!features.some((featureArg) => featureArg.id === sensor.key)) {
features.push({ id: sensor.key, capability: 'sensor', name: sensor.name, readable: true, writable: false, unit: sensor.unit });
state.push({ featureId: sensor.key, value: this.deviceStateValue(sensor.value(snapshotArg)), updatedAt });
}
}
for (const button of snapshotArg.buttons || []) {
features.push({ id: `button_${button.key}`, capability: 'media', name: button.name, readable: false, writable: true });
}
for (const select of snapshotArg.selects || []) {
features.push({ id: `select_${select.key}`, capability: 'media', name: select.name, readable: true, writable: true });
state.push({ featureId: `select_${select.key}`, value: select.current ?? null, updatedAt });
}
return [{
id: this.deviceId(snapshotArg),
integrationDomain: madvrDomain,
name: this.deviceName(snapshotArg),
protocol: 'unknown',
manufacturer: snapshotArg.deviceInfo.manufacturer || madvrManufacturer,
model: snapshotArg.deviceInfo.model || 'Envy',
online: this.available(snapshotArg) && this.powerState(snapshotArg) !== 'off',
features,
state,
metadata: this.cleanAttributes({
host: snapshotArg.deviceInfo.host,
port: snapshotArg.deviceInfo.port,
macAddress: snapshotArg.deviceInfo.macAddress,
serialNumber: snapshotArg.deviceInfo.serialNumber,
firmwareVersion: snapshotArg.deviceInfo.firmwareVersion,
processor: snapshotArg.processor,
projector: snapshotArg.projector,
display: snapshotArg.display,
}),
}];
}
public static toEntities(snapshotArg: IMadvrSnapshot): IIntegrationEntity[] {
const entities: IIntegrationEntity[] = [];
const usedIds = new Map<string, number>();
const deviceId = this.deviceId(snapshotArg);
const name = this.deviceName(snapshotArg);
const available = this.available(snapshotArg);
entities.push(this.entity('media_player', name, deviceId, this.uniqueId(snapshotArg, 'remote'), this.mediaState(snapshotArg), usedIds, {
deviceClass: 'receiver',
power: this.powerState(snapshotArg),
isOn: snapshotArg.processor.isOn,
isSignal: snapshotArg.processor.isSignal,
incoming: snapshotArg.processor.incoming,
outgoing: snapshotArg.processor.outgoing,
temperatures: snapshotArg.processor.temperatures,
aspect: snapshotArg.display.aspect,
masking: snapshotArg.display.masking,
hdrFlag: snapshotArg.display.hdrFlag,
outgoingHdrFlag: snapshotArg.display.outgoingHdrFlag,
profileName: snapshotArg.processor.profileName || snapshotArg.projector?.profileName,
profileNumber: snapshotArg.processor.profileNumber || snapshotArg.projector?.profileNumber,
macAddress: snapshotArg.deviceInfo.macAddress,
source: snapshotArg.processor.incoming?.resolution,
mediaTitle: this.mediaTitle(snapshotArg),
assumedState: true,
lastError: snapshotArg.processor.lastError,
}, available));
for (const sensor of sensors) {
entities.push(this.entity('sensor', `${name} ${sensor.name}`, deviceId, this.uniqueId(snapshotArg, sensor.key), sensor.value(snapshotArg) ?? null, usedIds, {
deviceClass: sensor.deviceClass,
stateClass: sensor.stateClass,
unit: sensor.unit,
options: sensor.options,
enabledDefault: sensor.enabledDefault,
}, available));
}
for (const binarySensor of binarySensors) {
entities.push(this.entity('binary_sensor', `${name} ${binarySensor.name}`, deviceId, this.uniqueId(snapshotArg, binarySensor.key), binarySensor.value(snapshotArg), usedIds, {
icon: binarySensor.icon,
}, available));
}
for (const button of snapshotArg.buttons || []) {
entities.push(this.buttonEntity(snapshotArg, button, deviceId, name, usedIds, available));
}
for (const select of snapshotArg.selects || []) {
entities.push(this.selectEntity(snapshotArg, select, deviceId, name, usedIds, available));
}
return entities;
}
public static findTargetEntity(snapshotArg: IMadvrSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined {
const entities = this.toEntities(snapshotArg);
if (requestArg.target.entityId) {
return entities.find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId);
}
if (requestArg.target.deviceId) {
return entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId && entityArg.platform === requestArg.domain)
|| entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId && entityArg.attributes?.writable === true);
}
return entities.find((entityArg) => entityArg.platform === requestArg.domain);
}
public static commandFromWritableEntity(entityArg: IIntegrationEntity, requestArg: IServiceCallRequest): IMadvrCommandRequest | undefined {
if (entityArg.platform === 'button' && requestArg.service === 'press' && isCommandRequest(entityArg.attributes?.madvrCommand)) {
return entityArg.attributes.madvrCommand;
}
if (entityArg.platform === 'select' && requestArg.service === 'select_option') {
const option = typeof requestArg.data?.option === 'string' ? requestArg.data.option : undefined;
if (!option) {
return undefined;
}
const commands = entityArg.attributes?.madvrSelectCommands;
if (isCommandMap(commands) && isCommandRequest(commands[option])) {
return commands[option];
}
if (isCommandRequest(entityArg.attributes?.madvrCommand)) {
return { ...entityArg.attributes.madvrCommand, args: [...(entityArg.attributes.madvrCommand.args || []), option] };
}
}
return undefined;
}
public static deviceId(snapshotArg: IMadvrSnapshot): string {
return `madvr.device.${this.slug(this.identity(snapshotArg))}`;
}
public static uniqueId(snapshotArg: IMadvrSnapshot, keyArg: string): string {
return `madvr_${this.slug(this.identity(snapshotArg))}_${this.slug(keyArg)}`;
}
private static buttonEntity(snapshotArg: IMadvrSnapshot, buttonArg: IMadvrButtonRecord, deviceIdArg: string, nameArg: string, usedIdsArg: Map<string, number>, availableArg: boolean): IIntegrationEntity {
return this.entity('button', `${nameArg} ${buttonArg.name}`, deviceIdArg, this.uniqueId(snapshotArg, `button_${buttonArg.key}`), 'idle', usedIdsArg, {
writable: true,
entityCategory: buttonArg.entityCategory,
icon: buttonArg.icon,
madvrCommand: normalizeEntityCommand(buttonArg.command),
}, availableArg && buttonArg.available !== false);
}
private static selectEntity(snapshotArg: IMadvrSnapshot, selectArg: IMadvrSelectRecord, deviceIdArg: string, nameArg: string, usedIdsArg: Map<string, number>, availableArg: boolean): IIntegrationEntity {
return this.entity('select', `${nameArg} ${selectArg.name}`, deviceIdArg, this.uniqueId(snapshotArg, `select_${selectArg.key}`), selectArg.current ?? 'unknown', usedIdsArg, {
writable: true,
entityCategory: selectArg.entityCategory,
options: selectArg.options,
madvrCommand: selectArg.command ? normalizeEntityCommand(selectArg.command) : undefined,
madvrSelectCommands: selectArg.commands ? Object.fromEntries(Object.entries(selectArg.commands).map(([keyArg, commandArg]) => [keyArg, normalizeEntityCommand(commandArg)])) : undefined,
}, availableArg && selectArg.available !== false);
}
private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map<string, number>, attributesArg: Record<string, unknown>, availableArg: boolean): IIntegrationEntity {
const baseId = `${platformArg}.${this.slug(nameArg) || this.slug(uniqueIdArg)}`;
const seen = usedIdsArg.get(baseId) || 0;
usedIdsArg.set(baseId, seen + 1);
return {
id: seen ? `${baseId}_${seen + 1}` : baseId,
uniqueId: uniqueIdArg,
integrationDomain: madvrDomain,
deviceId: deviceIdArg,
platform: platformArg,
name: nameArg,
state: stateArg,
attributes: this.cleanAttributes(attributesArg),
available: availableArg,
};
}
private static mediaState(snapshotArg: IMadvrSnapshot): string {
if (!this.available(snapshotArg)) {
return 'unavailable';
}
const power = this.powerState(snapshotArg);
if (power === 'off') {
return 'off';
}
if (power === 'unknown') {
return 'unknown';
}
return snapshotArg.processor.isSignal === false ? 'idle' : 'on';
}
private static powerState(snapshotArg: IMadvrSnapshot): TMadvrPowerState {
if (snapshotArg.processor.power) {
return snapshotArg.processor.power;
}
if (snapshotArg.processor.isOn === true) {
return 'on';
}
if (snapshotArg.processor.isOn === false) {
return 'off';
}
return 'unknown';
}
private static available(snapshotArg: IMadvrSnapshot): boolean {
return snapshotArg.available !== false;
}
private static mediaTitle(snapshotArg: IMadvrSnapshot): string | undefined {
const incoming = this.signalLabel(snapshotArg.processor.incoming);
const outgoing = this.signalLabel(snapshotArg.processor.outgoing);
if (incoming && outgoing) {
return `${incoming} to ${outgoing}`;
}
return incoming || outgoing;
}
private static signalLabel(signalArg: IMadvrSignalState | undefined): string | undefined {
if (!signalArg?.resolution) {
return undefined;
}
return [signalArg.resolution, signalArg.frameRate, signalArg.colorimetry].filter(Boolean).join(' ');
}
private static ratioLabel(ratioArg: IMadvrRatioState | undefined): string | null {
if (!ratioArg) {
return null;
}
return ratioArg.name || ratioArg.resolution || (typeof ratioArg.decimal === 'number' ? String(ratioArg.decimal) : null);
}
private static identity(snapshotArg: IMadvrSnapshot): string {
return snapshotArg.deviceInfo.macAddress
|| snapshotArg.deviceInfo.serialNumber
|| snapshotArg.deviceInfo.host
|| snapshotArg.deviceInfo.name
|| 'madvr-envy';
}
private static deviceName(snapshotArg: IMadvrSnapshot): string {
return snapshotArg.deviceInfo.name || snapshotArg.deviceInfo.model || 'madVR Envy';
}
private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null || this.isRecord(valueArg)) {
return valueArg;
}
return valueArg === undefined ? null : String(valueArg);
}
private static cleanAttributes(attributesArg: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined));
}
private static isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
}
private static slug(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'madvr';
}
}
const validTemperature = (valueArg: number | undefined): number | null => {
return typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0 ? valueArg : null;
};
const normalizeEntityCommand = (valueArg: unknown): IMadvrCommandRequest | undefined => {
if (typeof valueArg === 'string') {
const [command, ...args] = valueArg.split(',').map((partArg) => partArg.trim()).filter(Boolean);
return command ? { command, args } : undefined;
}
if (Array.isArray(valueArg)) {
const [command, ...args] = valueArg.filter((itemArg): itemArg is string => typeof itemArg === 'string' && Boolean(itemArg));
return command ? { command, args } : undefined;
}
if (isCommandRequest(valueArg)) {
return valueArg;
}
return undefined;
};
const isCommandRequest = (valueArg: unknown): valueArg is IMadvrCommandRequest => {
return typeof valueArg === 'object' && valueArg !== null && typeof (valueArg as IMadvrCommandRequest).command === 'string';
};
const isCommandMap = (valueArg: unknown): valueArg is Record<string, IMadvrCommandRequest> => {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
};
+304 -2
View File
@@ -1,4 +1,306 @@
export interface IHomeAssistantMadvrConfig {
// TODO: replace with the TypeScript-native config for madvr.
export const madvrDefaultPort = 44077;
export type TMadvrPowerState = 'on' | 'off' | 'unknown';
export type TMadvrCommandName =
| 'PowerOn'
| 'PowerOff'
| 'Standby'
| 'Restart'
| 'ReloadSoftware'
| 'Bye'
| 'ResetTemporary'
| 'ActivateProfile'
| 'OpenMenu'
| 'CloseMenu'
| 'KeyPress'
| 'KeyHold'
| 'GetIncomingSignalInfo'
| 'GetOutgoingSignalInfo'
| 'GetAspectRatio'
| 'GetMaskingRatio'
| 'GetTemperatures'
| 'GetMacAddress'
| 'Toggle'
| 'ToneMapOn'
| 'ToneMapOff'
| 'Hotplug'
| 'RefreshLicenseInfo'
| 'Force1080p60Output';
export type TMadvrRemoteKey =
| 'MENU'
| 'UP'
| 'DOWN'
| 'LEFT'
| 'RIGHT'
| 'OK'
| 'INPUT'
| 'SETTINGS'
| 'RED'
| 'GREEN'
| 'BLUE'
| 'YELLOW'
| 'POWER';
export type TMadvrMenu = 'Info' | 'Settings' | 'Configuration' | 'Profiles' | 'TestPatterns';
export type TMadvrProfileGroup = 'SOURCE' | 'DISPLAY' | 'CUSTOM';
export type TMadvrToggle =
| 'ToneMap'
| 'HighlightRecovery'
| 'ContrastRecovery'
| 'ShadowRecovery'
| '3DLUT'
| 'ScreenBoundaries'
| 'Histogram'
| 'DebugOSD';
export interface IMadvrSignalState {
resolution?: string;
frameRate?: string;
signalType?: '2D' | '3D' | string;
colorSpace?: 'RGB' | '444' | '422' | '420' | string;
bitDepth?: '8bit' | '10bit' | '12bit' | string;
hdr?: boolean;
colorimetry?: 'SDR' | 'HDR10' | 'HLG 601' | 'PAL' | '709' | 'DCI' | '2020' | string;
blackLevels?: 'TV' | 'PC' | string;
aspectRatio?: '16:9' | '4:3' | string;
}
export interface IMadvrTemperatures {
gpu?: number;
hdmi?: number;
cpu?: number;
mainboard?: number;
}
export interface IMadvrRatioState {
resolution?: string;
decimal?: number;
integer?: string;
name?: string;
}
export interface IMadvrProcessorState {
isOn?: boolean;
isSignal?: boolean;
power?: TMadvrPowerState;
incoming?: IMadvrSignalState;
outgoing?: IMadvrSignalState;
temperatures?: IMadvrTemperatures;
profileName?: string;
profileNumber?: string;
lastError?: string;
raw?: Record<string, unknown>;
}
export interface IMadvrProjectorState {
name?: string;
activeProfile?: string;
profileName?: string;
profileNumber?: string;
metadata?: Record<string, unknown>;
}
export interface IMadvrDisplayState {
hdrFlag?: boolean;
outgoingHdrFlag?: boolean;
aspect?: IMadvrRatioState;
masking?: IMadvrRatioState;
activeProfile?: string;
}
export interface IMadvrDeviceInfo {
host?: string;
port?: number;
name?: string;
manufacturer?: string;
model?: string;
macAddress?: string;
serialNumber?: string;
firmwareVersion?: string;
}
export interface IMadvrRawState {
is_on?: boolean;
isOn?: boolean;
is_signal?: boolean;
isSignal?: boolean;
hdr_flag?: boolean;
hdrFlag?: boolean;
outgoing_hdr_flag?: boolean;
outgoingHdrFlag?: boolean;
mac_address?: string;
macAddress?: string;
temp_gpu?: string | number | null;
tempGpu?: string | number | null;
temp_hdmi?: string | number | null;
tempHdmi?: string | number | null;
temp_cpu?: string | number | null;
tempCpu?: string | number | null;
temp_mainboard?: string | number | null;
tempMainboard?: string | number | null;
incoming_res?: string;
incomingRes?: string;
incoming_signal_type?: string;
incomingSignalType?: string;
incoming_frame_rate?: string;
incomingFrameRate?: string;
incoming_color_space?: string;
incomingColorSpace?: string;
incoming_bit_depth?: string;
incomingBitDepth?: string;
incoming_colorimetry?: string;
incomingColorimetry?: string;
incoming_black_levels?: string;
incomingBlackLevels?: string;
incoming_aspect_ratio?: string;
incomingAspectRatio?: string;
outgoing_res?: string;
outgoingRes?: string;
outgoing_signal_type?: string;
outgoingSignalType?: string;
outgoing_frame_rate?: string;
outgoingFrameRate?: string;
outgoing_color_space?: string;
outgoingColorSpace?: string;
outgoing_bit_depth?: string;
outgoingBitDepth?: string;
outgoing_colorimetry?: string;
outgoingColorimetry?: string;
outgoing_black_levels?: string;
outgoingBlackLevels?: string;
aspect_res?: string;
aspectRes?: string;
aspect_dec?: string | number | null;
aspectDec?: string | number | null;
aspect_int?: string;
aspectInt?: string;
aspect_name?: string;
aspectName?: string;
masking_res?: string;
maskingRes?: string;
masking_dec?: string | number | null;
maskingDec?: string | number | null;
masking_int?: string;
maskingInt?: string;
profile_name?: string;
profileName?: string;
profile_num?: string | number;
profileNumber?: string | number;
[key: string]: unknown;
}
export interface IMadvrCommandRequest {
command: TMadvrCommandName | string;
args?: string[];
source?: string;
target?: Record<string, unknown>;
}
export type TMadvrCommandInput = string | string[] | IMadvrCommandRequest;
export interface IMadvrCommandModel {
request: IMadvrCommandRequest;
transport: 'tcp' | 'wake_on_lan';
wireCommand?: string;
informational: boolean;
requiresConnection: boolean;
description?: string;
}
export interface IMadvrCommandResult {
executed: boolean;
command: IMadvrCommandModel;
transport?: 'tcp' | 'wake_on_lan' | 'executor';
response?: string;
data?: unknown;
}
export interface IMadvrCommandExecutor {
execute(commandArg: IMadvrCommandRequest, modelArg: IMadvrCommandModel): Promise<IMadvrCommandResult | string | void>;
getSnapshot?(): Promise<IMadvrSnapshot>;
destroy?(): Promise<void>;
}
export interface IMadvrButtonRecord {
key: string;
name: string;
command: TMadvrCommandInput;
entityCategory?: string;
icon?: string;
available?: boolean;
}
export interface IMadvrSelectRecord {
key: string;
name: string;
current?: string;
options: string[];
command?: TMadvrCommandInput;
commands?: Record<string, TMadvrCommandInput>;
entityCategory?: string;
available?: boolean;
}
export interface IMadvrSnapshot {
deviceInfo: IMadvrDeviceInfo;
processor: IMadvrProcessorState;
projector?: IMadvrProjectorState;
display: IMadvrDisplayState;
buttons?: IMadvrButtonRecord[];
selects?: IMadvrSelectRecord[];
raw?: IMadvrRawState;
available?: boolean;
updatedAt?: string;
}
export interface IMadvrConfig {
host?: string;
port?: number;
name?: string;
model?: string;
manufacturer?: string;
macAddress?: string;
serialNumber?: string;
state?: IMadvrRawState;
processor?: IMadvrProcessorState;
projector?: IMadvrProjectorState;
display?: IMadvrDisplayState;
buttons?: IMadvrButtonRecord[];
selects?: IMadvrSelectRecord[];
snapshot?: IMadvrSnapshot;
commandExecutor?: IMadvrCommandExecutor;
connectTimeoutMs?: number;
commandTimeoutMs?: number;
snapshotCollectMs?: number;
wolBroadcastAddress?: string;
wolPort?: number;
}
export interface IHomeAssistantMadvrConfig extends IMadvrConfig {}
export interface IMadvrManualEntry {
host?: string;
port?: number;
id?: string;
name?: string;
model?: string;
manufacturer?: string;
macAddress?: string;
serialNumber?: string;
metadata?: Record<string, unknown>;
}
export interface IMadvrMdnsRecord {
type?: string;
name?: string;
host?: string;
port?: number;
txt?: Record<string, string | undefined>;
properties?: Record<string, string | undefined>;
}
export type TMadvrDiscoveryRecord = IMadvrManualEntry | IMadvrMdnsRecord;
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -1,2 +1,6 @@
export * from './openrgb.classes.client.js';
export * from './openrgb.classes.configflow.js';
export * from './openrgb.classes.integration.js';
export * from './openrgb.discovery.js';
export * from './openrgb.mapper.js';
export * from './openrgb.types.js';
@@ -0,0 +1,764 @@
import * as plugins from '../../plugins.js';
import { OpenRGBMapper } from './openrgb.mapper.js';
import type {
IOpenRGBClientCommand,
IOpenRGBCommandResult,
IOpenRGBConfig,
IOpenRGBDevice,
IOpenRGBDeviceMetadata,
IOpenRGBEvent,
IOpenRGBLed,
IOpenRGBMode,
IOpenRGBRgbColor,
IOpenRGBSegment,
IOpenRGBSnapshot,
IOpenRGBZone,
TOpenRGBCommandOperation,
} from './openrgb.types.js';
import { openrgbDefaultClientName, openrgbDefaultPort } from './openrgb.types.js';
const protocolVersion = 4;
const headerSize = 16;
const packetTypes = {
requestControllerCount: 0,
requestControllerData: 1,
requestProtocolVersion: 40,
setClientName: 50,
requestProfileList: 150,
requestLoadProfile: 152,
updateLeds: 1050,
updateZoneLeds: 1051,
updateMode: 1101,
};
type TOpenRGBEventHandler = (eventArg: IOpenRGBEvent) => void;
export class OpenRGBClient {
private readonly events: IOpenRGBEvent[] = [];
private readonly eventHandlers = new Set<TOpenRGBEventHandler>();
constructor(private readonly config: IOpenRGBConfig) {}
public async getSnapshot(): Promise<IOpenRGBSnapshot> {
if (this.config.host) {
try {
const snapshot = await this.readLiveSnapshot();
this.emit({ type: 'snapshot', data: { deviceCount: snapshot.devices.length }, timestamp: Date.now() });
return OpenRGBMapper.toSnapshot({ ...this.config, snapshot, devices: undefined, device: undefined, manualEntries: undefined }, true, this.events);
} catch (error) {
this.emit({ type: 'error', data: { message: this.errorMessage(error) }, timestamp: Date.now() });
return OpenRGBMapper.toSnapshot(this.config, false, this.events);
}
}
return OpenRGBMapper.toSnapshot(this.config, undefined, this.events);
}
public onEvent(handlerArg: TOpenRGBEventHandler): () => void {
this.eventHandlers.add(handlerArg);
return () => this.eventHandlers.delete(handlerArg);
}
public async sendCommand(commandArg: IOpenRGBClientCommand): Promise<IOpenRGBCommandResult> {
this.emit({
type: 'command_mapped',
command: commandArg,
deviceId: commandArg.deviceId,
entityId: commandArg.entityId,
deviceKey: commandArg.deviceKey,
timestamp: Date.now(),
});
let result: IOpenRGBCommandResult;
if (this.config.commandExecutor) {
result = this.commandResult(await this.config.commandExecutor(commandArg), commandArg);
} else if (this.config.host) {
try {
const sdk = new OpenRGBSdkClient(this.config);
await sdk.connect();
try {
await sdk.executeCommand(commandArg);
} finally {
await sdk.disconnect();
}
result = { success: true, data: { command: commandArg } };
} catch (error) {
result = { success: false, error: this.errorMessage(error), data: { command: commandArg } };
}
} else {
result = {
success: false,
error: this.unsupportedLiveControlMessage(),
data: { command: commandArg },
};
}
this.emit({
type: result.success ? 'command_executed' : 'command_failed',
command: commandArg,
data: result,
deviceId: commandArg.deviceId,
entityId: commandArg.entityId,
deviceKey: commandArg.deviceKey,
timestamp: Date.now(),
});
return result;
}
public async destroy(): Promise<void> {
this.eventHandlers.clear();
}
private async readLiveSnapshot(): Promise<IOpenRGBSnapshot> {
const sdk = new OpenRGBSdkClient(this.config);
await sdk.connect();
try {
return await sdk.readSnapshot();
} finally {
await sdk.disconnect();
}
}
private emit(eventArg: IOpenRGBEvent): void {
this.events.push(eventArg);
for (const handler of this.eventHandlers) {
handler(eventArg);
}
}
private commandResult(resultArg: unknown, commandArg: IOpenRGBClientCommand): IOpenRGBCommandResult {
if (this.isCommandResult(resultArg)) {
return resultArg;
}
return { success: true, data: resultArg ?? { command: commandArg } };
}
private isCommandResult(valueArg: unknown): valueArg is IOpenRGBCommandResult {
return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg;
}
private unsupportedLiveControlMessage(): string {
return 'OpenRGB live SDK control requires a configured host, or commandExecutor for snapshot/manual configs.';
}
private errorMessage(errorArg: unknown): string {
return errorArg instanceof Error ? errorArg.message : String(errorArg);
}
}
export class OpenRGBSdkClient {
private socket?: plugins.net.Socket;
private chunks: Buffer[] = [];
private bufferedLength = 0;
private pending?: {
length: number;
resolve: (valueArg: Buffer) => void;
reject: (errorArg: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
private activeProtocolVersion = protocolVersion;
constructor(private readonly config: IOpenRGBConfig) {}
public async connect(): Promise<void> {
const host = this.config.host;
if (!host) {
throw new Error('OpenRGB SDK host is required.');
}
const port = this.config.port || openrgbDefaultPort;
const timeoutMs = this.timeoutMs();
await new Promise<void>((resolve, reject) => {
let settled = false;
const socket = plugins.net.createConnection({ host, port });
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 Error(`Timed out connecting to OpenRGB SDK server at ${host}:${port}.`));
socket.setTimeout(timeoutMs);
socket.once('connect', onConnect);
socket.once('error', onError);
socket.once('timeout', onTimeout);
this.socket = socket;
});
this.socket?.setTimeout(0);
this.socket?.on('data', (chunkArg) => this.onData(Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg)));
this.socket?.on('error', (errorArg) => this.rejectPending(errorArg));
this.socket?.on('close', () => this.rejectPending(new Error('OpenRGB SDK connection closed.')));
await this.negotiateProtocolVersion();
await this.setClientName();
}
public async disconnect(): Promise<void> {
if (!this.socket) {
return;
}
const socket = this.socket;
this.socket = undefined;
socket.removeAllListeners();
socket.destroy();
this.rejectPending(new Error('OpenRGB SDK connection closed.'));
}
public async readSnapshot(): Promise<IOpenRGBSnapshot> {
const countPacket = await this.sendAndRead(0, packetTypes.requestControllerCount);
this.assertPacketType(countPacket, packetTypes.requestControllerCount);
const controllerCount = countPacket.payload.readUInt32LE(0);
const devices: IOpenRGBDevice[] = [];
for (let index = 0; index < controllerCount; index++) {
const requestPayload = Buffer.alloc(4);
requestPayload.writeUInt32LE(this.activeProtocolVersion, 0);
const dataPacket = await this.sendAndRead(index, packetTypes.requestControllerData, requestPayload);
this.assertPacketType(dataPacket, packetTypes.requestControllerData);
devices.push(this.parseControllerData(dataPacket.payload, index));
}
const profiles = this.activeProtocolVersion >= 2 ? await this.readProfiles().catch(() => []) : [];
return {
connected: true,
host: this.config.host,
port: this.config.port || openrgbDefaultPort,
name: this.config.name || 'OpenRGB SDK Server',
protocolVersion: this.activeProtocolVersion,
controller: {
id: this.config.entryId || `${this.config.host}:${this.config.port || openrgbDefaultPort}`,
name: this.config.name || 'OpenRGB SDK Server',
host: this.config.host,
port: this.config.port || openrgbDefaultPort,
protocolVersion: this.activeProtocolVersion,
manufacturer: 'OpenRGB',
model: 'OpenRGB SDK Server',
softwareVersion: `${this.activeProtocolVersion} (Protocol)`,
},
devices,
profiles,
events: [],
};
}
public async executeCommand(commandArg: IOpenRGBClientCommand): Promise<void> {
for (const operation of commandArg.operations) {
await this.executeOperation(operation);
}
}
private async executeOperation(operationArg: TOpenRGBCommandOperation): Promise<void> {
if (operationArg.action === 'setMode') {
if (typeof operationArg.deviceIndex !== 'number') {
throw new Error('OpenRGB setMode operation requires a numeric device index for live SDK writes.');
}
if (!operationArg.mode) {
throw new Error(`OpenRGB setMode operation for ${operationArg.modeName} requires complete mode data.`);
}
const payload = this.packMode(operationArg.mode);
await this.sendPacket(operationArg.deviceIndex, packetTypes.updateMode, payload);
return;
}
if (operationArg.action === 'setColor') {
if (typeof operationArg.deviceIndex !== 'number') {
throw new Error('OpenRGB setColor operation requires a numeric device index for live SDK writes.');
}
if (operationArg.ledCount <= 0) {
throw new Error('OpenRGB setColor operation requires a positive LED count.');
}
if (typeof operationArg.zoneIndex === 'number') {
await this.sendPacket(operationArg.deviceIndex, packetTypes.updateZoneLeds, this.packZoneColors(operationArg.zoneIndex, operationArg.ledCount, operationArg.rgb));
return;
}
await this.sendPacket(operationArg.deviceIndex, packetTypes.updateLeds, this.packDeviceColors(operationArg.ledCount, operationArg.rgb));
return;
}
if (operationArg.action === 'loadProfile') {
await this.sendPacket(0, packetTypes.requestLoadProfile, Buffer.from(`${operationArg.profile}\0`, 'utf8'));
}
}
private async negotiateProtocolVersion(): Promise<void> {
const payload = Buffer.alloc(4);
const requestedVersion = this.config.protocolVersion && this.config.protocolVersion > 0 ? Math.min(this.config.protocolVersion, protocolVersion) : protocolVersion;
payload.writeUInt32LE(requestedVersion, 0);
const packet = await this.sendAndRead(0, packetTypes.requestProtocolVersion, payload);
this.assertPacketType(packet, packetTypes.requestProtocolVersion);
const serverVersion = packet.payload.length >= 4 ? packet.payload.readUInt32LE(0) : 0;
this.activeProtocolVersion = Math.min(serverVersion || requestedVersion, requestedVersion, protocolVersion);
}
private async setClientName(): Promise<void> {
await this.sendPacket(0, packetTypes.setClientName, Buffer.from(`${this.config.clientName || openrgbDefaultClientName}\0`, 'utf8'));
}
private async readProfiles(): Promise<string[]> {
const packet = await this.sendAndRead(0, packetTypes.requestProfileList);
this.assertPacketType(packet, packetTypes.requestProfileList);
if (packet.payload.length <= 4) {
return [];
}
const reader = new OpenRGBBinaryReader(packet.payload.subarray(4));
const count = reader.uint16();
const profiles: string[] = [];
for (let index = 0; index < count; index++) {
profiles.push(reader.string());
}
return profiles;
}
private async sendAndRead(deviceIdArg: number, packetTypeArg: number, payloadArg: Buffer = Buffer.alloc(0)): Promise<{ deviceId: number; type: number; payload: Buffer }> {
await this.sendPacket(deviceIdArg, packetTypeArg, payloadArg);
return this.readPacket();
}
private async sendPacket(deviceIdArg: number, packetTypeArg: number, payloadArg: Buffer = Buffer.alloc(0)): Promise<void> {
const socket = this.socket;
if (!socket) {
throw new Error('OpenRGB SDK socket is not connected.');
}
const header = Buffer.alloc(headerSize);
header.write('ORGB', 0, 'ascii');
header.writeUInt32LE(deviceIdArg, 4);
header.writeUInt32LE(packetTypeArg, 8);
header.writeUInt32LE(payloadArg.length, 12);
await this.write(Buffer.concat([header, payloadArg]));
}
private async write(dataArg: Buffer): Promise<void> {
const socket = this.socket;
if (!socket) {
throw new Error('OpenRGB SDK socket is not connected.');
}
await new Promise<void>((resolve, reject) => {
socket.write(dataArg, (errorArg) => errorArg ? reject(errorArg) : resolve());
});
}
private async readPacket(): Promise<{ deviceId: number; type: number; payload: Buffer }> {
const header = await this.readExact(headerSize);
if (header.subarray(0, 4).toString('ascii') !== 'ORGB') {
throw new Error('OpenRGB SDK response did not contain an ORGB header.');
}
const deviceId = header.readUInt32LE(4);
const type = header.readUInt32LE(8);
const size = header.readUInt32LE(12);
const payload = size ? await this.readExact(size) : Buffer.alloc(0);
return { deviceId, type, payload };
}
private readExact(lengthArg: number): Promise<Buffer> {
if (this.bufferedLength >= lengthArg) {
return Promise.resolve(this.consume(lengthArg));
}
if (this.pending) {
return Promise.reject(new Error('OpenRGB SDK already has a pending read.'));
}
return new Promise<Buffer>((resolve, reject) => {
const timer = setTimeout(() => {
if (this.pending?.length === lengthArg) {
this.pending = undefined;
reject(new Error(`Timed out waiting for ${lengthArg} bytes from OpenRGB SDK server.`));
}
}, this.timeoutMs());
this.pending = { length: lengthArg, resolve, reject, timer };
this.resolvePending();
});
}
private onData(chunkArg: Buffer): void {
this.chunks.push(chunkArg);
this.bufferedLength += chunkArg.length;
this.resolvePending();
}
private resolvePending(): void {
const pending = this.pending;
if (!pending || this.bufferedLength < pending.length) {
return;
}
this.pending = undefined;
clearTimeout(pending.timer);
pending.resolve(this.consume(pending.length));
}
private rejectPending(errorArg: Error): void {
if (!this.pending) {
return;
}
const pending = this.pending;
this.pending = undefined;
clearTimeout(pending.timer);
pending.reject(errorArg);
}
private consume(lengthArg: number): Buffer {
const output = Buffer.alloc(lengthArg);
let offset = 0;
while (offset < lengthArg) {
const chunk = this.chunks[0];
const needed = lengthArg - offset;
if (chunk.length <= needed) {
chunk.copy(output, offset);
offset += chunk.length;
this.chunks.shift();
} else {
chunk.copy(output, offset, 0, needed);
this.chunks[0] = chunk.subarray(needed);
offset += needed;
}
}
this.bufferedLength -= lengthArg;
return output;
}
private parseControllerData(payloadArg: Buffer, indexArg: number): IOpenRGBDevice {
const reader = new OpenRGBBinaryReader(payloadArg);
reader.uint32();
const rawType = reader.int32();
const name = reader.string();
const metadata = this.parseMetadata(reader);
const modeCount = reader.uint16();
const activeMode = reader.int32();
const modes: IOpenRGBMode[] = [];
for (let index = 0; index < modeCount; index++) {
modes.push(this.parseMode(reader, index));
}
const zones = this.parseList(reader, () => this.parseZone(reader));
const leds = this.parseList(reader, () => this.parseLed(reader));
const colors = this.parseList(reader, () => this.parseColor(reader));
let ledOffset = 0;
for (const zone of zones) {
zone.startIndex = ledOffset;
zone.leds = leds.slice(ledOffset, ledOffset + (zone.numLeds || 0));
zone.colors = colors.slice(ledOffset, ledOffset + (zone.numLeds || 0));
for (let index = 0; index < zone.leds.length; index++) {
zone.leds[index].color = zone.colors[index];
}
ledOffset += zone.numLeds || 0;
}
for (let index = 0; index < leds.length; index++) {
leds[index].color = colors[index];
}
return {
id: indexArg,
name,
type: rawType,
typeName: this.deviceTypeName(rawType),
metadata,
modes,
activeMode,
zones,
leds,
colors,
available: true,
};
}
private parseMetadata(readerArg: OpenRGBBinaryReader): IOpenRGBDeviceMetadata {
const vendor = this.activeProtocolVersion >= 1 ? readerArg.string() : undefined;
return {
vendor,
description: readerArg.string(),
version: readerArg.string(),
serial: readerArg.string(),
location: readerArg.string(),
};
}
private parseMode(readerArg: OpenRGBBinaryReader, indexArg: number): IOpenRGBMode {
const name = readerArg.string();
const value = readerArg.int32();
const flags = readerArg.uint32();
const speedMin = readerArg.uint32();
const speedMax = readerArg.uint32();
const brightnessMin = this.activeProtocolVersion >= 3 ? readerArg.uint32() : undefined;
const brightnessMax = this.activeProtocolVersion >= 3 ? readerArg.uint32() : undefined;
const colorsMin = readerArg.uint32();
const colorsMax = readerArg.uint32();
const speed = readerArg.uint32();
const brightness = this.activeProtocolVersion >= 3 ? readerArg.uint32() : undefined;
const direction = readerArg.uint32();
const colorMode = readerArg.uint32();
const colors = this.parseList(readerArg, () => this.parseColor(readerArg));
return {
id: indexArg,
name,
value,
flags,
speedMin: flags & (1 << 0) ? speedMin : undefined,
speedMax: flags & (1 << 0) ? speedMax : undefined,
brightnessMin: flags & (1 << 4) ? brightnessMin : undefined,
brightnessMax: flags & (1 << 4) ? brightnessMax : undefined,
colorsMin: colors.length ? colorsMin : undefined,
colorsMax: colors.length ? colorsMax : undefined,
speed: flags & (1 << 0) ? speed : undefined,
brightness: flags & (1 << 4) ? brightness : undefined,
direction: flags & ((1 << 1) | (1 << 2) | (1 << 3)) ? direction : undefined,
colorMode,
colors: colors.length ? colors : undefined,
supportsColor: colorMode === 1,
metadata: { colorModeName: this.colorModeName(colorMode) },
};
}
private parseZone(readerArg: OpenRGBBinaryReader): IOpenRGBZone {
const name = readerArg.string();
const rawType = readerArg.int32();
const ledsMin = readerArg.uint32();
const ledsMax = readerArg.uint32();
const numLeds = readerArg.uint32();
const matrixZoneSize = readerArg.uint16();
let matrixHeight: number | undefined;
let matrixWidth: number | undefined;
let matrixMap: Array<Array<number | null>> | undefined;
if (rawType === 2) {
matrixHeight = readerArg.uint32();
matrixWidth = readerArg.uint32();
matrixMap = [];
for (let y = 0; y < matrixHeight; y++) {
const row: Array<number | null> = [];
for (let x = 0; x < matrixWidth; x++) {
const value = readerArg.uint32();
row.push(value === 0xFFFFFFFF ? null : value);
}
matrixMap.push(row);
}
} else if (matrixZoneSize > 0) {
readerArg.skip(matrixZoneSize);
}
const segments = this.activeProtocolVersion >= 4 ? this.parseList(readerArg, () => this.parseSegment(readerArg)) : undefined;
return {
name,
type: rawType,
typeName: this.zoneTypeName(rawType),
ledsMin,
ledsMax,
numLeds,
matrixHeight,
matrixWidth,
matrixMap,
segments,
};
}
private parseSegment(readerArg: OpenRGBBinaryReader): IOpenRGBSegment {
const name = readerArg.string();
const rawType = readerArg.int32();
return {
name,
type: rawType,
typeName: this.zoneTypeName(rawType),
startIndex: readerArg.uint32(),
ledCount: readerArg.uint32(),
};
}
private parseLed(readerArg: OpenRGBBinaryReader): IOpenRGBLed {
return {
name: readerArg.string(),
value: readerArg.uint32(),
};
}
private parseColor(readerArg: OpenRGBBinaryReader): IOpenRGBRgbColor {
const red = readerArg.uint8();
const green = readerArg.uint8();
const blue = readerArg.uint8();
readerArg.skip(1);
return { red, green, blue };
}
private parseList<TValue>(readerArg: OpenRGBBinaryReader, parserArg: () => TValue): TValue[] {
const count = readerArg.uint16();
const values: TValue[] = [];
for (let index = 0; index < count; index++) {
values.push(parserArg());
}
return values;
}
private packDeviceColors(ledCountArg: number, colorArg: IOpenRGBRgbColor): Buffer {
const body = Buffer.concat([this.uint16(ledCountArg), ...Array.from({ length: ledCountArg }, () => this.packColor(colorArg))]);
return Buffer.concat([this.uint32(body.length + 4), body]);
}
private packZoneColors(zoneIndexArg: number, ledCountArg: number, colorArg: IOpenRGBRgbColor): Buffer {
const body = Buffer.concat([
this.int32(zoneIndexArg),
this.uint16(ledCountArg),
...Array.from({ length: ledCountArg }, () => this.packColor(colorArg)),
]);
return Buffer.concat([this.uint32(body.length + 4), body]);
}
private packMode(modeArg: IOpenRGBMode): Buffer {
if (typeof modeArg.id !== 'number' && typeof modeArg.value !== 'number') {
throw new Error(`OpenRGB mode ${modeArg.name} is missing SDK id/value data.`);
}
const flags = modeArg.flags || 0;
const chunks = [
this.int32(modeArg.id ?? modeArg.value ?? 0),
this.packString(modeArg.name),
this.int32(modeArg.value ?? modeArg.id ?? 0),
this.uint32(flags),
this.uint32(modeArg.speedMin ?? 0),
this.uint32(modeArg.speedMax ?? 0),
];
if (this.activeProtocolVersion >= 3) {
chunks.push(this.uint32(modeArg.brightnessMin ?? 0), this.uint32(modeArg.brightnessMax ?? 0));
}
chunks.push(
this.uint32(modeArg.colorsMin ?? 0),
this.uint32(modeArg.colorsMax ?? 0),
this.uint32(modeArg.speed ?? 0)
);
if (this.activeProtocolVersion >= 3) {
chunks.push(this.uint32(modeArg.brightness ?? 0));
}
chunks.push(
this.uint32(typeof modeArg.direction === 'number' ? modeArg.direction : 0),
this.uint32(this.colorModeValue(modeArg.colorMode)),
this.packColorList(modeArg.colors || [])
);
const body = Buffer.concat(chunks);
return Buffer.concat([this.uint32(body.length + 4), body]);
}
private packColorList(colorsArg: IOpenRGBRgbColor[]): Buffer {
return Buffer.concat([this.uint16(colorsArg.length), ...colorsArg.map((colorArg) => this.packColor(colorArg))]);
}
private packColor(colorArg: IOpenRGBRgbColor): Buffer {
return Buffer.from([
this.byte(colorArg.red),
this.byte(colorArg.green),
this.byte(colorArg.blue),
0,
]);
}
private packString(valueArg: string): Buffer {
const value = Buffer.from(valueArg, 'ascii');
return Buffer.concat([this.uint16(value.length + 1), value, Buffer.from([0])]);
}
private assertPacketType(packetArg: { type: number }, typeArg: number): void {
if (packetArg.type !== typeArg) {
throw new Error(`Unexpected OpenRGB SDK packet type ${packetArg.type}; expected ${typeArg}.`);
}
}
private colorModeValue(valueArg: IOpenRGBMode['colorMode']): number {
if (typeof valueArg === 'number') {
return valueArg;
}
const value = (valueArg || '').toLowerCase().replace(/[-\s]+/g, '_');
if (value === 'per_led' || value === 'perled') return 1;
if (value === 'mode_specific') return 2;
if (value === 'random') return 3;
return 0;
}
private colorModeName(valueArg: number): string {
return ['none', 'per_led', 'mode_specific', 'random'][valueArg] || 'unknown';
}
private deviceTypeName(valueArg: number): string {
return [
'motherboard', 'dram', 'gpu', 'cooler', 'ledstrip', 'keyboard', 'mouse', 'mousemat', 'headset', 'headset_stand',
'gamepad', 'light', 'speaker', 'virtual', 'storage', 'case', 'microphone', 'accessory', 'keypad', 'unknown',
][valueArg] || 'unknown';
}
private zoneTypeName(valueArg: number): string {
return ['single', 'linear', 'matrix'][valueArg] || 'unknown';
}
private int32(valueArg: number): Buffer {
const buffer = Buffer.alloc(4);
buffer.writeInt32LE(Math.trunc(valueArg), 0);
return buffer;
}
private uint32(valueArg: number): Buffer {
const buffer = Buffer.alloc(4);
buffer.writeUInt32LE(Math.max(0, Math.trunc(valueArg)), 0);
return buffer;
}
private uint16(valueArg: number): Buffer {
const buffer = Buffer.alloc(2);
buffer.writeUInt16LE(Math.max(0, Math.min(0xFFFF, Math.trunc(valueArg))), 0);
return buffer;
}
private byte(valueArg: number): number {
return Math.max(0, Math.min(255, Math.round(valueArg)));
}
private timeoutMs(): number {
return typeof this.config.timeoutMs === 'number' && this.config.timeoutMs > 0 ? this.config.timeoutMs : 5000;
}
}
class OpenRGBBinaryReader {
private offset = 0;
constructor(private readonly buffer: Buffer) {}
public uint8(): number {
this.ensure(1);
const value = this.buffer.readUInt8(this.offset);
this.offset += 1;
return value;
}
public uint16(): number {
this.ensure(2);
const value = this.buffer.readUInt16LE(this.offset);
this.offset += 2;
return value;
}
public uint32(): number {
this.ensure(4);
const value = this.buffer.readUInt32LE(this.offset);
this.offset += 4;
return value;
}
public int32(): number {
this.ensure(4);
const value = this.buffer.readInt32LE(this.offset);
this.offset += 4;
return value;
}
public string(): string {
const length = this.uint16();
this.ensure(length);
const value = this.buffer.subarray(this.offset, this.offset + length).toString('utf8').replace(/\0+$/, '');
this.offset += length;
return value;
}
public skip(lengthArg: number): void {
this.ensure(lengthArg);
this.offset += lengthArg;
}
private ensure(lengthArg: number): void {
if (this.offset + lengthArg > this.buffer.length) {
throw new Error('OpenRGB SDK response ended before parsing completed.');
}
}
}
@@ -0,0 +1,104 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { IOpenRGBConfig, IOpenRGBController, IOpenRGBSnapshot } from './openrgb.types.js';
import { openrgbDefaultClientName, openrgbDefaultPort } from './openrgb.types.js';
export class OpenRGBConfigFlow implements IConfigFlow<IOpenRGBConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IOpenRGBConfig>> {
void contextArg;
const defaults = this.defaultsFromCandidate(candidateArg);
return {
kind: 'form',
title: 'Connect OpenRGB',
description: 'Configure the local OpenRGB SDK server. OpenRGB uses TCP port 6742 by default.',
fields: [
{ name: 'name', label: 'Name', type: 'text', required: true },
{ name: 'host', label: 'Host or IP address', type: 'text', required: true },
{ name: 'port', label: 'SDK port', type: 'number' },
{ name: 'clientName', label: 'SDK client name', type: 'text' },
],
submit: async (valuesArg) => {
const host = this.stringValue(valuesArg.host) || defaults.host;
if (!host) {
return { kind: 'error', title: 'Invalid OpenRGB config', error: 'OpenRGB setup requires a host or IP address.' };
}
const port = this.numberValue(valuesArg.port) || defaults.port || openrgbDefaultPort;
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
return { kind: 'error', title: 'Invalid OpenRGB config', error: 'OpenRGB SDK port must be an integer between 1 and 65535.' };
}
const name = this.stringValue(valuesArg.name) || defaults.name || 'OpenRGB SDK Server';
const clientName = this.stringValue(valuesArg.clientName) || defaults.clientName || openrgbDefaultClientName;
return {
kind: 'done',
title: 'OpenRGB configured',
config: {
entryId: defaults.entryId,
name,
host,
port,
clientName,
protocolVersion: defaults.protocolVersion,
controller: {
...defaults.controller,
name,
host,
port,
},
snapshot: defaults.snapshot,
devices: defaults.devices,
profiles: defaults.profiles,
},
};
},
};
}
private defaultsFromCandidate(candidateArg: IDiscoveryCandidate): {
entryId?: string;
host?: string;
port?: number;
name?: string;
clientName?: string;
protocolVersion?: number;
controller?: IOpenRGBController;
snapshot?: IOpenRGBSnapshot;
devices?: IOpenRGBConfig['devices'];
profiles?: string[];
} {
const metadata = candidateArg.metadata || {};
const snapshot = this.isRecord(metadata.snapshot) ? metadata.snapshot as unknown as IOpenRGBSnapshot : undefined;
const controller = this.isRecord(metadata.controller) ? metadata.controller as unknown as IOpenRGBController : undefined;
const devices = Array.isArray(metadata.devices) ? metadata.devices as IOpenRGBConfig['devices'] : undefined;
const profiles = Array.isArray(metadata.profiles) ? metadata.profiles.filter((profileArg): profileArg is string => typeof profileArg === 'string') : undefined;
return {
entryId: candidateArg.id,
host: candidateArg.host || snapshot?.host,
port: candidateArg.port || snapshot?.port || openrgbDefaultPort,
name: candidateArg.name || snapshot?.name || controller?.name,
clientName: this.stringValue(metadata.clientName) || openrgbDefaultClientName,
protocolVersion: this.numberValue(metadata.protocolVersion) || snapshot?.protocolVersion,
controller,
snapshot,
devices,
profiles,
};
}
private stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
private 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 isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
}
}
@@ -1,27 +1,73 @@
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, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
import { OpenRGBClient } from './openrgb.classes.client.js';
import { OpenRGBConfigFlow } from './openrgb.classes.configflow.js';
import { createOpenRGBDiscoveryDescriptor } from './openrgb.discovery.js';
import { OpenRGBMapper } from './openrgb.mapper.js';
import type { IOpenRGBConfig } from './openrgb.types.js';
export class HomeAssistantOpenrgbIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "openrgb",
displayName: "OpenRGB",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/openrgb",
"upstreamDomain": "openrgb",
"integrationType": "hub",
"iotClass": "local_polling",
"qualityScale": "silver",
"requirements": [
"openrgb-python==0.3.6"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@felipecrs"
]
},
});
export class OpenRGBIntegration extends BaseIntegration<IOpenRGBConfig> {
public readonly domain = 'openrgb';
public readonly displayName = 'OpenRGB';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createOpenRGBDiscoveryDescriptor();
public readonly configFlow = new OpenRGBConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/openrgb',
upstreamDomain: 'openrgb',
integrationType: 'hub',
iotClass: 'local_polling',
qualityScale: 'silver',
requirements: ['openrgb-python==0.3.6'],
dependencies: [] as string[],
afterDependencies: [] as string[],
codeowners: ['@felipecrs'],
documentation: 'https://www.home-assistant.io/integrations/openrgb',
protocolDocumentation: 'https://gitlab.com/CalcProgrammer1/OpenRGB#openrgb-sdk',
defaultPort: 6742,
};
public async setup(configArg: IOpenRGBConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new OpenRGBRuntime(new OpenRGBClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantOpenrgbIntegration extends OpenRGBIntegration {}
class OpenRGBRuntime implements IIntegrationRuntime {
public domain = 'openrgb';
constructor(private readonly client: OpenRGBClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return OpenRGBMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return OpenRGBMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(OpenRGBMapper.toIntegrationEvent(eventArg)));
await this.client.getSnapshot();
return async () => unsubscribe();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
const snapshot = await this.client.getSnapshot();
const command = OpenRGBMapper.commandForService(snapshot, requestArg);
if (!command) {
return { success: false, error: `Unsupported OpenRGB service mapping: ${requestArg.domain}.${requestArg.service}` };
}
return this.client.sendCommand(command);
}
public async destroy(): Promise<void> {
await this.client.destroy();
}
}
@@ -0,0 +1,140 @@
import * as plugins from '../../plugins.js';
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryProbe, IDiscoveryProbeResult, IDiscoveryValidator } from '../../core/types.js';
import type { IOpenRGBManualDiscoveryRecord } from './openrgb.types.js';
import { openrgbDefaultPort } from './openrgb.types.js';
export class OpenRGBLocalSdkProbe implements IDiscoveryProbe {
public id = 'openrgb-local-sdk-probe';
public source = 'manual' as const;
public description = 'Check the default local OpenRGB SDK endpoint on 127.0.0.1:6742.';
public async probe(contextArg: IDiscoveryContext): Promise<IDiscoveryProbeResult> {
if (contextArg.abortSignal?.aborted) {
return { candidates: [] };
}
const reachable = await this.canConnect('127.0.0.1', openrgbDefaultPort, 400);
return {
candidates: reachable ? [{
source: 'manual',
integrationDomain: 'openrgb',
id: '127.0.0.1:6742',
host: '127.0.0.1',
port: openrgbDefaultPort,
name: 'OpenRGB SDK Server',
manufacturer: 'OpenRGB',
model: 'OpenRGB SDK Server',
metadata: { openrgb: true, discoveryProtocol: 'local-sdk' },
}] : [],
};
}
private async canConnect(hostArg: string, portArg: number, timeoutMsArg: number): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const socket = plugins.net.createConnection({ host: hostArg, port: portArg });
const finish = (valueArg: boolean) => {
if (settled) {
return;
}
settled = true;
socket.removeAllListeners();
socket.destroy();
resolve(valueArg);
};
socket.setTimeout(timeoutMsArg);
socket.once('connect', () => finish(true));
socket.once('error', () => finish(false));
socket.once('timeout', () => finish(false));
});
}
}
export class OpenRGBManualMatcher implements IDiscoveryMatcher<IOpenRGBManualDiscoveryRecord> {
public id = 'openrgb-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual OpenRGB SDK setup entries and fixture snapshots.';
public async matches(inputArg: IOpenRGBManualDiscoveryRecord): Promise<IDiscoveryMatch> {
const metadata = inputArg.metadata || {};
const snapshot = inputArg.snapshot || metadata.snapshot;
const text = [inputArg.integrationDomain, inputArg.name, inputArg.manufacturer, inputArg.model, metadata.manufacturer, metadata.model]
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
.join(' ')
.toLowerCase();
const matched = inputArg.integrationDomain === 'openrgb'
|| metadata.openrgb === true
|| Boolean(snapshot)
|| text.includes('openrgb')
|| inputArg.port === openrgbDefaultPort
|| Boolean(inputArg.host && !text);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain OpenRGB setup data.' };
}
const id = inputArg.id || (inputArg.host ? `${inputArg.host}:${inputArg.port || openrgbDefaultPort}` : 'openrgb-manual');
return {
matched: true,
confidence: snapshot ? 'certain' : inputArg.host && inputArg.port === openrgbDefaultPort ? 'high' : inputArg.host ? 'medium' : 'low',
reason: snapshot ? 'Manual entry includes an OpenRGB snapshot.' : 'Manual entry can start OpenRGB SDK setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: 'openrgb',
id,
host: inputArg.host,
port: inputArg.port || openrgbDefaultPort,
name: inputArg.name || 'OpenRGB SDK Server',
manufacturer: inputArg.manufacturer || 'OpenRGB',
model: inputArg.model || 'OpenRGB SDK Server',
metadata: {
...metadata,
openrgb: true,
manual: true,
controller: inputArg.controller,
device: inputArg.device,
devices: inputArg.devices,
profiles: inputArg.profiles,
snapshot,
},
},
metadata: { snapshotConfigured: Boolean(snapshot) },
};
}
}
export class OpenRGBCandidateValidator implements IDiscoveryValidator {
public id = 'openrgb-candidate-validator';
public description = 'Validate OpenRGB SDK candidates before local setup.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const text = [candidateArg.integrationDomain, candidateArg.name, candidateArg.manufacturer, candidateArg.model, metadata.manufacturer, metadata.model]
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
.join(' ')
.toLowerCase();
const matched = candidateArg.integrationDomain === 'openrgb'
|| metadata.openrgb === true
|| metadata.snapshot !== undefined
|| text.includes('openrgb')
|| candidateArg.port === openrgbDefaultPort;
const hasUsableEndpoint = Boolean(candidateArg.host && (!candidateArg.port || this.isValidPort(candidateArg.port)));
return {
matched: matched && (hasUsableEndpoint || metadata.snapshot !== undefined),
confidence: matched && metadata.snapshot !== undefined ? 'certain' : matched && candidateArg.host && candidateArg.port === openrgbDefaultPort ? 'high' : matched ? 'medium' : 'low',
reason: matched ? 'Candidate has OpenRGB SDK metadata or default port information.' : 'Candidate is not OpenRGB.',
candidate: matched && (hasUsableEndpoint || metadata.snapshot !== undefined) ? candidateArg : undefined,
normalizedDeviceId: candidateArg.id || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || openrgbDefaultPort}` : undefined),
};
}
private isValidPort(valueArg: number): boolean {
return Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
}
}
export const createOpenRGBDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: 'openrgb', displayName: 'OpenRGB' })
.addProbe(new OpenRGBLocalSdkProbe())
.addMatcher(new OpenRGBManualMatcher())
.addValidator(new OpenRGBCandidateValidator());
};
+857
View File
@@ -0,0 +1,857 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
import type {
IOpenRGBClientCommand,
IOpenRGBConfig,
IOpenRGBDevice,
IOpenRGBDeviceMetadata,
IOpenRGBEvent,
IOpenRGBManualEntry,
IOpenRGBMode,
IOpenRGBRgbColor,
IOpenRGBSnapshot,
IOpenRGBZone,
TOpenRGBCommandOperation,
} from './openrgb.types.js';
import { openrgbDefaultPort, openrgbUidSeparator } from './openrgb.types.js';
const openrgbModeOff = 'Off';
const openrgbModeStatic = 'Static';
const openrgbModeDirect = 'Direct';
const openrgbModeCustom = 'Custom';
const effectOffModes = new Set([openrgbModeStatic, openrgbModeDirect, openrgbModeCustom].map((modeArg) => modeArg.toLowerCase()));
const black: IOpenRGBRgbColor = { red: 0, green: 0, blue: 0 };
const white: IOpenRGBRgbColor = { red: 255, green: 255, blue: 255 };
const deviceTypeNames = [
'motherboard',
'dram',
'gpu',
'cooler',
'ledstrip',
'keyboard',
'mouse',
'mousemat',
'headset',
'headset_stand',
'gamepad',
'light',
'speaker',
'virtual',
'storage',
'case',
'microphone',
'accessory',
'keypad',
'unknown',
];
const zoneTypeNames = ['single', 'linear', 'matrix'];
interface IOpenRGBResolvedLight {
entity: IIntegrationEntity;
device: IOpenRGBDevice;
zone?: IOpenRGBZone;
info: IOpenRGBLightInfo;
}
interface IOpenRGBLightInfo {
activeMode?: IOpenRGBMode;
modeName?: string;
modeIsOff: boolean;
supportsColor: boolean;
supportedColorModes: string[];
rgbColor?: IOpenRGBRgbColor;
brightness?: number;
colorMode?: string;
isOn: boolean;
effect?: string;
effectList: string[];
effectToMode: Map<string, IOpenRGBMode>;
preferredNoEffectMode?: IOpenRGBMode;
offMode?: IOpenRGBMode;
supportsOffMode: boolean;
}
export class OpenRGBMapper {
public static toSnapshot(configArg: IOpenRGBConfig, connectedArg?: boolean, eventsArg: IOpenRGBEvent[] = []): IOpenRGBSnapshot {
const source = configArg.snapshot;
const host = configArg.host || source?.host;
const port = configArg.port || source?.port || openrgbDefaultPort;
const name = configArg.name || source?.name || source?.controller?.name || 'OpenRGB SDK Server';
const protocolVersion = configArg.protocolVersion || source?.protocolVersion || source?.controller?.protocolVersion;
const controller = {
...source?.controller,
...configArg.controller,
id: configArg.controller?.id || source?.controller?.id || configArg.entryId || (host ? `${host}:${port}` : name),
name,
host,
port,
protocolVersion,
manufacturer: configArg.controller?.manufacturer || source?.controller?.manufacturer || 'OpenRGB',
model: configArg.controller?.model || source?.controller?.model || 'OpenRGB SDK Server',
softwareVersion: configArg.controller?.softwareVersion || source?.controller?.softwareVersion || (protocolVersion === undefined ? undefined : `${protocolVersion} (Protocol)`),
};
const serverKey = configArg.entryId || controller.id || (host ? `${host}:${port}` : name);
const devices = this.uniqueDevices([
...(source?.devices || []),
...(configArg.devices || []),
...(configArg.device ? [configArg.device] : []),
...this.devicesFromManualEntries(configArg.manualEntries || []),
], String(serverKey));
return {
connected: connectedArg ?? source?.connected ?? false,
host,
port,
name,
protocolVersion,
controller,
devices,
profiles: [...new Set([...(source?.profiles || []), ...(configArg.profiles || []), ...this.profilesFromManualEntries(configArg.manualEntries || [])])],
events: [...(source?.events || []), ...(configArg.events || []), ...eventsArg],
metadata: {
...source?.metadata,
...configArg.metadata,
},
};
}
public static toDevices(snapshotArg: IOpenRGBSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = new Date().toISOString();
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [this.controllerDevice(snapshotArg, updatedAt)];
for (const device of snapshotArg.devices) {
devices.push(this.openrgbDevice(snapshotArg, device, updatedAt));
for (const zone of device.zones || []) {
devices.push(this.zoneDevice(snapshotArg, device, zone, updatedAt));
}
}
return devices;
}
public static toEntities(snapshotArg: IOpenRGBSnapshot): IIntegrationEntity[] {
const entities: IIntegrationEntity[] = [];
const usedIds = new Map<string, number>();
if (snapshotArg.profiles.length) {
const name = `${this.controllerName(snapshotArg)} Profile`;
entities.push(this.entity('select', name, this.controllerDeviceId(snapshotArg), `openrgb_${this.slug(this.controllerDeviceId(snapshotArg))}_profile`, null, usedIds, {
options: snapshotArg.profiles,
currentOption: undefined,
writable: true,
}, snapshotArg.connected));
}
for (const device of snapshotArg.devices) {
const info = this.lightInfo(device);
entities.push(this.lightEntity(snapshotArg, device, undefined, info, usedIds));
for (const zone of device.zones || []) {
const zoneInfo = this.lightInfo(device, zone);
entities.push(this.lightEntity(snapshotArg, device, zone, zoneInfo, usedIds));
}
}
return entities;
}
public static toIntegrationEvent(eventArg: IOpenRGBEvent): IIntegrationEvent {
return {
type: eventArg.type === 'command_failed' || eventArg.type === 'error' ? 'error' : 'state_changed',
integrationDomain: 'openrgb',
deviceId: eventArg.deviceId,
entityId: eventArg.entityId,
data: eventArg,
timestamp: eventArg.timestamp || Date.now(),
};
}
public static commandForService(snapshotArg: IOpenRGBSnapshot, requestArg: IServiceCallRequest): IOpenRGBClientCommand | undefined {
if (requestArg.domain === 'select' && requestArg.service === 'select_option') {
const profile = this.stringFromData(requestArg.data, ['option', 'value', 'profile']);
const targetEntity = this.toEntities(snapshotArg).find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId);
if (profile && targetEntity?.platform === 'select' && targetEntity.attributes?.options && Array.isArray(targetEntity.attributes.options)) {
return this.command(requestArg, targetEntity, undefined, undefined, [{ action: 'loadProfile', profile }], { profile });
}
}
const target = this.resolveLight(snapshotArg, requestArg);
if (!target) {
return undefined;
}
if (requestArg.domain !== 'light' && requestArg.domain !== 'select') {
return undefined;
}
if (requestArg.service === 'turn_off') {
return this.turnOffCommand(requestArg, target);
}
if (requestArg.service === 'turn_on') {
return this.turnOnCommand(requestArg, target);
}
if (requestArg.service === 'set_brightness' || requestArg.service === 'set_percentage') {
const brightness = this.brightnessFromData(requestArg.data, requestArg.service);
if (brightness === undefined) {
return undefined;
}
if (brightness <= 0) {
return this.turnOffCommand(requestArg, target);
}
return this.turnOnCommand({ ...requestArg, service: 'turn_on', data: { ...requestArg.data, brightness } }, target);
}
if (requestArg.service === 'set_color' || requestArg.service === 'set_rgb_color') {
const rgb = this.rgbFromData(requestArg.data);
if (!rgb) {
return undefined;
}
return this.turnOnCommand({ ...requestArg, service: 'turn_on', data: { ...requestArg.data, rgb_color: [rgb.red, rgb.green, rgb.blue] } }, target);
}
if (requestArg.service === 'select_effect') {
const effect = this.stringFromData(requestArg.data, ['effect', 'option', 'value']);
if (!effect) {
return undefined;
}
return this.turnOnCommand({ ...requestArg, service: 'turn_on', data: { ...requestArg.data, effect } }, target);
}
return undefined;
}
public static deviceKey(snapshotArg: IOpenRGBSnapshot, deviceArg: IOpenRGBDevice): string {
return deviceArg.key || this.deviceKeyFor(String(snapshotArg.controller.id || snapshotArg.host || snapshotArg.name || 'openrgb'), deviceArg);
}
public static deviceId(snapshotArg: IOpenRGBSnapshot, deviceArg: IOpenRGBDevice): string {
return `openrgb.device.${this.slug(this.deviceKey(snapshotArg, deviceArg))}`;
}
private static controllerDevice(snapshotArg: IOpenRGBSnapshot, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
];
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'connectivity', value: snapshotArg.connected ? 'online' : 'offline', updatedAt: updatedAtArg },
];
if (snapshotArg.protocolVersion !== undefined) {
features.push({ id: 'protocol_version', capability: 'sensor', name: 'Protocol version', readable: true, writable: false });
state.push({ featureId: 'protocol_version', value: snapshotArg.protocolVersion, updatedAt: updatedAtArg });
}
if (snapshotArg.profiles.length) {
features.push({ id: 'profile', capability: 'sensor', name: 'Profile', readable: true, writable: true });
state.push({ featureId: 'profile', value: null, updatedAt: updatedAtArg });
}
return {
id: this.controllerDeviceId(snapshotArg),
integrationDomain: 'openrgb',
name: this.controllerName(snapshotArg),
protocol: 'unknown',
manufacturer: snapshotArg.controller.manufacturer || 'OpenRGB',
model: snapshotArg.controller.model || 'OpenRGB SDK Server',
online: snapshotArg.connected,
features,
state,
metadata: {
host: snapshotArg.host,
port: snapshotArg.port || openrgbDefaultPort,
protocolVersion: snapshotArg.protocolVersion,
profiles: snapshotArg.profiles,
...snapshotArg.controller.metadata,
},
};
}
private static openrgbDevice(snapshotArg: IOpenRGBSnapshot, deviceArg: IOpenRGBDevice, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
const info = this.lightInfo(deviceArg);
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'availability', capability: 'sensor', name: 'Availability', readable: true, writable: false },
{ id: 'power', capability: 'light', name: 'Power', readable: true, writable: true },
{ id: 'active_mode', capability: 'sensor', name: 'Active mode', readable: true, writable: false },
];
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'availability', value: deviceArg.available === false ? 'offline' : 'online', updatedAt: updatedAtArg },
{ featureId: 'power', value: info.isOn, updatedAt: updatedAtArg },
{ featureId: 'active_mode', value: info.modeName || null, updatedAt: updatedAtArg },
];
if (info.supportsColor) {
features.push({ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true });
features.push({ id: 'rgb', capability: 'light', name: 'RGB color', readable: true, writable: true });
this.pushState(state, 'brightness', info.brightness, updatedAtArg);
this.pushState(state, 'rgb', info.rgbColor, updatedAtArg);
}
if (info.effectList.length) {
features.push({ id: 'effect', capability: 'light', name: 'Effect', readable: true, writable: true });
this.pushState(state, 'effect', info.effect, updatedAtArg);
}
if (deviceArg.zones?.length) {
features.push({ id: 'zones', capability: 'sensor', name: 'Zones', readable: true, writable: false });
state.push({ featureId: 'zones', value: deviceArg.zones.length, updatedAt: updatedAtArg });
}
const metadata = deviceArg.metadata || {};
return {
id: this.deviceId(snapshotArg, deviceArg),
integrationDomain: 'openrgb',
name: this.deviceName(deviceArg),
protocol: 'unknown',
manufacturer: metadata.vendor || 'OpenRGB',
model: this.deviceModel(deviceArg),
online: deviceArg.available !== false,
features,
state,
metadata: {
openrgbDeviceKey: this.deviceKey(snapshotArg, deviceArg),
openrgbDeviceIndex: typeof deviceArg.id === 'number' ? deviceArg.id : undefined,
openrgbType: this.deviceTypeName(deviceArg),
firmwareVersion: metadata.version,
serialNumber: metadata.serial,
location: metadata.location,
modes: (deviceArg.modes || []).map((modeArg) => modeArg.name),
effects: info.effectList,
},
};
}
private static zoneDevice(snapshotArg: IOpenRGBSnapshot, deviceArg: IOpenRGBDevice, zoneArg: IOpenRGBZone, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
const info = this.lightInfo(deviceArg, zoneArg);
const zoneId = this.zoneDeviceId(snapshotArg, deviceArg, zoneArg);
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'availability', capability: 'sensor', name: 'Availability', readable: true, writable: false },
{ id: 'power', capability: 'light', name: 'Power', readable: true, writable: true },
{ id: 'led_count', capability: 'sensor', name: 'LED count', readable: true, writable: false },
];
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'availability', value: deviceArg.available === false ? 'offline' : 'online', updatedAt: updatedAtArg },
{ featureId: 'power', value: info.isOn, updatedAt: updatedAtArg },
{ featureId: 'led_count', value: this.zoneLedCount(zoneArg), updatedAt: updatedAtArg },
];
if (info.supportsColor) {
features.push({ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true });
features.push({ id: 'rgb', capability: 'light', name: 'RGB color', readable: true, writable: true });
this.pushState(state, 'brightness', info.brightness, updatedAtArg);
this.pushState(state, 'rgb', info.rgbColor, updatedAtArg);
}
return {
id: zoneId,
integrationDomain: 'openrgb',
name: this.zoneName(deviceArg, zoneArg),
protocol: 'unknown',
manufacturer: deviceArg.metadata?.vendor || 'OpenRGB',
model: `OpenRGB zone (${zoneArg.typeName || this.zoneTypeName(zoneArg)})`,
online: deviceArg.available !== false,
features,
state,
metadata: {
parentDeviceId: this.deviceId(snapshotArg, deviceArg),
openrgbDeviceKey: this.deviceKey(snapshotArg, deviceArg),
openrgbDeviceIndex: typeof deviceArg.id === 'number' ? deviceArg.id : undefined,
openrgbZoneIndex: this.zoneIndex(deviceArg, zoneArg),
openrgbZoneType: zoneArg.typeName || this.zoneTypeName(zoneArg),
},
};
}
private static lightEntity(snapshotArg: IOpenRGBSnapshot, deviceArg: IOpenRGBDevice, zoneArg: IOpenRGBZone | undefined, infoArg: ReturnType<typeof OpenRGBMapper.lightInfo>, usedIdsArg: Map<string, number>): IIntegrationEntity {
const deviceKey = this.deviceKey(snapshotArg, deviceArg);
const deviceIndex = typeof deviceArg.id === 'number' ? deviceArg.id : undefined;
const zoneIndex = zoneArg ? this.zoneIndex(deviceArg, zoneArg) : undefined;
const name = zoneArg ? this.zoneName(deviceArg, zoneArg) : this.deviceName(deviceArg);
const uniqueId = zoneArg
? `openrgb_${this.slug(deviceKey)}_zone_${zoneIndex ?? this.slug(zoneArg.name || 'zone')}_light`
: `openrgb_${this.slug(deviceKey)}_light`;
return this.entity('light', name, zoneArg ? this.zoneDeviceId(snapshotArg, deviceArg, zoneArg) : this.deviceId(snapshotArg, deviceArg), uniqueId, infoArg.isOn ? 'on' : 'off', usedIdsArg, {
openrgbDeviceKey: deviceKey,
openrgbDeviceIndex: deviceIndex,
openrgbZoneIndex: zoneIndex,
openrgbMode: infoArg.modeName,
brightness: infoArg.brightness,
rgbColor: infoArg.rgbColor ? [infoArg.rgbColor.red, infoArg.rgbColor.green, infoArg.rgbColor.blue] : undefined,
colorMode: infoArg.colorMode,
effect: infoArg.effect,
effectList: zoneArg ? [] : infoArg.effectList,
supportedColorModes: infoArg.supportedColorModes,
preferredNoEffectMode: infoArg.preferredNoEffectMode?.name,
supportsOffMode: infoArg.supportsOffMode,
writable: true,
}, deviceArg.available !== false);
}
private static turnOffCommand(requestArg: IServiceCallRequest, targetArg: IOpenRGBResolvedLight): IOpenRGBClientCommand | undefined {
const operations: TOpenRGBCommandOperation[] = [];
if (!targetArg.zone && targetArg.info.offMode) {
operations.push(this.setModeOperation(targetArg.device, targetArg.info.offMode));
} else {
const colorOperation = this.setColorOperation(targetArg.device, targetArg.zone, black, 0);
if (!colorOperation) {
return undefined;
}
operations.push(colorOperation);
}
return this.command(requestArg, targetArg.entity, targetArg.device, targetArg.zone, operations, { state: false });
}
private static turnOnCommand(requestArg: IServiceCallRequest, targetArg: IOpenRGBResolvedLight): IOpenRGBClientCommand | undefined {
let mode: IOpenRGBMode | undefined;
const effect = this.stringFromData(requestArg.data, ['effect', 'option', 'value']);
if (effect) {
if (targetArg.zone) {
return undefined;
}
if (effect.toLowerCase() === 'off') {
mode = targetArg.info.preferredNoEffectMode;
} else {
mode = targetArg.info.effectToMode.get(this.slug(effect));
}
if (!mode) {
return undefined;
}
} else if (targetArg.info.modeIsOff || (targetArg.info.rgbColor === undefined && targetArg.info.brightness === undefined)) {
mode = targetArg.info.preferredNoEffectMode;
}
let modeForColor = mode || targetArg.info.activeMode;
let modeSupportsColor = this.modeSupportsColor(modeForColor);
const requestedBrightness = this.brightnessFromData(requestArg.data, requestArg.service);
const requestedRgb = this.rgbFromData(requestArg.data);
const colorOrBrightnessRequested = requestedBrightness !== undefined || requestedRgb !== undefined;
if (colorOrBrightnessRequested && !modeSupportsColor) {
return undefined;
}
let needColor = colorOrBrightnessRequested || (modeSupportsColor && (targetArg.info.rgbColor === undefined || targetArg.info.brightness === undefined));
if (needColor && !modeSupportsColor) {
mode = targetArg.info.preferredNoEffectMode;
modeForColor = mode || modeForColor;
modeSupportsColor = this.modeSupportsColor(modeForColor);
if (!modeSupportsColor) {
return undefined;
}
}
const operations: TOpenRGBCommandOperation[] = [];
if (mode) {
operations.push(this.setModeOperation(targetArg.device, mode));
}
if (needColor) {
const brightness = requestedBrightness ?? targetArg.info.brightness ?? 255;
const color = requestedRgb ?? targetArg.info.rgbColor ?? white;
const colorOperation = this.setColorOperation(targetArg.device, targetArg.zone, color, brightness);
if (!colorOperation) {
return undefined;
}
operations.push(colorOperation);
}
if (!operations.length) {
return undefined;
}
return this.command(requestArg, targetArg.entity, targetArg.device, targetArg.zone, operations, {
state: true,
effect,
brightness: requestedBrightness,
rgb: requestedRgb,
});
}
private static command(requestArg: IServiceCallRequest, entityArg: IIntegrationEntity, deviceArg: IOpenRGBDevice | undefined, zoneArg: IOpenRGBZone | undefined, operationsArg: TOpenRGBCommandOperation[], payloadArg: Record<string, unknown>): IOpenRGBClientCommand {
return {
type: 'openrgb.sdk_command',
service: requestArg.service,
deviceId: entityArg.deviceId,
entityId: entityArg.id,
deviceKey: typeof entityArg.attributes?.openrgbDeviceKey === 'string' ? entityArg.attributes.openrgbDeviceKey : deviceArg?.key,
zoneId: zoneArg ? String(zoneArg.id ?? zoneArg.name ?? '') : undefined,
target: requestArg.target,
operations: operationsArg,
payload: {
...payloadArg,
data: requestArg.data || {},
},
};
}
private static setModeOperation(deviceArg: IOpenRGBDevice, modeArg: IOpenRGBMode): TOpenRGBCommandOperation {
return {
action: 'setMode',
deviceIndex: typeof deviceArg.id === 'number' ? deviceArg.id : undefined,
deviceKey: deviceArg.key,
modeName: modeArg.name,
mode: modeArg,
};
}
private static setColorOperation(deviceArg: IOpenRGBDevice, zoneArg: IOpenRGBZone | undefined, colorArg: IOpenRGBRgbColor, brightnessArg: number): TOpenRGBCommandOperation | undefined {
const ledCount = zoneArg ? this.zoneLedCount(zoneArg) : this.deviceLedCount(deviceArg);
if (!ledCount) {
return undefined;
}
return {
action: 'setColor',
deviceIndex: typeof deviceArg.id === 'number' ? deviceArg.id : undefined,
deviceKey: deviceArg.key,
zoneIndex: zoneArg ? this.zoneIndex(deviceArg, zoneArg) : undefined,
zoneName: zoneArg?.name,
ledCount,
rgb: this.scaleColor(colorArg, brightnessArg),
brightness: brightnessArg,
};
}
private static resolveLight(snapshotArg: IOpenRGBSnapshot, requestArg: IServiceCallRequest): IOpenRGBResolvedLight | undefined {
const entities = this.toEntities(snapshotArg).filter((entityArg) => entityArg.platform === 'light');
let entity = requestArg.target.entityId
? entities.find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId)
: undefined;
if (!entity && requestArg.target.deviceId) {
entity = entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId)
|| entities.find((entityArg) => entityArg.attributes?.openrgbDeviceKey === requestArg.target.deviceId);
}
if (!entity) {
entity = entities.find((entityArg) => entityArg.available && entityArg.attributes?.writable === true);
}
if (!entity) {
return undefined;
}
const deviceKey = this.stringValue(entity.attributes?.openrgbDeviceKey);
const device = snapshotArg.devices.find((deviceArg) => this.deviceKey(snapshotArg, deviceArg) === deviceKey || this.deviceId(snapshotArg, deviceArg) === entity?.deviceId);
if (!device) {
return undefined;
}
const zoneIndex = this.numberValue(entity.attributes?.openrgbZoneIndex);
const zone = zoneIndex === undefined ? undefined : device.zones?.[zoneIndex];
return { entity, device, zone, info: this.lightInfo(device, zone) };
}
private static lightInfo(deviceArg: IOpenRGBDevice, zoneArg?: IOpenRGBZone): IOpenRGBLightInfo {
const modes = deviceArg.modes || [];
const activeMode = this.activeMode(deviceArg);
const modeName = activeMode?.name;
const modeIsOff = this.sameName(modeName, openrgbModeOff);
const supportsColor = !modeIsOff && this.modeSupportsColor(activeMode);
const colors = zoneArg?.colors?.length ? zoneArg.colors : deviceArg.colors || [];
const color = colors.find((colorArg) => !this.isBlack(colorArg)) || black;
const colorDetails = supportsColor && !this.isBlack(color) ? this.colorDetails(color) : {};
const effectToMode = new Map<string, IOpenRGBMode>();
for (const mode of modes) {
if (!this.sameName(mode.name, openrgbModeOff) && !effectOffModes.has(mode.name.toLowerCase())) {
effectToMode.set(this.slug(mode.name), mode);
}
}
const effectList = [...effectToMode.keys()];
const effect = !effectList.length || modeIsOff
? undefined
: modeName && effectOffModes.has(modeName.toLowerCase())
? 'off'
: modeName ? this.slug(modeName) : undefined;
return {
activeMode,
modeName,
modeIsOff,
supportsColor,
supportedColorModes: modes.filter((modeArg) => this.modeSupportsColor(modeArg)).map((modeArg) => modeArg.name),
rgbColor: colorDetails.rgbColor,
brightness: colorDetails.brightness,
colorMode: supportsColor ? 'rgb' : modeIsOff ? undefined : 'onoff',
isOn: modeIsOff ? false : supportsColor ? !this.isBlack(color) : true,
effect,
effectList: effectList.length ? ['off', ...effectList] : [],
effectToMode,
preferredNoEffectMode: this.preferredNoEffectMode(deviceArg),
offMode: modes.find((modeArg) => this.sameName(modeArg.name, openrgbModeOff)),
supportsOffMode: modes.some((modeArg) => this.sameName(modeArg.name, openrgbModeOff)),
};
}
private static devicesFromManualEntries(entriesArg: IOpenRGBManualEntry[]): IOpenRGBDevice[] {
const devices: IOpenRGBDevice[] = [];
for (const entry of entriesArg) {
if (entry.snapshot) {
devices.push(...entry.snapshot.devices);
}
if (entry.devices) {
devices.push(...entry.devices);
}
if (entry.device) {
devices.push(entry.device);
}
}
return devices;
}
private static profilesFromManualEntries(entriesArg: IOpenRGBManualEntry[]): string[] {
return entriesArg.flatMap((entryArg) => entryArg.snapshot?.profiles || entryArg.profiles || []);
}
private static uniqueDevices(devicesArg: IOpenRGBDevice[], serverKeyArg: string): IOpenRGBDevice[] {
const devices = new Map<string, IOpenRGBDevice>();
for (const device of devicesArg) {
const key = device.key || this.deviceKeyFor(serverKeyArg, device);
const previous = devices.get(key) || {};
devices.set(key, { ...previous, ...device, key });
}
return [...devices.values()];
}
private static controllerDeviceId(snapshotArg: IOpenRGBSnapshot): string {
return `openrgb.controller.${this.slug(String(snapshotArg.controller.id || snapshotArg.host || snapshotArg.name || 'server'))}`;
}
private static controllerName(snapshotArg: IOpenRGBSnapshot): string {
return snapshotArg.name || snapshotArg.controller.name || 'OpenRGB SDK Server';
}
private static zoneDeviceId(snapshotArg: IOpenRGBSnapshot, deviceArg: IOpenRGBDevice, zoneArg: IOpenRGBZone): string {
return `openrgb.zone.${this.slug(this.deviceKey(snapshotArg, deviceArg))}.${this.zoneIndex(deviceArg, zoneArg) ?? this.slug(zoneArg.name || 'zone')}`;
}
private static deviceKeyFor(serverKeyArg: string, deviceArg: IOpenRGBDevice): string {
const metadata = deviceArg.metadata || {};
return [
serverKeyArg,
this.deviceTypeName(deviceArg) || 'unknown',
metadata.vendor || 'none',
metadata.description || deviceArg.name || 'none',
metadata.serial || 'none',
metadata.location || 'none',
].join(openrgbUidSeparator);
}
private static deviceName(deviceArg: IOpenRGBDevice): string {
return deviceArg.name || deviceArg.metadata?.description || (deviceArg.metadata?.serial ? `OpenRGB ${deviceArg.metadata.serial}` : 'OpenRGB device');
}
private static zoneName(deviceArg: IOpenRGBDevice, zoneArg: IOpenRGBZone): string {
return `${this.deviceName(deviceArg)} ${zoneArg.name || `Zone ${this.zoneIndex(deviceArg, zoneArg) ?? ''}`.trim()}`;
}
private static deviceModel(deviceArg: IOpenRGBDevice): string {
const metadata = deviceArg.metadata || {};
const description = metadata.description || this.deviceTypeName(deviceArg) || 'OpenRGB device';
const type = this.deviceTypeName(deviceArg);
return type ? `${description} (${type})` : description;
}
private static deviceTypeName(deviceArg: IOpenRGBDevice): string | undefined {
if (typeof deviceArg.typeName === 'string') {
return deviceArg.typeName;
}
if (typeof deviceArg.type === 'string') {
return deviceArg.type;
}
if (typeof deviceArg.type === 'number') {
return deviceTypeNames[deviceArg.type] || 'unknown';
}
return undefined;
}
private static zoneTypeName(zoneArg: IOpenRGBZone): string | undefined {
if (typeof zoneArg.typeName === 'string') {
return zoneArg.typeName;
}
if (typeof zoneArg.type === 'string') {
return zoneArg.type;
}
if (typeof zoneArg.type === 'number') {
return zoneTypeNames[zoneArg.type] || 'unknown';
}
return undefined;
}
private static activeMode(deviceArg: IOpenRGBDevice): IOpenRGBMode | undefined {
const active = deviceArg.activeMode;
const modes = deviceArg.modes || [];
if (typeof active === 'number') {
return modes[active] || modes.find((modeArg) => modeArg.id === active);
}
if (typeof active === 'string') {
return modes.find((modeArg) => this.sameName(modeArg.name, active));
}
return modes[0];
}
private static preferredNoEffectMode(deviceArg: IOpenRGBDevice): IOpenRGBMode | undefined {
const modes = deviceArg.modes || [];
const findMode = (nameArg: string) => modes.find((modeArg) => this.sameName(modeArg.name, nameArg));
if (deviceArg.metadata?.description === 'ASRock Polychrome USB Device') {
return findMode(openrgbModeStatic) || findMode(openrgbModeDirect) || findMode(openrgbModeCustom);
}
return findMode(openrgbModeDirect) || findMode(openrgbModeCustom) || findMode(openrgbModeStatic);
}
private static modeSupportsColor(modeArg: IOpenRGBMode | undefined): boolean {
if (!modeArg) {
return false;
}
if (modeArg.supportsColor === true) {
return true;
}
if (typeof modeArg.colorMode === 'number') {
return modeArg.colorMode === 1;
}
if (typeof modeArg.colorMode === 'string') {
const colorMode = modeArg.colorMode.toLowerCase().replace(/[-\s]+/g, '_');
return colorMode === 'per_led' || colorMode === 'perled';
}
return typeof modeArg.flags === 'number' && (modeArg.flags & (1 << 5)) !== 0;
}
private static zoneIndex(deviceArg: IOpenRGBDevice, zoneArg: IOpenRGBZone): number | undefined {
if (typeof zoneArg.id === 'number') {
return zoneArg.id;
}
const index = (deviceArg.zones || []).indexOf(zoneArg);
return index >= 0 ? index : undefined;
}
private static deviceLedCount(deviceArg: IOpenRGBDevice): number {
if (deviceArg.colors?.length) {
return deviceArg.colors.length;
}
if (deviceArg.leds?.length) {
return deviceArg.leds.length;
}
return (deviceArg.zones || []).reduce((sumArg, zoneArg) => sumArg + this.zoneLedCount(zoneArg), 0);
}
private static zoneLedCount(zoneArg: IOpenRGBZone): number {
return zoneArg.numLeds || zoneArg.colors?.length || zoneArg.leds?.length || 0;
}
private static colorDetails(colorArg: IOpenRGBRgbColor): { rgbColor?: IOpenRGBRgbColor; brightness?: number } {
const brightness = Math.max(colorArg.red, colorArg.green, colorArg.blue);
if (brightness <= 0) {
return {};
}
return {
brightness,
rgbColor: {
red: this.clamp(Math.round(colorArg.red / brightness * 255), 0, 255),
green: this.clamp(Math.round(colorArg.green / brightness * 255), 0, 255),
blue: this.clamp(Math.round(colorArg.blue / brightness * 255), 0, 255),
},
};
}
private static scaleColor(colorArg: IOpenRGBRgbColor, brightnessArg: number): IOpenRGBRgbColor {
const factor = this.clamp(brightnessArg, 0, 255) / 255;
return {
red: this.clamp(Math.round(colorArg.red * factor), 0, 255),
green: this.clamp(Math.round(colorArg.green * factor), 0, 255),
blue: this.clamp(Math.round(colorArg.blue * factor), 0, 255),
};
}
private static rgbFromData(dataArg: Record<string, unknown> | undefined): IOpenRGBRgbColor | undefined {
const value = this.valueFromData(dataArg, ['rgb_color', 'rgb', 'color']);
if (Array.isArray(value) && value.length >= 3) {
const numbers = value.slice(0, 3).map((entryArg) => this.byteValue(entryArg));
return numbers.every((entryArg) => entryArg !== undefined)
? { red: numbers[0]!, green: numbers[1]!, blue: numbers[2]! }
: undefined;
}
if (this.isRecord(value)) {
const red = this.byteValue(value.red ?? value.r);
const green = this.byteValue(value.green ?? value.g);
const blue = this.byteValue(value.blue ?? value.b);
return red !== undefined && green !== undefined && blue !== undefined ? { red, green, blue } : undefined;
}
return undefined;
}
private static brightnessFromData(dataArg: Record<string, unknown> | undefined, serviceArg: string): number | undefined {
const percentage = this.numberFromData(dataArg, ['brightness_pct', 'percentage', 'percent']);
if (percentage !== undefined) {
return this.clamp(Math.round(percentage / 100 * 255), 0, 255);
}
const value = this.numberFromData(dataArg, serviceArg === 'set_percentage' ? ['value'] : ['brightness', 'value']);
return value === undefined ? undefined : this.clamp(Math.round(value), 0, 255);
}
private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map<string, number>, attributesArg: Record<string, unknown>, availableArg: boolean): IIntegrationEntity {
return {
id: this.entityId(platformArg, nameArg, usedIdsArg),
uniqueId: uniqueIdArg,
integrationDomain: 'openrgb',
deviceId: deviceIdArg,
platform: platformArg,
name: nameArg,
state: stateArg,
attributes: attributesArg,
available: availableArg,
};
}
private static entityId(platformArg: TEntityPlatform, nameArg: string, usedIdsArg: Map<string, number>): string {
const base = `${platformArg}.${this.slug(nameArg)}`;
const count = usedIdsArg.get(base) || 0;
usedIdsArg.set(base, count + 1);
return count ? `${base}_${count + 1}` : base;
}
private static pushState(stateArg: plugins.shxInterfaces.data.IDeviceState[], featureIdArg: string, valueArg: unknown, updatedAtArg: string): void {
if (valueArg === undefined) {
return;
}
stateArg.push({ featureId: featureIdArg, value: this.deviceStateValue(valueArg), updatedAt: updatedAtArg });
}
private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null || this.isRecord(valueArg)) {
return valueArg;
}
if (Array.isArray(valueArg)) {
return { values: valueArg };
}
return valueArg === undefined ? null : String(valueArg);
}
private static isBlack(colorArg: IOpenRGBRgbColor): boolean {
return colorArg.red <= 0 && colorArg.green <= 0 && colorArg.blue <= 0;
}
private static sameName(leftArg: string | undefined, rightArg: string): boolean {
return (leftArg || '').toLowerCase() === rightArg.toLowerCase();
}
private static valueFromData(dataArg: Record<string, unknown> | undefined, keysArg: string[]): unknown {
for (const key of keysArg) {
if (dataArg && key in dataArg) {
return dataArg[key];
}
}
return undefined;
}
private static numberFromData(dataArg: Record<string, unknown> | undefined, keysArg: string[]): number | undefined {
const value = this.valueFromData(dataArg, keysArg);
return this.numberValue(value);
}
private static stringFromData(dataArg: Record<string, unknown> | undefined, keysArg: string[]): string | undefined {
const value = this.valueFromData(dataArg, keysArg);
return this.stringValue(value);
}
private static byteValue(valueArg: unknown): number | undefined {
if (typeof valueArg !== 'number' || !Number.isFinite(valueArg)) {
return undefined;
}
return this.clamp(Math.round(valueArg), 0, 255);
}
private static numberValue(valueArg: unknown): number | undefined {
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
}
private static stringValue(valueArg: unknown): string | undefined {
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
}
private static slug(valueArg: string): string {
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'openrgb';
}
private static clamp(valueArg: number, minArg: number, maxArg: number): number {
return Math.max(minArg, Math.min(maxArg, valueArg));
}
private static isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
}
}
+253 -2
View File
@@ -1,4 +1,255 @@
export interface IHomeAssistantOpenrgbConfig {
// TODO: replace with the TypeScript-native config for openrgb.
import type { IServiceCallResult } from '../../core/types.js';
export const openrgbDefaultPort = 6742;
export const openrgbDefaultClientName = 'Home Assistant';
export const openrgbUidSeparator = '||';
export type TOpenRGBColorMode = 'none' | 'per_led' | 'mode_specific' | 'random' | string;
export type TOpenRGBDeviceType =
| 'motherboard'
| 'dram'
| 'gpu'
| 'cooler'
| 'ledstrip'
| 'keyboard'
| 'mouse'
| 'mousemat'
| 'headset'
| 'headset_stand'
| 'gamepad'
| 'light'
| 'speaker'
| 'virtual'
| 'storage'
| 'case'
| 'microphone'
| 'accessory'
| 'keypad'
| 'unknown'
| string;
export type TOpenRGBZoneType = 'single' | 'linear' | 'matrix' | string;
export interface IOpenRGBConfig {
entryId?: string;
name?: string;
host?: string;
port?: number;
clientName?: string;
protocolVersion?: number;
timeoutMs?: number;
controller?: IOpenRGBController;
device?: IOpenRGBDevice;
devices?: IOpenRGBDevice[];
snapshot?: IOpenRGBSnapshot;
manualEntries?: IOpenRGBManualEntry[];
profiles?: string[];
events?: IOpenRGBEvent[];
commandExecutor?: TOpenRGBCommandExecutor;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IHomeAssistantOpenrgbConfig extends IOpenRGBConfig {}
export interface IOpenRGBController {
id?: string;
name?: string;
host?: string;
port?: number;
protocolVersion?: number;
manufacturer?: string;
model?: string;
serialNumber?: string;
softwareVersion?: string;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IOpenRGBSnapshot {
connected: boolean;
host?: string;
port?: number;
name?: string;
protocolVersion?: number;
controller: IOpenRGBController;
devices: IOpenRGBDevice[];
profiles: string[];
events: IOpenRGBEvent[];
metadata?: Record<string, unknown>;
}
export interface IOpenRGBDeviceMetadata {
vendor?: string;
description?: string;
version?: string;
serial?: string;
location?: string;
[key: string]: unknown;
}
export interface IOpenRGBRgbColor {
red: number;
green: number;
blue: number;
}
export interface IOpenRGBLed {
id?: number;
name?: string;
value?: number;
color?: IOpenRGBRgbColor;
[key: string]: unknown;
}
export interface IOpenRGBSegment {
id?: number;
name?: string;
type?: number | TOpenRGBZoneType;
typeName?: TOpenRGBZoneType;
startIndex?: number;
ledCount?: number;
[key: string]: unknown;
}
export interface IOpenRGBZone {
id?: number;
name?: string;
type?: number | TOpenRGBZoneType;
typeName?: TOpenRGBZoneType;
ledsMin?: number;
ledsMax?: number;
numLeds?: number;
startIndex?: number;
matrixHeight?: number;
matrixWidth?: number;
matrixMap?: Array<Array<number | null>>;
segments?: IOpenRGBSegment[];
leds?: IOpenRGBLed[];
colors?: IOpenRGBRgbColor[];
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IOpenRGBMode {
id?: number;
name: string;
value?: number;
flags?: number;
speedMin?: number;
speedMax?: number;
brightnessMin?: number;
brightnessMax?: number;
colorsMin?: number;
colorsMax?: number;
speed?: number;
brightness?: number;
direction?: number | string;
colorMode?: number | TOpenRGBColorMode;
colors?: IOpenRGBRgbColor[];
supportsColor?: boolean;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IOpenRGBDevice {
id?: number | string;
key?: string;
name?: string;
type?: number | TOpenRGBDeviceType;
typeName?: TOpenRGBDeviceType;
metadata?: IOpenRGBDeviceMetadata;
leds?: IOpenRGBLed[];
zones?: IOpenRGBZone[];
modes?: IOpenRGBMode[];
colors?: IOpenRGBRgbColor[];
activeMode?: number | string;
available?: boolean;
host?: string;
port?: number;
extra?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IOpenRGBManualEntry {
entryId?: string;
id?: string;
host?: string;
port?: number;
name?: string;
manufacturer?: string;
model?: string;
protocolVersion?: number;
controller?: IOpenRGBController;
device?: IOpenRGBDevice;
devices?: IOpenRGBDevice[];
snapshot?: IOpenRGBSnapshot;
profiles?: string[];
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
export interface IOpenRGBEvent {
type: 'snapshot' | 'command_mapped' | 'command_executed' | 'command_failed' | 'sdk_response' | 'error' | string;
timestamp?: number;
deviceId?: string;
entityId?: string;
deviceKey?: string;
command?: IOpenRGBClientCommand;
data?: unknown;
[key: string]: unknown;
}
export interface IOpenRGBClientCommand {
type: string;
service: string;
deviceId?: string;
entityId?: string;
deviceKey?: string;
zoneId?: string;
target?: {
entityId?: string;
deviceId?: string;
};
operations: TOpenRGBCommandOperation[];
payload: Record<string, unknown>;
[key: string]: unknown;
}
export type TOpenRGBCommandOperation =
| IOpenRGBSetModeOperation
| IOpenRGBSetColorOperation
| IOpenRGBLoadProfileOperation;
export interface IOpenRGBSetModeOperation {
action: 'setMode';
deviceIndex?: number;
deviceKey?: string;
modeName: string;
mode?: IOpenRGBMode;
}
export interface IOpenRGBSetColorOperation {
action: 'setColor';
deviceIndex?: number;
deviceKey?: string;
zoneIndex?: number;
zoneName?: string;
ledCount: number;
rgb: IOpenRGBRgbColor;
brightness?: number;
}
export interface IOpenRGBLoadProfileOperation {
action: 'loadProfile';
profile: string;
}
export interface IOpenRGBCommandResult extends IServiceCallResult {}
export type TOpenRGBCommandExecutor = (
commandArg: IOpenRGBClientCommand
) => Promise<IOpenRGBCommandResult | unknown> | IOpenRGBCommandResult | unknown;
export interface IOpenRGBManualDiscoveryRecord extends IOpenRGBManualEntry {
integrationDomain?: string;
}
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -1,2 +1,6 @@
export * from './soundtouch.classes.integration.js';
export * from './soundtouch.classes.client.js';
export * from './soundtouch.classes.configflow.js';
export * from './soundtouch.discovery.js';
export * from './soundtouch.mapper.js';
export * from './soundtouch.types.js';
@@ -0,0 +1,581 @@
import type {
ISoundtouchCommandRequest,
ISoundtouchConfig,
ISoundtouchContentItem,
ISoundtouchDeviceInfo,
ISoundtouchPreset,
ISoundtouchRawCommandRequest,
ISoundtouchSnapshot,
ISoundtouchStatus,
ISoundtouchVolume,
ISoundtouchZoneMember,
ISoundtouchZoneStatus,
TSoundtouchCommand,
} from './soundtouch.types.js';
import { soundtouchDefaultDlnaPort, soundtouchDefaultPort, soundtouchDefaultSourceList } from './soundtouch.types.js';
const keyCommands: Record<Exclude<TSoundtouchCommand, 'turn_on' | 'turn_off' | 'set_volume' | 'mute' | 'select_source' | 'play_media' | 'play_preset'>, string> = {
play: 'PLAY',
pause: 'PAUSE',
play_pause: 'PLAY_PAUSE',
previous_track: 'PREV_TRACK',
next_track: 'NEXT_TRACK',
volume_up: 'VOLUME_UP',
volume_down: 'VOLUME_DOWN',
};
export class SoundtouchClient {
constructor(private readonly config: ISoundtouchConfig) {}
public async getSnapshot(): Promise<ISoundtouchSnapshot> {
if (this.config.snapshot) {
return this.normalizeSnapshot(this.cloneValue(this.config.snapshot));
}
if (!this.config.host && !this.config.commandExecutor) {
return this.normalizeSnapshot(this.manualSnapshot(false, 'SoundTouch refresh requires config.host, config.snapshot, or commandExecutor.'));
}
const infoXml = await this.fetchText('/info');
const deviceInfo = this.parseDeviceInfo(infoXml);
const [status, volume, presets, zone] = await Promise.all([
this.optionalText('/now_playing').then((xmlArg) => xmlArg ? this.parseStatus(xmlArg) : undefined),
this.optionalText('/volume').then((xmlArg) => xmlArg ? this.parseVolume(xmlArg) : undefined),
this.optionalText('/presets').then((xmlArg) => xmlArg ? this.parsePresets(xmlArg) : []),
this.optionalText('/getZone').then((xmlArg) => xmlArg ? this.parseZoneStatus(xmlArg) : undefined),
]);
return this.normalizeSnapshot({
config: deviceInfo,
status,
volume,
presets,
zone,
sourceList: this.config.sourceList,
available: true,
lastUpdated: new Date().toISOString(),
});
}
public async validateConnection(): Promise<ISoundtouchSnapshot> {
const snapshot = await this.getSnapshot();
if (!snapshot.config.deviceId && !snapshot.config.name) {
throw new Error('SoundTouch device did not provide a device identifier.');
}
return snapshot;
}
public async execute(requestArg: ISoundtouchCommandRequest): Promise<unknown> {
this.ensureCommandTransport();
if (requestArg.command === 'turn_on') {
return this.setPower(true);
}
if (requestArg.command === 'turn_off') {
return this.setPower(false);
}
if (requestArg.command in keyCommands) {
return this.sendKey(keyCommands[requestArg.command as keyof typeof keyCommands], requestArg.command);
}
if (requestArg.command === 'set_volume') {
return this.setVolume(requestArg);
}
if (requestArg.command === 'mute') {
return this.setMute(requestArg.muted);
}
if (requestArg.command === 'select_source') {
return this.selectSource(requestArg.source);
}
if (requestArg.command === 'play_preset') {
return this.playPreset(requestArg);
}
if (requestArg.command === 'play_media') {
return this.playMedia(requestArg);
}
throw new Error(`Unsupported SoundTouch command: ${requestArg.command}`);
}
public async destroy(): Promise<void> {}
private async setPower(poweredArg: boolean): Promise<unknown> {
const snapshot = await this.getSnapshot().catch(() => undefined);
const isStandby = snapshot?.status?.source === 'STANDBY';
if (poweredArg && snapshot?.status && !isStandby) {
return { skipped: true, reason: 'SoundTouch device is already on.' };
}
if (!poweredArg && isStandby) {
return { skipped: true, reason: 'SoundTouch device is already off.' };
}
return this.sendKey('POWER', poweredArg ? 'turn_on' : 'turn_off');
}
private async setVolume(requestArg: ISoundtouchCommandRequest): Promise<string> {
const volume = this.volumePercent(requestArg);
return this.requestText('POST', '/volume', 'set_volume', `<volume>${volume}</volume>`, { 'content-type': 'text/xml; charset="utf-8"' });
}
private async setMute(mutedArg: boolean | undefined): Promise<unknown> {
if (typeof mutedArg === 'boolean') {
const snapshot = await this.getSnapshot().catch(() => undefined);
if (typeof snapshot?.volume?.muted === 'boolean' && snapshot.volume.muted === mutedArg) {
return { skipped: true, reason: `SoundTouch mute is already ${mutedArg ? 'on' : 'off'}.` };
}
}
return this.sendKey('MUTE', 'mute');
}
private async selectSource(sourceArg: string | undefined): Promise<string> {
const source = sourceArg?.toUpperCase();
if (source !== 'AUX' && source !== 'BLUETOOTH') {
throw new Error('SoundTouch select_source supports AUX or BLUETOOTH.');
}
return this.sendSelectXml(this.contentItemXml({ source, sourceAccount: source === 'AUX' ? 'AUX' : undefined }), 'select_source');
}
private async playPreset(requestArg: ISoundtouchCommandRequest): Promise<string> {
const preset = requestArg.contentItem ? { presetId: requestArg.presetId || '', ...requestArg.contentItem } : await this.findPreset(requestArg);
const body = preset.sourceXml || this.contentItemXml(preset);
return this.sendSelectXml(body, 'play_preset');
}
private async playMedia(requestArg: ISoundtouchCommandRequest): Promise<unknown> {
const mediaId = requestArg.url || requestArg.mediaId;
if (mediaId?.toLowerCase().startsWith('http://')) {
return this.playUrl(mediaId);
}
if (mediaId?.toLowerCase().startsWith('https://')) {
throw new Error('SoundTouch play_media supports only http:// URLs for DLNA playback.');
}
return this.playPreset({ ...requestArg, presetId: requestArg.presetId || mediaId });
}
private async playUrl(urlArg: string): Promise<string> {
const body = `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"><InstanceID>0</InstanceID><CurrentURIMetaData></CurrentURIMetaData><CurrentURI>${escapeXml(urlArg)}</CurrentURI></u:SetAVTransportURI></s:Body></s:Envelope>`;
return this.requestText('POST', '/AVTransport/Control', 'play_media', body, {
'user-agent': 'smarthome.exchange',
accept: '*/*',
'content-type': 'text/xml; charset="utf-8"',
host: this.config.host ? `${this.config.host}:${this.dlnaPort()}` : `:${this.dlnaPort()}`,
soapaction: 'urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI',
}, this.dlnaPort());
}
private async sendKey(keyArg: string, commandArg: TSoundtouchCommand): Promise<string[]> {
const body = (stateArg: 'press' | 'release') => `<key state="${stateArg}" sender="Gabbo">${keyArg}</key>`;
return [
await this.requestText('POST', '/key', commandArg, body('press'), { 'content-type': 'text/xml; charset="utf-8"' }),
await this.requestText('POST', '/key', commandArg, body('release'), { 'content-type': 'text/xml; charset="utf-8"' }),
];
}
private async sendSelectXml(bodyArg: string, commandArg: TSoundtouchCommand): Promise<string> {
return this.requestText('POST', '/select', commandArg, bodyArg, { 'content-type': 'text/xml; charset="utf-8"' });
}
private async findPreset(requestArg: ISoundtouchCommandRequest): Promise<ISoundtouchPreset> {
const snapshot = await this.getSnapshot();
const preset = (snapshot.presets || []).find((presetArg) => {
return Boolean(
requestArg.presetId && String(presetArg.presetId) === String(requestArg.presetId)
|| requestArg.presetId && presetArg.name === requestArg.presetId
|| requestArg.presetName && presetArg.name === requestArg.presetName
);
});
if (!preset) {
throw new Error('SoundTouch play_preset requires a known preset id or name.');
}
return preset;
}
private contentItemXml(itemArg: ISoundtouchContentItem): string {
if (!itemArg.source) {
throw new Error('SoundTouch ContentItem requires source.');
}
const attributes: Record<string, string | undefined> = {
source: itemArg.source,
type: itemArg.type,
location: itemArg.location,
sourceAccount: itemArg.sourceAccount,
isPresetable: typeof itemArg.isPresetable === 'boolean' ? String(itemArg.isPresetable) : undefined,
};
const attrs = Object.entries(attributes)
.filter((entryArg): entryArg is [string, string] => typeof entryArg[1] === 'string' && entryArg[1] !== '')
.map(([keyArg, valueArg]) => `${keyArg}="${escapeXml(valueArg)}"`)
.join(' ');
const itemName = itemArg.name ? `<itemName>${escapeXml(itemArg.name)}</itemName>` : '';
return `<ContentItem ${attrs}>${itemName}</ContentItem>`;
}
private async fetchText(pathArg: string): Promise<string> {
return this.requestText('GET', pathArg, 'refresh');
}
private async optionalText(pathArg: string): Promise<string | undefined> {
try {
return await this.fetchText(pathArg);
} catch {
return undefined;
}
}
private async requestText(methodArg: 'GET' | 'POST', pathArg: string, commandArg: TSoundtouchCommand | 'refresh', bodyArg?: string, headersArg?: Record<string, string>, portArg = this.port()): Promise<string> {
const rawRequest = this.rawRequest(methodArg, pathArg, commandArg, bodyArg, headersArg, portArg);
if (this.config.commandExecutor) {
return this.executorResultToText(await this.config.commandExecutor.execute(rawRequest));
}
if (!rawRequest.host) {
throw new Error('SoundTouch command transport requires config.host or commandExecutor.');
}
const controller = this.config.timeoutMs ? new AbortController() : undefined;
const timeout = controller ? globalThis.setTimeout(() => controller.abort(), this.config.timeoutMs) : undefined;
try {
const response = await globalThis.fetch(rawRequest.url, {
method: rawRequest.method,
headers: rawRequest.headers,
body: rawRequest.body,
signal: controller?.signal,
});
const text = await response.text();
if (!response.ok) {
throw new Error(`SoundTouch request ${pathArg} failed with HTTP ${response.status}${text ? `: ${text}` : ''}`);
}
return text;
} finally {
if (timeout) {
globalThis.clearTimeout(timeout);
}
}
}
private rawRequest(methodArg: 'GET' | 'POST', pathArg: string, commandArg: TSoundtouchCommand | 'refresh', bodyArg: string | undefined, headersArg: Record<string, string> | undefined, portArg: number): ISoundtouchRawCommandRequest {
const path = pathArg.startsWith('/') ? pathArg : `/${pathArg}`;
return {
command: commandArg,
method: methodArg,
path,
url: `${this.baseUrl(portArg)}${path}`,
host: this.config.host,
port: portArg,
body: bodyArg,
headers: headersArg,
};
}
private parseDeviceInfo(xmlArg: string): ISoundtouchDeviceInfo {
const infoElement = readFirstElement(xmlArg, 'info') || xmlArg;
const infoAttrs = elementAttributes(infoElement, 'info');
return {
deviceId: infoAttrs.deviceID || this.config.deviceId,
name: readTagValue(infoElement, 'name') || this.config.name,
type: readTagValue(infoElement, 'type') || this.config.model,
accountUuid: readTagValue(infoElement, 'margeAccountUUID'),
moduleType: readTagValue(infoElement, 'moduleType'),
variant: readTagValue(infoElement, 'variant'),
variantMode: readTagValue(infoElement, 'variantMode'),
countryCode: readTagValue(infoElement, 'countryCode'),
regionCode: readTagValue(infoElement, 'regionCode'),
host: this.config.host,
port: this.port(),
networks: readAllElements(infoElement, 'networkInfo').map((networkArg) => ({
type: elementAttributes(networkArg, 'networkInfo').type,
macAddress: readTagValue(networkArg, 'macAddress'),
ipAddress: readTagValue(networkArg, 'ipAddress'),
})),
components: readAllElements(infoElement, 'component').map((componentArg) => ({
category: readTagValue(componentArg, 'componentCategory'),
softwareVersion: readTagValue(componentArg, 'softwareVersion'),
serialNumber: readTagValue(componentArg, 'serialNumber'),
})),
};
}
private parseStatus(xmlArg: string): ISoundtouchStatus {
const statusElement = readFirstElement(xmlArg, 'nowPlaying') || xmlArg;
const statusAttrs = elementAttributes(statusElement, 'nowPlaying');
const contentElement = readFirstElement(statusElement, 'ContentItem');
const artElement = readFirstElement(statusElement, 'art');
const artAttrs = artElement ? elementAttributes(artElement, 'art') : {};
const timeElement = readFirstElement(statusElement, 'time');
const timeAttrs = timeElement ? elementAttributes(timeElement, 'time') : {};
return {
source: statusAttrs.source,
contentItem: contentElement ? this.parseContentItem(contentElement) : undefined,
track: readTagValue(statusElement, 'track'),
artist: readTagValue(statusElement, 'artist'),
album: readTagValue(statusElement, 'album'),
image: artAttrs.artImageStatus === 'IMAGE_PRESENT' ? readTagValue(statusElement, 'art') : undefined,
duration: numberValue(timeAttrs.total),
position: numberValue(timeElement ? innerText(timeElement, 'time') : undefined),
playStatus: readTagValue(statusElement, 'playStatus'),
shuffleSetting: readTagValue(statusElement, 'shuffleSetting'),
repeatSetting: readTagValue(statusElement, 'repeatSetting'),
streamType: readTagValue(statusElement, 'streamType'),
trackId: readTagValue(statusElement, 'trackID'),
stationName: readTagValue(statusElement, 'stationName'),
description: readTagValue(statusElement, 'description'),
stationLocation: readTagValue(statusElement, 'stationLocation'),
};
}
private parseContentItem(xmlArg: string): ISoundtouchContentItem {
const attrs = elementAttributes(xmlArg, 'ContentItem');
return {
name: readTagValue(xmlArg, 'itemName'),
source: attrs.source,
type: attrs.type,
location: attrs.location,
sourceAccount: attrs.sourceAccount,
isPresetable: booleanValue(attrs.isPresetable),
};
}
private parseVolume(xmlArg: string): ISoundtouchVolume {
return {
actual: numberValue(readTagValue(xmlArg, 'actualvolume')),
target: numberValue(readTagValue(xmlArg, 'targetvolume')),
muted: booleanValue(readTagValue(xmlArg, 'muteenabled')),
};
}
private parsePresets(xmlArg: string): ISoundtouchPreset[] {
return readAllElements(xmlArg, 'preset').map((presetArg) => {
const attrs = elementAttributes(presetArg, 'preset');
const contentElement = readFirstElement(presetArg, 'ContentItem');
const content = contentElement ? this.parseContentItem(contentElement) : {};
return {
...content,
presetId: attrs.id || readTagValue(presetArg, 'id') || '',
sourceXml: contentElement,
};
}).filter((presetArg) => presetArg.presetId);
}
private parseZoneStatus(xmlArg: string): ISoundtouchZoneStatus | undefined {
const zoneElement = readFirstElement(xmlArg, 'zone') || xmlArg;
const members = readAllElements(zoneElement, 'member');
if (!members.length) {
return undefined;
}
const attrs = elementAttributes(zoneElement, 'zone');
return {
masterId: attrs.master,
masterIp: attrs.senderIPAddress,
isMaster: attrs.master && this.config.deviceId ? attrs.master === this.config.deviceId : attrs.senderIPAddress === undefined,
slaves: members.map((memberArg): ISoundtouchZoneMember => {
const memberAttrs = elementAttributes(memberArg, 'member');
return {
deviceId: stripTags(innerXml(memberArg, 'member')).trim() || undefined,
ipAddress: memberAttrs.ipaddress,
role: memberAttrs.role,
};
}),
};
}
private normalizeSnapshot(snapshotArg: ISoundtouchSnapshot): ISoundtouchSnapshot {
const config = {
...snapshotArg.config,
deviceId: snapshotArg.config.deviceId || this.config.deviceId || this.config.macAddress || this.config.host,
name: snapshotArg.config.name || this.config.name || this.config.host || 'Bose SoundTouch',
type: snapshotArg.config.type || this.config.model || 'SoundTouch',
host: snapshotArg.config.host || this.config.host,
port: snapshotArg.config.port || this.config.port || (this.config.host ? soundtouchDefaultPort : undefined),
networks: snapshotArg.config.networks?.length ? snapshotArg.config.networks : this.config.macAddress || this.config.host ? [{ macAddress: this.config.macAddress, ipAddress: this.config.host }] : [],
};
const zone = snapshotArg.zone ? {
...snapshotArg.zone,
isMaster: snapshotArg.zone.masterId && config.deviceId ? snapshotArg.zone.masterId === config.deviceId : snapshotArg.zone.isMaster,
slaves: snapshotArg.zone.slaves || [],
} : undefined;
return {
...snapshotArg,
config,
presets: snapshotArg.presets || [],
zone,
sourceList: snapshotArg.sourceList?.length ? snapshotArg.sourceList : [...soundtouchDefaultSourceList],
available: snapshotArg.available ?? Boolean(this.config.host || this.config.commandExecutor),
lastUpdated: snapshotArg.lastUpdated || new Date().toISOString(),
};
}
private manualSnapshot(availableArg: boolean, errorArg?: string): ISoundtouchSnapshot {
return {
config: {
deviceId: this.config.deviceId || this.config.macAddress || this.config.host,
name: this.config.name || this.config.host || 'Bose SoundTouch',
type: this.config.model || 'SoundTouch',
host: this.config.host,
port: this.config.port || (this.config.host ? soundtouchDefaultPort : undefined),
networks: this.config.macAddress || this.config.host ? [{ macAddress: this.config.macAddress, ipAddress: this.config.host }] : [],
},
presets: [],
sourceList: this.config.sourceList || [...soundtouchDefaultSourceList],
available: availableArg,
lastUpdated: new Date().toISOString(),
error: errorArg,
};
}
private ensureCommandTransport(): void {
if (!this.config.host && !this.config.commandExecutor) {
throw new Error('SoundTouch command transport requires config.host or commandExecutor. Static snapshots are read-only.');
}
}
private async executorResultToText(resultArg: unknown): Promise<string> {
if (typeof resultArg === 'string') {
return resultArg;
}
if (isFetchResponse(resultArg)) {
return resultArg.text();
}
if (isRecord(resultArg) && typeof resultArg.body === 'string') {
return resultArg.body;
}
if (isRecord(resultArg) && typeof resultArg.text === 'string') {
return resultArg.text;
}
return resultArg === undefined ? '' : JSON.stringify(resultArg);
}
private volumePercent(requestArg: ISoundtouchCommandRequest): number {
const value = requestArg.volume ?? (typeof requestArg.volumeLevel === 'number' ? requestArg.volumeLevel <= 1 ? requestArg.volumeLevel * 100 : requestArg.volumeLevel : undefined);
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new Error('SoundTouch volume command requires volumeLevel or volume.');
}
return Math.max(0, Math.min(100, Math.round(value)));
}
private port(): number {
return this.config.port || soundtouchDefaultPort;
}
private dlnaPort(): number {
return this.config.dlnaPort || soundtouchDefaultDlnaPort;
}
private baseUrl(portArg: number): string {
const rawHost = this.config.host || 'localhost';
if (/^https?:\/\//i.test(rawHost)) {
const url = new URL(rawHost);
if (!url.port) {
url.port = String(portArg);
}
return url.origin;
}
const host = rawHost.replace(/\/.*$/, '');
const hostPort = /^([^:]+):(\d+)$/.exec(host);
if (hostPort) {
return `http://${hostPort[1]}:${hostPort[2]}`;
}
return `http://${formatHost(host)}:${portArg}`;
}
private cloneValue<TValue>(valueArg: TValue): TValue {
return valueArg === undefined ? valueArg : JSON.parse(JSON.stringify(valueArg)) as TValue;
}
}
const readFirstElement = (xmlArg: string, tagArg: string): string | undefined => {
const escapedTag = escapeRegExp(tagArg);
const match = new RegExp(`<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b[^>]*>[\\s\\S]*?<\\/(?:[A-Za-z0-9_]+:)?${escapedTag}>`, 'i').exec(xmlArg)
|| new RegExp(`<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b[^>]*/>`, 'i').exec(xmlArg);
return match?.[0];
};
const readAllElements = (xmlArg: string, tagArg: string): string[] => {
const escapedTag = escapeRegExp(tagArg);
const regex = new RegExp(`<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b[^>]*>[\\s\\S]*?<\\/(?:[A-Za-z0-9_]+:)?${escapedTag}>|<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b[^>]*/>`, 'gi');
const values: string[] = [];
let match: RegExpExecArray | null;
while ((match = regex.exec(xmlArg))) {
values.push(match[0]);
}
return values;
};
const readTagValue = (xmlArg: string, tagArg: string): string | undefined => {
const element = readFirstElement(xmlArg, tagArg);
if (!element) {
return undefined;
}
return stripTags(innerXml(element, tagArg)).trim() || undefined;
};
const innerText = (elementArg: string, tagArg: string): string | undefined => {
return stripTags(innerXml(elementArg, tagArg)).trim() || undefined;
};
const innerXml = (elementArg: string, tagArg: string): string => {
const escapedTag = escapeRegExp(tagArg);
return elementArg
.replace(new RegExp(`^<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b[^>]*>`, 'i'), '')
.replace(new RegExp(`<\\/(?:[A-Za-z0-9_]+:)?${escapedTag}>$`, 'i'), '')
.replace(new RegExp(`^<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b[^>]*/>$`, 'i'), '');
};
const elementAttributes = (elementArg: string, tagArg: string): Record<string, string> => {
const escapedTag = escapeRegExp(tagArg);
const match = new RegExp(`<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b([^>]*)>`, 'i').exec(elementArg);
return parseAttributes(match?.[1] || '');
};
const parseAttributes = (valueArg: string): Record<string, string> => {
const attrs: Record<string, string> = {};
const regex = /([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(valueArg))) {
attrs[match[1]] = unescapeXml(match[2] ?? match[3] ?? '');
}
return attrs;
};
const stripTags = (valueArg: string): string => unescapeXml(valueArg.replace(/<[^>]+>/g, ''));
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 === 'string') {
const normalized = valueArg.toLowerCase();
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
return true;
}
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
return false;
}
}
return undefined;
};
const escapeXml = (valueArg: string): string => {
return valueArg.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
};
const unescapeXml = (valueArg: string): string => {
return valueArg.replace(/&apos;/g, "'").replace(/&quot;/g, '"').replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&');
};
const escapeRegExp = (valueArg: string): string => valueArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => {
return Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg));
};
const isFetchResponse = (valueArg: unknown): valueArg is Response => {
return Boolean(valueArg && typeof valueArg === 'object' && typeof (valueArg as Response).text === 'function');
};
const formatHost = (hostArg: string): string => hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
@@ -0,0 +1,55 @@
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
import type { ISoundtouchConfig } from './soundtouch.types.js';
import { soundtouchDefaultDlnaPort, soundtouchDefaultPort } from './soundtouch.types.js';
export class SoundtouchConfigFlow implements IConfigFlow<ISoundtouchConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<ISoundtouchConfig>> {
void contextArg;
return {
kind: 'form',
title: 'Connect Bose SoundTouch',
description: candidateArg.source === 'manual'
? 'Configure a local Bose SoundTouch speaker.'
: 'Confirm or adjust the discovered local Bose SoundTouch speaker.',
fields: [
{ name: 'host', label: 'Host', type: 'text', required: true },
{ name: 'port', label: 'Port', type: 'number' },
{ name: 'name', label: 'Name', type: 'text' },
],
submit: async (valuesArg) => {
const host = stringValue(valuesArg.host) || candidateArg.host;
if (!host) {
return { kind: 'error', title: 'SoundTouch host required', error: 'SoundTouch setup requires a host.' };
}
return {
kind: 'done',
title: 'Bose SoundTouch configured',
config: {
host,
port: numberValue(valuesArg.port) || candidateArg.port || soundtouchDefaultPort,
dlnaPort: soundtouchDefaultDlnaPort,
name: stringValue(valuesArg.name) || candidateArg.name,
deviceId: candidateArg.id,
model: candidateArg.model,
macAddress: candidateArg.macAddress,
},
};
},
};
}
}
const stringValue = (valueArg: unknown): string | undefined => {
return 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;
};
@@ -1,26 +1,178 @@
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 { SoundtouchClient } from './soundtouch.classes.client.js';
import { SoundtouchConfigFlow } from './soundtouch.classes.configflow.js';
import { createSoundtouchDiscoveryDescriptor } from './soundtouch.discovery.js';
import { SoundtouchMapper } from './soundtouch.mapper.js';
import type { ISoundtouchCommandRequest, ISoundtouchConfig, TSoundtouchCommand } from './soundtouch.types.js';
export class HomeAssistantSoundtouchIntegration extends DescriptorOnlyIntegration {
constructor() {
super({
domain: "soundtouch",
displayName: "Bose SoundTouch",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/soundtouch",
"upstreamDomain": "soundtouch",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"libsoundtouch==0.8"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@kroimon"
]
},
});
export class SoundtouchIntegration extends BaseIntegration<ISoundtouchConfig> {
public readonly domain = 'soundtouch';
public readonly displayName = 'Bose SoundTouch';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createSoundtouchDiscoveryDescriptor();
public readonly configFlow = new SoundtouchConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/soundtouch',
upstreamDomain: 'soundtouch',
integrationType: 'device',
iotClass: 'local_polling',
requirements: ['libsoundtouch==0.8'],
dependencies: [],
afterDependencies: [],
codeowners: ['@kroimon'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/soundtouch',
zeroconf: ['_soundtouch._tcp.local.'],
};
public async setup(configArg: ISoundtouchConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new SoundtouchRuntime(new SoundtouchClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantSoundtouchIntegration extends SoundtouchIntegration {}
class SoundtouchRuntime implements IIntegrationRuntime {
public domain = 'soundtouch';
constructor(private readonly client: SoundtouchClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return SoundtouchMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return SoundtouchMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
try {
if (requestArg.domain === 'media_player') {
return await this.callMediaPlayerService(requestArg);
}
if (requestArg.domain === 'soundtouch') {
return await this.callSoundtouchService(requestArg);
}
return { success: false, error: `Unsupported SoundTouch 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 callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
const command = this.commandFromMediaService(requestArg.service);
if (!command) {
return { success: false, error: `Unsupported SoundTouch media_player service: ${requestArg.service}` };
}
const result = await this.client.execute(this.commandRequest(command, requestArg));
return { success: true, data: result };
}
private async callSoundtouchService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
if (requestArg.service === 'play_preset' || requestArg.service === 'select_preset') {
const presetId = this.stringData(requestArg, 'preset_id') || this.stringData(requestArg, 'preset') || this.stringData(requestArg, 'presetId') || this.numberStringData(requestArg, 'preset');
const presetName = this.stringData(requestArg, 'preset_name') || this.stringData(requestArg, 'presetName') || this.stringData(requestArg, 'name');
const result = await this.client.execute({ command: 'play_preset', presetId, presetName });
return { success: true, data: result };
}
if (requestArg.service === 'play' || requestArg.service === 'pause' || requestArg.service === 'media_play' || requestArg.service === 'media_pause' || requestArg.service === 'volume_set' || requestArg.service === 'volume_mute' || requestArg.service === 'select_source' || requestArg.service === 'play_media') {
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
}
return { success: false, error: `Unsupported SoundTouch service: ${requestArg.service}` };
}
private commandFromMediaService(serviceArg: string): TSoundtouchCommand | undefined {
if (serviceArg === 'turn_on') {
return 'turn_on';
}
if (serviceArg === 'turn_off') {
return 'turn_off';
}
if (serviceArg === 'media_play' || serviceArg === 'play') {
return 'play';
}
if (serviceArg === 'media_pause' || serviceArg === 'pause') {
return 'pause';
}
if (serviceArg === 'media_play_pause' || serviceArg === 'play_pause') {
return 'play_pause';
}
if (serviceArg === 'media_previous_track' || serviceArg === 'previous_track') {
return 'previous_track';
}
if (serviceArg === 'media_next_track' || serviceArg === 'next_track') {
return 'next_track';
}
if (serviceArg === 'volume_up') {
return 'volume_up';
}
if (serviceArg === 'volume_down') {
return 'volume_down';
}
if (serviceArg === 'volume_set' || serviceArg === 'set_volume') {
return 'set_volume';
}
if (serviceArg === 'volume_mute' || serviceArg === 'mute') {
return 'mute';
}
if (serviceArg === 'select_source' || serviceArg === 'select_input') {
return 'select_source';
}
if (serviceArg === 'play_media') {
return 'play_media';
}
return undefined;
}
private commandRequest(commandArg: TSoundtouchCommand, requestArg: IServiceCallRequest): ISoundtouchCommandRequest {
const request: ISoundtouchCommandRequest = { command: commandArg };
if (commandArg === 'set_volume') {
request.volumeLevel = this.numberData(requestArg, 'volume_level') ?? this.numberData(requestArg, 'volumeLevel');
request.volume = this.numberData(requestArg, 'volume');
}
if (commandArg === 'mute') {
request.muted = this.boolData(requestArg, 'is_volume_muted') ?? this.boolData(requestArg, 'mute') ?? this.boolData(requestArg, 'muted');
}
if (commandArg === 'select_source') {
request.source = this.stringData(requestArg, 'source') || this.stringData(requestArg, 'input');
}
if (commandArg === 'play_media') {
request.mediaId = this.stringData(requestArg, 'media_content_id') || this.stringData(requestArg, 'mediaId') || this.stringData(requestArg, 'uri') || this.numberStringData(requestArg, 'media_content_id');
request.mediaType = this.stringData(requestArg, 'media_content_type') || this.stringData(requestArg, 'mediaType');
request.url = this.stringData(requestArg, 'url');
request.presetId = this.stringData(requestArg, 'preset_id') || this.stringData(requestArg, 'presetId') || this.numberStringData(requestArg, 'preset');
request.presetName = this.stringData(requestArg, 'preset_name') || this.stringData(requestArg, 'presetName');
}
return request;
}
private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
const value = requestArg.data?.[keyArg];
return typeof value === 'string' && value ? value : undefined;
}
private numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined {
const value = requestArg.data?.[keyArg];
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
private numberStringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
const value = this.numberData(requestArg, keyArg);
return typeof value === 'number' ? String(value) : undefined;
}
private boolData(requestArg: IServiceCallRequest, keyArg: string): boolean | undefined {
const value = requestArg.data?.[keyArg];
return typeof value === 'boolean' ? value : undefined;
}
}
@@ -0,0 +1,137 @@
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { ISoundtouchManualEntry, ISoundtouchMdnsRecord } from './soundtouch.types.js';
import { soundtouchDefaultPort } from './soundtouch.types.js';
const soundtouchDomain = 'soundtouch';
const soundtouchMdnsType = '_soundtouch._tcp.local.';
export class SoundtouchMdnsMatcher implements IDiscoveryMatcher<ISoundtouchMdnsRecord> {
public id = 'soundtouch-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize Bose SoundTouch zeroconf advertisements.';
public async matches(recordArg: ISoundtouchMdnsRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const type = recordArg.type || '';
const txt = recordArg.txt || {};
const matched = normalizeMdnsType(type) === normalizeMdnsType(soundtouchMdnsType);
if (!matched) {
return { matched: false, confidence: 'low', reason: 'mDNS record is not a SoundTouch advertisement.' };
}
const id = valueForKey(txt, 'deviceId') || valueForKey(txt, 'deviceID') || valueForKey(txt, 'id') || recordArg.name;
return {
matched: true,
confidence: recordArg.host ? 'certain' : 'high',
reason: 'mDNS record matches Bose SoundTouch zeroconf metadata.',
normalizedDeviceId: id,
candidate: {
source: 'mdns',
integrationDomain: soundtouchDomain,
id,
host: recordArg.host,
port: recordArg.port || soundtouchDefaultPort,
name: recordArg.name,
manufacturer: 'Bose Corporation',
model: valueForKey(txt, 'type') || valueForKey(txt, 'model'),
macAddress: valueForKey(txt, 'mac') || valueForKey(txt, 'macAddress'),
metadata: { mdnsName: recordArg.name, mdnsType: type, txt },
},
};
}
}
export class SoundtouchManualMatcher implements IDiscoveryMatcher<ISoundtouchManualEntry> {
public id = 'soundtouch-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Bose SoundTouch setup entries.';
public async matches(inputArg: ISoundtouchManualEntry, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''}`.toLowerCase();
const matched = Boolean(inputArg.host || inputArg.metadata?.soundtouch || haystack.includes('soundtouch') || haystack.includes('bose'));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain SoundTouch setup hints.' };
}
const id = inputArg.deviceId || inputArg.macAddress || inputArg.id || (inputArg.host ? `${inputArg.host}:${inputArg.port || soundtouchDefaultPort}` : undefined);
return {
matched: true,
confidence: inputArg.host ? 'high' : 'medium',
reason: 'Manual entry can start SoundTouch setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: soundtouchDomain,
id,
host: inputArg.host,
port: inputArg.port || soundtouchDefaultPort,
name: inputArg.name,
manufacturer: normalizedManufacturer(inputArg.manufacturer),
model: inputArg.model,
macAddress: inputArg.macAddress,
metadata: inputArg.metadata,
},
};
}
}
export class SoundtouchCandidateValidator implements IDiscoveryValidator {
public id = 'soundtouch-candidate-validator';
public description = 'Validate Bose SoundTouch candidates.';
public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
const metadata = candidateArg.metadata || {};
const matched = candidateArg.integrationDomain === soundtouchDomain
|| includesSoundtouch(candidateArg.name)
|| includesSoundtouch(candidateArg.model)
|| includesBose(candidateArg.manufacturer)
|| Boolean(metadata.soundtouch)
|| normalizeMdnsType(stringMetadata(metadata.mdnsType) || '') === normalizeMdnsType(soundtouchMdnsType);
return {
matched,
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
reason: matched ? 'Candidate has Bose SoundTouch metadata.' : 'Candidate is not SoundTouch.',
candidate: matched ? { ...candidateArg, port: candidateArg.port || soundtouchDefaultPort } : undefined,
normalizedDeviceId: candidateArg.id || candidateArg.macAddress,
};
}
}
export const createSoundtouchDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: soundtouchDomain, displayName: 'Bose SoundTouch' })
.addMatcher(new SoundtouchMdnsMatcher())
.addMatcher(new SoundtouchManualMatcher())
.addValidator(new SoundtouchCandidateValidator());
};
const valueForKey = (recordArg: Record<string, string | undefined> | undefined, keyArg: string): string | undefined => {
if (!recordArg) {
return undefined;
}
const lowerKey = keyArg.toLowerCase();
for (const [key, value] of Object.entries(recordArg)) {
if (key.toLowerCase() === lowerKey) {
return value;
}
}
return undefined;
};
const normalizeMdnsType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.$/, '');
const normalizedManufacturer = (valueArg: string | undefined): string => {
if (!valueArg || valueArg.toLowerCase().includes('bose')) {
return 'Bose Corporation';
}
return valueArg;
};
const includesSoundtouch = (valueArg: string | undefined): boolean => Boolean(valueArg?.toLowerCase().includes('soundtouch'));
const includesBose = (valueArg: string | undefined): boolean => Boolean(valueArg?.toLowerCase().includes('bose'));
const stringMetadata = (valueArg: unknown): string | undefined => typeof valueArg === 'string' ? valueArg : undefined;
@@ -0,0 +1,217 @@
import * as plugins from '../../plugins.js';
import type { IIntegrationEntity } from '../../core/types.js';
import type { ISoundtouchPreset, ISoundtouchSnapshot, ISoundtouchStatus } from './soundtouch.types.js';
export class SoundtouchMapper {
public static toDevices(snapshotArg: ISoundtouchSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
const updatedAt = snapshotArg.lastUpdated || new Date().toISOString();
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
{ id: 'playback', capability: 'media', name: 'Playback', 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: 'presets', capability: 'media', name: 'Presets', readable: true, writable: true },
];
const state: plugins.shxInterfaces.data.IDeviceState[] = [
{ featureId: 'playback', value: this.mediaState(snapshotArg.status), updatedAt },
{ featureId: 'source', value: snapshotArg.status?.source || null, updatedAt },
{ featureId: 'volume', value: typeof snapshotArg.volume?.actual === 'number' ? snapshotArg.volume.actual : null, updatedAt },
{ featureId: 'muted', value: typeof snapshotArg.volume?.muted === 'boolean' ? snapshotArg.volume.muted : null, updatedAt },
{ featureId: 'presets', value: snapshotArg.presets?.length || 0, updatedAt },
];
if (this.mediaTitle(snapshotArg.status)) {
features.push({ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false });
state.push({ featureId: 'current_title', value: this.mediaTitle(snapshotArg.status) || null, updatedAt });
}
return [{
id: this.playerDeviceId(snapshotArg),
integrationDomain: 'soundtouch',
name: this.deviceName(snapshotArg),
protocol: 'http',
manufacturer: 'Bose Corporation',
model: snapshotArg.config.type,
online: snapshotArg.available !== false,
features,
state,
metadata: {
deviceId: snapshotArg.config.deviceId,
host: snapshotArg.config.host,
port: snapshotArg.config.port,
macAddress: this.macAddress(snapshotArg),
accountUuid: snapshotArg.config.accountUuid,
moduleType: snapshotArg.config.moduleType,
variant: snapshotArg.config.variant,
softwareVersions: snapshotArg.config.components?.map((componentArg) => componentArg.softwareVersion).filter(Boolean),
},
}];
}
public static toEntities(snapshotArg: ISoundtouchSnapshot): IIntegrationEntity[] {
const base = this.entityBase(snapshotArg);
const entities: IIntegrationEntity[] = [{
id: `media_player.${base}`,
uniqueId: `soundtouch_${this.uniqueBase(snapshotArg)}`,
integrationDomain: 'soundtouch',
deviceId: this.playerDeviceId(snapshotArg),
platform: 'media_player',
name: this.deviceName(snapshotArg),
state: this.mediaState(snapshotArg.status),
attributes: {
deviceClass: 'speaker',
deviceId: snapshotArg.config.deviceId,
model: snapshotArg.config.type,
macAddress: this.macAddress(snapshotArg),
volumeLevel: typeof snapshotArg.volume?.actual === 'number' ? Math.max(0, Math.min(1, snapshotArg.volume.actual / 100)) : undefined,
volume: snapshotArg.volume?.actual,
targetVolume: snapshotArg.volume?.target,
isVolumeMuted: snapshotArg.volume?.muted,
source: snapshotArg.status?.source,
sourceList: snapshotArg.sourceList || [],
presetList: this.presetList(snapshotArg),
mediaContentType: snapshotArg.status?.contentItem?.type || snapshotArg.status?.streamType,
mediaContentId: snapshotArg.status?.contentItem?.location || snapshotArg.status?.trackId,
mediaTitle: this.mediaTitle(snapshotArg.status),
mediaArtist: snapshotArg.status?.artist,
mediaTrack: snapshotArg.status?.track,
mediaAlbumName: snapshotArg.status?.album,
mediaImageUrl: snapshotArg.status?.image,
mediaDuration: snapshotArg.status?.duration,
mediaPosition: snapshotArg.status?.position,
shuffle: snapshotArg.status?.shuffleSetting,
repeat: snapshotArg.status?.repeatSetting,
stationName: snapshotArg.status?.stationName,
stationLocation: snapshotArg.status?.stationLocation,
soundtouchZone: this.zoneAttribute(snapshotArg),
soundtouchGroup: this.groupAttribute(snapshotArg),
},
available: snapshotArg.available !== false,
}];
entities.push({
id: `sensor.${base}_soundtouch_presets`,
uniqueId: `soundtouch_${this.uniqueBase(snapshotArg)}_presets`,
integrationDomain: 'soundtouch',
deviceId: this.playerDeviceId(snapshotArg),
platform: 'sensor',
name: `${this.deviceName(snapshotArg)} SoundTouch Presets`,
state: snapshotArg.presets?.length || 0,
attributes: { presets: this.presetList(snapshotArg) },
available: snapshotArg.available !== false,
});
entities.push({
id: `sensor.${base}_soundtouch_sources`,
uniqueId: `soundtouch_${this.uniqueBase(snapshotArg)}_sources`,
integrationDomain: 'soundtouch',
deviceId: this.playerDeviceId(snapshotArg),
platform: 'sensor',
name: `${this.deviceName(snapshotArg)} SoundTouch Sources`,
state: snapshotArg.sourceList?.length || 0,
attributes: { sourceList: snapshotArg.sourceList || [] },
available: snapshotArg.available !== false,
});
entities.push({
id: `sensor.${base}_soundtouch_media`,
uniqueId: `soundtouch_${this.uniqueBase(snapshotArg)}_media`,
integrationDomain: 'soundtouch',
deviceId: this.playerDeviceId(snapshotArg),
platform: 'sensor',
name: `${this.deviceName(snapshotArg)} SoundTouch Media`,
state: this.mediaTitle(snapshotArg.status) || snapshotArg.status?.source || 'None',
attributes: { status: snapshotArg.status },
available: snapshotArg.available !== false,
});
return entities;
}
public static playerDeviceId(snapshotArg: ISoundtouchSnapshot): string {
return `soundtouch.player.${this.uniqueBase(snapshotArg)}`;
}
public static slug(valueArg: string | undefined): string {
return (valueArg || 'soundtouch').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'soundtouch';
}
private static mediaState(statusArg: ISoundtouchStatus | undefined): string {
if (!statusArg || statusArg.source === 'STANDBY' || statusArg.playStatus === 'STOP_STATE') {
return 'off';
}
if (statusArg.source === 'INVALID_SOURCE') {
return 'unknown';
}
if (statusArg.playStatus === 'PLAY_STATE' || statusArg.playStatus === 'BUFFERING_STATE') {
return 'playing';
}
if (statusArg.playStatus === 'PAUSE_STATE') {
return 'paused';
}
return 'idle';
}
private static mediaTitle(statusArg: ISoundtouchStatus | undefined): string | undefined {
if (!statusArg) {
return undefined;
}
if (statusArg.stationName) {
return statusArg.stationName;
}
if (statusArg.artist && statusArg.track) {
return `${statusArg.artist} - ${statusArg.track}`;
}
return statusArg.track || statusArg.contentItem?.name || statusArg.description;
}
private static presetList(snapshotArg: ISoundtouchSnapshot): Array<Record<string, unknown>> {
return (snapshotArg.presets || []).map((presetArg: ISoundtouchPreset) => ({
presetId: presetArg.presetId,
name: presetArg.name,
source: presetArg.source,
type: presetArg.type,
location: presetArg.location,
sourceAccount: presetArg.sourceAccount,
isPresetable: presetArg.isPresetable,
}));
}
private static zoneAttribute(snapshotArg: ISoundtouchSnapshot): Record<string, unknown> | undefined {
if (!snapshotArg.zone) {
return undefined;
}
return {
master: snapshotArg.zone.masterId,
masterIp: snapshotArg.zone.masterIp,
isMaster: snapshotArg.zone.isMaster,
slaves: snapshotArg.zone.slaves,
};
}
private static groupAttribute(snapshotArg: ISoundtouchSnapshot): string[] | undefined {
if (!snapshotArg.zone) {
return undefined;
}
return [
snapshotArg.zone.masterId || snapshotArg.zone.masterIp,
...snapshotArg.zone.slaves.map((slaveArg) => slaveArg.deviceId || slaveArg.ipAddress),
].filter((valueArg): valueArg is string => Boolean(valueArg));
}
private static deviceName(snapshotArg: ISoundtouchSnapshot): string {
return snapshotArg.config.name || snapshotArg.config.type || 'Bose SoundTouch';
}
private static entityBase(snapshotArg: ISoundtouchSnapshot): string {
return this.slug(this.deviceName(snapshotArg));
}
private static uniqueBase(snapshotArg: ISoundtouchSnapshot): string {
return this.slug(snapshotArg.config.deviceId || this.macAddress(snapshotArg) || snapshotArg.config.host || this.deviceName(snapshotArg));
}
private static macAddress(snapshotArg: ISoundtouchSnapshot): string | undefined {
return snapshotArg.config.networks?.find((networkArg) => networkArg.type === 'SMSC')?.macAddress || snapshotArg.config.networks?.[0]?.macAddress;
}
}
+198 -3
View File
@@ -1,4 +1,199 @@
export interface IHomeAssistantSoundtouchConfig {
// TODO: replace with the TypeScript-native config for soundtouch.
[key: string]: unknown;
export const soundtouchDefaultPort = 8090;
export const soundtouchDefaultDlnaPort = 8091;
export const soundtouchDefaultSourceList = ['AUX', 'BLUETOOTH'] as const;
export type TSoundtouchPlayStatus = 'PLAY_STATE' | 'BUFFERING_STATE' | 'PAUSE_STATE' | 'STOP_STATE' | string;
export type TSoundtouchSource =
| 'SLAVE_SOURCE'
| 'INTERNET_RADIO'
| 'PANDORA'
| 'AIRPLAY'
| 'STORED_MUSIC'
| 'AUX'
| 'OFF_SOURCE'
| 'CURRATED_RADIO'
| 'STANDBY'
| 'UPDATE'
| 'DEEZER'
| 'SPOTIFY'
| 'IHEART'
| 'LOCAL_MUSIC'
| 'BLUETOOTH'
| 'INVALID_SOURCE'
| string;
export type TSoundtouchContentType = 'uri' | 'track' | 'album' | 'playlist' | string;
export type TSoundtouchCommand =
| 'turn_on'
| 'turn_off'
| 'play'
| 'pause'
| 'play_pause'
| 'previous_track'
| 'next_track'
| 'volume_up'
| 'volume_down'
| 'set_volume'
| 'mute'
| 'select_source'
| 'play_media'
| 'play_preset';
export interface ISoundtouchConfig {
host?: string;
port?: number;
dlnaPort?: number;
name?: string;
deviceId?: string;
model?: string;
macAddress?: string;
sourceList?: string[];
timeoutMs?: number;
snapshot?: ISoundtouchSnapshot;
commandExecutor?: ISoundtouchCommandExecutor;
}
export interface IHomeAssistantSoundtouchConfig extends ISoundtouchConfig {}
export interface ISoundtouchCommandExecutor {
execute(requestArg: ISoundtouchRawCommandRequest): Promise<unknown>;
}
export interface ISoundtouchRawCommandRequest {
command: TSoundtouchCommand | 'refresh';
method: 'GET' | 'POST';
path: string;
url: string;
host?: string;
port: number;
body?: string;
headers?: Record<string, string>;
}
export interface ISoundtouchCommandRequest {
command: TSoundtouchCommand;
source?: string;
presetId?: string;
presetName?: string;
mediaId?: string;
mediaType?: string;
url?: string;
volumeLevel?: number;
volume?: number;
muted?: boolean;
contentItem?: ISoundtouchContentItem;
}
export interface ISoundtouchSnapshot {
config: ISoundtouchDeviceInfo;
status?: ISoundtouchStatus;
volume?: ISoundtouchVolume;
presets?: ISoundtouchPreset[];
zone?: ISoundtouchZoneStatus;
sourceList?: string[];
available?: boolean;
lastUpdated?: string;
error?: string;
}
export interface ISoundtouchDeviceInfo {
deviceId?: string;
name?: string;
type?: string;
accountUuid?: string;
moduleType?: string;
variant?: string;
variantMode?: string;
countryCode?: string;
regionCode?: string;
host?: string;
port?: number;
networks?: ISoundtouchNetworkInfo[];
components?: ISoundtouchComponentInfo[];
}
export interface ISoundtouchNetworkInfo {
type?: string;
macAddress?: string;
ipAddress?: string;
}
export interface ISoundtouchComponentInfo {
category?: string;
softwareVersion?: string;
serialNumber?: string;
}
export interface ISoundtouchContentItem {
name?: string;
source?: TSoundtouchSource;
type?: TSoundtouchContentType;
location?: string;
sourceAccount?: string;
isPresetable?: boolean;
}
export interface ISoundtouchStatus {
source?: TSoundtouchSource;
contentItem?: ISoundtouchContentItem;
track?: string;
artist?: string;
album?: string;
image?: string;
duration?: number;
position?: number;
playStatus?: TSoundtouchPlayStatus;
shuffleSetting?: string;
repeatSetting?: string;
streamType?: string;
trackId?: string;
stationName?: string;
description?: string;
stationLocation?: string;
}
export interface ISoundtouchVolume {
actual?: number;
target?: number;
muted?: boolean;
}
export interface ISoundtouchPreset extends ISoundtouchContentItem {
presetId: string;
sourceXml?: string;
}
export interface ISoundtouchZoneStatus {
masterId?: string;
masterIp?: string;
isMaster?: boolean;
slaves: ISoundtouchZoneMember[];
}
export interface ISoundtouchZoneMember {
deviceId?: string;
ipAddress?: string;
role?: string;
}
export interface ISoundtouchMdnsRecord {
name?: string;
type?: string;
host?: string;
port?: number;
txt?: Record<string, string | undefined>;
}
export interface ISoundtouchManualEntry {
host?: string;
port?: number;
id?: string;
name?: string;
manufacturer?: string;
model?: string;
deviceId?: string;
macAddress?: string;
metadata?: Record<string, unknown>;
}