Add native local media controller integrations
This commit is contained in:
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user