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();